feat: publish Agent Studio integrations and setup guide
This commit is contained in:
@@ -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 }>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
Reference in New Issue
Block a user