@xiaohhhh1/canvas-agent 0.4.81 → 0.4.83

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.
@@ -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,33 @@
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 live;
22
+ private locks;
23
+ constructor(directory: string, analyze: Analyze);
24
+ get activeCount(): number;
25
+ start(source: LocalVideoLearningSource & {
26
+ archived_sha256?: string;
27
+ archivedSha256?: string;
28
+ }, requestId: string): Promise<Job>;
29
+ get(id: string): Promise<Job | null>;
30
+ private run;
31
+ private save;
32
+ }
33
+ export {};
@@ -0,0 +1,106 @@
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
+ live = new Map();
10
+ locks = new Map();
11
+ constructor(directory, analyze) {
12
+ this.directory = directory;
13
+ this.analyze = analyze;
14
+ }
15
+ get activeCount() { return this.active.size; }
16
+ async start(source, requestId) {
17
+ if (!requestId || requestId.length > 100)
18
+ throw new Error("视频分析请求标识无效");
19
+ const identity = source.archived_sha256 || source.archivedSha256 || source.sourceUrl || source.source_url;
20
+ if (!source.id || !identity)
21
+ throw new Error("视频分析缺少稳定原片身份,请刷新后重试");
22
+ const id = createHash("sha256").update(JSON.stringify([source.id, identity, localVideoAnalysisPrompt(source, 0, "", [])])).digest("hex");
23
+ // Serialize admission for the same source, including network replay.
24
+ const previous = this.locks.get(id) || Promise.resolve();
25
+ const admission = previous.catch(() => undefined).then(async () => {
26
+ const existing = await this.get(id);
27
+ if (existing && (existing.status === "completed" || this.active.has(id) || existing.requestId === requestId))
28
+ return existing;
29
+ const job = { id, attemptId: randomUUID(), requestId, status: "running", stage: "读取原片与抽帧", updatedAt: new Date().toISOString() };
30
+ await this.save(job);
31
+ this.live.set(id, { ...job });
32
+ const task = this.run(job, source).catch(() => undefined).finally(() => {
33
+ if (this.active.get(id) === task) {
34
+ this.active.delete(id);
35
+ this.live.delete(id);
36
+ }
37
+ });
38
+ this.active.set(id, task);
39
+ return { ...job };
40
+ });
41
+ this.locks.set(id, admission);
42
+ try {
43
+ return await admission;
44
+ }
45
+ finally {
46
+ if (this.locks.get(id) === admission)
47
+ this.locks.delete(id);
48
+ }
49
+ }
50
+ async get(id) {
51
+ if (!/^[a-f0-9]{64}$/.test(id))
52
+ throw new Error("视频分析任务标识无效");
53
+ // Polling and duplicate starts must not read the JSON file while an
54
+ // atomic replacement is in progress. Windows can reject that rename
55
+ // with EPERM, which used to fail the live analysis and admit a retry.
56
+ const live = this.live.get(id);
57
+ if (live)
58
+ return { ...live };
59
+ let job;
60
+ try {
61
+ job = JSON.parse(await readFile(path.join(this.directory, id + ".json"), "utf8"));
62
+ }
63
+ catch (error) {
64
+ if (error.code === "ENOENT")
65
+ return null;
66
+ throw error;
67
+ }
68
+ if (job.status === "running" && !this.active.has(id)) {
69
+ job = { ...job, status: "failed", error: "本机程序曾中断,原视频保留;点击重试可重新分析。", stage: "等待手动重试" };
70
+ await this.save(job);
71
+ }
72
+ return job;
73
+ }
74
+ async run(job, source) {
75
+ try {
76
+ const result = await this.analyze(source, async (stage) => {
77
+ job.stage = stage;
78
+ await this.save(job);
79
+ });
80
+ // Persist before the browser can import the result. A lost response
81
+ // or refreshed page reads the same output without another model call.
82
+ job.result = result;
83
+ job.status = "completed";
84
+ job.stage = "分析已保存,等待网页回传";
85
+ }
86
+ catch (error) {
87
+ job.status = "failed";
88
+ const detail = error instanceof Error ? error.message : "本机视频分析失败";
89
+ job.error = detail === "fetch failed" ? `${job.stage}时网络连接失败;原片保留,请恢复网络后重试。` : detail;
90
+ job.stage = "分析失败,可手动重试";
91
+ }
92
+ await this.save(job);
93
+ }
94
+ async save(job) {
95
+ job.updatedAt = new Date().toISOString();
96
+ await mkdir(this.directory, { recursive: true });
97
+ const file = path.join(this.directory, job.id + ".json");
98
+ const temporary = file + "." + randomUUID() + ".tmp";
99
+ await writeFile(temporary, JSON.stringify(job), { mode: 0o600 });
100
+ await rename(temporary, file);
101
+ // Expose only the last successfully persisted snapshot. In particular,
102
+ // a completed result cannot reach the browser before its rename lands.
103
+ if (this.live.has(job.id))
104
+ this.live.set(job.id, { ...job });
105
+ }
106
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AgentAttachment } from "../agent/types.js";
2
2
  import { type FastMossIntegration } from "../integrations/fastmoss.js";
3
+ export declare const VIDEO_ANALYSIS_MODEL_ARGS: string[];
3
4
  export type LocalVideoLearningSource = {
4
5
  id?: string;
5
6
  source?: string;
@@ -53,7 +54,10 @@ export declare class LocalVideoIntelligence {
53
54
  durationMs: number;
54
55
  };
55
56
  }>;
