feat: publish Agent Studio integrations and setup guide

This commit is contained in:
Wenxin Duan
2026-09-16 16:32:16 +08:00
parent 2ad089d68a
commit 05e6060490
106 changed files with 4942 additions and 198 deletions
@@ -11,17 +11,18 @@ export const recordSchema = z.object({
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(),
continuations: z.array(z.object({ fromSessionId: z.string().regex(/^ses_[\w-]+$/), toSessionId: z.string().regex(/^ses_[\w-]+$/), revision: z.number().int().nonnegative(), at: z.string().datetime() }).strict()).max(100).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 type ImageVideoPublicTask = { id: string; revision: number; 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 }>;
initialize(record: ImageVideoRecord, bytes: Buffer): Promise<{ uploadId: string; objectKey: string; uploadUrl: string } | { reused: true; assetId: string }>;
put(record: ImageVideoRecord, bytes: Buffer): Promise<void>;
finalize(record: ImageVideoRecord): Promise<void>;
finalize(record: ImageVideoRecord, bytes: Buffer): 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 }>;
+64 -15
View File
@@ -1,4 +1,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createHash, randomUUID } from "node:crypto";
import { lstat, open, rename } from "node:fs/promises";
import { join } from "node:path";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { z } from "zod";
@@ -7,6 +10,7 @@ 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 { taskIdSchema } 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;
@@ -21,15 +25,25 @@ export function imageVideoResponse(value: unknown): Record<string, unknown> {
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"); }
const body = z.record(z.string(), z.unknown()).parse(result);
if (body.error_code !== undefined) {
// Provider errors are sometimes returned as successful MCP text envelopes.
// Never forward arbitrary error details (which can contain credentials).
const code = body.error_code === "invalid_arguments" ? "invalid_arguments" : "remote_error";
throw new HarnessError("I2V_REMOTE_REJECTED", `SW Ads rejected the request (${code})`);
}
return body;
} catch (error) {
if (error instanceof HarnessError) throw error;
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 }) {}
constructor(private readonly options: { token: string; receiptRoot?: 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 () => {
@@ -53,30 +67,57 @@ export class ImageVideoMcpRemote implements ImageVideoRemote {
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>> {
private async call(name: typeof names[number], args: Record<string, unknown>, receipt?: { record: ImageVideoRecord; operation: "initialize" | "finalize" | "submit" }): 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); }
if (!schema) { await this.preflight(); return this.call(name, args, receipt); }
assertImageVideoArguments(schema, args);
try {
const result = await (await this.connect()).callTool({ name, arguments: args }, undefined, { timeout: 60_000 });
if (receipt && this.options.receiptRoot) {
// Retain the exact upstream response BEFORE interpreting it. Only this
// process owner's private task directory receives signed URLs/details.
const r = receipt.record;
const directory = join(this.options.receiptRoot, taskIdSchema.parse(r.id));
const info = await lstat(directory);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe receipt directory");
const path = join(directory, `remote-${receipt.operation}-${randomUUID()}.json`);
const temp = `${path}.tmp`;
const file = await open(temp, "wx", 0o600);
try { await file.writeFile(JSON.stringify({ id: r.id, revision: r.revision, requestHash: r.requestHash, operation: receipt.operation, receivedAt: new Date().toISOString(), response: result })); await file.sync(); }
finally { await file.close(); }
await rename(temp, path);
}
return imageVideoResponse(result);
} catch { throw new HarnessError("I2V_REMOTE_FAILED", "SW Ads request failed or its response is unsupported; inspect saved task state"); }
} catch (error) {
if (error instanceof HarnessError) throw error;
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" };
return { tenant_id: r.identity.tenantId, platforms: [r.identity.platform], platform: r.identity.platform, media_kind: "image", review_status: "review_pending", asset_role: "reference", ratio: "9:16", prompt: r.draft.prompt };
}
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 }));
const body = await this.call("creative_upload_from_url", this.uploadMetadata(r, bytes), { record: r, operation: "initialize" });
if (body.reused === true) {
// Deduplicated uploads have no PUT URL. Accept only an existing asset
// cryptographically bound to the approved local image, never a URL guess.
const file = z.object({ creative_factory_asset_id: nonempty, mime_type: z.literal(r.referenceType), size_bytes: z.literal(bytes.length), attachment_kind: z.literal("image"), asset_metadata: z.object({ content_sha256: z.literal(r.referenceHash) }) }).safeParse(body.reused_file);
if (!file.success) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Reused reference does not match the frozen image");
return { reused: true as const, assetId: file.data.creative_factory_asset_id };
}
const receipt = z.object({ upload_id: nonempty, object_key: nonempty, upload_url: z.string().url() }).parse(body);
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) {
private uploadMetadata(r: ImageVideoRecord, bytes: Buffer) {
return { ...this.uploadArgs(r), file_name: `${r.id}.${r.referenceType.split("/")[1]}`, content_type: r.referenceType, size_bytes: bytes.length, content_sha256: r.referenceHash };
}
async finalize(r: ImageVideoRecord, bytes: Buffer) {
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 });
const result = await this.call("creative_upload_from_url", { ...this.uploadMetadata(r, bytes), upload_id: r.upload.uploadId, object_key: r.upload.objectKey }, { record: r, operation: "finalize" });
if (result.status !== "finalizing") throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Finalization receipt is unclear; poll the saved upload ID");
}
async importStatus(r: ImageVideoRecord) {
@@ -89,7 +130,11 @@ export class ImageVideoMcpRemote implements ImageVideoRemote {
}
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" }));
// The gateway adds a caller namespace within a 128-character limit.
// Keep the full collision-resistant task/request binding in just 68 chars.
// Unknown submissions remain blocked by the service; never re-key a retry.
const key = `i2v:${createHash("sha256").update(`${r.id}/${r.requestHash}`).digest("hex")}`;
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: key, skill_id: "swads-image-to-video" }, { record: r, operation: "submit" }));
return { jobId: result.job_id };
}
async status(r: ImageVideoRecord) {
@@ -100,9 +145,13 @@ export class ImageVideoMcpRemote implements ImageVideoRemote {
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");
const artifacts = z.array(z.object({ content_type: z.string(), job_id: nonempty.optional(), storage_url: z.string().url().nullish(), url: z.string().url().nullish() })).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 };
const artifact = artifacts[0];
if ((artifact.job_id !== undefined && artifact.job_id !== r.jobId) || (artifact.storage_url && artifact.url && artifact.storage_url !== artifact.url)) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Artifact receipt conflicts with registered job or URL");
const artifactUrl = artifact.storage_url ?? artifact.url;
if (!artifactUrl) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Registered MP4 has no storage URL");
await validateImageVideoUrl(artifactUrl);
return { state: "succeeded" as const, artifactUrl };
}
}
+26 -1
View File
@@ -1,6 +1,7 @@
import { request } from "node:https";
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { randomUUID } from "node:crypto";
import { readFile, writeFile, rename } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
@@ -79,13 +80,37 @@ export async function verifyImageVideo(path: string, directory: string, duration
return parseImageVideoValidation(result, duration);
}
/** H3 may return a few extra frames. Trim only a small overrun, never stretch,
* loop, pad missing time or overwrite the provider's original MP4. */
export async function conformImageVideoDuration(source: string, directory: string, duration: number): Promise<string> {
const { ffmpeg, ffprobe } = await mediaRuntime();
const signal = new AbortController().signal;
const probe = z.object({ streams: z.array(z.object({ codec_type: z.string(), duration: z.string().optional(), start_time: z.string().optional(), avg_frame_rate: z.string().optional(), r_frame_rate: z.string().optional() })) }).parse(JSON.parse(await runMedia(ffprobe, ["-v", "error", "-protocol_whitelist", "file,pipe", "-format_whitelist", "mov", "-show_streams", "-of", "json", source], directory, signal)));
const video = probe.streams.filter(s => s.codec_type === "video");
const audio = probe.streams.filter(s => s.codec_type === "audio");
const vd = Number(video[0]?.duration); const ad = Number(audio[0]?.duration);
const [n, d = "1"] = (video[0]?.avg_frame_rate ?? "0").split("/"); const fps = Number(n) / Number(d);
// H3's verified worker rounds 24fps output up to 17k+5 frames:
// 2s -> 56 frames (2.333s), 10s -> 243 frames (10.125s).
// Extend the small-overrun allowance ONLY for that exact native grid result.
const nativeSeconds = (5 + 17 * Math.ceil((Math.max(5, Math.round(duration * 24)) - 5) / 17)) / 24;
const maximum = fps === 24 && Math.abs(vd - nativeSeconds) <= 0.002 ? Math.max(duration + 0.25, nativeSeconds + 0.002) : duration + 0.25;
if (video.length !== 1 || audio.length !== 1 || !Number.isFinite(duration) || duration <= 0 || !Number.isFinite(vd) || !Number.isFinite(ad) || !Number.isFinite(fps) || fps <= 0 || fps > 120 || video[0]?.avg_frame_rate !== video[0]?.r_frame_rate || Math.abs(Number(video[0]?.start_time)) > 0.002 || Math.abs(Number(audio[0]?.start_time)) > 0.05 || vd < duration - 0.002 || ad < duration - 0.002 || vd > maximum || ad > maximum || Math.abs(duration * fps - Math.round(duration * fps)) > 0.002) throw new HarnessError("I2V_VALIDATION_FAILED", "Output duration cannot be safely conformed without changing speed or adding content");
if (Math.abs(vd - duration) <= 0.002 && Math.abs(ad - duration) <= 0.05) return source;
const output = join(directory, `conformed-${randomUUID()}.mp4`);
await runMedia(ffmpeg, ["-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe", "-format_whitelist", "mov", "-i", source, "-map", "0:v:0", "-map", "0:a:0", "-vf", `trim=duration=${duration},setpts=PTS-STARTPTS`, "-af", `atrim=duration=${duration},asetpts=PTS-STARTPTS`, "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-n", output], directory, signal);
return output;
}
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 localMediaStorage(writeFile(path, bytes, { mode: 0o600 }));
const source = join(directory, "candidate.mp4"); await localMediaStorage(writeFile(source, bytes, { mode: 0o600 }));
const path = await conformImageVideoDuration(source, directory, duration);
const validation = await verifyImageVideo(path, directory, duration, options);
if (validation.sha256 !== sha256(await localMediaStorage(readFile(path)))) throw new HarnessError("I2V_INVALID_MP4", "Validated media changed");
await localMediaStorage(writeFile(join(directory, "artifact-provenance.json"), JSON.stringify({ sourceSha256: sha256(bytes), outputSha256: validation.sha256, durationSeconds: duration, operation: source === path ? "unchanged" : "trim-small-tail-overrun-original-speed", checkedAt: new Date().toISOString() }), { mode: 0o600 }));
await localMediaStorage(rename(path, join(directory, "video.mp4")));
return validation;
};
+37 -5
View File
@@ -9,6 +9,10 @@ const locks = new Map<string, Promise<unknown>>();
const submissionReceiptSchema = recordSchema.pick({ version: true, id: true, revision: true, requestHash: true, jobId: true }).required().strict();
export const sha256 = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
const fail = (code: string, message: string): never => { throw new HarnessError(code, message); };
function safeRemoteCode(error: unknown): string {
const codes = ["I2V_REMOTE_REJECTED", "I2V_RESPONSE_UNSUPPORTED", "I2V_REMOTE_FAILED", "I2V_SCHEMA_CHANGED", "I2V_TRANSFER_TRANSIENT"];
return error instanceof HarnessError && codes.includes(error.code) ? error.code : "I2V_OPERATION_FAILED";
}
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. */
@@ -37,6 +41,25 @@ export class ImageVideoService {
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); }); }
/** Local-page confirmation only. Not exposed to the model as a tool. */
async continueTask(sessionId: string, id: string, expectedRevision: number, ownerIsActive: (sessionId: string) => boolean, requireTargetSession: () => void): Promise<ImageVideoPublicTask> {
return this.lock(id, async () => {
const r = await this.read(id);
requireTargetSession();
if (r.revision !== expectedRevision) return fail("I2V_STALE_REVISION", "任务已更新,请重新核对后接续。");
if (!["draft", "awaiting_submission"].includes(r.state) || r.jobId || (r.state === "awaiting_submission" && !r.assetId)) return fail("I2V_CONTINUE_BLOCKED", "当前状态不允许接续;不得重置未知结果或重新生成已有任务。");
if (r.ownerSessionId === sessionId) return this.public(r);
if (ownerIsActive(r.ownerSessionId)) return fail("I2V_OWNER_ACTIVE", "原会话仍有效,请回原页面继续,不能由其他会话接管。");
if ((r.continuations?.length ?? 0) >= 100) return fail("I2V_CONTINUE_BLOCKED", "接续记录已达上限,请人工核实。");
await this.reference(r);
requireTargetSession();
r.continuations = [...(r.continuations ?? []), { fromSessionId: r.ownerSessionId, toSessionId: sessionId, revision: r.revision, at: new Date().toISOString() }];
r.ownerSessionId = sessionId;
await this.save(r);
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);
@@ -47,10 +70,19 @@ export class ImageVideoService {
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);
const initialized = await this.options.remote.initialize(r, bytes);
if ("reused" in initialized) {
r.assetId = initialized.assetId; r.state = "awaiting_submission"; delete r.error; await this.save(r);
return this.public(r);
}
r.upload = initialized; 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); }
await this.options.remote.finalize(r, bytes); r.state = "importing"; await this.save(r);
} catch (error) {
const stage = r.state;
const code = safeRemoteCode(error);
r.state = "unknown_upload"; r.error = `参考图上传或入库未完成(${stage}/${code});禁止重复导入,请核实本任务收据。`; await this.save(r);
}
return this.public(r);
});
}
@@ -69,7 +101,7 @@ export class ImageVideoService {
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); }
} catch (error) { r.state = "unknown_submission"; r.error = `生成提交结果未知(submitting/${safeRemoteCode(error)});禁止重投。请核实私有回执,避免重复计费。`; await this.save(r); }
return this.public(r);
});
}
@@ -118,7 +150,7 @@ export class ImageVideoService {
}
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` } : {}) };
return { id: r.id, revision: r.revision, 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"); }