@xiaohhhh1/canvas-agent 0.4.80 → 0.4.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,7 @@ export declare function flowCCodexWorkerStatus(): {
48
48
  active: number;
49
49
  limit: number;
50
50
  };
51
+ export declare function boundedWorkflowProcessAttempts(value?: number): number;
51
52
  /**
52
53
  * 记录尚未确认 OS exit 的 worker 进程。worker 可以释放给队列,但在对应
53
54
  * barrier 完成前只能等待/失败,绝不能启动替代 app-server。
@@ -70,6 +71,7 @@ export declare function interruptCodexTurn(threadId?: string): Promise<boolean>;
70
71
  export declare function runCodexWorkflowTurn(prompt: string, emit: AgentEmit, options: CodexRunOptions & {
71
72
  timeoutMs: number;
72
73
  attachments?: AgentAttachment[];
74
+ maxProcessAttempts?: number;
73
75
  onWorkerStart?: () => void;
74
76
  onWorkerFinish?: () => void;
75
77
  }): Promise<CodexWorkflowRunResult>;
@@ -12,6 +12,11 @@ export const FLOW_C_CODEX_WORKER_CONCURRENCY = boundedWorkerConcurrency(process.
12
12
  export const FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS = 3;
13
13
  const FLOW_C_CODEX_MIN_START_BUDGET_MS = 5_000;
14
14
  export function flowCCodexWorkerStatus() { return { active: workflowCodexPool.activeCount, limit: FLOW_C_CODEX_WORKER_CONCURRENCY }; }
15
+ export function boundedWorkflowProcessAttempts(value) {
16
+ if (value === undefined)
17
+ return FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS;
18
+ return Math.max(1, Math.min(FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS, Math.floor(Number(value) || 1)));
19
+ }
15
20
  let codexQueue = Promise.resolve();
16
21
  let codexApp = null;
17
22
  let codexAppStart = null;
@@ -88,6 +93,7 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
88
93
  let modelMs = 0;
89
94
  let app;
90
95
  let processAttempts = 0;
96
+ const processMaxAttempts = boundedWorkflowProcessAttempts(options.maxProcessAttempts);
91
97
  let processRecoveryObserved = false;
92
98
  let cleanupConfirmed = true;
93
99
  let files = [];
@@ -136,17 +142,17 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
136
142
  threadStartMs += Date.now() - attemptStartedAt;
137
143
  }
138
144
  }, {
139
- maxAttempts: FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS,
145
+ maxAttempts: processMaxAttempts,
140
146
  backoffMs: (failedAttempt) => Math.max(0, Math.min(failedAttempt === 1 ? 250 : 750, deadlineAt - Date.now())),
141
147
  onRetry: async (error, failedAttempt) => {
142
148
  processRecoveryObserved = true;
143
- cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, `Flow C 本机脚本引擎第 ${failedAttempt}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次进程异常,正在换新进程重试`);
149
+ cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, `Flow C 本机脚本引擎第 ${failedAttempt}/${processMaxAttempts} 次进程异常,正在换新进程重试`);
144
150
  app = undefined;
145
151
  if (!cleanupConfirmed)
146
152
  throw error;
147
153
  if (deadlineAt - Date.now() < FLOW_C_CODEX_MIN_START_BUDGET_MS)
148
154
  throw new CodexWorkflowTimeoutError();
149
- emit("agent_log", { text: `Flow C 本机脚本引擎异常,已回收当前 worker,将进行第 ${failedAttempt + 1}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次尝试(${error.message})` });
155
+ emit("agent_log", { text: `Flow C 本机脚本引擎异常,已回收当前 worker,将进行第 ${failedAttempt + 1}/${processMaxAttempts} 次尝试(${error.message})` });
150
156
  },
151
157
  });
152
158
  return { ok: true, text, timings: { queueWaitMs, threadStartMs, modelMs } };
@@ -167,7 +173,7 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
167
173
  let message = deadlineAfterProcessFailure
168
174
  ? `本机 Codex 脚本引擎进程异常后未能在原 8 分钟截止时间内完成恢复,已停止当前任务:${rawMessage}`
169
175
  : transportFailure
170
- ? `本机 Codex 脚本引擎已自动尝试 ${Math.max(1, processAttempts)}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次仍失败:${rawMessage}`
176
+ ? `本机 Codex 脚本引擎已自动尝试 ${Math.max(1, processAttempts)}/${processMaxAttempts} 次仍失败:${rawMessage}`
171
177
  : timeoutFailure ? "Flow C 脚本执行链路超过 8 分钟,已自动终止当前 worker" : rawMessage;
172
178
  if ((processFailure || timeoutFailure) && app) {
173
179
  cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, "Flow C 脚本执行结束,正在确认当前 worker 已退出", timeoutFailure);
@@ -1,4 +1,5 @@
1
1
  import WebSocket from "ws";
2
+ import { relayLocalRequest } from "./relay-request.js";
2
3
  const DEFAULT_RELAY_URL = "wss://canvas.xiaohhhh1.com/api/agent-relay";
3
4
  const RECONNECT_DELAY_MS = 3_000;
4
5
  const HEARTBEAT_INTERVAL_MS = 15_000;
@@ -78,18 +79,20 @@ export function startRelayBridge(config, options = {}) {
78
79
  const headers = new Headers(message.headers || {});
79
80
  headers.set("x-canvas-agent-token", config.token);
80
81
  const body = message.bodyBase64 ? Buffer.from(message.bodyBase64, "base64") : undefined;
81
- const response = await fetch(target, { method: message.method || "GET", headers, body });
82
- const bytes = Buffer.from(await response.arrayBuffer());
82
+ const response = await relayLocalRequest(target, message.method || "GET", headers, body);
83
83
  send({
84
84
  type: "response",
85
85
  id: message.id,
86
86
  status: response.status,
87
- contentType: response.headers.get("content-type") || "application/octet-stream",
88
- bodyBase64: bytes.toString("base64"),
87
+ contentType: response.contentType,
88
+ bodyBase64: response.bytes.toString("base64"),
89
89
  });
90
90
  }
91
91
  catch (error) {
92
- send({ type: "response", id: message.id, status: 502, contentType: "application/json", bodyBase64: Buffer.from(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "Agent relay request failed" })).toString("base64") });
92
+ const detail = error instanceof Error && error.message.startsWith("本机处理等待超时")
93
+ ? error.message
94
+ : "本机 Agent 请求连接中断;原视频仍保留,请恢复连接后重试。";
95
+ send({ type: "response", id: message.id, status: 502, contentType: "application/json", bodyBase64: Buffer.from(JSON.stringify({ ok: false, error: detail })).toString("base64") });
93
96
  }
94
97
  };
95
98
  const onMessage = (raw) => {
@@ -0,0 +1,6 @@
1
+ export declare function relayRequestTimeoutMs(pathname: string): number;
2
+ export declare function relayLocalRequest(target: URL, method: string, headers: Headers, body?: Buffer, timeoutMs?: number): Promise<{
3
+ status: number;
4
+ contentType: string;
5
+ bytes: Buffer;
6
+ }>;
@@ -0,0 +1,33 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ export function relayRequestTimeoutMs(pathname) {
4
+ if (["/agent/integrations/fastmoss/capture", "/agent/integrations/fastmoss/video-learning/capture"].includes(pathname))
5
+ return 25 * 60_000;
6
+ if (["/agent/video-intelligence/analyze", "/agent/video-intelligence/archive"].includes(pathname))
7
+ return 12 * 60_000;
8
+ return 20_000;
9
+ }
10
+ // Native fetch has a separate five-minute response-header deadline. A longer
11
+ // AbortSignal does not extend it: use Node's HTTP client with one explicit deadline.
12
+ export async function relayLocalRequest(target, method, headers, body, timeoutMs = relayRequestTimeoutMs(target.pathname)) {
13
+ return await new Promise((resolve, reject) => {
14
+ const request = (target.protocol === "https:" ? https : http).request(target, {
15
+ method, headers: Object.fromEntries(headers.entries()),
16
+ }, (response) => {
17
+ const chunks = [];
18
+ response.on("data", (chunk) => chunks.push(chunk));
19
+ response.on("error", reject);
20
+ response.on("end", () => resolve({
21
+ status: response.statusCode || 502,
22
+ contentType: String(response.headers["content-type"] || "application/octet-stream"),
23
+ bytes: Buffer.concat(chunks),
24
+ }));
25
+ response.on("close", () => clearTimeout(timer));
26
+ });
27
+ const timer = setTimeout(() => request.destroy(new Error("本机处理等待超时;任务可能仍在运行,请先查看状态,勿重复提交。")), timeoutMs);
28
+ timer.unref();
29
+ request.on("error", reject);
30
+ request.on("close", () => clearTimeout(timer));
31
+ request.end(body);
32
+ });
33
+ }
@@ -5,9 +5,10 @@ import express from "express";
5
5
  import { runClaudeTurn } from "../agent/claude.js";
6
6
  import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
7
7
  import { CanvasSession } from "../canvas/session.js";
8
- import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, VERSION } from "../config.js";
8
+ import { CONFIG_DIR, DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, VERSION } from "../config.js";
9
9
  import { FastMossIntegration } from "../integrations/fastmoss.js";
10
10
  import { LocalVideoIntelligence } from "../video-intelligence/local-analysis.js";
11
+ import { VideoAnalysisJobs } from "../video-intelligence/jobs.js";
11
12
  import { startRelayBridge } from "../relay-bridge.js";
12
13
  import { logger } from "../utils/logger.js";
13
14
  import { windowsRootExecutable, windowsSystemExecutable } from "../utils/windows.js";
@@ -35,6 +36,7 @@ export function startHttpServer() {
35
36
  const workflows = new WorkflowManager(config, emit);
36
37
  const fastmoss = new FastMossIntegration();
37
38
  const videoIntelligence = new LocalVideoIntelligence(fastmoss);
39
+ const videoAnalysisJobs = new VideoAnalysisJobs(path.join(CONFIG_DIR, "video-analysis-jobs"), (source, onStage) => videoIntelligence.analyze(source, { onStage, timeoutMs: 30 * 60_000 }));
38
40
  let relayStatus = { ready: false };
39
41
  const app = express();
40
42
  app.disable("x-powered-by");
@@ -59,7 +61,7 @@ export function startHttpServer() {
59
61
  return void res.json({});
60
62
  next();
61
63
  });
62
- app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, workflow: workflows.health(), relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
64
+ app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, workflow: workflows.health(), videoAnalysisJobs: { version: 1, active: videoAnalysisJobs.activeCount }, relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
63
65
  app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
64
66
  app.use((req, res, next) => {
65
67
  if (validToken(req, requestUrl(req, config), config.token))
@@ -145,6 +147,13 @@ export function startHttpServer() {
145
147
  app.post("/agent/integrations/fastmoss/video-learning/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.captureLearningVideos(req.body || {}) })));
146
148
  app.post("/agent/video-intelligence/archive", route(async (req, res) => res.json({ ok: true, ...await videoIntelligence.archive(req.body?.source || {}, req.body?.upload || {}) })));
147
149
  app.post("/agent/video-intelligence/analyze", route(async (req, res) => res.json({ ok: true, ...await videoIntelligence.analyze(req.body?.source || req.body || {}) })));
150
+ app.post("/agent/video-intelligence/jobs", route(async (req, res) => res.json({ ok: true, job: await videoAnalysisJobs.start(req.body?.source || {}, String(req.body?.requestId || "")) })));
151
+ app.get("/agent/video-intelligence/jobs/:id", route(async (req, res) => {
152
+ const job = await videoAnalysisJobs.get(routeParam(req.params.id));
153
+ if (!job)
154
+ return res.status(404).json({ ok: false, error: "本机视频分析任务不存在" });
155
+ return res.json({ ok: true, job });
156
+ }));
148
157
  app.post("/agent/integrations/fastmoss/close", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.close() })));
149
158
  app.get("/agent/codex/workspace", (_req, res) => {
150
159
  const workspace = ensureSiteWorkspace(config);
@@ -0,0 +1,32 @@
1
+ import { type LocalVideoLearningSource } from "./local-analysis.js";
2
+ type Result = {
3
+ analysis: Record<string, unknown>;
4
+ evidence: Record<string, unknown>;
5
+ };
6
+ type Job = {
7
+ id: string;
8
+ attemptId: string;
9
+ requestId: string;
10
+ status: "running" | "completed" | "failed";
11
+ stage: string;
12
+ updatedAt: string;
13
+ result?: Result;
14
+ error?: string;
15
+ };
16
+ type Analyze = (source: LocalVideoLearningSource, stage: (value: string) => Promise<void>) => Promise<Result>;
17
+ export declare class VideoAnalysisJobs {
18
+ private directory;
19
+ private analyze;
20
+ private active;
21
+ private locks;
22
+ constructor(directory: string, analyze: Analyze);
23
+ get activeCount(): number;
24
+ start(source: LocalVideoLearningSource & {
25
+ archived_sha256?: string;
26
+ archivedSha256?: string;
27
+ }, requestId: string): Promise<Job>;
28
+ get(id: string): Promise<Job | null>;
29
+ private run;
30
+ private save;
31
+ }
32
+ export {};
@@ -0,0 +1,89 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { localVideoAnalysisPrompt } from "./local-analysis.js";
5
+ export class VideoAnalysisJobs {
6
+ directory;
7
+ analyze;
8
+ active = new Map();
9
+ locks = new Map();
10
+ constructor(directory, analyze) {
11
+ this.directory = directory;
12
+ this.analyze = analyze;
13
+ }
14
+ get activeCount() { return this.active.size; }
15
+ async start(source, requestId) {
16
+ if (!requestId || requestId.length > 100)
17
+ throw new Error("视频分析请求标识无效");
18
+ const identity = source.archived_sha256 || source.archivedSha256 || source.sourceUrl || source.source_url;
19
+ if (!source.id || !identity)
20
+ throw new Error("视频分析缺少稳定原片身份,请刷新后重试");
21
+ const id = createHash("sha256").update(JSON.stringify([source.id, identity, localVideoAnalysisPrompt(source, 0, "", [])])).digest("hex");
22
+ // Serialize admission for the same source, including network replay.
23
+ const previous = this.locks.get(id) || Promise.resolve();
24
+ const admission = previous.catch(() => undefined).then(async () => {
25
+ const existing = await this.get(id);
26
+ if (existing && (existing.status === "completed" || this.active.has(id) || existing.requestId === requestId))
27
+ return existing;
28
+ const job = { id, attemptId: randomUUID(), requestId, status: "running", stage: "读取原片与抽帧", updatedAt: new Date().toISOString() };
29
+ await this.save(job);
30
+ const task = this.run(job, source).catch(() => undefined).finally(() => this.active.delete(id));
31
+ this.active.set(id, task);
32
+ return { ...job };
33
+ });
34
+ this.locks.set(id, admission);
35
+ try {
36
+ return await admission;
37
+ }
38
+ finally {
39
+ if (this.locks.get(id) === admission)
40
+ this.locks.delete(id);
41
+ }
42
+ }
43
+ async get(id) {
44
+ if (!/^[a-f0-9]{64}$/.test(id))
45
+ throw new Error("视频分析任务标识无效");
46
+ let job;
47
+ try {
48
+ job = JSON.parse(await readFile(path.join(this.directory, id + ".json"), "utf8"));
49
+ }
50
+ catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return null;
53
+ throw error;
54
+ }
55
+ if (job.status === "running" && !this.active.has(id)) {
56
+ job = { ...job, status: "failed", error: "本机程序曾中断,原视频保留;点击重试可重新分析。", stage: "等待手动重试" };
57
+ await this.save(job);
58
+ }
59
+ return job;
60
+ }
61
+ async run(job, source) {
62
+ try {
63
+ const result = await this.analyze(source, async (stage) => {
64
+ job.stage = stage;
65
+ await this.save(job);
66
+ });
67
+ // Persist before the browser can import the result. A lost response
68
+ // or refreshed page reads the same output without another model call.
69
+ job.result = result;
70
+ job.status = "completed";
71
+ job.stage = "分析已保存,等待网页回传";
72
+ }
73
+ catch (error) {
74
+ job.status = "failed";
75
+ const detail = error instanceof Error ? error.message : "本机视频分析失败";
76
+ job.error = detail === "fetch failed" ? `${job.stage}时网络连接失败;原片保留,请恢复网络后重试。` : detail;
77
+ job.stage = "分析失败,可手动重试";
78
+ }
79
+ await this.save(job);
80
+ }
81
+ async save(job) {
82
+ job.updatedAt = new Date().toISOString();
83
+ await mkdir(this.directory, { recursive: true });
84
+ const file = path.join(this.directory, job.id + ".json");
85
+ const temporary = file + "." + randomUUID() + ".tmp";
86
+ await writeFile(temporary, JSON.stringify(job), { mode: 0o600 });
87
+ await rename(temporary, file);
88
+ }
89
+ }
@@ -53,7 +53,10 @@ export declare class LocalVideoIntelligence {
53
53
  durationMs: number;
54
54
  };
55
55
  }>;
56
- analyze(source: LocalVideoLearningSource): Promise<{
56
+ analyze(source: LocalVideoLearningSource, options?: {
57
+ onStage?: (stage: string) => Promise<void>;
58
+ timeoutMs?: number;
59
+ }): Promise<{
57
60
  analysis: Record<string, unknown>;
58
61
  evidence: {
59
62
  durationMs: number;
@@ -100,4 +103,9 @@ export declare function assertVideoMediaProbe(parsed: VideoProbeResult): {
100
103
  };
101
104
  export declare function transcribeLocalMedia(videoFile: string | undefined, workDir: string): Promise<string>;
102
105
  export declare function resolveLocalCodexEntrypoint(): string;
106
+ /** CLI stderr can contain the prompt and unrelated MCP logs; never return it to the website. */
107
+ export declare function localCodexAnalysisError(result: {
108
+ stdout: string;
109
+ error: string;
110
+ }): "本机 Codex 视频理解失败:Agent 自带的 Codex 版本过旧,当前模型需要更新运行程序。原视频已保存,更新后可直接重试反推,无需重新上传。" | "本机 Codex 视频理解失败:当前账号的模型额度或请求频率受限,请待额度恢复后重试。原视频已保存,无需重新上传。" | "本机 Codex 视频理解失败:本机 Codex 登录已失效,请恢复登录后重试。原视频已保存,无需重新上传。" | "本机 Codex 视频理解超时,原视频已保存,可直接重试反推,无需重新上传。" | "本机 Codex 未完成视频理解,原视频已保存,可直接重试反推。若持续失败,请检查本机 Codex 运行状态。";
103
111
  export {};
