feat: add durable CMS-free image video subsystem and UI components
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type ImageVideoTask = { id: string; title: string; prompt: string; durationSeconds: number; state: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: { videoSeconds?: number; width?: number; height?: number; fps?: number } };
|
||||
export function isImageVideoTask(value: unknown): value is ImageVideoTask {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const task = value as Partial<ImageVideoTask>;
|
||||
return typeof task.id === "string" && /^i2v_[0-9a-f-]{36}$/.test(task.id) && typeof task.title === "string" && typeof task.prompt === "string" && typeof task.state === "string" && typeof task.durationSeconds === "number" && task.referenceUrl === `/api/image-video/tasks/${task.id}/reference`;
|
||||
}
|
||||
const stateNames: Record<string, string> = { draft: "本地草稿 · 待图片上传审批", initializing_upload: "准备上传", uploading: "上传中", finalizing: "入库确认中", importing: "参考图入库中", awaiting_submission: "参考图已入库 · 待生成审批", submitting: "提交中", queued: "排队中", running: "生成中", validating: "成片技术校验中", ready: "生成预览 · 待视听检查", failed: "失败", unknown_submission: "提交状态未知 · 禁止重投", unknown_upload: "上传状态未知 · 禁止重复导入" };
|
||||
|
||||
export function ImageVideoTaskCard({ task }: { task: ImageVideoTask }) {
|
||||
if (!isImageVideoTask(task)) return null;
|
||||
const video = task.state === "ready" && task.videoUrl === `/api/image-video/tasks/${task.id}/video` ? task.videoUrl : undefined;
|
||||
return <article className="i2v-task">
|
||||
<img className="i2v-reference" src={task.referenceUrl} alt={`${task.title} 参考图`} loading="lazy" />
|
||||
<div><h3>{task.title}</h3><p role="status">{stateNames[task.state] ?? "未知状态 · 请核实"}</p><p>{task.durationSeconds} 秒 · H3 ref2va · 9:16 · 768p</p><p>{task.prompt}</p>
|
||||
{task.jobId ? <p>生成任务:<code>{task.jobId}</code></p> : null}
|
||||
{task.error ? <p role="alert">{task.error}</p> : null}
|
||||
{video ? <><video aria-label="生成预览播放器" src={video} controls playsInline preload="metadata" /><a className="secondary-button" href={`${video}?download=1`} download>下载 MP4</a><p>技术验证通过,待视听检查;音轨存在不能证明声音内容正确。</p></> : null}
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
async function jsonRequest(url: string, init?: RequestInit): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(url, { cache: "no-store", ...init });
|
||||
if (!response.ok) throw new Error("图生视频请求失败,请检查连接或任务状态。");
|
||||
return await response.json() as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ImageVideoPanel({ sessionId, onDraft }: { sessionId?: string; onDraft?: (task: ImageVideoTask) => void }) {
|
||||
const [tasks, setTasks] = useState<ImageVideoTask[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string>();
|
||||
const [file, setFile] = useState<File>(); const [title, setTitle] = useState(""); const [prompt, setPrompt] = useState(""); const [duration, setDuration] = useState(10); const [saving, setSaving] = useState(false);
|
||||
const [reference, setReference] = useState<string>(); const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true; const controller = new AbortController(); let active = false;
|
||||
const refresh = async () => { if (active) return; active = true; try { const data = await jsonRequest("/api/image-video/tasks", { signal: controller.signal }); if (!controller.signal.aborted) { setTasks(Array.isArray(data.tasks) ? data.tasks.filter(isImageVideoTask) : []); setError(undefined); } } catch { if (!controller.signal.aborted) setError("无法读取任务历史,请检查本机服务。"); } finally { active = false; if (!controller.signal.aborted) setLoading(false); } };
|
||||
void refresh(); const timer = window.setInterval(() => void refresh(), 30_000);
|
||||
return () => { mounted.current = false; controller.abort(); window.clearInterval(timer); };
|
||||
}, []);
|
||||
useEffect(() => { if (!file) { setReference(undefined); return; } const url = URL.createObjectURL(file); setReference(url); return () => URL.revokeObjectURL(url); }, [file]);
|
||||
const create = async () => {
|
||||
if (!file || !sessionId || saving || !title.trim() || !prompt.trim()) return;
|
||||
setSaving(true); setError(undefined);
|
||||
try {
|
||||
const body = new FormData(); body.append("file", file);
|
||||
const upload = await jsonRequest(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, { method: "POST", body });
|
||||
const attachment = upload.attachment as { attachmentId?: unknown } | undefined;
|
||||
if (typeof attachment?.attachmentId !== "string") throw new Error("附件接收失败。");
|
||||
const result = await jsonRequest(`/api/sessions/${encodeURIComponent(sessionId)}/image-video/tasks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ attachmentId: attachment.attachmentId, title, prompt, durationSeconds: duration }) });
|
||||
if (!isImageVideoTask(result.task)) throw new Error("任务响应无效。");
|
||||
const task = result.task;
|
||||
if (mounted.current) { setTasks(current => [task, ...current]); onDraft?.(task); }
|
||||
} catch (error) { if (mounted.current) setError(error instanceof Error ? error.message : "准备任务失败。"); } finally { if (mounted.current) setSaving(false); }
|
||||
};
|
||||
return <section className="i2v-panel" aria-label="图生视频工作区">
|
||||
<h2>图片 → 有声视频</h2><p>单张参考图,H3 ref2va。无需 CMS。默认竖屏、自然速度、有环境声;10 秒是目标,实际支持尚待验证。</p>
|
||||
{sessionId && onDraft ? <form onSubmit={event => { event.preventDefault(); void create(); }}>
|
||||
<label>参考图片<input aria-label="参考图片" type="file" accept="image/png,image/jpeg,image/webp" disabled={saving} onChange={event => setFile(event.currentTarget.files?.[0])} /></label>
|
||||
{reference ? <img className="i2v-reference" src={reference} alt="本机参考图片预览" /> : null}
|
||||
<label>标题<input aria-label="标题" value={title} maxLength={120} disabled={saving} onChange={event => setTitle(event.currentTarget.value)} /></label>
|
||||
<label>动作与声音<textarea aria-label="动作与声音" placeholder="例如:人物自然转向窗外,镜头缓慢推近,有雨声。" value={prompt} maxLength={4000} disabled={saving} onChange={event => setPrompt(event.currentTarget.value)} /></label>
|
||||
<label>目标时长(秒)<input aria-label="目标时长(秒)" type="number" min="0.001" max="60" step="0.001" value={duration} disabled={saving} onChange={event => setDuration(Number(event.currentTarget.value))} /></label>
|
||||
<p>准备仅保存到本机。随后在聊天中分别确认图片外传与 H3 生成。此缩略图不会传给聊天模型;H3 将在批准后接收图片。费用未知。</p>
|
||||
<button className="secondary-button" disabled={saving || !file || !title.trim() || !prompt.trim() || duration <= 0 || duration > 60} type="submit">{saving ? "保存中…" : "准备本地任务"}</button>
|
||||
</form> : null}
|
||||
{loading ? <p role="status">正在读取持久任务…</p> : null}
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{!loading && tasks.length === 0 ? <p>尚无图生视频任务。</p> : null}
|
||||
<div className="i2v-history">{tasks.map(task => <ImageVideoTaskCard key={task.id} task={task} />)}</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ImageVideoApprovalDetails({ taskId, onReady }: { taskId: unknown; onReady: (ready: boolean) => void }) {
|
||||
const [task, setTask] = useState<ImageVideoTask>(); const [error, setError] = useState(false);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); onReady(false); setTask(undefined); setError(false);
|
||||
if (typeof taskId !== "string" || !/^i2v_[0-9a-f-]{36}$/.test(taskId)) { setError(true); return; }
|
||||
void jsonRequest(`/api/image-video/tasks/${taskId}`, { signal: controller.signal }).then(result => {
|
||||
if (!isImageVideoTask(result.task) || result.task.id !== taskId) throw new Error("Task mismatch");
|
||||
if (!controller.signal.aborted) { setTask(result.task); onReady(true); }
|
||||
}).catch(() => { if (!controller.signal.aborted) setError(true); });
|
||||
return () => controller.abort();
|
||||
}, [taskId, onReady]);
|
||||
if (error) return <p role="alert">无法读取本次冻结参数,审批已禁用。请拒绝并核实任务。</p>;
|
||||
return task ? <ImageVideoTaskCard task={task} /> : <p role="status">读取本次参考图和冻结参数…</p>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApprovalDialog } from "../src/components/approvals/ApprovalDialog.js";
|
||||
import { ImageVideoPanel, ImageVideoTaskCard } from "../src/components/tools/ImageVideoPanel.js";
|
||||
|
||||
const id = "i2v_11111111-1111-1111-1111-111111111111";
|
||||
const task = { id, title: "Window", prompt: "Turn naturally toward the window; ambient rain", durationSeconds: 10, state: "queued", referenceUrl: `/api/image-video/tasks/${id}/reference`, createdAt: "2026-09-15T00:00:00Z", updatedAt: "2026-09-15T00:00:00Z" };
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||
|
||||
describe("image video UI", () => {
|
||||
it("restores queued tasks, polls at 30 seconds, and cleans up after unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetcher = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ tasks: [task] }))));
|
||||
vi.stubGlobal("fetch", fetcher); const view = render(<ImageVideoPanel />);
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
expect(screen.getByText("Window")).toBeVisible(); expect(screen.getByText(/排队中/)).toBeVisible();
|
||||
await act(() => vi.advanceTimersByTimeAsync(29_999)); expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
await act(() => vi.advanceTimersByTimeAsync(1)); expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
view.unmount(); await act(() => vi.advanceTimersByTimeAsync(60_000)); expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("creates a draft from a local image without needing model image capability", async () => {
|
||||
const onDraft = vi.fn(); const requests: { url: string; body: unknown }[] = [];
|
||||
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => { requests.push({ url, body: init?.body }); return Promise.resolve(new Response(JSON.stringify(url.includes("attachments") ? { attachment: { attachmentId: "att_local" } } : init?.method === "POST" ? { task } : { tasks: [] }))); });
|
||||
render(<ImageVideoPanel sessionId="ses_1" onDraft={onDraft} />); const user = userEvent.setup();
|
||||
await user.upload(screen.getByLabelText("参考图片"), new File(["test"], "test.png", { type: "image/png" }));
|
||||
await user.type(screen.getByLabelText("标题"), "Window"); await user.type(screen.getByLabelText("动作与声音"), task.prompt);
|
||||
await user.click(screen.getByRole("button", { name: "准备本地任务" }));
|
||||
await waitFor(() => expect(onDraft).toHaveBeenCalledWith(expect.objectContaining({ id })));
|
||||
expect(requests.some(request => request.url.includes("/sessions/ses_1/attachments"))).toBe(true);
|
||||
expect(requests.filter(request => request.url.endsWith("/image-video/tasks") && typeof request.body === "string")[0]?.body).toContain('"attachmentId":"att_local"');
|
||||
expect(requests.some(request => /submit|upload_reference/.test(request.url))).toBe(false);
|
||||
});
|
||||
it("renders a native player/download only for this registered task video", () => {
|
||||
const view = render(<ImageVideoTaskCard task={{ ...task, state: "ready", videoUrl: `/api/image-video/tasks/${id}/video` }} />);
|
||||
expect(screen.getByLabelText("生成预览播放器")).toHaveAttribute("controls");
|
||||
expect(screen.getByRole("link", { name: "下载 MP4" })).toHaveAttribute("href", `/api/image-video/tasks/${id}/video?download=1`);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("待视听检查");
|
||||
view.rerender(<ImageVideoTaskCard task={{ ...task, state: "ready", videoUrl: "https://evil.example/video" }} />);
|
||||
expect(screen.queryByRole("link", { name: "下载 MP4" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("requires an unchecked confirmation after showing the frozen reference/prompt/duration", async () => {
|
||||
vi.stubGlobal("fetch", () => Promise.resolve(new Response(JSON.stringify({ task }))));
|
||||
const approve = vi.fn();
|
||||
render(<ApprovalDialog request={{ approvalId: "ap_i2v", toolCallId: "tc", toolName: "i2v_submit", arguments: { taskId: id }, riskLevel: "medium", expiresAt: "2099-01-01T00:00:00Z", status: "pending" }} onApprove={approve} onDeny={vi.fn()} />);
|
||||
expect(await screen.findByText(task.prompt)).toBeVisible();
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", task.referenceUrl);
|
||||
expect(screen.getByText(/10 秒/)).toBeVisible(); expect(screen.getByText(/费用未知/)).toBeVisible();
|
||||
const button = screen.getByRole("button", { name: "批准 H3 生成" }); expect(button).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("checkbox")); expect(button).toBeEnabled();
|
||||
await userEvent.click(button); expect(approve).toHaveBeenCalledWith("ap_i2v");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user