feat: add durable CMS-free image video subsystem and UI components

This commit is contained in:
Wenxin Duan
2026-09-15 01:04:09 +08:00
parent 9349317eb8
commit 40e03e393c
14 changed files with 795 additions and 0 deletions
@@ -0,0 +1,29 @@
import { z } from "zod";
export const ImageVideoDraftSchema = z.object({ attachmentId: z.string().regex(/^att_[\w-]+$/), title: z.string().trim().min(1).max(120), prompt: z.string().trim().min(1).max(4000), durationSeconds: z.number().positive().max(60) }).strict();
export type ImageVideoDraft = z.infer<typeof ImageVideoDraftSchema>;
export const taskIdSchema = z.string().regex(/^i2v_[0-9a-f-]{36}$/);
export const validationSchema = z.object({ videoSeconds: z.number().positive(), audioSeconds: z.number().positive(), width: z.number().int().positive(), height: z.number().int().positive(), fps: z.number().positive(), decoded: z.literal(true), sha256: z.string().regex(/^[a-f0-9]{64}$/), visualReview: z.literal("pending"), audioReview: z.literal("pending") }).strict();
export type ImageVideoValidation = z.infer<typeof validationSchema>;
export const recordSchema = z.object({
version: z.literal(1), id: taskIdSchema, ownerSessionId: z.string().regex(/^ses_[\w-]+$/), revision: z.number().int().nonnegative(),
draft: ImageVideoDraftSchema, requestHash: z.string().regex(/^[a-f0-9]{64}$/), referenceHash: z.string().regex(/^[a-f0-9]{64}$/), referenceType: z.enum(["image/jpeg", "image/png", "image/webp"]),
state: z.enum(["draft", "initializing_upload", "uploading", "finalizing", "importing", "awaiting_submission", "submitting", "queued", "running", "validating", "ready", "failed", "unknown_submission", "unknown_upload"]),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), lastPollAt: z.number().optional(),
identity: z.object({ tenantId: z.string().min(1), platform: z.string().min(1) }).strict().optional(),
upload: z.object({ uploadId: z.string().min(1), objectKey: z.string().min(1), uploadUrl: z.string().url() }).strict().optional(),
assetId: z.string().min(1).optional(), jobId: z.string().min(1).max(200).optional(), artifactUrl: z.string().url().optional(),
error: z.string().max(500).optional(), validation: validationSchema.optional(),
}).strict();
export type ImageVideoRecord = z.infer<typeof recordSchema>;
export type ImageVideoPublicTask = { id: string; title: string; prompt: string; durationSeconds: number; state: string; createdAt: string; updatedAt: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: ImageVideoValidation };
export interface ImageVideoRemote {
preflight(): Promise<{ tenantId: string; platform: string }>;
initialize(record: ImageVideoRecord, bytes: Buffer): Promise<{ uploadId: string; objectKey: string; uploadUrl: string }>;
put(record: ImageVideoRecord, bytes: Buffer): Promise<void>;
finalize(record: ImageVideoRecord): Promise<void>;
importStatus(record: ImageVideoRecord): Promise<{ state: "pending" | "ready" | "failed"; assetId?: string }>;
submit(record: ImageVideoRecord): Promise<{ jobId: string }>;
status(record: ImageVideoRecord): Promise<{ state: "queued" | "running" | "succeeded" | "failed"; artifactUrl?: string }>;
}
export const IMAGE_VIDEO_RESOURCES = ["references/selection.md", "references/swads.md", "references/local-processing.md", "scripts/media.py"];
+108
View File
@@ -0,0 +1,108 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { z } from "zod";
import { Check } from "typebox/value";
import type { TSchema } from "typebox";
import { HarnessError } from "../errors/harness-error.js";
import { SWADS_MCP_URL } from "../tools/swads-mcp.js";
import type { ImageVideoRecord, ImageVideoRemote } from "./contracts.js";
import { imageVideoTransfer, validateImageVideoUrl } from "./media.js";
const names = ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"] as const;
const nonempty = z.string().min(1).max(2048);
export function assertImageVideoArguments(schema: Record<string, unknown>, args: Record<string, unknown>): void {
const properties = z.record(z.string(), z.unknown()).parse(schema.properties ?? {});
if (Object.keys(args).some(key => !(key in properties)) || !Check(schema as TSchema, args)) throw new HarnessError("I2V_SCHEMA_CHANGED", "SW Ads request schema changed; operation blocked");
}
export function imageVideoResponse(value: unknown): Record<string, unknown> {
try {
const envelope = z.object({ isError: z.boolean().optional(), structuredContent: z.unknown().optional(), content: z.array(z.object({ type: z.string(), text: z.string().optional() })).optional() }).parse(value);
if (envelope.isError) throw new Error("Remote error");
const texts = envelope.content?.filter(part => part.type === "text");
const result = envelope.structuredContent ?? (texts?.length === 1 && texts[0]?.text ? JSON.parse(texts[0].text) as unknown : undefined);
return z.record(z.string(), z.unknown()).parse(result);
} catch { throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "SW Ads response contract is unclear; retain the task and inspect its receipt"); }
}
/** Dedicated capability set. The analytics client's allowlist is never broadened. */
export class ImageVideoMcpRemote implements ImageVideoRemote {
#connection: Promise<Client> | undefined;
#schemas = new Map<string, Record<string, unknown>>();
constructor(private readonly options: { token: string }) {}
private async connect() {
if (!this.options.token.trim()) throw new HarnessError("SWADS_NOT_CONFIGURED", "SWADS_MCP_TOKEN is not configured on the server");
this.#connection ??= (async () => {
const client = new Client({ name: "agent-studio-image-video", version: "1.0.0" });
try { await client.connect(new StreamableHTTPClientTransport(new URL(SWADS_MCP_URL), { requestInit: { redirect: "error", headers: { Authorization: `Bearer ${this.options.token}` } } }) as Transport, { timeout: 20_000 }); return client; }
catch { await client.close().catch(() => undefined); this.#connection = undefined; throw new HarnessError("I2V_CONNECTION_FAILED", "SW Ads connection failed"); }
})();
return this.#connection;
}
async close() { const client = await this.#connection?.catch(() => undefined); this.#connection = undefined; await client?.close(); }
async preflight() {
const client = await this.connect(); this.#schemas.clear();
let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : {}, { timeout: 20_000 });
for (const tool of page.tools) if (names.some(name => name === tool.name)) this.#schemas.set(tool.name, tool.inputSchema);
cursor = page.nextCursor;
} while (cursor);
for (const name of names) if (!this.#schemas.has(name)) throw new HarnessError("I2V_CAPABILITY_MISSING", `Required capability unavailable: ${name}`);
const identity = z.object({ access: z.object({ current_account: z.object({ tenant_id: nonempty, platform: nonempty }) }) }).safeParse(await this.call("swads_whoami", {}));
if (!identity.success) throw new HarnessError("I2V_IDENTITY_AMBIGUOUS", "Current MCP tenant/platform is not unambiguous; upload blocked");
return { tenantId: identity.data.access.current_account.tenant_id, platform: identity.data.access.current_account.platform };
}
private async call(name: typeof names[number], args: Record<string, unknown>): Promise<Record<string, unknown>> {
if (!names.includes(name)) throw new HarnessError("I2V_TOOL_DENIED", "Tool unavailable");
const schema = this.#schemas.get(name);
// Status collection after restart discovers the same live capability set.
if (!schema) { await this.preflight(); return this.call(name, args); }
assertImageVideoArguments(schema, args);
try {
const result = await (await this.connect()).callTool({ name, arguments: args }, undefined, { timeout: 60_000 });
return imageVideoResponse(result);
} catch { throw new HarnessError("I2V_REMOTE_FAILED", "SW Ads request failed or its response is unsupported; inspect saved task state"); }
}
private uploadArgs(r: ImageVideoRecord) {
if (!r.identity) throw new HarnessError("I2V_IDENTITY_AMBIGUOUS", "Missing approved upload identity");
return { tenant_id: r.identity.tenantId, platforms: [r.identity.platform], platform: r.identity.platform, media_kind: "image", review_status: "review_pending", asset_role: "reference" };
}
async initialize(r: ImageVideoRecord, bytes: Buffer) {
const ext = r.referenceType.split("/")[1];
const receipt = z.object({ upload_id: nonempty, object_key: nonempty, upload_url: z.string().url() }).parse(await this.call("creative_upload_from_url", { ...this.uploadArgs(r), file_name: `${r.id}.${ext}`, content_type: r.referenceType, size_bytes: bytes.length, content_sha256: r.referenceHash }));
return { uploadId: receipt.upload_id, objectKey: receipt.object_key, uploadUrl: receipt.upload_url };
}
async put(r: ImageVideoRecord, bytes: Buffer) { if (!r.upload) throw new Error("Missing receipt"); await imageVideoTransfer(r.upload.uploadUrl, { bytes, contentType: r.referenceType }); }
async finalize(r: ImageVideoRecord) {
if (!r.upload) throw new Error("Missing receipt");
const result = await this.call("creative_upload_from_url", { ...this.uploadArgs(r), upload_id: r.upload.uploadId, object_key: r.upload.objectKey });
if (result.status !== "finalizing") throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Finalization receipt is unclear; poll the saved upload ID");
}
async importStatus(r: ImageVideoRecord) {
if (!r.upload) throw new Error("Missing receipt");
const value = await this.call("creative_import_status", { upload_id: r.upload.uploadId });
if (typeof value.creative_factory_asset_id === "string" && value.creative_factory_asset_id) return { state: "ready" as const, assetId: value.creative_factory_asset_id };
if (value.status === "failed") return { state: "failed" as const };
if (["pending", "finalizing", "processing"].includes(String(value.status))) return { state: "pending" as const };
throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Import response has no unambiguous asset receipt");
}
async submit(r: ImageVideoRecord) {
if (!r.assetId) throw new Error("Missing reference asset");
const result = z.object({ job_id: nonempty }).parse(await this.call("video_h3_submit", { prompt: r.draft.prompt, title: r.draft.title, reference_asset_ids: [r.assetId], aspect_ratio: "9:16", duration_seconds: r.draft.durationSeconds, resolution: "768p", h3_mode: "ref2va", idempotency_key: `${r.id}/${r.requestHash}`, skill_id: "swads-image-to-video" }));
return { jobId: result.job_id };
}
async status(r: ImageVideoRecord) {
if (!r.jobId) throw new Error("Missing job");
const value = await this.call("video_job_status", { job_id: r.jobId });
if (value.job_id !== undefined && value.job_id !== r.jobId) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Returned job does not match registered job");
if (["pending", "queued"].includes(String(value.status))) return { state: "queued" as const };
if (["running", "processing"].includes(String(value.status))) return { state: "running" as const };
if (["failed", "rejected", "cancelled"].includes(String(value.status))) return { state: "failed" as const };
if (!["succeeded", "completed", "success"].includes(String(value.status))) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Unknown generation state");
const artifacts = z.array(z.object({ content_type: z.string(), url: z.string().url() })).parse(value.artifacts).filter(artifact => artifact.content_type === "video/mp4");
if (artifacts.length !== 1 || !artifacts[0]) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Expected one registered MP4 artifact");
await validateImageVideoUrl(artifacts[0].url);
return { state: "succeeded" as const, artifactUrl: artifacts[0].url };
}
}
+75
View File
@@ -0,0 +1,75 @@
import { request } from "node:https";
import { isIP } from "node:net";
import { readFile, writeFile, rename } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import { HarnessError } from "../errors/harness-error.js";
import { validateBaseUrl, type AddressResolver } from "../models/base-url-policy.js";
import { mediaRuntime, runMedia } from "../media/media-worker.js";
import { validationSchema } from "./contracts.js";
import { sha256 } from "./service.js";
export async function validateImageVideoUrl(value: string, resolver?: AddressResolver) {
let url: URL;
try { url = new URL(value); } catch { throw new HarnessError("I2V_URL_DENIED", "Invalid media URL"); }
// The storage provider may require a signed query. Only remove it from host validation.
const hostOnly = new URL(url); hostOnly.search = "";
const checked = await validateBaseUrl(hostOnly.href, {}, resolver);
const address = checked.addresses[0];
if (!address) throw new HarnessError("I2V_URL_DENIED", "No public media address");
return { url, address };
}
/** HTTPS with DNS pinned to the validated address; redirects and oversized bodies fail closed. */
export async function imageVideoTransfer(value: string, options: { bytes?: Buffer; contentType?: string; maxBytes?: number } = {}): Promise<Buffer> {
const { url, address } = await validateImageVideoUrl(value);
return new Promise((resolve, reject) => {
const error = () => new HarnessError("I2V_TRANSFER_FAILED", "Media transfer failed or exceeded its bounds");
const req = request(url, {
family: isIP(address),
method: options.bytes ? "PUT" : "GET",
headers: options.bytes ? { "Content-Type": options.contentType ?? "application/octet-stream", "Content-Length": options.bytes.length } : {},
lookup: (_hostname, _options, callback) => callback(null, address, isIP(address)),
}, response => {
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { response.destroy(); req.destroy(error()); return; }
const limit = options.maxBytes ?? (options.bytes ? 1024 * 1024 : 256 * 1024 * 1024);
if (Number(response.headers["content-length"] ?? 0) > limit) { response.destroy(); req.destroy(error()); return; }
if (!options.bytes && !/^video\/mp4(?:;|$)/i.test(response.headers["content-type"] ?? "")) { response.destroy(); req.destroy(error()); return; }
let total = 0; const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer) => { total += chunk.length; if (total > limit) req.destroy(error()); else chunks.push(chunk); });
response.once("error", () => reject(error()));
response.once("end", () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
});
const timer = setTimeout(() => req.destroy(error()), 120_000);
req.once("error", () => { clearTimeout(timer); reject(error()); });
req.end(options.bytes);
});
}
export function parseImageVideoValidation(value: unknown, duration: number) {
const raw = z.object({ video_seconds: z.number().positive(), audio_seconds: z.number().positive(), width: z.number().positive(), height: z.number().positive(), fps: z.string(), decoded: z.literal(true), sha256: z.string() }).parse(value);
const parts = raw.fps.split("/"); const fps = Number(parts[0]) / Number(parts[1] ?? 1);
if (Math.abs(raw.video_seconds - duration) > 0.002 || Math.abs(raw.audio_seconds - duration) > 0.05 || Math.abs(raw.width / raw.height - 9 / 16) > 0.01 || fps > 120) throw new HarnessError("I2V_VALIDATION_FAILED", "H3 output does not match approved duration or portrait dimensions");
return validationSchema.parse({ videoSeconds: raw.video_seconds, audioSeconds: raw.audio_seconds, width: raw.width, height: raw.height, fps, decoded: true, sha256: raw.sha256, visualReview: "pending", audioReview: "pending" });
}
export async function verifyImageVideo(path: string, directory: string, duration: number, options: { skillRoot: string; python: string }) {
const { ffmpeg, ffprobe } = await mediaRuntime();
// Patch the module object used by the unmodified original Skill. Every subprocess is
// restricted, including ffprobe, full decode and the audio-peak ffmpeg invocation.
const wrapper = `import runpy,sys,subprocess\noriginal=subprocess.run\nallowed=set(sys.argv[2:4])\ndef local_run(command,*args,**kwargs):\n if not isinstance(command,list) or command[0] not in allowed: raise RuntimeError('Unexpected media command')\n return original([command[0],'-protocol_whitelist','file,pipe','-format_whitelist','mov']+command[1:],*args,**kwargs)\nsubprocess.run=local_run\nscript=sys.argv[1]\nsys.argv=[script,'--ffmpeg',sys.argv[2],'--ffprobe',sys.argv[3],'verify','--input',sys.argv[4],'--minimum',sys.argv[5]]\nrunpy.run_path(script,run_name='__main__')`;
const result: unknown = JSON.parse(await runMedia(options.python, ["-c", wrapper, join(options.skillRoot, "scripts/media.py"), ffmpeg, ffprobe, path, String(duration)], directory, new AbortController().signal, 180_000));
return parseImageVideoValidation(result, duration);
}
export function createImageVideoCollector(options: { skillRoot: string; python: string }) {
return async (url: string, directory: string, duration: number) => {
const bytes = await imageVideoTransfer(url);
if (bytes.length < 16 || bytes.subarray(4, 8).toString("ascii") !== "ftyp") throw new HarnessError("I2V_INVALID_MP4", "Artifact is not an MP4 file");
const path = join(directory, "candidate.mp4"); await writeFile(path, bytes, { mode: 0o600 });
const validation = await verifyImageVideo(path, directory, duration, options);
if (validation.sha256 !== sha256(await readFile(path))) throw new HarnessError("I2V_INVALID_MP4", "Validated media changed");
await rename(path, join(directory, "video.mp4"));
return validation;
};
}
+147
View File
@@ -0,0 +1,147 @@
import { createHash, randomUUID } from "node:crypto";
import { lstat, mkdir, open, readFile, readdir, rename, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { AttachmentStore } from "../attachments/attachment-store.js";
import { HarnessError } from "../errors/harness-error.js";
import { ImageVideoDraftSchema, recordSchema, taskIdSchema, validationSchema, type ImageVideoDraft, type ImageVideoPublicTask, type ImageVideoRecord, type ImageVideoRemote, type ImageVideoValidation } from "./contracts.js";
const locks = new Map<string, Promise<unknown>>();
export const sha256 = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
const fail = (code: string, message: string): never => { throw new HarnessError(code, message); };
export type ImageVideoOptions = { root: string; attachments: Pick<AttachmentStore, "get" | "openNormalized">; remote: ImageVideoRemote; collect: (url: string, directory: string, duration: number) => Promise<ImageVideoValidation> };
/** One process owns this private store. Per-task locks are shared by service instances. */
export class ImageVideoService {
readonly root: string;
constructor(private readonly options: ImageVideoOptions) { this.root = resolve(options.root); }
async create(sessionId: string, input: ImageVideoDraft): Promise<ImageVideoPublicTask> {
const draft = ImageVideoDraftSchema.parse(input);
const attachment = this.options.attachments.get(sessionId, draft.attachmentId);
if (!attachment || attachment.sessionId !== sessionId) return fail("I2V_TASK_NOT_FOUND", "Reference image not found in this Session");
const bytes = await this.options.attachments.openNormalized(sessionId, draft.attachmentId);
const now = new Date().toISOString(); const id = `i2v_${randomUUID()}`;
const record = recordSchema.parse({ version: 1, id, ownerSessionId: sessionId, revision: 0, draft, referenceHash: sha256(bytes), requestHash: sha256(JSON.stringify(draft)), referenceType: attachment.mediaType, state: "draft", createdAt: now, updatedAt: now });
await mkdir(join(this.root, id), { recursive: true, mode: 0o700 });
await this.atomic(join(this.root, id, "reference"), bytes);
await this.atomic(join(this.root, id, "record.json"), JSON.stringify(record));
return this.public(record);
}
async list(): Promise<ImageVideoPublicTask[]> {
const entries = await readdir(this.root).catch(() => []);
const tasks = await Promise.all(entries.filter(id => taskIdSchema.safeParse(id).success).map(id => this.view(id).catch(() => undefined)));
return tasks.filter((task): task is ImageVideoPublicTask => task !== undefined).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
async view(id: string): Promise<ImageVideoPublicTask> { return this.lock(id, async () => this.public(await this.read(id))); }
async owned(sessionId: string, id: string): Promise<ImageVideoPublicTask> { return this.lock(id, async () => { const r = await this.read(id); this.owner(r, sessionId); return this.public(r); }); }
async upload(sessionId: string, id: string): Promise<ImageVideoPublicTask> {
return this.lock(id, async () => {
const r = await this.read(id); this.owner(r, sessionId);
if (r.state === "unknown_upload") return fail("I2V_UNKNOWN_UPLOAD", "Upload outcome unknown; inspect the saved receipt before any new import");
if (r.state !== "draft") return this.public(r);
// Read-only discovery and identity validation happen before any remote mutation intent.
r.identity = await this.options.remote.preflight();
const bytes = await this.reference(r);
r.state = "initializing_upload"; await this.save(r);
try {
r.upload = await this.options.remote.initialize(r, bytes); r.state = "uploading"; await this.save(r);
await this.options.remote.put(r, bytes); r.state = "finalizing"; await this.save(r);
await this.options.remote.finalize(r); r.state = "importing"; await this.save(r);
} catch { r.state = "unknown_upload"; r.error = "参考图上传或入库结果未知;禁止重复导入。请核实本任务收据。"; await this.save(r); }
return this.public(r);
});
}
async submit(sessionId: string, id: string): Promise<ImageVideoPublicTask> {
return this.lock(id, async () => {
const r = await this.read(id); this.owner(r, sessionId);
if (r.jobId) return this.public(r);
if (r.state === "unknown_submission") return fail("I2V_UNKNOWN_SUBMISSION", "Submission outcome unknown; no safe retry or new billing request is allowed");
if (r.state !== "awaiting_submission" || !r.assetId) return fail("I2V_NOT_READY", "Reference import must be ready before generation approval");
await this.options.remote.preflight(); await this.reference(r);
r.state = "submitting"; await this.save(r);
try {
// Deliberately independent of chat cancellation: retain any returned receipt.
const receipt = await this.options.remote.submit(r);
if (!receipt.jobId) throw new Error("Missing job ID");
await this.atomic(join(this.root, id, "submission-receipt.json"), JSON.stringify({ version: 1, id, revision: r.revision, requestHash: r.requestHash, jobId: receipt.jobId }));
r.jobId = receipt.jobId; r.state = "queued"; delete r.lastPollAt; await this.save(r);
} catch { r.state = "unknown_submission"; r.error = "生成提交结果未知;禁止重投。请人工核实,避免重复计费。"; await this.save(r); }
return this.public(r);
});
}
async refresh(id: string): Promise<ImageVideoPublicTask> {
return this.lock(id, async () => {
const r = await this.read(id);
if (r.state === "ready" || r.state === "failed" || Date.now() - (r.lastPollAt ?? 0) < 30_000) return this.public(r);
if (!r.jobId && !(r.upload && ["importing", "unknown_upload"].includes(r.state))) return this.public(r);
r.lastPollAt = Date.now(); await this.save(r);
try {
if (!r.jobId) {
const result = await this.options.remote.importStatus(r);
if (result.state === "ready" && result.assetId) { r.assetId = result.assetId; r.state = "awaiting_submission"; delete r.error; }
else if (result.state === "failed") { r.state = "failed"; r.error = "参考图入库失败。"; }
} else {
const result = await this.options.remote.status(r);
if (result.state === "succeeded") {
if (!result.artifactUrl) throw new Error("No unambiguous MP4 artifact");
r.artifactUrl = result.artifactUrl; r.state = "validating"; await this.save(r);
r.validation = validationSchema.parse(await this.options.collect(result.artifactUrl, join(this.root, id), r.draft.durationSeconds));
if (Math.abs(r.validation.videoSeconds - r.draft.durationSeconds) > 0.002 || Math.abs(r.validation.audioSeconds - r.draft.durationSeconds) > 0.05) throw new Error("Duration mismatch");
r.state = "ready"; delete r.error;
} else { r.state = result.state; if (result.state === "failed") r.error = "H3 生成失败;未自动重投。"; }
}
} catch {
if (r.state === "validating") r.state = "failed";
r.error = r.state === "failed" ? "成片技术验证失败:请核实 MP4、精确时长、尺寸、帧率和音轨;未自动重投。" : "状态响应暂不可用或契约不明确;保留原任务,下次只读查询。";
}
await this.save(r); return this.public(r);
});
}
async media(id: string, kind: "reference" | "video") {
return this.lock(id, async () => {
const r = await this.read(id);
if (kind === "video" && (r.state !== "ready" || !r.validation)) return fail("I2V_MEDIA_NOT_FOUND", "Validated video not ready");
const path = join(this.root, id, kind === "reference" ? "reference" : "video.mp4");
await this.regular(path); const bytes = await readFile(path);
if (sha256(bytes) !== (kind === "reference" ? r.referenceHash : r.validation?.sha256)) return fail("I2V_MEDIA_INVALID", "Registered media hash mismatch");
return { path, size: (await stat(path)).size, contentType: kind === "reference" ? r.referenceType : "video/mp4" };
});
}
private owner(r: ImageVideoRecord, sessionId: string) { if (r.ownerSessionId !== sessionId) fail("I2V_TASK_NOT_FOUND", "Task not found in this Session"); }
private public(r: ImageVideoRecord): ImageVideoPublicTask {
return { id: r.id, title: r.draft.title, prompt: r.draft.prompt, durationSeconds: r.draft.durationSeconds, state: r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, referenceUrl: `/api/image-video/tasks/${r.id}/reference`, ...(r.jobId ? { jobId: r.jobId } : {}), ...(r.error ? { error: r.error } : {}), ...(r.validation ? { validation: r.validation } : {}), ...(r.state === "ready" ? { videoUrl: `/api/image-video/tasks/${r.id}/video` } : {}) };
}
private async reference(r: ImageVideoRecord) { await this.regular(join(this.root, r.id, "reference")); const bytes = await readFile(join(this.root, r.id, "reference")); if (sha256(bytes) !== r.referenceHash) fail("I2V_MEDIA_INVALID", "Reference changed after draft"); return bytes; }
private async regular(path: string) { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) fail("I2V_RECORD_INVALID", "Unsafe registered file"); }
private async read(id: string): Promise<ImageVideoRecord> {
if (!taskIdSchema.safeParse(id).success) return fail("I2V_TASK_NOT_FOUND", "Task not found");
const path = join(this.root, id, "record.json");
const dir = await lstat(join(this.root, id)).catch(() => undefined);
if (!dir || !dir.isDirectory() || dir.isSymbolicLink()) return fail("I2V_TASK_NOT_FOUND", "Task not found");
try {
await this.regular(path); const r = recordSchema.parse(JSON.parse(await readFile(path, "utf8")));
if (r.id !== id || r.requestHash !== sha256(JSON.stringify(r.draft))) throw new Error("Binding mismatch");
if (r.state === "submitting") { r.state = "unknown_submission"; await this.save(r); }
if (["initializing_upload", "uploading", "finalizing"].includes(r.state)) { r.state = "unknown_upload"; await this.save(r); }
return r;
} catch { return fail("I2V_RECORD_INVALID", "Task record is invalid; manual recovery required"); }
}
private async save(r: ImageVideoRecord) {
const path = join(this.root, r.id, "record.json");
const previous = recordSchema.parse(JSON.parse(await readFile(path, "utf8")));
if (previous.revision !== r.revision) return fail("I2V_STALE_REVISION", "Stale task result rejected");
r.revision++; r.updatedAt = new Date().toISOString();
await this.atomic(path, JSON.stringify(recordSchema.parse(r)));
}
private async atomic(path: string, content: Buffer | string) { const temp = `${path}.${randomUUID()}.tmp`; const file = await open(temp, "wx", 0o600); try { await file.writeFile(content); await file.sync(); } finally { await file.close(); } await rename(temp, path); }
private async lock<T>(id: string, operation: () => Promise<T>): Promise<T> {
const key = `${this.root}/${id}`; const previous = locks.get(key) ?? Promise.resolve();
const work = previous.catch(() => undefined).then(operation); locks.set(key, work);
try { return await work; } finally { if (locks.get(key) === work) locks.delete(key); }
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import type { ImageVideoService } from "./service.js";
const task = Type.Object({ taskId: Type.String({ pattern: "^i2v_[0-9a-f-]{36}$" }) }, { additionalProperties: false });
export function createImageVideoTools(service: ImageVideoService, sessionId: string) {
const output = (details: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(details) }], details });
return [
defineTool({ name: "i2v_create_task", label: "Prepare local image video draft", description: "Read swads-image-to-video SKILL.md and references first. Copy this Session's existing attachment into a durable local draft. No external transmission. Use the user's exact title, prompt and duration; default portrait 9:16, H3 ref2va,768p. A form-created taskId already exists: do not create it again. Image display does not send it to the chat model. Never require CMS.", parameters: Type.Object({ attachmentId: Type.String(), title: Type.String(), prompt: Type.String(), durationSeconds: Type.Number() }, { additionalProperties: false }), async execute(_id, args) { return output(await service.create(sessionId, args)); } }),
defineTool({ name: "i2v_upload_reference", label: "Approve reference transfer", description: "Separate explicit page approval: transfer this registered draft's frozen image to SW Ads storage and create review_pending, unassigned reference asset. No account assignment or publishing. Arguments accept only taskId. Refusal performs no transfer. Wait for import status before requesting generation approval.", parameters: task, async execute(_id, args) { return output(await service.upload(sessionId, args.taskId)); } }),
defineTool({ name: "i2v_submit", label: "Approve H3 generation", description: "Separate explicit page approval: submit this registered task's exact frozen prompt, reference asset and target duration once to H3 ref2va,9:16,768p. Cost unknown; target duration support unverified. Never modify duration, retry unknown submission, or switch provider. Returns durable jobId immediately; UI polls the original job without an LLM. Technical preview is not human-reviewed.", parameters: task, async execute(_id, args) { return output(await service.submit(sessionId, args.taskId)); } }),
defineTool({ name: "i2v_status", label: "Read registered image video task", description: "Read only this Session's registered task. Poll at most every 30 seconds; preserve queued or unknown state. Does not submit or approve. The webpage restores task history after restart.", parameters: task, async execute(_id, args) { await service.owned(sessionId, args.taskId); return output(await service.refresh(args.taskId)); } }),
];
}