@@ -60,7 +60,7 @@ export class LocalVideoIntelligence {
60
60
  await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
61
61
  }
62
62
  }
63
- async analyze(source) {
63
+ async analyze(source, options) {
64
64
  const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
65
65
  const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
66
66
  const hasArchivedMedia = Boolean(archivedVideoUrl);
@@ -74,6 +74,7 @@ export class LocalVideoIntelligence {
74
74
  let transcript = archivedTranscript;
75
75
  let transcriptSource = archivedTranscript ? "website-archive-caption" : "";
76
76
  if (!transcript && hasArchivedMedia) {
77
+ await options?.onStage?.("识别原片口播");
77
78
  transcript = await transcribeLocalMedia("videoFile" in visualEvidence && typeof visualEvidence.videoFile === "string" ? visualEvidence.videoFile : undefined, workDir);
78
79
  transcriptSource = transcript ? `local-asr:${LOCAL_ASR_MODEL}` : "local-asr:no-speech-detected";
79
80
  }
@@ -85,7 +86,8 @@ export class LocalVideoIntelligence {
85
86
  throw new Error("没有取得任何真实视频画面,已停止分析");
86
87
  const attachments = await materializeFrames(visualEvidence.frames, workDir);
87
88
  const prompt = localVideoAnalysisPrompt(source, visualEvidence.durationMs, transcript, attachments);
88
- const analysis = await runLocalCodexAnalysis(prompt, attachments, workDir);
89
+ await options?.onStage?.("Codex 正在对齐画面与口播,生成详细蓝图");
90
+ const analysis = await runLocalCodexAnalysis(prompt, attachments, workDir, options?.timeoutMs);
89
91
  assertJointVisualAndSpokenEvidence(analysis);
90
92
  return {
91
93
  analysis,
@@ -485,19 +487,19 @@ function transcriptPreference(file) {
485
487
  return 3;
486
488
  return 1;
487
489
  }
488
- async function runLocalCodexAnalysis(prompt, attachments, workDir) {
490
+ async function runLocalCodexAnalysis(prompt, attachments, workDir, timeoutMs = ANALYSIS_TIMEOUT_MS) {
489
491
  const outputFile = path.join(workDir, "analysis.json");
490
492
  // npm hoists dependencies next to the installed package in production, while
491
493
  // local development may keep them at a different ancestor. Resolve from this
492
494
  // module instead of assuming a nested node_modules directory.
493
495
  const codexEntrypoint = resolveLocalCodexEntrypoint();
494
- const args = [codexEntrypoint, "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", "--output-last-message", outputFile];
496
+ const args = [codexEntrypoint, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", "--output-last-message", outputFile];
495
497
  for (const attachment of attachments)
496
498
  args.push("--image", path.join(workDir, String(attachment.name)));
497
499
  args.push("-");
498
- const result = await runProcess(process.execPath, args, { cwd: workDir, timeoutMs: ANALYSIS_TIMEOUT_MS, stdin: prompt });
500
+ const result = await runProcess(process.execPath, args, { cwd: workDir, timeoutMs, stdin: prompt });
499
501
  if (!result.ok)
500
- throw new Error(`本机 Codex 视频理解失败:${result.error}`);
502
+ throw new Error(localCodexAnalysisError(result));
501
503
  const raw = (await readFile(outputFile, "utf8")).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
502
504
  try {
503
505
  return JSON.parse(raw);
@@ -509,6 +511,34 @@ async function runLocalCodexAnalysis(prompt, attachments, workDir) {
509
511
  export function resolveLocalCodexEntrypoint() {
510
512
  return createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
511
513
  }
514
+ /** CLI stderr can contain the prompt and unrelated MCP logs; never return it to the website. */
515
+ export function localCodexAnalysisError(result) {
516
+ const events = result.stdout.split(/\r?\n/).flatMap((line) => {
517
+ try {
518
+ const event = JSON.parse(line);
519
+ if (["error", "turn.failed"].includes(String(event.type)))
520
+ return [String(event.error?.message || event.message || "")];
521
+ if (event.item?.type === "error")
522
+ return [String(event.item.message || "")];
523
+ }
524
+ catch { /* Non-event output is not a user-facing diagnostic. */ }
525
+ return [];
526
+ });
527
+ const terminal = events.at(-1) || result.error;
528
+ if (/requires a newer version of Codex|please upgrade to the latest app or CLI/i.test(terminal)) {
529
+ return "本机 Codex 视频理解失败:Agent 自带的 Codex 版本过旧,当前模型需要更新运行程序。原视频已保存,更新后可直接重试反推,无需重新上传。";
530
+ }
531
+ if (/usage limit|rate.?limit|quota exceeded|too many requests/i.test(terminal)) {
532
+ return "本机 Codex 视频理解失败:当前账号的模型额度或请求频率受限,请待额度恢复后重试。原视频已保存,无需重新上传。";
533
+ }
534
+ if (/unauthori[sz]ed|authentication|not logged in|please (?:log|sign) in/i.test(terminal)) {
535
+ return "本机 Codex 视频理解失败:本机 Codex 登录已失效,请恢复登录后重试。原视频已保存,无需重新上传。";
536
+ }
537
+ if (/处理超过 \d+ 秒|timed? ?out|timeout/i.test(terminal)) {
538
+ return "本机 Codex 视频理解超时,原视频已保存,可直接重试反推,无需重新上传。";
539
+ }
540
+ return "本机 Codex 未完成视频理解,原视频已保存,可直接重试反推。若持续失败,请检查本机 Codex 运行状态。";
541
+ }
512
542
  async function runProcess(command, args, options) {
513
543
  return await new Promise((resolve) => {
514
544
  let stdout = "";
@@ -2,6 +2,8 @@ export declare const FLOW_C_CONTENT_STRATEGY_VERSION = "flow-c-content-strategy-
2
2
  export declare const FLOW_C_GENERATED_MONTAGE_VERSION = "flow-c-generated-montage-v1";
3
3
  export declare const FLOW_C_GENERATED_MONTAGE_STYLE = "generated-montage";
4
4
  export declare const FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED = "FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED";
5
+ export declare const FLOW_C_VOICE_PACING_REPAIR_MIN_WORDS_PER_SECOND = 3;
6
+ export declare const FLOW_C_VOICE_PACING_REPAIR_MIN_EXCESS_WORDS = 3;
5
7
  export type FlowCContentStrategy = {
6
8
  contractVersion: typeof FLOW_C_CONTENT_STRATEGY_VERSION;
7
9
  mode: "smart-diverse" | "best-match";
@@ -38,6 +40,9 @@ export type FlowCContentAdvisory = {
38
40
  durationSeconds?: number;
39
41
  matchedOrdinal?: number;
40
42
  };
43
+ export type FlowCVoicePacingRepairIssue = Required<Pick<FlowCContentAdvisory, "ordinal" | "segment" | "shot" | "wordCount" | "suggestedMaxWords" | "durationSeconds">> & {
44
+ code: "voice_pacing";
45
+ };
41
46
  /** Explicit styles are capability-versioned; an unknown pair must never silently fall back. */
42
47
  export declare function flowCGeneratedMontage(value: unknown): boolean;
43
48
  /** A short same-turn writing/review sequence. It never creates another model stage or output field. */
@@ -58,6 +63,59 @@ export declare function flowCContentAdvisories(jobs: unknown, strategy: FlowCCon
58
63
  targetLanguage?: unknown;
59
64
  recentScripts?: unknown;
60
65
  }): FlowCContentAdvisory[];
66
+ /**
67
+ * A deliberately narrower pre-delivery repair trigger than the public pacing
68
+ * advisory. It only covers explicit English/Spanish text that is both very fast
69
+ * and materially over the normal two-words-per-second writing budget.
70
+ */
71
+ export declare function flowCVoicePacingRepairIssues(jobs: unknown, options?: {
72
+ targetLanguage?: unknown;
73
+ }): FlowCVoicePacingRepairIssue[];
74
+ /** Compact, text-bearing repair input derived only from an already validated draft. */
75
+ export declare function flowCVoicePacingRepairScaffold(value: unknown): {
76
+ segments: {
77
+ voiceCue: unknown;
78
+ endingState: {
79
+ [k: string]: unknown;
80
+ };
81
+ shots: {
82
+ [k: string]: unknown;
83
+ }[];
84
+ }[];
85
+ ordinal: unknown;
86
+ productIndex: unknown;
87
+ sellingFormId: unknown;
88
+ voiceProfile: {
89
+ [k: string]: unknown;
90
+ };
91
+ openingState: {
92
+ openingFrame: unknown;
93
+ };
94
+ } | {
95
+ segment: {
96
+ voiceCue: unknown;
97
+ endingState: {
98
+ [k: string]: unknown;
99
+ };
100
+ shots: {
101
+ [k: string]: unknown;
102
+ }[];
103
+ };
104
+ ordinal: unknown;
105
+ productIndex: unknown;
106
+ sellingFormId: unknown;
107
+ voiceProfile: {
108
+ [k: string]: unknown;
109
+ };
110
+ openingState: {
111
+ openingFrame: unknown;
112
+ };
113
+ };
114
+ /** One bounded correction turn; the caller still enforces VO-only projection. */
115
+ export declare function flowCVoicePacingRepairPrompt(jobs: unknown, options?: {
116
+ targetLanguage?: unknown;
117
+ frameworkOrdinals?: readonly number[];
118
+ }): string;
61
119
  export declare function flowCContentMethodPrompt(strategy: FlowCContentStrategy | null, options: {
62
120
  recentScripts?: unknown;
63
121
  productIndexes: number[];
@@ -2,6 +2,8 @@ export const FLOW_C_CONTENT_STRATEGY_VERSION = "flow-c-content-strategy-v1";
2
2
  export const FLOW_C_GENERATED_MONTAGE_VERSION = "flow-c-generated-montage-v1";
3
3
  export const FLOW_C_GENERATED_MONTAGE_STYLE = "generated-montage";
4
4
  export const FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED = "FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED";
5
+ export const FLOW_C_VOICE_PACING_REPAIR_MIN_WORDS_PER_SECOND = 3;
6
+ export const FLOW_C_VOICE_PACING_REPAIR_MIN_EXCESS_WORDS = 3;
5
7
  function object(value) {
6
8
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
7
9
  }
@@ -11,6 +13,22 @@ function text(value, limit) {
11
13
  function summaryText(value, limit) {
12
14
  return text(typeof value === "string" ? value.replace(/https?:\/\/\S+|data:\S+/gi, "[link]") : "", limit);
13
15
  }
16
+ function spokenText(value) {
17
+ const line = text(typeof value === "string" ? value : "", 20_000);
18
+ return /^(?:none|无|sin voz|sin diálogo)$/i.test(line) ? "" : line;
19
+ }
20
+ function usesWhitespaceWordBudget(value) {
21
+ const language = text(value, 160).toLowerCase();
22
+ return /^(?:en|es)(?:[-_]|$)|\b(?:english|spanish|español|espanol|inglés|ingles)\b|英语|英語|美语|美語|西班牙语|西班牙語|西语|西語/u.test(language);
23
+ }
24
+ function voicePacingMeasurement(value, durationSeconds, targetLanguage) {
25
+ const voice = spokenText(value);
26
+ const seconds = Number(durationSeconds);
27
+ if (!voice || !usesWhitespaceWordBudget(targetLanguage) || /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(voice) || !Number.isFinite(seconds) || seconds <= 0)
28
+ return null;
29
+ const wordCount = (voice.match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu) || []).length;
30
+ return { wordCount, suggestedMaxWords: Math.floor(seconds * 2), durationSeconds: seconds };
31
+ }
14
32
  /** Explicit styles are capability-versioned; an unknown pair must never silently fall back. */
15
33
  export function flowCGeneratedMontage(value) {
16
34
  const input = object(value);
@@ -109,12 +127,6 @@ export function mergeFlowCContentSummaries(values, jobs, acceptedOrdinals) {
109
127
  export function flowCContentAdvisories(jobs, strategy, options = {}) {
110
128
  if (!strategy || !Array.isArray(jobs))
111
129
  return [];
112
- const language = text(options.targetLanguage, 160).toLowerCase();
113
- const wordBudgetApplies = /^(?:en|es)(?:[-_]|$)|\b(?:english|spanish|español|espanol|inglés|ingles)\b|英语|英語|美语|美語|西班牙语|西班牙語|西语|西語/u.test(language);
114
- const spoken = (value) => {
115
- const line = text(typeof value === "string" ? value : "", 20_000);
116
- return /^(?:none|无|sin voz|sin diálogo)$/i.test(line) ? "" : line;
117
- };
118
130
  const seen = (Array.isArray(options.recentScripts) ? options.recentScripts : []).map(summary).filter((item) => Boolean(item));
119
131
  const advisories = [];
120
132
  for (const value of jobs) {
@@ -128,7 +140,7 @@ export function flowCContentAdvisories(jobs, strategy, options = {}) {
128
140
  const segment = object(segmentValue);
129
141
  const shots = Array.isArray(segment.shots) ? segment.shots.map(object) : [];
130
142
  for (const [shotIndex, shot] of shots.entries()) {
131
- const voice = spoken(shot.voiceover);
143
+ const voice = spokenText(shot.voiceover);
132
144
  if (!opening && voice)
133
145
  opening = voice;
134
146
  const evidence = text(shot.evidence, 20_000).replace(/[.!。!]+$/u, "").toLowerCase();
@@ -137,12 +149,9 @@ export function flowCContentAdvisories(jobs, strategy, options = {}) {
137
149
  advisories.push({ ordinal, code: "voice_without_visible_evidence", segment: segmentIndex + 1, shot: shotIndex + 1 });
138
150
  const seconds = Number(shot.endSeconds) - Number(shot.startSeconds);
139
151
  // Do not apply an English/Spanish word estimate to other scripts.
140
- if (!voice || !wordBudgetApplies || /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(voice) || !Number.isFinite(seconds) || seconds <= 0)
141
- continue;
142
- const words = (voice.match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu) || []).length;
143
- const suggestedMaxWords = Math.floor(seconds * 2);
144
- if (words > suggestedMaxWords)
145
- advisories.push({ ordinal, code: "voice_pacing", segment: segmentIndex + 1, shot: shotIndex + 1, wordCount: words, suggestedMaxWords, durationSeconds: seconds });
152
+ const pacing = voicePacingMeasurement(voice, seconds, options.targetLanguage);
153
+ if (pacing && pacing.wordCount > pacing.suggestedMaxWords)
154
+ advisories.push({ ordinal, code: "voice_pacing", segment: segmentIndex + 1, shot: shotIndex + 1, ...pacing });
146
155
  }
147
156
  const frame = text(object(segment.endingState).endingFrame, 20_000);
148
157
  if (shots.length && frame && !text(shots.at(-1)?.visual, 20_000).endsWith(frame)) {
@@ -150,7 +159,7 @@ export function flowCContentAdvisories(jobs, strategy, options = {}) {
150
159
  }
151
160
  }
152
161
  if (strategy.mode === "smart-diverse" && opening) {
153
- const match = seen.find((item) => item.ordinal !== ordinal && item.productIndex === job.productIndex && spoken(item.voiceover).toLowerCase() === opening.toLowerCase());
162
+ const match = seen.find((item) => item.ordinal !== ordinal && item.productIndex === job.productIndex && spokenText(item.voiceover).toLowerCase() === opening.toLowerCase());
154
163
  if (match)
155
164
  advisories.push({ ordinal, code: "repeated_opening", matchedOrdinal: match.ordinal });
156
165
  seen.push({ ordinal, productIndex: Number(job.productIndex), opening: "", proof: "", voiceover: opening });
@@ -158,6 +167,83 @@ export function flowCContentAdvisories(jobs, strategy, options = {}) {
158
167
  }
159
168
  return advisories;
160
169
  }
170
+ /**
171
+ * A deliberately narrower pre-delivery repair trigger than the public pacing
172
+ * advisory. It only covers explicit English/Spanish text that is both very fast
173
+ * and materially over the normal two-words-per-second writing budget.
174
+ */
175
+ export function flowCVoicePacingRepairIssues(jobs, options = {}) {
176
+ if (!Array.isArray(jobs))
177
+ return [];
178
+ const issues = [];
179
+ for (const value of jobs) {
180
+ const job = object(value);
181
+ if (!Number.isInteger(job.ordinal) || Number(job.ordinal) < 1)
182
+ continue;
183
+ const segments = Array.isArray(job.segments) ? job.segments : job.segment ? [job.segment] : [];
184
+ for (const [segmentIndex, segmentValue] of segments.entries()) {
185
+ const shots = Array.isArray(object(segmentValue).shots) ? object(segmentValue).shots.map(object) : [];
186
+ for (const [shotIndex, shot] of shots.entries()) {
187
+ const pacing = voicePacingMeasurement(shot.voiceover, Number(shot.endSeconds) - Number(shot.startSeconds), options.targetLanguage);
188
+ if (!pacing || pacing.wordCount / pacing.durationSeconds < FLOW_C_VOICE_PACING_REPAIR_MIN_WORDS_PER_SECOND || pacing.wordCount - pacing.suggestedMaxWords < FLOW_C_VOICE_PACING_REPAIR_MIN_EXCESS_WORDS)
189
+ continue;
190
+ issues.push({ ordinal: Number(job.ordinal), code: "voice_pacing", segment: segmentIndex + 1, shot: shotIndex + 1, ...pacing });
191
+ }
192
+ }
193
+ }
194
+ return issues;
195
+ }
196
+ const repairContinuityFields = ["character", "wardrobe", "location", "lighting", "productState", "unfinishedAction", "nextGoal"];
197
+ const repairVoiceProfileFields = ["gender", "ageImpression", "pitch", "timbre", "speakingRate", "accent", "pauseHabit", "emotionalBaseline"];
198
+ const repairShotFields = ["startSeconds", "endSeconds", "visual", "voiceover", "onScreenText", "evidence", "soundBgm", "emotionalNote"];
199
+ function repairPromptSegment(value) {
200
+ const segment = object(value);
201
+ const endingState = object(segment.endingState);
202
+ const shots = Array.isArray(segment.shots) ? segment.shots.map((shotValue) => {
203
+ const shot = object(shotValue);
204
+ return Object.fromEntries(repairShotFields.map((field) => [field, shot[field]]));
205
+ }) : [];
206
+ return {
207
+ voiceCue: segment.voiceCue,
208
+ endingState: Object.fromEntries([...repairContinuityFields, "endingFrame"].map((field) => [field, endingState[field]])),
209
+ shots,
210
+ };
211
+ }
212
+ /** Compact, text-bearing repair input derived only from an already validated draft. */
213
+ export function flowCVoicePacingRepairScaffold(value) {
214
+ const job = object(value);
215
+ const segments = Array.isArray(job.segments) ? job.segments : job.segment ? [job.segment] : [];
216
+ const firstContinuity = object(object(segments[0]).continuity);
217
+ const voiceProfile = object(job.voiceProfile);
218
+ const base = {
219
+ ordinal: job.ordinal,
220
+ productIndex: job.productIndex,
221
+ sellingFormId: job.sellingFormId,
222
+ voiceProfile: Object.fromEntries(repairVoiceProfileFields.map((field) => [field, voiceProfile[field]])),
223
+ openingState: {
224
+ ...Object.fromEntries(repairContinuityFields.map((field) => [field, firstContinuity[field]])),
225
+ openingFrame: firstContinuity.previousEndingFrame,
226
+ },
227
+ };
228
+ return Array.isArray(job.segments)
229
+ ? { ...base, segments: segments.map(repairPromptSegment) }
230
+ : { ...base, segment: repairPromptSegment(segments[0]) };
231
+ }
232
+ /** One bounded correction turn; the caller still enforces VO-only projection. */
233
+ export function flowCVoicePacingRepairPrompt(jobs, options = {}) {
234
+ const values = Array.isArray(jobs) ? jobs : [];
235
+ const issues = flowCVoicePacingRepairIssues(values, { targetLanguage: options.targetLanguage });
236
+ const issueOrdinals = [...new Set(issues.map((issue) => issue.ordinal))];
237
+ const frameworkOrdinals = [...new Set((Array.isArray(options.frameworkOrdinals) ? options.frameworkOrdinals : []).map(Number).filter((ordinal) => issueOrdinals.includes(ordinal)))];
238
+ const payload = values.filter((value) => issueOrdinals.includes(Number(object(value).ordinal))).map(flowCVoicePacingRepairScaffold);
239
+ return `这是 Flow C 首次回传前唯一一次、仅针对 ordinal ${JSON.stringify(issueOrdinals)} 的短镜口播定向修复。目标口播语言保持为 ${text(options.targetLanguage, 160) || "原稿的显式目标语言"}。只返回下方完整 strict jobs,不调用工具、不创建媒体、不输出分析或新增字段。
240
+ - 只允许修改每个 shot.voiceover;逐字复制其它所有字段,包括 ordinal/productIndex/sellingFormId、voiceProfile、openingState、segment/segments 数量、voiceCue、shots 数量和顺序、startSeconds/endSeconds、visual、onScreenText、evidence、soundBgm、emotionalNote 与 endingState。不得改画面、时轴、商品、事实、用户框架、所选结构、分段承接或末帧。
241
+ - 保留原口播的核心购买理由、画面对应事实、语气、CTA 意图和目标语言。先把模型自行增加的赘词压成能自然说完的短句,再按完整词组或自然分句重分配到展示相关动作的镜头;不得截断单词、留下未完句、提高语速或把超载片段简单改成 none。若一个原本有口播的局部 10 秒段修后完全静默,视为失败。
242
+ - 英语/西语逐镜以约 2 词/秒为写作目标并留呼吸;当前硬修复命中位置:${JSON.stringify(issues.map(({ ordinal, segment, shot, wordCount, suggestedMaxWords, durationSeconds }) => ({ ordinal, segment, shot, wordCount, suggestedMaxWords, durationSeconds })))}。不能用同段其它静默镜头抵消当前短镜超载。
243
+ - 用户逐字框架 ordinal ${JSON.stringify(frameworkOrdinals)}:所有原口播文字与顺序必须保持,不能删、换词或压缩,只能在同一局部 10 秒段内按完整自然短语重新分配到语义相关、时长足够的镜头;无法容纳就原样返回,让 Agent 明确交给人工处理。
244
+ - 其它 ordinal 可以压缩模型自增措辞,但不能通过删除整段口播来消除告警。每个 10 秒段仍须在自然句法边界收尾,segmentVoiceovers 与渲染 script 将由 Agent 从最终逐镜 voiceover 确定性重建。
245
+ 待修复的已校验原稿:${JSON.stringify({ jobs: payload })}`;
246
+ }
161
247
  export function flowCContentMethodPrompt(strategy, options) {
162
248
  if (!strategy)
163
249
  return "";
@@ -7,6 +7,8 @@ export declare const FLOW_C_CODEX_TURN_TIMEOUT_MS: number;
7
7
  export declare const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 45000;
8
8
  export declare const FLOW_C_SCRIPT_REWRITE_MAX_ATTEMPTS = 2;
9
9
  export declare const FLOW_C_CANDIDATE_REVISION_MAX_ATTEMPTS = 2;
10
+ export declare const FLOW_C_VOICE_PACING_REPAIR_MAX_ATTEMPTS = 1;
11
+ export declare const FLOW_C_VOICE_PACING_REVIEW_REQUIRED = "FLOW_C_VOICE_PACING_REVIEW_REQUIRED";
10
12
  export declare const FLOW_C_LEGACY_DIRECT_CONTRACT_VERSION = "flow-c-template-direct-v2";
11
13
  export declare const FLOW_C_PRODUCT_PROFILE_DIRECT_CONTRACT_VERSION = "flow-c-product-profile-direct-v1";
12
14
  export declare const FLOW_C_CREATIVE_SOURCE_DIRECT_CONTRACT_VERSION = "flow-c-creative-source-direct-v1";
@@ -27,6 +29,13 @@ type ScriptRecord = {
27
29
  activeChunks?: number;
28
30
  productProfiles?: FlowCProductExecutionProfile[];
29
31
  pendingScriptJobs?: DraftJob[];
32
+ /** Locally recoverable originals; unlike pendingScriptJobs these must never be replayed to the center. */
33
+ voicePacingReviewJobs?: DraftJob[];
34
+ voicePacingRepairAttempts?: Array<{
35
+ ordinal: number;
36
+ candidateRevision: string;
37
+ attempts: number;
38
+ }>;
30
39
  contentStrategy?: FlowCContentStrategy;
31
40
  contentSummaries?: FlowCContentSummary[];
32
41
  contentAdvisories?: FlowCContentAdvisory[];
@@ -187,7 +196,8 @@ type DraftJob = {
187
196
  type ScriptChunkResult = {
188
197
  error?: string;
189
198
  terminal: boolean;
190
- terminalKind?: "contract" | "transport" | "timeout" | "turn" | "delivery";
199
+ terminalKind?: "contract" | "transport" | "timeout" | "turn" | "delivery" | "review";
200
+ affectedOrdinals?: number[];
191
201
  replanOrdinals?: number[];
192
202
  };
193
203
  /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
@@ -392,6 +402,13 @@ export declare class WorkflowManager {
392
402
  private runCandidateChunk;
393
403
  /** 一个 chunk 使用独立线程;解析与中心持久化失败不会影响并行 lane。 */
394
404
  private runScriptChunk;
405
+ private submitScriptJobsWithVoicePacingRepair;
406
+ /**
407
+ * One correction turn only. Originals live outside pendingScriptJobs, so a
408
+ * restart or response loss can never replay an overcrowded draft as accepted.
409
+ */
410
+ private repairVoicePacingJobs;
411
+ private runVoicePacingRepairTurn;
395
412
  /** Nonblocking, text-free diagnostics for newly generated policy scripts only. */
396
413
  private noteScriptContentAdvisories;
397
414
  /** 只记录阶段、ordinal 和毫秒数;禁止记录 prompt、令牌或中心能力。 */
@@ -409,6 +426,16 @@ export declare class WorkflowManager {
409
426
  private save;
410
427
  }
411
428
  export declare function compareScriptQueueRecords(left: Pick<ScriptRecord, "priorityAt" | "updatedAt">, right: Pick<ScriptRecord, "priorityAt" | "updatedAt">): number;
429
+ export declare function flowCVoicePacingRepairTurnOptions(): {
430
+ readonly maxProcessAttempts: 1;
431
+ };
432
+ export declare function voicePacingRepairAttemptCount(record: Pick<ScriptRecord, "voicePacingRepairAttempts">, job: Pick<DraftJob, "ordinal" | "expectedCandidateRevision">): number;
433
+ /**
434
+ * Accept only repaired per-shot speech. Every visual/timeline/state/selection
435
+ * field comes from the locally validated original and rendered projections are
436
+ * rebuilt from that one final voice source.
437
+ */
438
+ export declare function applyFlowCVoicePacingRepair(originalValue: unknown, candidateValue: unknown, preserveExactTranscript?: boolean): DraftJob;
412
439
  /** A deterministic response-format failure stops fallback isolation immediately. */
413
440
  export declare function terminalScriptChunkError(results: Array<{
414
441
  error?: string;
@@ -418,11 +445,13 @@ export declare function terminalScriptChunkFailure<T extends {
418
445
  error?: string;
419
446
  terminal?: boolean;
420
447
  terminalKind?: ScriptChunkResult["terminalKind"];
421
- }>(results: T[]): T | undefined;
448
+ affectedOrdinals?: number[];
449
+ }>(results: T[], receivedOrdinals?: number[]): T | undefined;
422
450
  export declare function scriptCreativeReplanOrdinals(results: unknown[]): number[];
423
451
  /** Keep a mixed reset signal after an exact-duplicate rewrite finishes. */
424
452
  export declare function preserveScriptRecoveryReplans(result: ScriptChunkResult, error: unknown): ScriptChunkResult;
425
453
  export declare function creativeReplanOrdinals(error: unknown): number[];
454
+ export declare function scriptDeliveryOrdinals(error: unknown): number[];
426
455
  export declare function candidateRevisionChangedOrdinals(error: unknown): number[];
427
456
  export declare function scriptRewriteOrdinals(error: unknown): number[];
428
457
  export declare function terminalScriptValidationError(error: unknown): boolean;
@@ -14,12 +14,14 @@ import { FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, flowCCreativeCandidateOutpu
14
14
  import { FLOW_C_LEGACY_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION, FLOW_C_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION, flowCProductExecutionProfileOutputSchema, parseFlowCProductExecutionProfileOutput } from "./product-profile.js";
15
15
  import { flowCScriptOutputSchema, parseFlowCScriptOutput } from "./script-output.js";
16
16
  import { commerceJson, CommerceRequestError } from "./commerce-http.js";
17
- import { FLOW_C_CONTENT_STRATEGY_VERSION, FLOW_C_GENERATED_MONTAGE_STYLE, FLOW_C_GENERATED_MONTAGE_VERSION, FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED, flowCContentAdvisories, flowCContentDirection, flowCContentMethodPrompt, flowCContentStrategy, flowCGeneratedMontage, flowCGeneratedMontagePrompt, mergeFlowCContentSummaries } from "./content-method.js";
17
+ import { FLOW_C_CONTENT_STRATEGY_VERSION, FLOW_C_GENERATED_MONTAGE_STYLE, FLOW_C_GENERATED_MONTAGE_VERSION, FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED, flowCContentAdvisories, flowCContentDirection, flowCContentMethodPrompt, flowCContentStrategy, flowCGeneratedMontage, flowCGeneratedMontagePrompt, flowCVoicePacingRepairIssues, flowCVoicePacingRepairPrompt, flowCVoicePacingRepairScaffold, mergeFlowCContentSummaries } from "./content-method.js";
18
18
  const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
19
19
  export const FLOW_C_CODEX_TURN_TIMEOUT_MS = 8 * 60 * 1000;
20
20
  export const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 45_000;
21
21
  export const FLOW_C_SCRIPT_REWRITE_MAX_ATTEMPTS = 2;
22
22
  export const FLOW_C_CANDIDATE_REVISION_MAX_ATTEMPTS = 2;
23
+ export const FLOW_C_VOICE_PACING_REPAIR_MAX_ATTEMPTS = 1;
24
+ export const FLOW_C_VOICE_PACING_REVIEW_REQUIRED = "FLOW_C_VOICE_PACING_REVIEW_REQUIRED";
23
25
  export const FLOW_C_LEGACY_DIRECT_CONTRACT_VERSION = "flow-c-template-direct-v2";
24
26
  export const FLOW_C_PRODUCT_PROFILE_DIRECT_CONTRACT_VERSION = "flow-c-product-profile-direct-v1";
25
27
  export const FLOW_C_CREATIVE_SOURCE_DIRECT_CONTRACT_VERSION = "flow-c-creative-source-direct-v1";
@@ -70,6 +72,8 @@ export class WorkflowManager {
70
72
  activeChunks: 0,
71
73
  productProfiles: previous?.productProfiles || [],
72
74
  pendingScriptJobs: previous?.pendingScriptJobs || [],
75
+ ...(previous?.voicePacingReviewJobs ? { voicePacingReviewJobs: previous.voicePacingReviewJobs } : {}),
76
+ ...(previous?.voicePacingRepairAttempts ? { voicePacingRepairAttempts: previous.voicePacingRepairAttempts } : {}),
73
77
  ...(previous?.contentAdvisories ? { contentAdvisories: previous.contentAdvisories } : {}),
74
78
  lastFailure: previous?.lastFailure,
75
79
  priorityAt: now(),
@@ -92,6 +96,7 @@ export class WorkflowManager {
92
96
  return this.scriptStatus(id);
93
97
  }
94
98
  delete record.retryRequested;
99
+ resetVoicePacingRepairAttempts(record);
95
100
  record.status = "queued";
96
101
  record.activeChunks = 0;
97
102
  // A failed manual attempt may leave a very large or interrupted thread.
@@ -126,6 +131,10 @@ export class WorkflowManager {
126
131
  task.content_recent_scripts = record.contentSummaries;
127
132
  }
128
133
  record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
134
+ record.voicePacingReviewJobs = (record.voicePacingReviewJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
135
+ if (!record.voicePacingReviewJobs.length)
136
+ delete record.voicePacingReviewJobs;
137
+ pruneVoicePacingRepairAttempts(record);
129
138
  record.expiresAt = task.expires_at;
130
139
  record.updatedAt = now();
131
140
  this.save();
@@ -157,6 +166,10 @@ export class WorkflowManager {
157
166
  if (flowCContentStrategy(record.contentStrategy))
158
167
  record.contentSummaries = mergeFlowCContentSummaries(record.contentSummaries, jobs, record.receivedOrdinals);
159
168
  record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
169
+ record.voicePacingReviewJobs = (record.voicePacingReviewJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
170
+ if (!record.voicePacingReviewJobs.length)
171
+ delete record.voicePacingReviewJobs;
172
+ pruneVoicePacingRepairAttempts(record);
160
173
  record.status = data.status === "ready" ? "complete" : "running";
161
174
  record.message = data.status === "ready"
162
175
  ? flowCContentStrategy(record.contentStrategy) ? `全部 ${record.requestedCount} 条脚本已回传(内容语义仍需审阅)` : `全部 ${record.requestedCount} 条高质量脚本已回传`
@@ -354,8 +367,10 @@ export class WorkflowManager {
354
367
  }));
355
368
  const results = pipelineResults.flat();
356
369
  task = await this.scriptTask(id);
357
- const terminalFailure = terminalScriptChunkFailure(results);
370
+ const terminalFailure = terminalScriptChunkFailure(results, this.scriptRecord(id).receivedOrdinals);
358
371
  if (terminalFailure) {
372
+ if (terminalFailure.terminalKind === "review")
373
+ throw new Error(terminalFailure.error);
359
374
  if (terminalFailure.terminalKind === "delivery")
360
375
  throw new Error(terminalFailure.error);
361
376
  if (terminalFailure.terminalKind === "transport")
@@ -405,8 +420,10 @@ export class WorkflowManager {
405
420
  this.save();
406
421
  const results = await Promise.all(wave.chunks.map((ordinals) => this.runScriptChunk(id, task, ordinals, workspace.workspacePath)));
407
422
  task = await this.scriptTask(id);
408
- const terminalFailure = terminalScriptChunkFailure(results);
423
+ const terminalFailure = terminalScriptChunkFailure(results, this.scriptRecord(id).receivedOrdinals);
409
424
  if (terminalFailure) {
425
+ if (terminalFailure.terminalKind === "review")
426
+ throw new Error(terminalFailure.error);
410
427
  if (terminalFailure.terminalKind === "delivery")
411
428
  throw new Error(terminalFailure.error);
412
429
  if (terminalFailure.terminalKind === "transport")
@@ -468,6 +485,7 @@ export class WorkflowManager {
468
485
  const retry = record.retryRequested && record.status !== "complete";
469
486
  delete record.retryRequested;
470
487
  if (retry) {
488
+ resetVoicePacingRepairAttempts(record);
471
489
  record.status = "queued";
472
490
  delete record.threadId;
473
491
  record.message = "已保存本机结果,正在优先重试";
@@ -628,11 +646,35 @@ export class WorkflowManager {
628
646
  if (!ordinals.length)
629
647
  return { terminal: false };
630
648
  }
649
+ const selectedForRecovery = selectedCandidatesForOrdinals(task, ordinals);
650
+ const localization = compactTaskLocalization(task);
651
+ const targetLanguage = localization.targetLanguage || task.target_language || localization.targetLocale;
652
+ const storedReview = (record.voicePacingReviewJobs || []).filter((job) => {
653
+ if (!ordinals.includes(job.ordinal))
654
+ return false;
655
+ const candidate = selectedForRecovery.get(job.ordinal);
656
+ return String(job.expectedCandidateRevision || "") === String(candidate?.candidateRevision || "");
657
+ });
658
+ const repairableStoredReview = storedReview.filter((job) => flowCVoicePacingRepairIssues([job], { targetLanguage }).length > 0);
659
+ if (repairableStoredReview.length) {
660
+ const reviewResult = await this.repairVoicePacingJobs(id, task, repairableStoredReview, cwd);
661
+ if (reviewResult.error || reviewResult.terminal)
662
+ return reviewResult;
663
+ ordinals = ordinals.filter((ordinal) => !this.scriptRecord(id).receivedOrdinals.includes(ordinal));
664
+ if (!ordinals.length)
665
+ return { terminal: false };
666
+ }
667
+ const retainedOrdinals = new Set(repairableStoredReview.map((job) => job.ordinal));
668
+ const activeRecord = this.scriptRecord(id);
669
+ activeRecord.voicePacingReviewJobs = (activeRecord.voicePacingReviewJobs || []).filter((job) => !ordinals.includes(job.ordinal) || retainedOrdinals.has(job.ordinal));
670
+ if (!activeRecord.voicePacingReviewJobs.length)
671
+ delete activeRecord.voicePacingReviewJobs;
672
+ pruneVoicePacingRepairAttempts(activeRecord);
631
673
  const durationSeconds = Number(task.duration_seconds || 10);
632
674
  let prompt;
633
675
  try {
634
676
  const promptTask = flowCContentStrategy(task.creative_strategy)
635
- ? { ...task, received_ordinals: record.receivedOrdinals, content_recent_scripts: mergeFlowCContentSummaries([...(Array.isArray(task.content_recent_scripts) ? task.content_recent_scripts : []), ...(record.contentSummaries || [])], [], record.receivedOrdinals) }
677
+ ? { ...task, received_ordinals: activeRecord.receivedOrdinals, content_recent_scripts: mergeFlowCContentSummaries([...(Array.isArray(task.content_recent_scripts) ? task.content_recent_scripts : []), ...(activeRecord.contentSummaries || [])], [], activeRecord.receivedOrdinals) }
636
678
  : task;
637
679
  prompt = scriptChunkPrompt(id, promptTask, ordinals, rewriteAttempt);
638
680
  }
@@ -666,11 +708,10 @@ export class WorkflowManager {
666
708
  creativePlan: { ...(job.creativePlan || {}), ...selectedCandidatePlan(selected.get(job.ordinal), flowCContentStrategy(task.creative_strategy)) },
667
709
  }));
668
710
  this.emitScriptStage(id, ordinals, "parse", Date.now() - parseStartedAt);
669
- this.noteScriptContentAdvisories(id, task, jobs);
670
711
  const persistStartedAt = Date.now();
671
- await this.submitGeneratedScriptJobs(id, jobs);
712
+ const deliveryResult = await this.submitScriptJobsWithVoicePacingRepair(id, task, jobs, cwd);
672
713
  this.emitScriptStage(id, ordinals, "persist", Date.now() - persistStartedAt);
673
- return { terminal: false };
714
+ return deliveryResult;
674
715
  }
675
716
  catch (error) {
676
717
  // Only explicit semantic rejection may discard the affected saved output.
@@ -740,7 +781,7 @@ export class WorkflowManager {
740
781
  return preserveScriptRecoveryReplans(rewriteResult, error);
741
782
  }
742
783
  if (error instanceof CommerceRequestError && !creativeReplanOrdinals(error).length)
743
- return { error: error.message, terminal: true, terminalKind: "delivery" };
784
+ return { error: error.message, terminal: true, terminalKind: "delivery", affectedOrdinals: scriptDeliveryOrdinals(error).length ? scriptDeliveryOrdinals(error) : [...ordinals] };
744
785
  return {
745
786
  error: error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验",
746
787
  terminal: terminalScriptValidationError(error),
@@ -748,6 +789,133 @@ export class WorkflowManager {
748
789
  };
749
790
  }
750
791
  }
792
+ async submitScriptJobsWithVoicePacingRepair(id, task, jobs, cwd) {
793
+ const record = this.scriptRecord(id);
794
+ const current = jobs.filter((job) => !record.receivedOrdinals.includes(job.ordinal));
795
+ if (!current.length)
796
+ return { terminal: false };
797
+ const selected = selectedCandidatesForOrdinals(task, current.map((job) => job.ordinal));
798
+ const repairEnabled = Boolean(flowCContentStrategy(task.creative_strategy)) || [...selected.values()].some((candidate) => flowCGeneratedMontage(candidate));
799
+ const localization = compactTaskLocalization(task);
800
+ const targetLanguage = localization.targetLanguage || task.target_language || localization.targetLocale;
801
+ const repairIssues = repairEnabled ? flowCVoicePacingRepairIssues(current, { targetLanguage }) : [];
802
+ const repairOrdinals = new Set(repairIssues.map((issue) => issue.ordinal));
803
+ this.noteScriptContentAdvisories(id, task, current);
804
+ if (!repairOrdinals.size) {
805
+ await this.submitGeneratedScriptJobs(id, current);
806
+ return { terminal: false };
807
+ }
808
+ const review = new Map((record.voicePacingReviewJobs || []).map((job) => [job.ordinal, job]));
809
+ for (const job of current)
810
+ if (repairOrdinals.has(job.ordinal))
811
+ review.set(job.ordinal, structuredClone(job));
812
+ record.voicePacingReviewJobs = [...review.values()].sort((left, right) => left.ordinal - right.ordinal);
813
+ record.message = `检测到 ordinal ${[...repairOrdinals].join(", ")} 的短镜口播明显过密;合格稿先回传,受影响稿只做 1 次定向压缩/重分配`;
814
+ record.updatedAt = now();
815
+ this.save();
816
+ const clean = current.filter((job) => !repairOrdinals.has(job.ordinal));
817
+ if (clean.length)
818
+ await this.submitGeneratedScriptJobs(id, clean);
819
+ const affected = current.filter((job) => repairOrdinals.has(job.ordinal) && !this.scriptRecord(id).receivedOrdinals.includes(job.ordinal));
820
+ if (!affected.length)
821
+ return { terminal: false };
822
+ return this.repairVoicePacingJobs(id, task, affected, cwd);
823
+ }
824
+ /**
825
+ * One correction turn only. Originals live outside pendingScriptJobs, so a
826
+ * restart or response loss can never replay an overcrowded draft as accepted.
827
+ */
828
+ async repairVoicePacingJobs(id, task, originals, cwd) {
829
+ const record = this.scriptRecord(id);
830
+ const current = originals.filter((job) => !record.receivedOrdinals.includes(job.ordinal));
831
+ if (!current.length)
832
+ return { terminal: false };
833
+ const eligible = current.filter((job) => voicePacingRepairAttemptCount(record, job) < FLOW_C_VOICE_PACING_REPAIR_MAX_ATTEMPTS);
834
+ const exhausted = current.filter((job) => !eligible.includes(job));
835
+ if (!eligible.length)
836
+ return voicePacingReviewFailure(exhausted.map((job) => job.ordinal), "本机已记录本次自动修复机会,后台恢复不会再次调用模型");
837
+ const localization = compactTaskLocalization(task);
838
+ const targetLanguage = localization.targetLanguage || task.target_language || localization.targetLocale;
839
+ const selected = selectedCandidatesForOrdinals(task, eligible.map((job) => job.ordinal));
840
+ const frameworkOrdinals = [...selected.values()].filter(isFlowCUserFrameworkCandidate).map((candidate) => candidate.ordinal);
841
+ const ordinals = eligible.map((job) => job.ordinal);
842
+ const durationSeconds = Number(task.duration_seconds || 10);
843
+ const prompt = flowCVoicePacingRepairPrompt(eligible, { targetLanguage, frameworkOrdinals });
844
+ for (const job of eligible)
845
+ recordVoicePacingRepairAttempt(record, job);
846
+ record.message = `正在为 ordinal ${ordinals.join(", ")} 做第 1/${FLOW_C_VOICE_PACING_REPAIR_MAX_ATTEMPTS} 次短镜口播定向修复;画面、时轴与已选框架保持不变`;
847
+ record.updatedAt = now();
848
+ this.save();
849
+ let result;
850
+ try {
851
+ result = await this.runVoicePacingRepairTurn(id, prompt, cwd, durationSeconds, ordinals.length);
852
+ }
853
+ catch (error) {
854
+ const received = this.scriptRecord(id).receivedOrdinals;
855
+ const missing = [...exhausted.map((job) => job.ordinal), ...ordinals].filter((ordinal) => !received.includes(ordinal));
856
+ return missing.length ? voicePacingReviewFailure(missing, error instanceof Error ? error.message : "口播修复回合异常") : { terminal: false };
857
+ }
858
+ this.emitScriptStage(id, ordinals, "voice_repair", result.timings.queueWaitMs + result.timings.threadStartMs + result.timings.modelMs);
859
+ const missingAfterTurn = [...exhausted.map((job) => job.ordinal), ...ordinals].filter((ordinal) => !this.scriptRecord(id).receivedOrdinals.includes(ordinal));
860
+ if (!missingAfterTurn.length)
861
+ return { terminal: false };
862
+ if (!result.ok)
863
+ return voicePacingReviewFailure(missingAfterTurn, result.error || "Codex 未返回口播修复稿");
864
+ if (!result.text)
865
+ return voicePacingReviewFailure(missingAfterTurn, "Codex 未返回口播修复稿");
866
+ let candidates;
867
+ try {
868
+ candidates = parseFlowCScriptOutput(result.text, ordinals);
869
+ }
870
+ catch (error) {
871
+ const missing = missingAfterTurn.filter((ordinal) => !this.scriptRecord(id).receivedOrdinals.includes(ordinal));
872
+ return missing.length ? voicePacingReviewFailure(missing, error instanceof Error ? error.message : "口播修复稿未通过结构校验") : { terminal: false };
873
+ }
874
+ const byOrdinal = new Map(candidates.map((job) => [job.ordinal, job]));
875
+ const accepted = [];
876
+ const failed = new Map(exhausted.map((job) => [job.ordinal, "本次自动修复机会已经使用"]));
877
+ for (const original of eligible.filter((job) => !this.scriptRecord(id).receivedOrdinals.includes(job.ordinal))) {
878
+ const candidate = byOrdinal.get(original.ordinal);
879
+ if (!candidate) {
880
+ failed.set(original.ordinal, "修复回合未返回该 ordinal");
881
+ continue;
882
+ }
883
+ try {
884
+ const repaired = applyFlowCVoicePacingRepair(original, candidate, frameworkOrdinals.includes(original.ordinal));
885
+ const remaining = flowCVoicePacingRepairIssues([repaired], { targetLanguage });
886
+ if (remaining.length) {
887
+ failed.set(original.ordinal, `仍有 ${remaining.length} 个明显过密短镜`);
888
+ continue;
889
+ }
890
+ accepted.push(repaired);
891
+ }
892
+ catch (error) {
893
+ failed.set(original.ordinal, error instanceof Error ? error.message : "修复稿改变了受保护字段");
894
+ }
895
+ }
896
+ const stillMissing = accepted.filter((job) => !this.scriptRecord(id).receivedOrdinals.includes(job.ordinal));
897
+ if (stillMissing.length) {
898
+ this.noteScriptContentAdvisories(id, task, stillMissing);
899
+ await this.submitGeneratedScriptJobs(id, stillMissing);
900
+ }
901
+ for (const ordinal of this.scriptRecord(id).receivedOrdinals)
902
+ failed.delete(ordinal);
903
+ if (failed.size)
904
+ return voicePacingReviewFailure([...failed.keys()], [...failed.entries()].map(([ordinal, reason]) => `ordinal ${ordinal}: ${reason}`).join(";"));
905
+ return { terminal: false };
906
+ }
907
+ runVoicePacingRepairTurn(id, prompt, cwd, durationSeconds, count) {
908
+ return runCodexWorkflowTurn(prompt, this.emit, {
909
+ cwd,
910
+ permissionMode: "full",
911
+ timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
912
+ ...flowCVoicePacingRepairTurnOptions(),
913
+ outputSchema: flowCScriptOutputSchema(durationSeconds, count),
914
+ onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
915
+ onWorkerStart: () => { const next = this.scriptRecord(id); next.activeChunks = Number(next.activeChunks || 0) + 1; next.updatedAt = now(); this.save(); },
916
+ onWorkerFinish: () => { const next = this.scriptRecord(id); next.activeChunks = Math.max(0, Number(next.activeChunks || 0) - 1); next.updatedAt = now(); this.save(); },
917
+ });
918
+ }
751
919
  /** Nonblocking, text-free diagnostics for newly generated policy scripts only. */
752
920
  noteScriptContentAdvisories(id, task, jobs) {
753
921
  const strategy = flowCContentStrategy(task.creative_strategy);
@@ -778,7 +946,7 @@ export class WorkflowManager {
778
946
  }
779
947
  catch (error) {
780
948
  if (jobs.length === 1 || error instanceof CommerceRequestError && ![400, 409, 422].includes(error.status || 0))
781
- throw error;
949
+ throw markScriptDeliveryOrdinals(error, jobs);
782
950
  let accepted = 0;
783
951
  const failures = [];
784
952
  for (const job of jobs) {
@@ -788,7 +956,7 @@ export class WorkflowManager {
788
956
  }
789
957
  catch (jobError) {
790
958
  if (jobError instanceof CommerceRequestError && ![400, 409, 422].includes(jobError.status || 0))
791
- throw jobError;
959
+ throw markScriptDeliveryOrdinals(jobError, [job]);
792
960
  failures.push(jobError);
793
961
  }
794
962
  }
@@ -936,12 +1104,134 @@ export function compareScriptQueueRecords(left, right) {
936
1104
  return String(right.priorityAt || "").localeCompare(String(left.priorityAt || ""));
937
1105
  return left.updatedAt.localeCompare(right.updatedAt);
938
1106
  }
1107
+ function pacingObject(value) {
1108
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1109
+ }
1110
+ export function flowCVoicePacingRepairTurnOptions() {
1111
+ return { maxProcessAttempts: FLOW_C_VOICE_PACING_REPAIR_MAX_ATTEMPTS };
1112
+ }
1113
+ function voicePacingCandidateRevision(job) {
1114
+ return String(job.expectedCandidateRevision || "");
1115
+ }
1116
+ export function voicePacingRepairAttemptCount(record, job) {
1117
+ const revision = voicePacingCandidateRevision(job);
1118
+ return Math.max(0, ...((record.voicePacingRepairAttempts || []).filter((entry) => Number(entry.ordinal) === Number(job.ordinal) && String(entry.candidateRevision || "") === revision).map((entry) => Math.floor(Number(entry.attempts) || 0))));
1119
+ }
1120
+ function recordVoicePacingRepairAttempt(record, job) {
1121
+ const revision = voicePacingCandidateRevision(job);
1122
+ const attempts = (record.voicePacingRepairAttempts || []).filter((entry) => Number(entry.ordinal) !== job.ordinal || String(entry.candidateRevision || "") !== revision);
1123
+ attempts.push({ ordinal: job.ordinal, candidateRevision: revision, attempts: voicePacingRepairAttemptCount(record, job) + 1 });
1124
+ record.voicePacingRepairAttempts = attempts.sort((left, right) => left.ordinal - right.ordinal || left.candidateRevision.localeCompare(right.candidateRevision));
1125
+ }
1126
+ function pruneVoicePacingRepairAttempts(record) {
1127
+ const active = new Set((record.voicePacingReviewJobs || []).map((job) => `${job.ordinal}\u0000${voicePacingCandidateRevision(job)}`));
1128
+ record.voicePacingRepairAttempts = (record.voicePacingRepairAttempts || []).filter((entry) => active.has(`${Number(entry.ordinal)}\u0000${String(entry.candidateRevision || "")}`));
1129
+ if (!record.voicePacingRepairAttempts.length)
1130
+ delete record.voicePacingRepairAttempts;
1131
+ }
1132
+ function resetVoicePacingRepairAttempts(record) {
1133
+ delete record.voicePacingRepairAttempts;
1134
+ }
1135
+ function pacingSegments(value) {
1136
+ const job = pacingObject(value);
1137
+ return Array.isArray(job.segments) ? job.segments.map(pacingObject) : job.segment ? [pacingObject(job.segment)] : [];
1138
+ }
1139
+ function isPacingSilence(value) {
1140
+ return /^(?:none|无|sin voz|sin diálogo)$/i.test(String(value || "").trim());
1141
+ }
1142
+ function exactSegmentTranscript(shots) {
1143
+ return shots.map((shot) => String(shot.voiceover || "").trim()).filter((line) => line && !isPacingSilence(line)).join(" ").replace(/\s+/gu, " ").trim();
1144
+ }
1145
+ function stablePacingValue(value) {
1146
+ if (Array.isArray(value))
1147
+ return value.map(stablePacingValue);
1148
+ if (!value || typeof value !== "object")
1149
+ return value;
1150
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, stablePacingValue(child)]));
1151
+ }
1152
+ function protectedPacingProjection(value) {
1153
+ const job = structuredClone(pacingObject(value));
1154
+ delete job.script;
1155
+ delete job.masterScript;
1156
+ delete job.segmentVoiceovers;
1157
+ for (const segment of pacingSegments(job)) {
1158
+ delete segment.script;
1159
+ const shots = Array.isArray(segment.shots) ? segment.shots.map(pacingObject) : [];
1160
+ for (const shot of shots)
1161
+ shot.voiceover = "[voiceover-only-repair]";
1162
+ }
1163
+ return stablePacingValue(job);
1164
+ }
1165
+ /**
1166
+ * Accept only repaired per-shot speech. Every visual/timeline/state/selection
1167
+ * field comes from the locally validated original and rendered projections are
1168
+ * rebuilt from that one final voice source.
1169
+ */
1170
+ export function applyFlowCVoicePacingRepair(originalValue, candidateValue, preserveExactTranscript = false) {
1171
+ const original = pacingObject(originalValue);
1172
+ const candidate = pacingObject(candidateValue);
1173
+ const ordinal = Number(original.ordinal);
1174
+ if (!Number.isInteger(ordinal) || Number(candidate.ordinal) !== ordinal || Number(candidate.productIndex) !== Number(original.productIndex))
1175
+ throw new Error("修复稿 ordinal/productIndex 与原稿不一致");
1176
+ if (JSON.stringify(protectedPacingProjection(flowCVoicePacingRepairScaffold(candidate))) !== JSON.stringify(protectedPacingProjection(flowCVoicePacingRepairScaffold(original)))) {
1177
+ throw new Error("修复稿改变了 VO 之外的画面、事实、声音方向或连续性字段");
1178
+ }
1179
+ const originalSegments = pacingSegments(original);
1180
+ const candidateSegments = pacingSegments(candidate);
1181
+ if (!originalSegments.length || candidateSegments.length !== originalSegments.length)
1182
+ throw new Error("修复稿改变了分段数量");
1183
+ const patchedSegments = originalSegments.map((originalSegment, segmentIndex) => {
1184
+ const originalShots = Array.isArray(originalSegment.shots) ? originalSegment.shots.map(pacingObject) : [];
1185
+ const candidateShots = Array.isArray(candidateSegments[segmentIndex]?.shots) ? candidateSegments[segmentIndex].shots.map(pacingObject) : [];
1186
+ if (!originalShots.length || candidateShots.length !== originalShots.length)
1187
+ throw new Error(`segment ${segmentIndex + 1} 改变了镜头数量`);
1188
+ for (const [shotIndex, originalShot] of originalShots.entries()) {
1189
+ const candidateShot = candidateShots[shotIndex];
1190
+ if (Number(candidateShot.startSeconds) !== Number(originalShot.startSeconds) || Number(candidateShot.endSeconds) !== Number(originalShot.endSeconds))
1191
+ throw new Error(`segment ${segmentIndex + 1} shot ${shotIndex + 1} 改变了镜头时轴`);
1192
+ if (!String(candidateShot.voiceover || "").trim())
1193
+ throw new Error(`segment ${segmentIndex + 1} shot ${shotIndex + 1} 缺少 voiceover`);
1194
+ }
1195
+ const originalTranscript = exactSegmentTranscript(originalShots);
1196
+ const repairedTranscript = exactSegmentTranscript(candidateShots);
1197
+ if (originalTranscript && !repairedTranscript)
1198
+ throw new Error(`segment ${segmentIndex + 1} 删除了整段口播`);
1199
+ if (preserveExactTranscript && repairedTranscript !== originalTranscript)
1200
+ throw new Error(`segment ${segmentIndex + 1} 改写了用户逐字框架口播`);
1201
+ return {
1202
+ ...originalSegment,
1203
+ shots: originalShots.map((shot, shotIndex) => ({ ...shot, voiceover: String(candidateShots[shotIndex].voiceover).trim() })),
1204
+ };
1205
+ });
1206
+ const patched = Array.isArray(original.segments) ? { ...original, segments: patchedSegments } : { ...original, segment: patchedSegments[0] };
1207
+ const scaffold = flowCVoicePacingRepairScaffold(patched);
1208
+ const rendered = parseFlowCScriptOutput(JSON.stringify({ jobs: [scaffold] }), [ordinal])[0];
1209
+ const result = { ...original, ...rendered };
1210
+ if (JSON.stringify(protectedPacingProjection(result)) !== JSON.stringify(protectedPacingProjection(original)))
1211
+ throw new Error("修复稿改变了 VO 之外的受保护字段");
1212
+ return result;
1213
+ }
1214
+ function voicePacingReviewFailure(ordinals, reason) {
1215
+ const scoped = [...new Set(ordinals.map(Number).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right);
1216
+ return {
1217
+ error: `${FLOW_C_VOICE_PACING_REVIEW_REQUIRED}: ordinal ${scoped.join(", ")} 的短镜口播在唯一一次定向修复后仍未安全落入镜头时长;原稿仅保存在本机待审区,未作为可重传稿提交。${reason}。请人工审阅,或确认逐字框架可调整后手动重试`,
1218
+ terminal: true,
1219
+ terminalKind: "review",
1220
+ affectedOrdinals: scoped,
1221
+ };
1222
+ }
939
1223
  /** A deterministic response-format failure stops fallback isolation immediately. */
940
1224
  export function terminalScriptChunkError(results) {
941
1225
  return terminalScriptChunkFailure(results)?.error || "";
942
1226
  }
943
- export function terminalScriptChunkFailure(results) {
944
- return results.find((result) => result.terminal);
1227
+ export function terminalScriptChunkFailure(results, receivedOrdinals = []) {
1228
+ const received = new Set(receivedOrdinals.map(Number).filter(Number.isInteger));
1229
+ return results.find((result) => {
1230
+ if (!result.terminal)
1231
+ return false;
1232
+ const affected = Array.isArray(result.affectedOrdinals) ? result.affectedOrdinals.map(Number).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0) : [];
1233
+ return !affected.length || affected.some((ordinal) => !received.has(ordinal));
1234
+ });
945
1235
  }
946
1236
  export function scriptCreativeReplanOrdinals(results) {
947
1237
  return [...new Set(results.flatMap((result) => (result && typeof result === "object" ? result.replanOrdinals || [] : [])).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right);
@@ -960,6 +1250,17 @@ export function creativeReplanOrdinals(error) {
960
1250
  const values = error.resetOrdinals;
961
1251
  return Array.isArray(values) ? [...new Set(values.map(Number).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right) : [];
962
1252
  }
1253
+ function markScriptDeliveryOrdinals(error, jobs) {
1254
+ if (error && typeof error === "object") {
1255
+ const current = scriptDeliveryOrdinals(error);
1256
+ error.deliveryOrdinals = [...new Set([...current, ...jobs.map((job) => Number(job.ordinal))].filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right);
1257
+ }
1258
+ return error;
1259
+ }
1260
+ export function scriptDeliveryOrdinals(error) {
1261
+ const values = error && typeof error === "object" ? error.deliveryOrdinals : [];
1262
+ return Array.isArray(values) ? [...new Set(values.map(Number).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right) : [];
1263
+ }
963
1264
  export function candidateRevisionChangedOrdinals(error) {
964
1265
  if (!error || typeof error !== "object")
965
1266
  return [];
@@ -1105,7 +1406,7 @@ export function scriptChunkPrompt(id, task, ordinals, rewriteAttempt = 0) {
1105
1406
  recentScripts: mergeFlowCContentSummaries(task.content_recent_scripts, [], task.received_ordinals),
1106
1407
  productIndexes: products.map((product) => product.productIndex),
1107
1408
  ordinals,
1108
- frameworkOrdinals: [...selected.values()].filter((candidate) => candidate.selectionMode === "user-framework").map((candidate) => candidate.ordinal),
1409
+ frameworkOrdinals: [...selected.values()].filter(isFlowCUserFrameworkCandidate).map((candidate) => candidate.ordinal),
1109
1410
  montageOrdinals: generatedMontageOrdinals,
1110
1411
  });
1111
1412
  const generatedMontageMethod = flowCGeneratedMontagePrompt(generatedMontageOrdinals);
@@ -1326,6 +1627,9 @@ function positiveDuration(value) {
1326
1627
  const duration = Number(value);
1327
1628
  return Number.isFinite(duration) && duration > 0 ? Math.round(duration * 1000) / 1000 : null;
1328
1629
  }
1630
+ function isFlowCUserFrameworkCandidate(value) {
1631
+ return value.selectionMode === "user-framework" || value.scriptSource === "user-framework" || value.creativeSource === "user-framework";
1632
+ }
1329
1633
  export function selectedCandidatePlan(value, contentStrategy = null) {
1330
1634
  if (!value)
1331
1635
  throw new Error("中心缺少选中的创意候选");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.80",
3
+ "version": "0.4.82",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.12.1",
25
25
  "@huggingface/transformers": "4.2.0",
26
- "@openai/codex": "0.145.0",
26
+ "@openai/codex": "0.153.4",
27
27
  "express": "^5.1.0",
28
28
  "playwright-core": "^1.62.1",
29
29
  "ws": "^8.18.3",