56
- analyze(source: LocalVideoLearningSource): Promise<{
57
+ analyze(source: LocalVideoLearningSource, options?: {
58
+ onStage?: (stage: string) => Promise<void>;
59
+ timeoutMs?: number;
60
+ }): Promise<{
57
61
  analysis: Record<string, unknown>;
58
62
  evidence: {
59
63
  durationMs: number;
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { videoEvidenceTimestamps } from "../integrations/fastmoss.js";
8
8
  const ANALYSIS_TIMEOUT_MS = 10 * 60_000;
9
+ export const VIDEO_ANALYSIS_MODEL_ARGS = ["--model", "gpt-5.6-terra", "-c", 'model_reasoning_effort="medium"'];
9
10
  const TRANSCRIPT_LANGUAGES = "en.*,es.*,zh.*,pt.*,fr.*,de.*,vi.*,th.*,id.*,ms.*,ja.*,ko.*";
10
11
  const LOCAL_ASR_MODEL = "onnx-community/whisper-base";
11
12
  const VIDEO_INTELLIGENCE_SCHEMA_VERSION = "commerce-video-intelligence-v7";
@@ -60,7 +61,7 @@ export class LocalVideoIntelligence {
60
61
  await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
61
62
  }
62
63
  }
63
- async analyze(source) {
64
+ async analyze(source, options) {
64
65
  const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
65
66
  const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
66
67
  const hasArchivedMedia = Boolean(archivedVideoUrl);
@@ -74,6 +75,7 @@ export class LocalVideoIntelligence {
74
75
  let transcript = archivedTranscript;
75
76
  let transcriptSource = archivedTranscript ? "website-archive-caption" : "";
76
77
  if (!transcript && hasArchivedMedia) {
78
+ await options?.onStage?.("识别原片口播");
77
79
  transcript = await transcribeLocalMedia("videoFile" in visualEvidence && typeof visualEvidence.videoFile === "string" ? visualEvidence.videoFile : undefined, workDir);
78
80
  transcriptSource = transcript ? `local-asr:${LOCAL_ASR_MODEL}` : "local-asr:no-speech-detected";
79
81
  }
@@ -85,7 +87,8 @@ export class LocalVideoIntelligence {
85
87
  throw new Error("没有取得任何真实视频画面,已停止分析");
86
88
  const attachments = await materializeFrames(visualEvidence.frames, workDir);
87
89
  const prompt = localVideoAnalysisPrompt(source, visualEvidence.durationMs, transcript, attachments);
88
- const analysis = await runLocalCodexAnalysis(prompt, attachments, workDir);
90
+ await options?.onStage?.("Codex 正在对齐画面与口播,生成详细蓝图");
91
+ const analysis = await runLocalCodexAnalysis(prompt, attachments, workDir, options?.timeoutMs);
89
92
  assertJointVisualAndSpokenEvidence(analysis);
90
93
  return {
91
94
  analysis,
@@ -485,17 +488,17 @@ function transcriptPreference(file) {
485
488
  return 3;
486
489
  return 1;
487
490
  }
488
- async function runLocalCodexAnalysis(prompt, attachments, workDir) {
491
+ async function runLocalCodexAnalysis(prompt, attachments, workDir, timeoutMs = ANALYSIS_TIMEOUT_MS) {
489
492
  const outputFile = path.join(workDir, "analysis.json");
490
493
  // npm hoists dependencies next to the installed package in production, while
491
494
  // local development may keep them at a different ancestor. Resolve from this
492
495
  // module instead of assuming a nested node_modules directory.
493
496
  const codexEntrypoint = resolveLocalCodexEntrypoint();
494
- const args = [codexEntrypoint, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", "--output-last-message", outputFile];
497
+ const args = [codexEntrypoint, "exec", ...VIDEO_ANALYSIS_MODEL_ARGS, "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", "--output-last-message", outputFile];
495
498
  for (const attachment of attachments)
496
499
  args.push("--image", path.join(workDir, String(attachment.name)));
497
500
  args.push("-");
498
- const result = await runProcess(process.execPath, args, { cwd: workDir, timeoutMs: ANALYSIS_TIMEOUT_MS, stdin: prompt });
501
+ const result = await runProcess(process.execPath, args, { cwd: workDir, timeoutMs, stdin: prompt });
499
502
  if (!result.ok)
500
503
  throw new Error(localCodexAnalysisError(result));
501
504
  const raw = (await readFile(outputFile, "utf8")).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
@@ -1,10 +1,11 @@
1
1
  /** 中心/MCP 兼容上限仍为 30;结构化 10 秒脚本按更小子批受控并行。 */
2
2
  export declare const FLOW_C_SCRIPT_CHUNK_MAX = 30;
3
+ export declare const FLOW_C_VIDEO_MODEL_CONTRACT_VERSION = "flow-c-video-models-v1";
3
4
  export declare const FLOW_C_SCRIPT_CHUNK_SIZES: readonly [10, 5, 1];
4
5
  /**
5
6
  * 直接蓝图契约不再重复 creativePlan、executionBindings 和 masterScript,
6
7
  * 20/30 秒可先尝试更大的低开销子批,失败仍逐级缩小到单条。
7
8
  */
8
- export declare function flowCScriptChunkSizes(durationSeconds: 10 | 20 | 30): readonly [10, 5, 1] | readonly [4, 2, 1] | readonly [2, 1];
9
+ export declare function flowCScriptChunkSizes(durationSeconds: 10 | 15 | 20 | 30): readonly [10, 5, 1] | readonly [4, 2, 1] | readonly [2, 1];
9
10
  /** 将当前缺失 ordinal 切成受控子批;长视频绝不恢复 30 条大回合。 */
10
- export declare function flowCScriptChunks(durationSeconds: 10 | 20 | 30, ordinals: number[], size?: number): number[][];
11
+ export declare function flowCScriptChunks(durationSeconds: 10 | 15 | 20 | 30, ordinals: number[], size?: number): number[][];
@@ -1,5 +1,6 @@
1
1
  /** 中心/MCP 兼容上限仍为 30;结构化 10 秒脚本按更小子批受控并行。 */
2
2
  export const FLOW_C_SCRIPT_CHUNK_MAX = 30;
3
+ export const FLOW_C_VIDEO_MODEL_CONTRACT_VERSION = "flow-c-video-models-v1";
3
4
  export const FLOW_C_SCRIPT_CHUNK_SIZES = [10, 5, 1];
4
5
  /**
5
6
  * 直接蓝图契约不再重复 creativePlan、executionBindings 和 masterScript,
@@ -50,7 +50,7 @@ export declare function flowCContentWritingReviewPrompt(strategy: FlowCContentSt
50
50
  frameworkOrdinals: readonly number[];
51
51
  }): string;
52
52
  /** Rules for the separately selected generated-montage content style. */
53
- export declare function flowCGeneratedMontagePrompt(ordinals: readonly number[]): string;
53
+ export declare function flowCGeneratedMontagePrompt(ordinals: readonly number[], segmentSeconds?: 10 | 15): string;
54
54
  /** Missing/unknown versions keep historical tasks on their exact original prompt. */
55
55
  export declare function flowCContentStrategy(value: unknown): FlowCContentStrategy | null;
56
56
  export declare function flowCContentDirection(value: unknown, strategy: FlowCContentStrategy | null): FlowCContentDirection | null;
@@ -115,6 +115,7 @@ export declare function flowCVoicePacingRepairScaffold(value: unknown): {
115
115
  export declare function flowCVoicePacingRepairPrompt(jobs: unknown, options?: {
116
116
  targetLanguage?: unknown;
117
117
  frameworkOrdinals?: readonly number[];
118
+ segmentSeconds?: 10 | 15;
118
119
  }): string;
119
120
  export declare function flowCContentMethodPrompt(strategy: FlowCContentStrategy | null, options: {
120
121
  recentScripts?: unknown;
@@ -122,4 +123,5 @@ export declare function flowCContentMethodPrompt(strategy: FlowCContentStrategy
122
123
  ordinals: number[];
123
124
  frameworkOrdinals: number[];
124
125
  montageOrdinals?: number[];
126
+ segmentSeconds?: 10 | 15;
125
127
  }): string;
@@ -55,15 +55,16 @@ export function flowCContentWritingReviewPrompt(strategy, options) {
55
55
  - 逐句对照:逐镜核对每个非 none 的 voiceover 片段与该镜 visual/evidence 及已知商品事实;商品事实句必须有同镜可见依据,处境、情绪或 CTA 不必伪装成产品证明但必须符合正在发生的画面。最后核对末镜实际动作、visual 末尾收尾短句与 endingState.endingFrame 精确同锚点;这里只做结构性自审,不声称靠词面规则完成语义验收。`;
56
56
  }
57
57
  /** Rules for the separately selected generated-montage content style. */
58
- export function flowCGeneratedMontagePrompt(ordinals) {
58
+ export function flowCGeneratedMontagePrompt(ordinals, segmentSeconds = 10) {
59
59
  const scoped = [...new Set((Array.isArray(ordinals) ? ordinals : []).map(Number).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].slice(0, 100);
60
60
  if (!scoped.length)
61
61
  return "";
62
+ const longDurationLabel = segmentSeconds === 15 ? "30 秒双段" : "20/30 秒";
62
63
  return `\n原创混剪(${FLOW_C_GENERATED_MONTAGE_VERSION},仅 ordinal ${JSON.stringify(scoped)}):
63
64
  - 围绕一个由当前商品事实支持的核心购买理由,选择抓眼但真实可执行的使用、细节、多个适用画面或可见结果镜头;每次切镜带来新的有用观察,不做无关美图轮播,也不强制编痛点剧情、完整人物故事或为了差异放弃好创意。
64
65
  - 跨镜、跨全片的人物、服装和场景一致性不是目标或验收门槛;用户框架明确指定角色/场景时仍严格尊重。每个单镜动作须自然,若一个动作明确跨相邻镜继续则保持该动作的手部、商品与物理状态连续;人物或地点变化时在下一 shot.visual 明写 HARD CUT,切后可直接进入新示例,但所有镜头的 SKU、颜色、结构、材质、数量、包装和表面文字图案始终不变。
65
- - 仍是每个局部 0–10 秒、1–8 个 shots。20/30 秒后一段 openingState 精确继承上一段 endingState 只是技术开场交接;紧接着可以明写 HARD CUT 进入新人物或新场景,不要求让上一段人物/场景贯穿下一段,也不得为连续剧情浪费本段秒数。
66
- - 一句自然口播可以跨镜延续,但每镜 voiceover 只保存该镜实际说出的完整词组/分句;按该镜真实时长留呼吸,不能把整句塞进一秒镜头再借后续静默时长冲抵。每个 10 秒段都以自然完整的句法边界收尾,不把半个词、未完短语或待补 CTA 留给另一次模型调用。
66
+ - 仍是每个局部 0–${segmentSeconds} 秒、1–8 个 shots。${longDurationLabel}后一段 openingState 精确继承上一段 endingState 只是技术开场交接;紧接着可以明写 HARD CUT 进入新人物或新场景,不要求让上一段人物/场景贯穿下一段,也不得为连续剧情浪费本段秒数。
67
+ - 一句自然口播可以跨镜延续,但每镜 voiceover 只保存该镜实际说出的完整词组/分句;按该镜真实时长留呼吸,不能把整句塞进一秒镜头再借后续静默时长冲抵。每个 ${segmentSeconds} 秒段都以自然完整的句法边界收尾,不把半个词、未完短语或待补 CTA 留给另一次模型调用。
67
68
  - 这些规则只改变当前已选脚本的内容表达;不新增候选或模型阶段,不改严格输出 schema、首帧/分镜媒体依赖、收费、队列、重试或归档。`;
68
69
  }
69
70
  /** Missing/unknown versions keep historical tasks on their exact original prompt. */
@@ -235,13 +236,14 @@ export function flowCVoicePacingRepairPrompt(jobs, options = {}) {
235
236
  const issues = flowCVoicePacingRepairIssues(values, { targetLanguage: options.targetLanguage });
236
237
  const issueOrdinals = [...new Set(issues.map((issue) => issue.ordinal))];
237
238
  const frameworkOrdinals = [...new Set((Array.isArray(options.frameworkOrdinals) ? options.frameworkOrdinals : []).map(Number).filter((ordinal) => issueOrdinals.includes(ordinal)))];
239
+ const segmentSeconds = options.segmentSeconds === 15 ? 15 : 10;
238
240
  const payload = values.filter((value) => issueOrdinals.includes(Number(object(value).ordinal))).map(flowCVoicePacingRepairScaffold);
239
241
  return `这是 Flow C 首次回传前唯一一次、仅针对 ordinal ${JSON.stringify(issueOrdinals)} 的短镜口播定向修复。目标口播语言保持为 ${text(options.targetLanguage, 160) || "原稿的显式目标语言"}。只返回下方完整 strict jobs,不调用工具、不创建媒体、不输出分析或新增字段。
240
242
  - 只允许修改每个 shot.voiceover;逐字复制其它所有字段,包括 ordinal/productIndex/sellingFormId、voiceProfile、openingState、segment/segments 数量、voiceCue、shots 数量和顺序、startSeconds/endSeconds、visual、onScreenText、evidence、soundBgm、emotionalNote 与 endingState。不得改画面、时轴、商品、事实、用户框架、所选结构、分段承接或末帧。
241
- - 保留原口播的核心购买理由、画面对应事实、语气、CTA 意图和目标语言。先把模型自行增加的赘词压成能自然说完的短句,再按完整词组或自然分句重分配到展示相关动作的镜头;不得截断单词、留下未完句、提高语速或把超载片段简单改成 none。若一个原本有口播的局部 10 秒段修后完全静默,视为失败。
243
+ - 保留原口播的核心购买理由、画面对应事实、语气、CTA 意图和目标语言。先把模型自行增加的赘词压成能自然说完的短句,再按完整词组或自然分句重分配到展示相关动作的镜头;不得截断单词、留下未完句、提高语速或把超载片段简单改成 none。若一个原本有口播的局部 ${segmentSeconds} 秒段修后完全静默,视为失败。
242
244
  - 英语/西语逐镜以约 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
+ - 用户逐字框架 ordinal ${JSON.stringify(frameworkOrdinals)}:所有原口播文字与顺序必须保持,不能删、换词或压缩,只能在同一局部 ${segmentSeconds} 秒段内按完整自然短语重新分配到语义相关、时长足够的镜头;无法容纳就原样返回,让 Agent 明确交给人工处理。
246
+ - 其它 ordinal 可以压缩模型自增措辞,但不能通过删除整段口播来消除告警。每个 ${segmentSeconds} 秒段仍须在自然句法边界收尾,segmentVoiceovers 与渲染 script 将由 Agent 从最终逐镜 voiceover 确定性重建。
245
247
  待修复的已校验原稿:${JSON.stringify({ jobs: payload })}`;
246
248
  }
247
249
  export function flowCContentMethodPrompt(strategy, options) {
@@ -256,6 +258,11 @@ export function flowCContentMethodPrompt(strategy, options) {
256
258
  const writingReview = flowCContentWritingReviewPrompt(strategy, { frameworkOrdinals: options.frameworkOrdinals });
257
259
  const montageOrdinals = [...new Set((Array.isArray(options.montageOrdinals) ? options.montageOrdinals : []).map(Number).filter((ordinal) => Number.isInteger(ordinal) && options.ordinals.includes(ordinal)))];
258
260
  const ordinaryOrdinals = options.ordinals.filter((ordinal) => !montageOrdinals.includes(ordinal));
261
+ const segmentSeconds = options.segmentSeconds === 15 ? 15 : 10;
262
+ const longDurationLabel = segmentSeconds === 15 ? "30秒双段" : "20/30秒";
263
+ const segmentPacingRule = segmentSeconds === 15
264
+ ? "整段15秒按自然语速留足动作与呼吸,可以更少,不是最低字数要求"
265
+ : "整段10秒约12–20词只是起点,可以更少,不是最低字数要求";
259
266
  const scenarioRule = montageOrdinals.length
260
267
  ? `1. 本条“先明确谁在生活节点遇到麻烦/需求”的剧情组织只适用于其余普通脚本 ordinal ${JSON.stringify(ordinaryOrdinals)};原创混剪 ordinal ${JSON.stringify(montageOrdinals)} 不强制痛点、麻烦、待解决问题或人物反转,可以从商品事实支持的好结果、真实使用动作或可见细节直接开场。普通脚本再选择地点里的动作坐标与必要可见物件;两类都用现有 shot.visual 写出具体微场景,不用“某人在家里”或泛气氛替代。人物是普通创作者/情景角色;真实身份、经历、用户证言、专家或权威背书只能来自明确提供的事实。缺非关键创意细节时用保守日常设定软适配,不追问、不拒绝、不编新事实。`
261
268
  : "1. 先明确谁在什么生活节点遇到什么具体麻烦/需求,再选择地点里的动作坐标与必要可见物件;用现有 shot.visual 写出微场景,不用“某人在家里”或泛气氛替代。人物是普通创作者/情景角色;真实身份、经历、用户证言、专家或权威背书只能来自明确提供的事实。缺非关键创意细节时用保守日常设定软适配,不追问、不拒绝、不编新事实。";
@@ -267,8 +274,8 @@ ${diversity}
267
274
  ${writingReview}
268
275
  ${scenarioRule}
269
276
  ${openingRule}
270
- 3. 分开检查叙事段落逻辑、每镜目的与口播呼吸节奏:段落负责铺垫/证明/收束,镜头负责展示动作与证据,一句口播不必切一镜、一镜也不必念完一段。保留选中蓝图的因果顺序、节拍与镜头能量;仍为每段局部0–10秒、1–8镜,20/30秒连续关系和三种媒体共同内容契约不变。
271
- 4. 自检上方已定稿的目标语言的自然短句,不再按镜头补新台词。逐镜按实际时长 endSeconds − startSeconds 检查,先为关键动作/可见结果留静默与呼吸。仅英语、西语等通常按空格分词的语言,以写作起点粗估约2词/秒,动作/结果镜头更少:1.5秒约2–3词,不塞完整8词问句;2秒约3–4词,不塞10词CTA;2.6秒约4–5词,不塞13词整句。整段10秒约12–20词只是起点,可以更少,不是最低字数要求;全段词数够少也不能把口播挤在一两个短镜头,不能借其它静默镜头的时长冲抵当前镜头超载。若用户锁定对白需要更多时间,先延长承载它的镜头并压缩无声过渡或合并相邻同动作镜头;不得改写锁定词,也不得先把它塞进短镜头再用后续静默冲抵。锁定对白分配妥当后才可加入其它口播,剩余时长不足就让其它镜头保持 none。不能套用到中文、日语等其它语言,不套统一英语词数或固定八秒模板。这是同轮软预算,不是语言验收拒绝条件或新重写环节;只在用户锁定内容之外收窄意图、缩短句子,不得提高语速硬塞。voiceProfile.speakingRate/pauseHabit 与 voiceCue 要符合实际可说完的语速,不得标成 unhurried/慢速却塞满台词,也不能用 brisk 掩盖超载;emotionalNote 只写镜头目的/情绪转折。不输出计算、推理或预算报告。
272
- 5. contentDirection 只细化既有蓝图可替换的人物、微场景、措辞和执行槽位,不改变商品身份、纹理文字、能力证据、0–10秒分段或媒体制作方式。creativeBrief 始终最高优先;用户框架 ordinal ${JSON.stringify(options.frameworkOrdinals)} 的开头、事件顺序、核心剧情和结尾不可被方向卡/避重覆盖,只能在其留白处具体化。
277
+ 3. 分开检查叙事段落逻辑、每镜目的与口播呼吸节奏:段落负责铺垫/证明/收束,镜头负责展示动作与证据,一句口播不必切一镜、一镜也不必念完一段。保留选中蓝图的因果顺序、节拍与镜头能量;仍为每段局部0–${segmentSeconds}秒、1–8镜,${longDurationLabel}连续关系和三种媒体共同内容契约不变。
278
+ 4. 自检上方已定稿的目标语言的自然短句,不再按镜头补新台词。逐镜按实际时长 endSeconds − startSeconds 检查,先为关键动作/可见结果留静默与呼吸。仅英语、西语等通常按空格分词的语言,以写作起点粗估约2词/秒,动作/结果镜头更少:1.5秒约2–3词,不塞完整8词问句;2秒约3–4词,不塞10词CTA;2.6秒约4–5词,不塞13词整句。${segmentPacingRule};全段词数够少也不能把口播挤在一两个短镜头,不能借其它静默镜头的时长冲抵当前镜头超载。若用户锁定对白需要更多时间,先延长承载它的镜头并压缩无声过渡或合并相邻同动作镜头;不得改写锁定词,也不得先把它塞进短镜头再用后续静默冲抵。锁定对白分配妥当后才可加入其它口播,剩余时长不足就让其它镜头保持 none。不能套用到中文、日语等其它语言,不套统一英语词数或固定八秒模板。这是同轮软预算,不是语言验收拒绝条件或新重写环节;只在用户锁定内容之外收窄意图、缩短句子,不得提高语速硬塞。voiceProfile.speakingRate/pauseHabit 与 voiceCue 要符合实际可说完的语速,不得标成 unhurried/慢速却塞满台词,也不能用 brisk 掩盖超载;emotionalNote 只写镜头目的/情绪转折。不输出计算、推理或预算报告。
279
+ 5. contentDirection 只细化既有蓝图可替换的人物、微场景、措辞和执行槽位,不改变商品身份、纹理文字、能力证据、0–${segmentSeconds}秒分段或媒体制作方式。creativeBrief 始终最高优先;用户框架 ordinal ${JSON.stringify(options.frameworkOrdinals)} 的开头、事件顺序、核心剧情和结尾不可被方向卡/避重覆盖,只能在其留白处具体化。
273
280
  返回前按以上内容要求自检,修正能在本次写作中修正的空泛描述。自检不是新验收闸门;只输出原严格 schema 的既有字段,不输出推理、自检报告、contentDirection 或其它新增字段。\n`;
274
281
  }
@@ -143,7 +143,8 @@ type ScriptTask = {
143
143
  creative_strategy?: unknown;
144
144
  content_recent_scripts?: FlowCContentSummary[];
145
145
  script_source_default?: FlowCScriptSource;
146
- duration_seconds?: 10 | 20 | 30;
146
+ duration_seconds?: 10 | 15 | 20 | 30;
147
+ video_model_key?: "veo-omni-flash" | "seedance-2.0-mini-15s" | "seedance-2.0-fast-15s";
147
148
  script_output_contract_version?: string;
148
149
  storyboard_layout_version?: StoryboardLayoutVersion;
149
150
  storyboardLayoutVersion?: StoryboardLayoutVersion;
@@ -435,7 +436,7 @@ export declare function voicePacingRepairAttemptCount(record: Pick<ScriptRecord,
435
436
  * field comes from the locally validated original and rendered projections are
436
437
  * rebuilt from that one final voice source.
437
438
  */
438
- export declare function applyFlowCVoicePacingRepair(originalValue: unknown, candidateValue: unknown, preserveExactTranscript?: boolean): DraftJob;
439
+ export declare function applyFlowCVoicePacingRepair(originalValue: unknown, candidateValue: unknown, preserveExactTranscript?: boolean, segmentSeconds?: 10 | 15): DraftJob;
439
440
  /** A deterministic response-format failure stops fallback isolation immediately. */
440
441
  export declare function terminalScriptChunkError(results: Array<{
441
442
  error?: string;
@@ -530,7 +531,7 @@ export declare function selectedCandidatePlan(value: SelectedCandidate | undefin
530
531
  * 已选创意永远优先进入脚本扩写;只有当前没有可写脚本时才生成下一小波候选。
531
532
  * 每次最多占用受控 worker 数量的子批,避免把整批候选提前塞满 worker 队列。
532
533
  */
533
- export declare function nextScriptPipelineWave(task: Pick<ScriptTask, "requested_count" | "selected_candidates">, receivedOrdinals: number[], durationSeconds: 10 | 20 | 30, scriptChunkSize: number, candidateChunkSize?: number, concurrency?: number): {
534
+ export declare function nextScriptPipelineWave(task: Pick<ScriptTask, "requested_count" | "selected_candidates">, receivedOrdinals: number[], durationSeconds: 10 | 15 | 20 | 30, scriptChunkSize: number, candidateChunkSize?: number, concurrency?: number): {
534
535
  stage: "script";
535
536
  chunks: number[][];
536
537
  } | {
@@ -9,13 +9,17 @@ import { FLOW_C_CODEX_MODEL, FLOW_C_CODEX_REASONING_EFFORT, FLOW_C_CODEX_WORKER_
9
9
  import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { windowsPowerShellExecutable } from "../utils/windows.js";
12
- import { FLOW_C_SCRIPT_CHUNK_MAX, flowCScriptChunks, flowCScriptChunkSizes } from "./constants.js";
12
+ import { FLOW_C_SCRIPT_CHUNK_MAX, FLOW_C_VIDEO_MODEL_CONTRACT_VERSION, flowCScriptChunks, flowCScriptChunkSizes } from "./constants.js";
13
13
  import { FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, flowCCreativeCandidateOutputSchema, parseFlowCCreativeCandidateOutput } from "./creative-candidates.js";
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
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
+ const FLOW_C_AGENT_CAPABILITY_HEADERS = {
20
+ "x-flow-c-montage-version": FLOW_C_GENERATED_MONTAGE_VERSION,
21
+ "x-flow-c-video-contract-version": FLOW_C_VIDEO_MODEL_CONTRACT_VERSION,
22
+ };
19
23
  export const FLOW_C_CODEX_TURN_TIMEOUT_MS = 8 * 60 * 1000;
20
24
  export const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 45_000;
21
25
  export const FLOW_C_SCRIPT_REWRITE_MAX_ATTEMPTS = 2;
@@ -33,6 +37,9 @@ function productProfileContractForTask(task) {
33
37
  ? FLOW_C_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION
34
38
  : FLOW_C_LEGACY_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION;
35
39
  }
40
+ function flowCTaskSegmentSeconds(task) {
41
+ return String(task.video_model_key || "veo-omni-flash").startsWith("seedance-2.0-") ? 15 : 10;
42
+ }
36
43
  /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
37
44
  export class WorkflowManager {
38
45
  config;
@@ -116,7 +123,7 @@ export class WorkflowManager {
116
123
  /** MCP 读取服务端持久化的完整任务,不向模型暴露令牌。 */
117
124
  async scriptTask(idValue) {
118
125
  const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
119
- const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token", { headers: { "x-flow-c-montage-version": FLOW_C_GENERATED_MONTAGE_VERSION } }, this.scriptRequestOptions(record));
126
+ const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token", { headers: FLOW_C_AGENT_CAPABILITY_HEADERS }, this.scriptRequestOptions(record));
120
127
  const task = data.handoff;
121
128
  for (const candidate of task.selected_candidates || [])
122
129
  flowCGeneratedMontage(candidate);
@@ -152,7 +159,7 @@ export class WorkflowManager {
152
159
  pending.set(job.ordinal, structuredClone(job));
153
160
  record.pendingScriptJobs = [...pending.values()].sort((left, right) => left.ordinal - right.ordinal);
154
161
  this.save();
155
- const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", headers: { "x-flow-c-montage-version": FLOW_C_GENERATED_MONTAGE_VERSION }, body: JSON.stringify({ jobs }) }, this.scriptRequestOptions(record));
162
+ const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", headers: FLOW_C_AGENT_CAPABILITY_HEADERS, body: JSON.stringify({ jobs }) }, this.scriptRequestOptions(record));
156
163
  if (Array.isArray(data.receivedOrdinals))
157
164
  record.receivedOrdinals = [...new Set([...record.receivedOrdinals, ...data.receivedOrdinals.map(Number).filter(Number.isInteger)])].sort((left, right) => left - right);
158
165
  else {
@@ -329,11 +336,12 @@ export class WorkflowManager {
329
336
  throw new Error("这是旧版候选/执行绑定脚本任务,已停止继续写入;请在网页放弃该任务并用新版直接蓝图重新创建");
330
337
  const workspace = ensureSiteWorkspace(this.config);
331
338
  const durationSeconds = Number(task.duration_seconds || 10);
339
+ const segmentSeconds = flowCTaskSegmentSeconds(task);
332
340
  const chunkSizes = flowCScriptChunkSizes(durationSeconds);
333
341
  // Fail locally before starting any worker if a future schema edit
334
342
  // violates strict response-format invariants.
335
343
  for (const chunkSize of new Set(chunkSizes))
336
- flowCScriptOutputSchema(durationSeconds, chunkSize);
344
+ flowCScriptOutputSchema(durationSeconds, chunkSize, segmentSeconds);
337
345
  if (isProductProfileDirectContract(task.script_output_contract_version))
338
346
  flowCProductExecutionProfileOutputSchema(1, productProfileContractForTask(task));
339
347
  record.activeChunks = 0;
@@ -671,6 +679,7 @@ export class WorkflowManager {
671
679
  delete activeRecord.voicePacingReviewJobs;
672
680
  pruneVoicePacingRepairAttempts(activeRecord);
673
681
  const durationSeconds = Number(task.duration_seconds || 10);
682
+ const segmentSeconds = flowCTaskSegmentSeconds(task);
674
683
  let prompt;
675
684
  try {
676
685
  const promptTask = flowCContentStrategy(task.creative_strategy)
@@ -687,7 +696,7 @@ export class WorkflowManager {
687
696
  cwd,
688
697
  permissionMode: "full",
689
698
  timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
690
- outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
699
+ outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length, segmentSeconds),
691
700
  onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
692
701
  onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
693
702
  onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
@@ -701,7 +710,7 @@ export class WorkflowManager {
701
710
  return { error: "Codex 未返回可用的结构化脚本", terminal: false };
702
711
  const parseStartedAt = Date.now();
703
712
  const selected = selectedCandidatesForOrdinals(task, ordinals);
704
- const jobs = parseFlowCScriptOutput(result.text, ordinals).map((job) => ({
713
+ const jobs = parseFlowCScriptOutput(result.text, ordinals, segmentSeconds).map((job) => ({
705
714
  ...job,
706
715
  sellingFormId: selected.get(job.ordinal)?.sellingFormCardId || job.sellingFormId,
707
716
  expectedCandidateRevision: selected.get(job.ordinal)?.candidateRevision,
@@ -840,7 +849,8 @@ export class WorkflowManager {
840
849
  const frameworkOrdinals = [...selected.values()].filter(isFlowCUserFrameworkCandidate).map((candidate) => candidate.ordinal);
841
850
  const ordinals = eligible.map((job) => job.ordinal);
842
851
  const durationSeconds = Number(task.duration_seconds || 10);
843
- const prompt = flowCVoicePacingRepairPrompt(eligible, { targetLanguage, frameworkOrdinals });
852
+ const segmentSeconds = flowCTaskSegmentSeconds(task);
853
+ const prompt = flowCVoicePacingRepairPrompt(eligible, { targetLanguage, frameworkOrdinals, segmentSeconds });
844
854
  for (const job of eligible)
845
855
  recordVoicePacingRepairAttempt(record, job);
846
856
  record.message = `正在为 ordinal ${ordinals.join(", ")} 做第 1/${FLOW_C_VOICE_PACING_REPAIR_MAX_ATTEMPTS} 次短镜口播定向修复;画面、时轴与已选框架保持不变`;
@@ -848,7 +858,7 @@ export class WorkflowManager {
848
858
  this.save();
849
859
  let result;
850
860
  try {
851
- result = await this.runVoicePacingRepairTurn(id, prompt, cwd, durationSeconds, ordinals.length);
861
+ result = await this.runVoicePacingRepairTurn(id, prompt, cwd, durationSeconds, ordinals.length, segmentSeconds);
852
862
  }
853
863
  catch (error) {
854
864
  const received = this.scriptRecord(id).receivedOrdinals;
@@ -865,7 +875,7 @@ export class WorkflowManager {
865
875
  return voicePacingReviewFailure(missingAfterTurn, "Codex 未返回口播修复稿");
866
876
  let candidates;
867
877
  try {
868
- candidates = parseFlowCScriptOutput(result.text, ordinals);
878
+ candidates = parseFlowCScriptOutput(result.text, ordinals, segmentSeconds);
869
879
  }
870
880
  catch (error) {
871
881
  const missing = missingAfterTurn.filter((ordinal) => !this.scriptRecord(id).receivedOrdinals.includes(ordinal));
@@ -881,7 +891,7 @@ export class WorkflowManager {
881
891
  continue;
882
892
  }
883
893
  try {
884
- const repaired = applyFlowCVoicePacingRepair(original, candidate, frameworkOrdinals.includes(original.ordinal));
894
+ const repaired = applyFlowCVoicePacingRepair(original, candidate, frameworkOrdinals.includes(original.ordinal), segmentSeconds);
885
895
  const remaining = flowCVoicePacingRepairIssues([repaired], { targetLanguage });
886
896
  if (remaining.length) {
887
897
  failed.set(original.ordinal, `仍有 ${remaining.length} 个明显过密短镜`);
@@ -904,13 +914,13 @@ export class WorkflowManager {
904
914
  return voicePacingReviewFailure([...failed.keys()], [...failed.entries()].map(([ordinal, reason]) => `ordinal ${ordinal}: ${reason}`).join(";"));
905
915
  return { terminal: false };
906
916
  }
907
- runVoicePacingRepairTurn(id, prompt, cwd, durationSeconds, count) {
917
+ runVoicePacingRepairTurn(id, prompt, cwd, durationSeconds, count, segmentSeconds) {
908
918
  return runCodexWorkflowTurn(prompt, this.emit, {
909
919
  cwd,
910
920
  permissionMode: "full",
911
921
  timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
912
922
  ...flowCVoicePacingRepairTurnOptions(),
913
- outputSchema: flowCScriptOutputSchema(durationSeconds, count),
923
+ outputSchema: flowCScriptOutputSchema(durationSeconds, count, segmentSeconds),
914
924
  onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
915
925
  onWorkerStart: () => { const next = this.scriptRecord(id); next.activeChunks = Number(next.activeChunks || 0) + 1; next.updatedAt = now(); this.save(); },
916
926
  onWorkerFinish: () => { const next = this.scriptRecord(id); next.activeChunks = Math.max(0, Number(next.activeChunks || 0) - 1); next.updatedAt = now(); this.save(); },
@@ -1167,7 +1177,7 @@ function protectedPacingProjection(value) {
1167
1177
  * field comes from the locally validated original and rendered projections are
1168
1178
  * rebuilt from that one final voice source.
1169
1179
  */
1170
- export function applyFlowCVoicePacingRepair(originalValue, candidateValue, preserveExactTranscript = false) {
1180
+ export function applyFlowCVoicePacingRepair(originalValue, candidateValue, preserveExactTranscript = false, segmentSeconds = 10) {
1171
1181
  const original = pacingObject(originalValue);
1172
1182
  const candidate = pacingObject(candidateValue);
1173
1183
  const ordinal = Number(original.ordinal);
@@ -1205,7 +1215,7 @@ export function applyFlowCVoicePacingRepair(originalValue, candidateValue, prese
1205
1215
  });
1206
1216
  const patched = Array.isArray(original.segments) ? { ...original, segments: patchedSegments } : { ...original, segment: patchedSegments[0] };
1207
1217
  const scaffold = flowCVoicePacingRepairScaffold(patched);
1208
- const rendered = parseFlowCScriptOutput(JSON.stringify({ jobs: [scaffold] }), [ordinal])[0];
1218
+ const rendered = parseFlowCScriptOutput(JSON.stringify({ jobs: [scaffold] }), [ordinal], segmentSeconds)[0];
1209
1219
  const result = { ...original, ...rendered };
1210
1220
  if (JSON.stringify(protectedPacingProjection(result)) !== JSON.stringify(protectedPacingProjection(original)))
1211
1221
  throw new Error("修复稿改变了 VO 之外的受保护字段");
@@ -1387,15 +1397,17 @@ async function productImageAttachment(url, productIndex, imageIndex) {
1387
1397
  }
1388
1398
  export function scriptChunkPrompt(id, task, ordinals, rewriteAttempt = 0) {
1389
1399
  const duration = Number(task.duration_seconds || 10);
1400
+ const segmentSeconds = flowCTaskSegmentSeconds(task);
1401
+ const videoModelName = segmentSeconds === 15 ? "Seedance" : "Omni";
1390
1402
  const products = relevantProductInputs(task, ordinals);
1391
1403
  const productFacts = scriptPromptProductFacts(products, task.product_execution_profiles || []);
1392
1404
  const selected = selectedCandidatesForOrdinals(task, ordinals);
1393
1405
  if (selected.size !== ordinals.length)
1394
1406
  throw new Error("中心尚未为当前 ordinal 完成创意选题");
1395
1407
  const generatedMontageOrdinals = [...selected.values()].filter((candidate) => flowCGeneratedMontage(candidate)).map((candidate) => candidate.ordinal);
1396
- const durationRules = duration === 10
1397
- ? "每条只输出 openingState 和一个完整 0–10 秒 segment,不生成 masterScript。"
1398
- : `每条只输出 openingState 和 ${duration / 10} 个各自 0–10 秒的 segments;Agent 会确定性合成 masterScript,不得重复输出总稿,也不得写 10–20 或 20–30 全局时轴。`;
1408
+ const durationRules = duration === segmentSeconds
1409
+ ? `每条只输出 openingState 和一个完整 0–${segmentSeconds} 秒 segment,不生成 masterScript。`
1410
+ : `每条只输出 openingState 和 ${duration / segmentSeconds} 个各自 0–${segmentSeconds} 秒的 segments;Agent 会确定性合成 masterScript,不得重复输出总稿,也不得写跨段全局时轴。`;
1399
1411
  const rewriteInstruction = rewriteAttempt > 0
1400
1412
  ? `\n精确重复定向重写(第 ${rewriteAttempt}/${FLOW_C_SCRIPT_REWRITE_MAX_ATTEMPTS} 次):中心只因这些 ordinal 的完整脚本文本与同批已有结果完全相同而拒绝。必须继续使用上方同一 executionBlueprint、productAdaptation、商品身份、所选因果结构、节拍比例、动作强度、运镜和画质;禁止重新匹配、重新选题、降低镜头质量或改商品。把 ordinalBindings 中的 variationSeed 与本轮 rewriteSeed(${ordinals.map((ordinal) => `${ordinal}=rewrite-${rewriteAttempt}-ordinal-${ordinal}`).join(";")})同时视为强制差异指令,实质改写该商品的开场措辞、可见执行细节、口播表达、屏幕字及收束反应,使完整脚本明显不同但结构与质量不变;不得只改空格、标点或同义词。\n`
1401
1413
  : "";
@@ -1408,8 +1420,9 @@ export function scriptChunkPrompt(id, task, ordinals, rewriteAttempt = 0) {
1408
1420
  ordinals,
1409
1421
  frameworkOrdinals: [...selected.values()].filter(isFlowCUserFrameworkCandidate).map((candidate) => candidate.ordinal),
1410
1422
  montageOrdinals: generatedMontageOrdinals,
1423
+ segmentSeconds,
1411
1424
  });
1412
- const generatedMontageMethod = flowCGeneratedMontagePrompt(generatedMontageOrdinals);
1425
+ const generatedMontageMethod = flowCGeneratedMontagePrompt(generatedMontageOrdinals, segmentSeconds);
1413
1426
  return `你正在后台完成 Flow C 脚本交接 ${id},只写 ordinal:${ordinals.join(", ")};映射:${scriptProductAssignments(task.product_quantities, ordinals)}。
1414
1427
  目标市场:${task.market}。目标口播语言:${targetVoiceLanguage}。结构化当地化事实(国家、语言/locale、出镜者、受众、生活场景、创作者口吻和CTA彼此独立;不得从国家推断族群):${compactJson(localization, 3_000, "当前脚本子批的当地化事实")}。商品事实:${compactJson(productFacts, 12_000, "当前脚本子批的商品事实")}
1415
1428
  中心已完成当前 ordinal 的创意选择。executionBlueprints 只保存一次共享的模型无关创意蓝图(可能来自已学习爆款,也可能来自已筛选带货形式卡);productAdaptations 只保存一次每个不同的当前商品执行卡;ordinalBindings ${contentStrategy ? "用 blueprintRef/adaptationRef、variationSeed 和逐条 contentDirection" : "只用 blueprintRef/adaptationRef 和 variationSeed"} 映射到具体脚本。存在 blueprintRef 时,对应 executionBlueprint 是唯一结构权威;没有 blueprintRef 时,对应 productAdaptation 是用户框架的唯一执行权威。相同蓝图可供同一商品整批复用,直接替换成当前商品执行,不要生成候选、重新选题或重新评分:
@@ -1417,12 +1430,12 @@ ${compactJson(selectedBlueprintPromptPayload([...selected.values()], duration, c
1417
1430
  ${rewriteInstruction}
1418
1431
  ${durationRules}
1419
1432
  写作要求:
1420
- 1. visualHook、conflict、escalation/turn、productIntervention、visibleProof、purchaseReason、callbackMotivation 与所有 shots 必须执行选中卡的 visualPremise、firstFrame、spectacleEscalation、productProofAction、purchaseReason、truthBoundary 以及七项视觉执行字段;保留所选蓝图的因果时间线、节拍比例、镜头/剪辑节奏、动作压力、证明方式和购买逻辑,只替换当前商品、人物、场景与当地口播槽位,不得稀释构图或换成普通模板。爆款来源的旧商品身份/动作必须替换;形式卡只提供抽象创意结构,绝不能反向改变现有首帧、分段、Omni或参考图制作方式。形式库中的工厂、仓库、街访、使用者体验、主理人、探店、补货、对比、促销和耐用形式都可以用 AI 人物与场景正常演绎;缺少现实素材或事实证据不是脚本失败条件,必须按 executionBlueprint.executionAdaptation 保留所选形式,同时不把演绎的角色、地点、订单、库存、销量、价格、身份或经历写成已核验的现实事实。若 productAdaptation.templateMatch.actionCompatibility 的 openingAction、proofAction 或 spectacleAction 为 false,对应 productAdaptation 的 firstFrame、productProofAction 或 spectacleEscalation 对商品动作内容具有绝对优先级,必须删除其旧商品动作,不得折中混用。productProofAction 必须同时写清一个商品图或执行档案能支持的可见动作及其镜头内可见结果;purchaseReason 只解释该结果为何值得目标用户购买,不能变成泛泛 CTA。
1433
+ 1. visualHook、conflict、escalation/turn、productIntervention、visibleProof、purchaseReason、callbackMotivation 与所有 shots 必须执行选中卡的 visualPremise、firstFrame、spectacleEscalation、productProofAction、purchaseReason、truthBoundary 以及七项视觉执行字段;保留所选蓝图的因果时间线、节拍比例、镜头/剪辑节奏、动作压力、证明方式和购买逻辑,只替换当前商品、人物、场景与当地口播槽位,不得稀释构图或换成普通模板。爆款来源的旧商品身份/动作必须替换;形式卡只提供抽象创意结构,绝不能反向改变现有首帧、分段、${videoModelName}或参考图制作方式。形式库中的工厂、仓库、街访、使用者体验、主理人、探店、补货、对比、促销和耐用形式都可以用 AI 人物与场景正常演绎;缺少现实素材或事实证据不是脚本失败条件,必须按 executionBlueprint.executionAdaptation 保留所选形式,同时不把演绎的角色、地点、订单、库存、销量、价格、身份或经历写成已核验的现实事实。若 productAdaptation.templateMatch.actionCompatibility 的 openingAction、proofAction 或 spectacleAction 为 false,对应 productAdaptation 的 firstFrame、productProofAction 或 spectacleEscalation 对商品动作内容具有绝对优先级,必须删除其旧商品动作,不得折中混用。productProofAction 必须同时写清一个商品图或执行档案能支持的可见动作及其镜头内可见结果;purchaseReason 只解释该结果为何值得目标用户购买,不能变成泛泛 CTA。
1421
1434
  2. 严格执行 executionBlueprints 中的 sourceDurationSeconds → targetDurationSeconds 和 retimingMode:expand 只增加有用的证明停留、使用语境与购买理由,compress 只压缩重复、冗余铺垫和转场,same 保持原节拍比例;三种模式都必须保留 Hook、证明和购买因果,禁止整体拉伸、截断或机械加速。
1422
- 3. 每条生成一份稳定 voiceProfile;每段只写简短 voiceCue。口播必须使用${targetVoiceLanguage},并执行 creatorVoiceStyle/ctaStyle;未提供 creatorVoiceStyle 时使用当地 TikTok 带货创作者真实会说的口吻。${contentStrategy || generatedMontageOrdinals.length > 0 ? '不是逐字翻译。Hook → Body/visible proof → Close 是画面叙事结构,不等于口播结构;镜头数量不等于台词数量。先基于已经确定的画面,为每个局部10秒拟一份按实际镜头时长能自然说完的很短当地口播:用户锁定对白先以原词计入并优先给承载镜头足够秒数,不得删改;除此之外整段只留1–2个必要短句。再按完整词组或自然分句分配到真正展示相关动作/判断的镜头,允许一句在连续相关 cuts 间自然延续,但每镜 voiceover 只存该镜实际说出的片段,不逐镜复述 visual。镜头时长不足时先删除模型自行增加的赘句,不追加语速、不填满有声镜;其它镜头默认 voiceover="none",只在 soundBgm 保留动作现场声。结尾的一个简短自然 CTA 并入上述已定短句;若锁定对白占满可用口播时长就不再追加 CTA。用户锁定的对白、原框架和目标语言优先;上述拟稿与分配在同一次写作内完成,不输出中间声音轨。' : '不是逐字翻译,整条内容按 Hook → Body/visible proof → Close 组织,短句口语化且与当前镜头可见动作同步,结尾只给一个简短自然 CTA。'}只使用少量可信的 localSceneProfile/audienceContext 日常细节;presenterContext 没填写时使用普通创作者,不得把国家代码、语言或地区自动等同于人物族群。商品事实没有提供时不得编造价格、折扣、销量、库存或稀缺性。商品标题只用于理解商品类型与事实,所有输出不得主动写出或念出品牌名、品牌广告语、包装文案,也不得要求模型把品牌字样变得更清晰;商品外观与已有标识的位置、比例、颜色和版式只服从第1张产品参考图。TikTok/TikTok Shop 只用于定义这条写作规则,不得把平台名写入 voiceover、onScreenText、visual、soundBgm、emotionalNote、openingState、endingState 或任何会进入故事板/视频的导演字段。导演说明统一用简洁制作英文;这里只做写作指令,不做语言检测字段。
1423
- 4. 每段永远是独立 0–10 秒,含 1–8 个按剧情需要决定的 ${contentStrategy ? 'shots。每镜 voiceover 字段必须存在,但不等于必须有台词:只填上一步分配给该镜的原句,其余填非空静默标记 "none",绝不让配音念出 none;每镜同时给出准确 onScreenText、evidence、soundBgm、emotionalNote,并提供每段 endingState。先写末镜实际动作,再在末镜 visual 最后用一个短句明确第10秒的最终画面;endingState.endingFrame 逐字复用这个收尾短句,其余 endingState 字段也只描述同一瞬间,不另编姿势或动作。已走出画面的人不能仍在画内回头,已经落地的脚不能又悬在半空,已经完成的动作不能仍写为待完成;确已完成且无续接动作时 unfinishedAction 写 none。下一段首镜从这个真实终点继续,不重置人物、物体或动作;最终一段也要检查,不能只保证跨段字段相等' : 'shots、准确 voiceover、onScreenText、evidence、soundBgm、emotionalNote 与 endingState'};模型不要输出 continuity 或 continuityMode。
1435
+ 3. 每条生成一份稳定 voiceProfile;每段只写简短 voiceCue。口播必须使用${targetVoiceLanguage},并执行 creatorVoiceStyle/ctaStyle;未提供 creatorVoiceStyle 时使用当地 TikTok 带货创作者真实会说的口吻。${contentStrategy || generatedMontageOrdinals.length > 0 ? `不是逐字翻译。Hook → Body/visible proof → Close 是画面叙事结构,不等于口播结构;镜头数量不等于台词数量。先基于已经确定的画面,为每个局部${segmentSeconds}秒拟一份按实际镜头时长能自然说完的很短当地口播:用户锁定对白先以原词计入并优先给承载镜头足够秒数,不得删改;除此之外整段只留1–2个必要短句。再按完整词组或自然分句分配到真正展示相关动作/判断的镜头,允许一句在连续相关 cuts 间自然延续,但每镜 voiceover 只存该镜实际说出的片段,不逐镜复述 visual。镜头时长不足时先删除模型自行增加的赘句,不追加语速、不填满有声镜;其它镜头默认 voiceover="none",只在 soundBgm 保留动作现场声。结尾的一个简短自然 CTA 并入上述已定短句;若锁定对白占满可用口播时长就不再追加 CTA。用户锁定的对白、原框架和目标语言优先;上述拟稿与分配在同一次写作内完成,不输出中间声音轨。` : '不是逐字翻译,整条内容按 Hook → Body/visible proof → Close 组织,短句口语化且与当前镜头可见动作同步,结尾只给一个简短自然 CTA。'}只使用少量可信的 localSceneProfile/audienceContext 日常细节;presenterContext 没填写时使用普通创作者,不得把国家代码、语言或地区自动等同于人物族群。商品事实没有提供时不得编造价格、折扣、销量、库存或稀缺性。商品标题只用于理解商品类型与事实,所有输出不得主动写出或念出品牌名、品牌广告语、包装文案,也不得要求模型把品牌字样变得更清晰;商品外观与已有标识的位置、比例、颜色和版式只服从第1张产品参考图。TikTok/TikTok Shop 只用于定义这条写作规则,不得把平台名写入 voiceover、onScreenText、visual、soundBgm、emotionalNote、openingState、endingState 或任何会进入故事板/视频的导演字段。导演说明统一用简洁制作英文;这里只做写作指令,不做语言检测字段。
1436
+ 4. 每段永远是独立 0–${segmentSeconds} 秒,含 1–8 个按剧情需要决定的 ${contentStrategy ? `shots。每镜 voiceover 字段必须存在,但不等于必须有台词:只填上一步分配给该镜的原句,其余填非空静默标记 "none",绝不让配音念出 none;每镜同时给出准确 onScreenText、evidence、soundBgm、emotionalNote,并提供每段 endingState。先写末镜实际动作,再在末镜 visual 最后用一个短句明确第${segmentSeconds}秒的最终画面;endingState.endingFrame 逐字复用这个收尾短句,其余 endingState 字段也只描述同一瞬间,不另编姿势或动作。已走出画面的人不能仍在画内回头,已经落地的脚不能又悬在半空,已经完成的动作不能仍写为待完成;确已完成且无续接动作时 unfinishedAction 写 none。下一段首镜从这个真实终点继续,不重置人物、物体或动作;最终一段也要检查,不能只保证跨段字段相等` : 'shots、准确 voiceover、onScreenText、evidence、soundBgm、emotionalNote 与 endingState'};模型不要输出 continuity 或 continuityMode。
1424
1437
  5. shot.visual 要直接写出主体位置、景别、光线、材质、动作峰值和当地自然环境,并保留真实皮肤、细发、布料/商品微纹理和实景混合光;禁止塑料蜡感、静态举产品、品牌广告片或电视购物。允许执行所选形式的 AI 工厂/仓库/零售/人物场景,但不得声称它们是该商家真实工厂、产地、生产档案、订单、库存、销量、实名客户证言或线下价格。
1425
- 6. 20/30 秒的后一段剧情承接前段 endingState,但每段仍只使用自己的局部 0–10 秒执行内容。
1438
+ 6. 多段视频的后一段剧情承接前段 endingState,但每段仍只使用自己的局部 0–${segmentSeconds} 秒执行内容。
1426
1439
  7. 不要输出 creativePlan、executionBindings 或 masterScript;这些字段由 Agent 从选中蓝图确定性回填/合成,避免重复 token 和脆弱的文字绑定校验。若 selectionMode=user-framework,用户原框架的开头、事件顺序、核心剧情和结尾仍是最高权威。
1427
1440
  8. 商品身份以按顺序提供的原商品图为最高权威:第1张是主SKU身份图,当前商品标题和大概类目只补充商品用途与事实,后续图片只是同一SKU补充视角。任何爆款源商品、首帧、故事板或上一段尾帧都不得改变商品颜色、款式、结构、比例、材质、包装、标识位置、部件、配件和实际套装数量;修正身份时不得降低动作、运镜、节奏、景深、真实感或画质。商品本体或包装表面的印花、图案、微纹理、已印文字/字符、图标、色块、行距、相对位置和朝向全部视为不可编辑的原图纹理;不得在 shot.visual、onScreenText、voiceover、evidence、openingState、endingState 或任何导演字段中要求重写、翻译、替换、删改、重排、镜像这些表面内容,也不得要求生成“另一段清晰可读文案”。新增屏幕字只能是与商品像素分离的场景叠加字幕,绝不能印到商品或包装上。旋转、翻转、弯折或开合商品时,完整表面纹理必须随实体整体运动,不能漂移、翻面后复写或重新生成。若 executionBlueprint 或 productAdaptation 与本规则冲突,只保留其钩子、动作因果和镜头节奏,改写构图、机位或表演,绝不改商品表面。
1428
1441
  ${contentMethod}${generatedMontageMethod}固定使用 GPT-5.6 Terra 中等推理。媒体制作方式与分镜布局由中心在脚本完成后管理,不得影响这里的创意、镜头、口播或结构化脚本。只返回当前结构化 jobs,不调用工具、不创建任何图片或视频任务。`;
@@ -1,12 +1,12 @@
1
1
  type JsonSchema = Record<string, unknown>;
2
2
  /** Codex app-server 的结构化脚本返回契约;回传由 Agent 完成,不依赖模型调用 MCP。 */
3
- export declare function flowCScriptOutputSchema(durationSeconds: 10 | 20 | 30, count: number): JsonSchema;
3
+ export declare function flowCScriptOutputSchema(durationSeconds: 10 | 15 | 20 | 30, count: number, segmentSeconds?: 10 | 15): JsonSchema;
4
4
  /**
5
5
  * OpenAI strict structured output requires every declared object property to
6
6
  * appear in `required`. Optional semantics must therefore be represented by a
7
7
  * required nullable field, never by omitting that key from `required`.
8
8
  */
9
9
  export declare function assertStrictResponseSchema(schemaValue: unknown, path?: string): asserts schemaValue is JsonSchema;
10
- export declare function parseFlowCScriptOutput(value: string, expectedOrdinals: number[]): unknown[];
11
- export declare function validateFlowCLocalSegmentTimeline(segmentValue: unknown, label?: string): unknown;
10
+ export declare function parseFlowCScriptOutput(value: string, expectedOrdinals: number[], segmentSeconds?: 10 | 15): unknown[];
11
+ export declare function validateFlowCLocalSegmentTimeline(segmentValue: unknown, label?: string, segmentSeconds?: 10 | 15): unknown;
12
12
  export {};
@@ -21,7 +21,7 @@ function voiceProfileSchema() {
21
21
  emotionalBaseline: text,
22
22
  });
23
23
  }
24
- function segmentSchema() {
24
+ function segmentSchema(segmentSeconds = 10) {
25
25
  return object({
26
26
  voiceCue: text,
27
27
  endingState: continuitySchema("endingFrame"),
@@ -30,8 +30,8 @@ function segmentSchema() {
30
30
  minItems: 1,
31
31
  maxItems: 8,
32
32
  items: object({
33
- startSeconds: { type: "number", minimum: 0, maximum: 10 },
34
- endSeconds: { type: "number", minimum: 0, maximum: 10 },
33
+ startSeconds: { type: "number", minimum: 0, maximum: segmentSeconds },
34
+ endSeconds: { type: "number", minimum: 0, maximum: segmentSeconds },
35
35
  visual: text,
36
36
  voiceover: text,
37
37
  onScreenText: text,
@@ -43,7 +43,7 @@ function segmentSchema() {
43
43
  });
44
44
  }
45
45
  /** Codex app-server 的结构化脚本返回契约;回传由 Agent 完成,不依赖模型调用 MCP。 */
46
- export function flowCScriptOutputSchema(durationSeconds, count) {
46
+ export function flowCScriptOutputSchema(durationSeconds, count, segmentSeconds = 10) {
47
47
  const properties = {
48
48
  ordinal: { type: "integer", minimum: 1 },
49
49
  productIndex: { type: "integer", minimum: 0 },
@@ -51,10 +51,10 @@ export function flowCScriptOutputSchema(durationSeconds, count) {
51
51
  voiceProfile: voiceProfileSchema(),
52
52
  openingState: openingStateSchema(),
53
53
  };
54
- if (durationSeconds === 10)
55
- properties.segment = segmentSchema();
54
+ if (durationSeconds === segmentSeconds)
55
+ properties.segment = segmentSchema(segmentSeconds);
56
56
  else {
57
- properties.segments = { type: "array", minItems: durationSeconds / 10, maxItems: durationSeconds / 10, items: segmentSchema() };
57
+ properties.segments = { type: "array", minItems: durationSeconds / segmentSeconds, maxItems: durationSeconds / segmentSeconds, items: segmentSchema(segmentSeconds) };
58
58
  }
59
59
  const schema = object({ jobs: { type: "array", minItems: count, maxItems: count, items: object(properties) } });
60
60
  assertStrictResponseSchema(schema);
@@ -93,7 +93,7 @@ export function assertStrictResponseSchema(schemaValue, path = "$") {
93
93
  branches.forEach((branch, index) => assertStrictResponseSchema(branch, `${path}.${branchKey}[${index}]`));
94
94
  }
95
95
  }
96
- export function parseFlowCScriptOutput(value, expectedOrdinals) {
96
+ export function parseFlowCScriptOutput(value, expectedOrdinals, segmentSeconds = 10) {
97
97
  const source = String(value || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
98
98
  const parsed = JSON.parse(source);
99
99
  if (!Array.isArray(parsed.jobs) || !parsed.jobs.length)
@@ -113,7 +113,7 @@ export function parseFlowCScriptOutput(value, expectedOrdinals) {
113
113
  if (!accepted.has(ordinal))
114
114
  continue;
115
115
  try {
116
- valid.push(validateJobLocalTimelines(canonicalizeSegmentContinuity(accepted.get(ordinal)), ordinal));
116
+ valid.push(validateJobLocalTimelines(canonicalizeSegmentContinuity(accepted.get(ordinal), segmentSeconds), ordinal, segmentSeconds));
117
117
  }
118
118
  catch (error) {
119
119
  timelineError = error instanceof Error ? error : new Error(String(error));
@@ -123,17 +123,17 @@ export function parseFlowCScriptOutput(value, expectedOrdinals) {
123
123
  throw timelineError;
124
124
  return valid;
125
125
  }
126
- function validateJobLocalTimelines(jobValue, ordinal) {
126
+ function validateJobLocalTimelines(jobValue, ordinal, segmentSeconds) {
127
127
  const job = recordOf(jobValue);
128
128
  if (!job)
129
129
  return jobValue;
130
130
  if (Array.isArray(job.segments))
131
- job.segments.forEach((segment, index) => validateFlowCLocalSegmentTimeline(segment, `ordinal ${ordinal} segment ${index + 1}`));
131
+ job.segments.forEach((segment, index) => validateFlowCLocalSegmentTimeline(segment, `ordinal ${ordinal} segment ${index + 1}`, segmentSeconds));
132
132
  else if (recordOf(job.segment))
133
- validateFlowCLocalSegmentTimeline(job.segment, `ordinal ${ordinal} segment 1`);
133
+ validateFlowCLocalSegmentTimeline(job.segment, `ordinal ${ordinal} segment 1`, segmentSeconds);
134
134
  return jobValue;
135
135
  }
136
- export function validateFlowCLocalSegmentTimeline(segmentValue, label = "segment") {
136
+ export function validateFlowCLocalSegmentTimeline(segmentValue, label = "segment", segmentSeconds = 10) {
137
137
  const segment = recordOf(segmentValue);
138
138
  const shots = Array.isArray(segment?.shots) ? segment.shots : [];
139
139
  if (!shots.length || shots.length > 8)
@@ -143,13 +143,13 @@ export function validateFlowCLocalSegmentTimeline(segmentValue, label = "segment
143
143
  const shot = recordOf(shotValue);
144
144
  const startSeconds = preciseSecond(shot?.startSeconds);
145
145
  const endSeconds = preciseSecond(shot?.endSeconds);
146
- if (startSeconds !== cursor || startSeconds < 0 || startSeconds > 10 || endSeconds <= startSeconds || endSeconds > 10) {
147
- throw new Error(`${label} shot ${index + 1} must continue the local 0-10 second timeline without gaps or overlaps`);
146
+ if (startSeconds !== cursor || startSeconds < 0 || startSeconds > segmentSeconds || endSeconds <= startSeconds || endSeconds > segmentSeconds) {
147
+ throw new Error(`${label} shot ${index + 1} must continue the local 0-${segmentSeconds} second timeline without gaps or overlaps`);
148
148
  }
149
149
  cursor = endSeconds;
150
150
  }
151
- if (cursor !== 10)
152
- throw new Error(`${label} must end at exactly 10 seconds`);
151
+ if (cursor !== segmentSeconds)
152
+ throw new Error(`${label} must end at exactly ${segmentSeconds} seconds`);
153
153
  return segmentValue;
154
154
  }
155
155
  function preciseSecond(value) {
@@ -161,7 +161,7 @@ function preciseSecond(value) {
161
161
  * 文字复述角色/场景,中心严格校验时会误判不连续;这里复制模型自己写的
162
162
  * endingState,不发明新内容,同时保证生成阶段真正无缝承接。
163
163
  */
164
- function canonicalizeSegmentContinuity(jobValue) {
164
+ function canonicalizeSegmentContinuity(jobValue, segmentSeconds = 10) {
165
165
  const job = recordOf(jobValue);
166
166
  if (!job)
167
167
  return jobValue;
@@ -171,7 +171,7 @@ function canonicalizeSegmentContinuity(jobValue) {
171
171
  if (!segment)
172
172
  return jobValue;
173
173
  const canonical = { ...segment, continuityMode: "reset", continuity: continuityFromOpeningState(openingState) };
174
- const rendered = withLegacySegmentScript(canonical, 0, 1, job.voiceProfile);
174
+ const rendered = withLegacySegmentScript(canonical, 0, 1, job.voiceProfile, segmentSeconds);
175
175
  const { openingState: _openingState, segment: _segment, ...persistedJob } = job;
176
176
  return { ...persistedJob, script: recordOf(rendered)?.script, segmentVoiceovers: [segmentVoiceoverLines(canonical)], segment: rendered };
177
177
  }
@@ -190,7 +190,7 @@ function canonicalizeSegmentContinuity(jobValue) {
190
190
  return { ...segment, continuityMode: "continue", continuity };
191
191
  });
192
192
  const { openingState: _openingState, ...persistedJob } = job;
193
- const renderedSegments = segments.map((segment, index) => withLegacySegmentScript(segment, index, segments.length, job.voiceProfile));
193
+ const renderedSegments = segments.map((segment, index) => withLegacySegmentScript(segment, index, segments.length, job.voiceProfile, segmentSeconds));
194
194
  const masterScript = renderedSegments.map((segment) => String(recordOf(segment)?.script || "").trim()).filter(Boolean).join("\n\n");
195
195
  return { ...persistedJob, script: masterScript, masterScript, segmentVoiceovers: segments.map(segmentVoiceoverLines), segments: renderedSegments };
196
196
  }
@@ -208,7 +208,7 @@ function continuityFromOpeningState(openingState) {
208
208
  * 正式站可能仍运行只接收 segment.script 的旧协议。脚本正文直接由同一份
209
209
  * structured shots/continuity 渲染,既不要求模型重复输出,也不改变新协议内容。
210
210
  */
211
- function withLegacySegmentScript(segmentValue, segmentIndex, segmentCount, voiceProfileValue) {
211
+ function withLegacySegmentScript(segmentValue, segmentIndex, segmentCount, voiceProfileValue, segmentSeconds = 10) {
212
212
  const segment = recordOf(segmentValue);
213
213
  const continuity = recordOf(segment?.continuity);
214
214
  const endingState = recordOf(segment?.endingState);
@@ -220,7 +220,7 @@ function withLegacySegmentScript(segmentValue, segmentIndex, segmentCount, voice
220
220
  const ending = `CHARACTER: ${endingState.character}; WARDROBE: ${endingState.wardrobe}; LOCATION: ${endingState.location}; LIGHTING: ${endingState.lighting}; PRODUCT STATE: ${endingState.productState}; UNFINISHED ACTION: ${endingState.unfinishedAction}; NEXT GOAL: ${endingState.nextGoal}; ENDING FRAME: ${endingState.endingFrame}`;
221
221
  const voice = voiceProfile ? `GENDER: ${voiceProfile.gender}; AGE IMPRESSION: ${voiceProfile.ageImpression}; PITCH: ${voiceProfile.pitch}; TIMBRE: ${voiceProfile.timbre}; SPEAKING RATE: ${voiceProfile.speakingRate}; ACCENT: ${voiceProfile.accent}; PAUSE HABIT: ${voiceProfile.pauseHabit}; EMOTIONAL BASELINE: ${voiceProfile.emotionalBaseline}` : "Use the shared voice profile for this video";
222
222
  const timeline = shots.map((shot, index) => `SHOT ${index + 1} | ${shot.startSeconds}-${shot.endSeconds}s | VISUAL: ${shot.visual} | VO: ${shot.voiceover} | ON-SCREEN TEXT: ${shot.onScreenText} | EVIDENCE: ${shot.evidence} | SOUND/BGM: ${shot.soundBgm} | EMOTION: ${shot.emotionalNote}`).join("\n");
223
- return { ...segment, script: `FLOW C INDEPENDENT SEGMENT ${segmentIndex + 1}/${segmentCount}\nLOCAL TIMELINE: 0-10 seconds only. Never reference or draw a full-video timeline.\nVOICE PROFILE: ${voice}\nSEGMENT VOICE CUE: ${segment.voiceCue}\nOPENING CONTINUITY: ${context}\n${timeline}\nENDING STATE: ${ending}` };
223
+ return { ...segment, script: `FLOW C INDEPENDENT SEGMENT ${segmentIndex + 1}/${segmentCount}\nLOCAL TIMELINE: 0-${segmentSeconds} seconds only. Never reference or draw a full-video timeline.\nVOICE PROFILE: ${voice}\nSEGMENT VOICE CUE: ${segment.voiceCue}\nOPENING CONTINUITY: ${context}\n${timeline}\nENDING STATE: ${ending}` };
224
224
  }
225
225
  function recordOf(value) {
226
226
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.81",
3
+ "version": "0.4.83",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",