feat: add durable CMS-free image video subsystem and UI components
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { HarnessError, ImageVideoDraftSchema, taskIdSchema } from "@agent-studio/harness";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
export function registerImageVideoRoutes(app: FastifyInstance, dependencies: AppDependencies) {
|
||||
const context = (request: FastifyRequest) => {
|
||||
const host = request.headers.host ?? "";
|
||||
const ports = dependencies.imageVideoPorts ?? [80, 3001, 5173, 4173];
|
||||
let url: URL;
|
||||
try { url = new URL(`http://${host}`); } catch { throw new HarnessError("I2V_ORIGIN_DENIED", "Local origin required"); }
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || !ports.includes(Number(url.port || 80)) || request.headers["sec-fetch-site"] === "cross-site" || (request.headers.origin !== undefined && request.headers.origin !== url.origin)) throw new HarnessError("I2V_ORIGIN_DENIED", "Image video tasks are available only from the local page");
|
||||
if (!dependencies.imageVideo) throw new HarnessError("I2V_TASK_NOT_FOUND", "Image video service unavailable");
|
||||
return dependencies.imageVideo;
|
||||
};
|
||||
app.get("/api/image-video/tasks", async (request, reply) => {
|
||||
const service = context(request); void reply.header("Cache-Control", "no-store");
|
||||
const history = await service.list();
|
||||
return { tasks: await Promise.all(history.map(task => service.refresh(task.id))) };
|
||||
});
|
||||
app.get("/api/image-video/tasks/:taskId", async (request, reply) => {
|
||||
const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params);
|
||||
void reply.header("Cache-Control", "no-store"); return { task: await service.refresh(taskId) };
|
||||
});
|
||||
app.post("/api/sessions/:sessionId/image-video/tasks", async (request, reply) => {
|
||||
const service = context(request); const { sessionId } = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/) }).parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Draft creation requires JSON");
|
||||
void reply.header("Cache-Control", "no-store"); return { task: await service.create(sessionId, ImageVideoDraftSchema.parse(request.body)) };
|
||||
});
|
||||
for (const kind of ["reference", "video"] as const) app.get(`/api/image-video/tasks/:taskId/${kind}`, async (request, reply) => {
|
||||
const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params);
|
||||
const query = z.object({ download: z.literal("1").optional() }).strict().parse(request.query);
|
||||
const media = await service.media(taskId, kind);
|
||||
void reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff").header("Cross-Origin-Resource-Policy", "same-origin").header("Accept-Ranges", "bytes").type(media.contentType);
|
||||
if (query.download === "1") void reply.header("Content-Disposition", `attachment; filename="${taskId}${kind === "video" ? ".mp4" : ".image"}"`);
|
||||
const range = request.headers.range;
|
||||
if (range !== undefined) {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(range); let start = 0; let end = media.size - 1;
|
||||
if (match?.[1]) { start = Number(match[1]); if (match[2]) end = Math.min(Number(match[2]), end); }
|
||||
else if (match?.[2]) start = Math.max(0, media.size - Number(match[2]));
|
||||
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= media.size || end < 0) return reply.code(416).header("Content-Range", `bytes */${String(media.size)}`).send();
|
||||
return reply.code(206).header("Content-Range", `bytes ${String(start)}-${String(end)}/${String(media.size)}`).header("Content-Length", end - start + 1).send(createReadStream(media.path, { start, end }));
|
||||
}
|
||||
return reply.header("Content-Length", media.size).send(createReadStream(media.path));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp, type AppDependencies } from "../src/app.js";
|
||||
import { ImageVideoService, FileAttachmentStore } from "@agent-studio/harness";
|
||||
import type { ImageVideoPublicTask } from "@agent-studio/harness";
|
||||
import { mkdtemp, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "i2v-api-")); await mkdir(join(root, "ses_1"));
|
||||
const attachments = new FileAttachmentStore({ sessionsRoot: root });
|
||||
const image = await attachments.add("ses_1", { name: "pixel.png", mediaType: "image/png", stream: Readable.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAE0lEQVQImWP4z8DwnwGM/zMwAAAf7gP9qS/A4gAAAABJRU5ErkJggg==", "base64")) });
|
||||
const service = new ImageVideoService({ root: join(root, "tasks"), attachments, remote: {} as never, collect: () => Promise.reject(new Error("No external operations permitted")) });
|
||||
const dependencies = { imageVideo: service, sessionRegistry: { get: (id: string) => id === "ses_1" ? { sessionId: id } : undefined, disposeAll: async () => {} }, skillCatalog: { list: () => [] }, modelStore: {}, attachmentStore: attachments, approvalBroker: {} } as unknown as AppDependencies;
|
||||
return { app: buildApp(dependencies), service, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } };
|
||||
}
|
||||
|
||||
describe("image video local API", () => {
|
||||
it("creates locally, persists history independently from Sessions and serves registered reference ranges", async () => {
|
||||
const { app, service, draft } = await fixture();
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/sessions/ses_1/image-video/tasks", payload: draft });
|
||||
expect(response.statusCode).toBe(200); const task = response.json<{ task: ImageVideoPublicTask }>().task;
|
||||
expect(task.state).toBe("draft");
|
||||
expect((await app.inject({ method: "GET", url: "/api/image-video/tasks" })).json<{ tasks: ImageVideoPublicTask[] }>().tasks).toHaveLength(1);
|
||||
const ranged = await app.inject({ method: "GET", url: task.referenceUrl, headers: { range: "bytes=0-3" } });
|
||||
expect(ranged.statusCode).toBe(206); expect(ranged.rawPayload.length).toBe(4); expect(ranged.headers["cache-control"]).toBe("no-store");
|
||||
expect((await app.inject({ method: "GET", url: task.referenceUrl, headers: { range: "bytes=999999-" } })).statusCode).toBe(416);
|
||||
expect((await app.inject({ method: "GET", url: `/api/image-video/tasks/${task.id}/video` })).statusCode).toBe(404);
|
||||
expect((await service.list())[0]?.id).toBe(task.id);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
it("rejects foreign origins, rebinding hosts, unknown sessions, arbitrary media paths and mutation types", async () => {
|
||||
const { app, draft } = await fixture();
|
||||
try {
|
||||
for (const headers of [{ host: "evil.example", origin: "http://evil.example" }, { origin: "https://evil.example" }, { "sec-fetch-site": "cross-site" }]) expect((await app.inject({ method: "GET", url: "/api/image-video/tasks", headers })).statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect((await app.inject({ method: "POST", url: "/api/sessions/ses_other/image-video/tasks", payload: draft })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: "POST", url: "/api/sessions/ses_1/image-video/tasks", payload: "text", headers: { "content-type": "text/plain" } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: "GET", url: "/api/image-video/tasks/secret.env/reference" })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: "POST", url: "/api/sessions/ses_1/image-video/tasks", payload: { ...draft, path: "/etc/passwd" } })).statusCode).toBe(400);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
@@ -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