@remixmate/cli 0.9.35 → 0.9.37

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,24 @@
1
1
  export declare function traceHeaders(): Record<string, string>;
2
- export declare function cleanDiagnosticText(value: string): string;
2
+ /**
3
+ * Redact a string for anything that leaves this process (diagnostic lines,
4
+ * execution traces).
5
+ *
6
+ * Kept in lockstep with ab-agent's `lib/telemetry.cleanText` — the two feed the
7
+ * same `media_chat_turn_trace` table, and a table whose rows were sanitized by
8
+ * two different rulesets is a table nobody can reason about. ANSI stripping,
9
+ * e-mail and phone redaction were added here to close that gap.
10
+ *
11
+ * `max` caps the result in BYTES, keeping the head. Callers that need the tail
12
+ * (a command's decisive line is its last one) slice before calling.
13
+ */
14
+ export declare function cleanDiagnosticText(value: string, max?: number): string;
15
+ /**
16
+ * Exit code → the outcome vocabulary of the logging contract.
17
+ *
18
+ * Exported because the execution trace (project/trace.ts) reports the same
19
+ * verdict for the same run: two mappings would let a run be `failure` in the
20
+ * diagnostic line and `success` in the database.
21
+ */
22
+ export declare function outcomeOf(exitCode: number): 'success' | 'cancelled' | 'rejected' | 'failure';
3
23
  /** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
4
24
  export declare function diagnostic(exitCode: number, durationMs: number, error?: unknown): void;
@@ -8,8 +8,22 @@ export function traceHeaders() {
8
8
  process.env.AB_OPERATION_ID ||= randomUUID();
9
9
  return { traceparent: header };
10
10
  }
11
- export function cleanDiagnosticText(value) {
12
- let text = value.replace(/https?:\/\/[^\s"'<>]+/g, raw => {
11
+ /**
12
+ * Redact a string for anything that leaves this process (diagnostic lines,
13
+ * execution traces).
14
+ *
15
+ * Kept in lockstep with ab-agent's `lib/telemetry.cleanText` — the two feed the
16
+ * same `media_chat_turn_trace` table, and a table whose rows were sanitized by
17
+ * two different rulesets is a table nobody can reason about. ANSI stripping,
18
+ * e-mail and phone redaction were added here to close that gap.
19
+ *
20
+ * `max` caps the result in BYTES, keeping the head. Callers that need the tail
21
+ * (a command's decisive line is its last one) slice before calling.
22
+ */
23
+ export function cleanDiagnosticText(value, max = 2048) {
24
+ let text = value
25
+ .replace(/\x1b\[[0-9;]*m/g, '')
26
+ .replace(/https?:\/\/[^\s"'<>]+/g, raw => {
13
27
  try {
14
28
  const url = new URL(raw);
15
29
  return url.origin + url.pathname;
@@ -17,19 +31,36 @@ export function cleanDiagnosticText(value) {
17
31
  catch {
18
32
  return '[url]';
19
33
  }
20
- }).replace(/(Bearer\s+|(?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;}]+/gi, '$1[redacted]');
34
+ })
35
+ .replace(/(Bearer\s+|(?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;}]+/gi, '$1[redacted]')
36
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
37
+ .replace(/\b1[3-9]\d{9}\b/g, '[phone]');
21
38
  for (const [key, secret] of Object.entries(process.env)) {
22
39
  if (/TOKEN|SECRET|PASSWORD|API_KEY/i.test(key) && secret && secret.length >= 6)
23
40
  text = text.split(secret).join('[redacted]');
24
41
  }
25
- return Buffer.from(text).subarray(0, 2048).toString('utf8');
42
+ return Buffer.from(text).subarray(0, max).toString('utf8');
43
+ }
44
+ /**
45
+ * Exit code → the outcome vocabulary of the logging contract.
46
+ *
47
+ * Exported because the execution trace (project/trace.ts) reports the same
48
+ * verdict for the same run: two mappings would let a run be `failure` in the
49
+ * diagnostic line and `success` in the database.
50
+ */
51
+ export function outcomeOf(exitCode) {
52
+ if (exitCode === 0)
53
+ return 'success';
54
+ if (exitCode === 130)
55
+ return 'cancelled';
56
+ return [2, 3, 4].includes(exitCode) ? 'rejected' : 'failure';
26
57
  }
27
58
  /** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
28
59
  export function diagnostic(exitCode, durationMs, error) {
29
60
  if (process.env.AB_DIAGNOSTICS !== '1')
30
61
  return;
31
62
  traceHeaders();
32
- const outcome = exitCode === 0 ? 'success' : exitCode === 130 ? 'cancelled' : [2, 3, 4].includes(exitCode) ? 'rejected' : 'failure';
63
+ const outcome = outcomeOf(exitCode);
33
64
  const record = { schema_version: 1, timestamp: new Date().toISOString(), service: 'remixmate-cli', environment: process.env.APP_ENV ?? 'dev',
34
65
  level: outcome === 'failure' ? 'error' : 'info', event: 'cli.execution.completed', message: 'CLI execution completed', outcome,
35
66
  trace_id: process.env.AB_TRACEPARENT?.split('-')[1], operation_id: process.env.AB_OPERATION_ID,
@@ -88,13 +88,26 @@ export async function genVoice(input, ctxIn) {
88
88
  if (speed < voice.speedMin || speed > voice.speedMax) {
89
89
  throw new SkillError(`❌ speed must be in the range ${voice.speedMin}~${voice.speedMax}`);
90
90
  }
91
+ // Language hint for mixed-script narration. Like `local`, this is deliberately
92
+ // absent from skill.json's `parameters`: the service already applies a sane
93
+ // default for every caller, so handing the model a 40-value enum only invites
94
+ // it to guess. Reachable from the DSL (global/scene `narration.languageBoost`,
95
+ // forwarded by render_video) and by typing the flag yourself. Omitted =
96
+ // the service default.
97
+ const languageBoost = input.language_boost?.trim() || undefined;
91
98
  const ctx = await resolveHttpContext(ctxIn.skillName, {
92
99
  apiBaseUrl,
93
100
  privateToken: input.priv_token,
94
101
  preflightToken: ctxIn.auth?.token,
95
102
  });
96
103
  emitProgress({ phase: 'gen-voice:request', voiceId, speed });
97
- const data = await mmPost(ctx, '/tool/tts', { provider: voice.provider, text, voiceSetting: { voiceId, speed }, outputFormat: 'url' }, { timeoutMs: 120_000 });
104
+ const data = await mmPost(ctx, '/tool/tts', {
105
+ provider: voice.provider,
106
+ text,
107
+ voiceSetting: { voiceId, speed },
108
+ outputFormat: 'url',
109
+ ...(languageBoost ? { languageBoost } : {}),
110
+ }, { timeoutMs: 120_000 });
98
111
  const audioUrl = data.audio;
99
112
  if (!audioUrl) {
100
113
  throw new SkillError(`❌ API response did not contain an audio URL: ${JSON.stringify(data)}`);
package/dist/install.d.ts CHANGED
@@ -37,6 +37,7 @@ export declare function detectHosts(): Host[];
37
37
  * 完全看不到它,只会以为这个 CLI 不支持 —— 正是这次 WorkBuddy 的翻车方式。
38
38
  */
39
39
  export declare function projectOnlyHosts(): HostId[];
40
+ export declare function packageVersion(): string;
40
41
  export declare function installUsage(): void;
41
42
  export declare function uninstallUsage(): void;
42
43
  export declare function runInstall(argv: string[]): number;
package/dist/install.js CHANGED
@@ -147,7 +147,7 @@ export function detectHosts() {
147
147
  export function projectOnlyHosts() {
148
148
  return HOST_IDS.filter((id) => isInstalled(id) && !spec(id).userDirName);
149
149
  }
150
- function packageVersion() {
150
+ export function packageVersion() {
151
151
  try {
152
152
  const raw = readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf-8');
153
153
  return JSON.parse(raw).version ?? 'unknown';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.35",
4
- "generatedAt": "2026-09-18T05:50:52.540Z",
3
+ "version": "0.9.37",
4
+ "generatedAt": "2026-09-21T03:49:51.210Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -350,18 +350,6 @@
350
350
  "type": "string",
351
351
  "description": "Video topic (required)"
352
352
  },
353
- "platform": {
354
- "type": "string",
355
- "enum": [
356
- "douyin",
357
- "xiaohongshu",
358
- "bilibili",
359
- "wechat",
360
- "youtube",
361
- "generic"
362
- ],
363
- "description": "Target platform"
364
- },
365
353
  "duration": {
366
354
  "type": "number",
367
355
  "description": "Target duration in seconds"
@@ -442,7 +430,6 @@
442
430
  ],
443
431
  "advanced": [],
444
432
  "hidden": [
445
- "platform",
446
433
  "duration",
447
434
  "style",
448
435
  "ratio",
@@ -23,6 +23,10 @@ export interface EnsuredTake {
23
23
  projectId: string;
24
24
  created: boolean;
25
25
  attempt: number;
26
+ /** The grouping key this take was resolved under — reused as the trace's session id. */
27
+ sessionKey: string;
28
+ /** Provenance label of the host we're running inside ('' when unknown). */
29
+ host: string;
26
30
  }
27
31
  /**
28
32
  * Look up the take for the current grouping key without creating anything.
@@ -62,6 +62,8 @@ export async function findTake(ctx) {
62
62
  projectId: res.projectId ?? '',
63
63
  created: false,
64
64
  attempt: res.attempt ?? 0,
65
+ sessionKey: takeKey.key,
66
+ host: takeKey.host,
65
67
  };
66
68
  }
67
69
  /**
@@ -98,5 +100,7 @@ export async function ensureTake(ctx, opts) {
98
100
  projectId,
99
101
  created: res.created,
100
102
  attempt: res.attempt,
103
+ sessionKey: takeKey.key,
104
+ host: takeKey.host,
101
105
  };
102
106
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Execution-trace self-reporting — tell the backend what this invocation
3
+ * actually did, so a locally-run pipeline is not a blind spot.
4
+ *
5
+ * Why this exists: the cloud agent records every tool call, its arguments AND
6
+ * its result into `media_chat_turn_trace` (ab-agent/src/trace-persist.ts). The
7
+ * local path records nothing — a take created from codex / claude code has an
8
+ * empty conversation, zero tool calls and zero credits in the analytics tables,
9
+ * so every session-level metric silently excludes it.
10
+ *
11
+ * What we can honestly report and what we cannot: the CLI is a child process
12
+ * spawned once per skill call. It never sees the user's prompt or the host's
13
+ * reply — those live in the host's own transcript. So this module reports the
14
+ * one thing it genuinely observes: the invocation itself.
15
+ *
16
+ * See docs/cli-session-record-design.md §5.
17
+ */
18
+ import { type HttpContext } from '../http.js';
19
+ import type { ParsedArgs } from '../argv.js';
20
+ import type { EnsuredTake } from './take.js';
21
+ export interface TraceOptions {
22
+ skillName: string;
23
+ args: ParsedArgs;
24
+ take: EnsuredTake;
25
+ ctx: HttpContext;
26
+ }
27
+ /**
28
+ * Whether this invocation should report at all.
29
+ *
30
+ * The rule is deliberately narrow: report only when THIS process resolved the
31
+ * take. Everything else is already covered by someone else —
32
+ * - under the cloud agent, CONVERSATION_ID arrives pre-set and ab-agent's own
33
+ * TurnTracer already recorded the very same call;
34
+ * - a nested `gen-image` spawned by prepare-video-assets inherits its
35
+ * parent's CONVERSATION_ID, and the parent's row already covers it.
36
+ * Since runner.ts only builds a tracer after resolving a take itself, this
37
+ * function just honours the kill switch.
38
+ */
39
+ export declare function tracingEnabled(env?: NodeJS.ProcessEnv): boolean;
40
+ /**
41
+ * Collects one invocation's evidence and posts it as a single turn.
42
+ *
43
+ * Lifecycle (runner.ts): `begin()` → output flows through the tap → `finish()`
44
+ * in a finally block. Every failure mode ends in a warning on stderr: a lost
45
+ * trace row must never cost the user a render they already paid for.
46
+ */
47
+ export declare class InvocationTrace {
48
+ private readonly opts;
49
+ private chunks;
50
+ private restore;
51
+ private flushed;
52
+ private constructor();
53
+ /** Returns null when tracing is off — callers can then skip output capture entirely. */
54
+ static begin(opts: TraceOptions): InvocationTrace | null;
55
+ /**
56
+ * Tap both output streams.
57
+ *
58
+ * One tap covers both dispatch paths: in-process handlers write here
59
+ * directly, and the Python child's piped output is re-emitted through the
60
+ * same `write` (see runner.ts). Restored in finish().
61
+ */
62
+ installCapture(): void;
63
+ /** Feed captured output. Keeps a bounded tail — the decisive line is the last one. */
64
+ absorb(chunk: unknown): void;
65
+ /**
66
+ * Emit the turn. Idempotent; never throws.
67
+ *
68
+ * `error` is the dispatcher's caught exception when there is one — a Python
69
+ * child that exits non-zero gives us no exception, only its output, which is
70
+ * exactly why the tail buffer exists.
71
+ */
72
+ finish(exitCode: number, durationMs: number, error?: unknown): Promise<void>;
73
+ }
74
+ /**
75
+ * Flags → a trace-safe object.
76
+ *
77
+ * Credentials are dropped outright; long strings are cut to their opening
78
+ * because an argument like `--prompt` is *content*, and this table is evidence,
79
+ * not an archive of what the user wrote.
80
+ */
81
+ export declare function sanitiseArgs(args: ParsedArgs): Record<string, unknown>;
82
+ /**
83
+ * Captured output → the tail we store.
84
+ *
85
+ * Two transformations before truncating, both about spending the 16KB on
86
+ * things a human would read:
87
+ * - progress lines are the machine protocol — one fat JSON object per phase,
88
+ * marked by a `__progress__` field (progress.ts, and the same shape from the
89
+ * Python skills). Their payload is noise here, but WHICH PHASE a failed run
90
+ * reached is the single most useful fact about it, so the line collapses to
91
+ * its phase name instead of being dropped.
92
+ * - `__diagnostic_v1__` lines are our own structured log of this very run;
93
+ * keeping them would store the same verdict twice.
94
+ *
95
+ * Then keeps the TAIL, unlike ab-agent's head-first cleanText: a command's
96
+ * verdict — the error, or the URL it produced — is its last line.
97
+ */
98
+ export declare function condense(raw: string): string;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Execution-trace self-reporting — tell the backend what this invocation
3
+ * actually did, so a locally-run pipeline is not a blind spot.
4
+ *
5
+ * Why this exists: the cloud agent records every tool call, its arguments AND
6
+ * its result into `media_chat_turn_trace` (ab-agent/src/trace-persist.ts). The
7
+ * local path records nothing — a take created from codex / claude code has an
8
+ * empty conversation, zero tool calls and zero credits in the analytics tables,
9
+ * so every session-level metric silently excludes it.
10
+ *
11
+ * What we can honestly report and what we cannot: the CLI is a child process
12
+ * spawned once per skill call. It never sees the user's prompt or the host's
13
+ * reply — those live in the host's own transcript. So this module reports the
14
+ * one thing it genuinely observes: the invocation itself.
15
+ *
16
+ * See docs/cli-session-record-design.md §5.
17
+ */
18
+ import os from 'node:os';
19
+ import { cleanDiagnosticText, outcomeOf } from '../diagnostics.js';
20
+ import { billingSummary } from '../billing.js';
21
+ import { packageVersion } from '../install.js';
22
+ import { mmPost } from '../http.js';
23
+ /** Same ceiling ab-agent uses for a tool result — one table, one limit. */
24
+ const TOOL_RESULT_MAX = 16 * 1024;
25
+ /** Raw bytes held before trimming. Generous: trimming is the tail, cleaning is cheap. */
26
+ const RAW_CAPTURE_MAX = 64 * 1024;
27
+ /** Per-argument ceiling. Prompts and narration bodies are content, not evidence. */
28
+ const ARG_VALUE_MAX = 512;
29
+ /** `error_summary` is varchar(1024). */
30
+ const ERROR_SUMMARY_MAX = 1024;
31
+ const FLUSH_TIMEOUT_MS = 10_000;
32
+ const SENSITIVE_KEY = /token|secret|password|api[_-]?key|authorization/i;
33
+ /**
34
+ * Whether this invocation should report at all.
35
+ *
36
+ * The rule is deliberately narrow: report only when THIS process resolved the
37
+ * take. Everything else is already covered by someone else —
38
+ * - under the cloud agent, CONVERSATION_ID arrives pre-set and ab-agent's own
39
+ * TurnTracer already recorded the very same call;
40
+ * - a nested `gen-image` spawned by prepare-video-assets inherits its
41
+ * parent's CONVERSATION_ID, and the parent's row already covers it.
42
+ * Since runner.ts only builds a tracer after resolving a take itself, this
43
+ * function just honours the kill switch.
44
+ */
45
+ export function tracingEnabled(env = process.env) {
46
+ return (env.REMIXMATE_TRACE ?? '').trim() !== '0';
47
+ }
48
+ /**
49
+ * Collects one invocation's evidence and posts it as a single turn.
50
+ *
51
+ * Lifecycle (runner.ts): `begin()` → output flows through the tap → `finish()`
52
+ * in a finally block. Every failure mode ends in a warning on stderr: a lost
53
+ * trace row must never cost the user a render they already paid for.
54
+ */
55
+ export class InvocationTrace {
56
+ opts;
57
+ chunks = '';
58
+ restore = null;
59
+ flushed = false;
60
+ constructor(opts) {
61
+ this.opts = opts;
62
+ }
63
+ /** Returns null when tracing is off — callers can then skip output capture entirely. */
64
+ static begin(opts) {
65
+ if (!tracingEnabled())
66
+ return null;
67
+ if (!opts.take.conversationId)
68
+ return null;
69
+ return new InvocationTrace(opts);
70
+ }
71
+ /**
72
+ * Tap both output streams.
73
+ *
74
+ * One tap covers both dispatch paths: in-process handlers write here
75
+ * directly, and the Python child's piped output is re-emitted through the
76
+ * same `write` (see runner.ts). Restored in finish().
77
+ */
78
+ installCapture() {
79
+ if (this.restore)
80
+ return;
81
+ const out = process.stdout.write.bind(process.stdout);
82
+ const err = process.stderr.write.bind(process.stderr);
83
+ const tap = (original) => ((chunk, ...rest) => {
84
+ this.absorb(chunk);
85
+ return original(chunk, ...rest);
86
+ });
87
+ process.stdout.write = tap(out);
88
+ process.stderr.write = tap(err);
89
+ this.restore = () => {
90
+ process.stdout.write = out;
91
+ process.stderr.write = err;
92
+ };
93
+ }
94
+ /** Feed captured output. Keeps a bounded tail — the decisive line is the last one. */
95
+ absorb(chunk) {
96
+ try {
97
+ const text = typeof chunk === 'string'
98
+ ? chunk
99
+ : chunk instanceof Uint8Array
100
+ ? Buffer.from(chunk).toString('utf8')
101
+ : '';
102
+ if (!text)
103
+ return;
104
+ this.chunks += text;
105
+ if (this.chunks.length > RAW_CAPTURE_MAX * 2) {
106
+ this.chunks = this.chunks.slice(-RAW_CAPTURE_MAX);
107
+ }
108
+ }
109
+ catch {
110
+ // Capturing must never break the thing being captured.
111
+ }
112
+ }
113
+ /**
114
+ * Emit the turn. Idempotent; never throws.
115
+ *
116
+ * `error` is the dispatcher's caught exception when there is one — a Python
117
+ * child that exits non-zero gives us no exception, only its output, which is
118
+ * exactly why the tail buffer exists.
119
+ */
120
+ async finish(exitCode, durationMs, error) {
121
+ this.restore?.();
122
+ this.restore = null;
123
+ if (this.flushed)
124
+ return;
125
+ this.flushed = true;
126
+ const { skillName, args, take, ctx } = this.opts;
127
+ const output = condense(this.chunks);
128
+ const outcome = outcomeOf(exitCode);
129
+ const isError = exitCode !== 0;
130
+ const rows = [];
131
+ let stepSeq = 0;
132
+ // One env row per take, not per call: it describes the toolchain, and the
133
+ // toolchain does not change between two calls of the same pipeline.
134
+ if (take.created) {
135
+ rows.push({ sessionId: take.sessionKey, stepSeq: stepSeq++, kind: 'env', env: envSnapshot(take) });
136
+ }
137
+ rows.push({
138
+ sessionId: take.sessionKey,
139
+ stepSeq: stepSeq++,
140
+ kind: 'tool_call',
141
+ toolName: `cli__${skillName.replace(/-/g, '_')}`,
142
+ toolUseId: (process.env.AB_OPERATION_ID ?? '').slice(0, 64),
143
+ toolInput: sanitiseArgs(args),
144
+ toolResult: output || '[no output captured]',
145
+ isError,
146
+ durationMs: Math.round(durationMs),
147
+ });
148
+ rows.push({
149
+ sessionId: take.sessionKey,
150
+ stepSeq: stepSeq++,
151
+ kind: 'turn_result',
152
+ outcome,
153
+ errorSummary: isError ? summariseError(error, output) : '',
154
+ credits: billingSummary()?.credits ?? 0,
155
+ });
156
+ try {
157
+ await mmPost(ctx, `/chat/conversation/${encodeURIComponent(take.conversationId)}/trace`,
158
+ // appendTurn: the server allocates turn_seq. N unrelated processes
159
+ // cannot number themselves, and cli-project-binding §5.1 already ruled
160
+ // out keeping local state. Older backends ignore the field and every
161
+ // row lands on turn 0 — still ordered, just ungrouped.
162
+ { appendTurn: true, traces: rows }, { timeoutMs: FLUSH_TIMEOUT_MS });
163
+ }
164
+ catch (err) {
165
+ process.stderr.write(`⚠️ 执行轨迹未能上报(不影响本次产物): ${cleanDiagnosticText(err.message, 256)}\n`);
166
+ }
167
+ }
168
+ }
169
+ /** Which rung of the grouping ladder produced this key (cli-project-binding §5.1). */
170
+ function keySource(key) {
171
+ if (key.startsWith('custom:'))
172
+ return 'env-override';
173
+ if (key.startsWith('job:'))
174
+ return 'render-job';
175
+ return 'host';
176
+ }
177
+ /**
178
+ * What this take was produced by. Mirrors ab-agent's envSnapshot() in purpose:
179
+ * answer "which version started failing" without storing anything large.
180
+ */
181
+ function envSnapshot(take) {
182
+ return {
183
+ // The discriminator every cross-channel metric needs — a CLI "turn" is one
184
+ // skill call, a cloud turn is a whole user request. Never compare blindly.
185
+ channel: 'cli',
186
+ cli_version: packageVersion(),
187
+ host: take.host || 'unknown',
188
+ take_key_source: keySource(take.sessionKey),
189
+ node: process.version,
190
+ platform: `${os.platform()}-${os.arch()}`,
191
+ };
192
+ }
193
+ /**
194
+ * Flags → a trace-safe object.
195
+ *
196
+ * Credentials are dropped outright; long strings are cut to their opening
197
+ * because an argument like `--prompt` is *content*, and this table is evidence,
198
+ * not an archive of what the user wrote.
199
+ */
200
+ export function sanitiseArgs(args) {
201
+ const out = {};
202
+ for (const [key, value] of Object.entries(args).slice(0, 40)) {
203
+ if (SENSITIVE_KEY.test(key)) {
204
+ out[key] = '[redacted]';
205
+ continue;
206
+ }
207
+ if (typeof value === 'boolean') {
208
+ out[key] = value;
209
+ continue;
210
+ }
211
+ if (Array.isArray(value)) {
212
+ out[key] = value.slice(0, 20).map((v) => cleanDiagnosticText(String(v), ARG_VALUE_MAX));
213
+ continue;
214
+ }
215
+ out[key] = cleanDiagnosticText(String(value), ARG_VALUE_MAX);
216
+ }
217
+ return out;
218
+ }
219
+ /**
220
+ * Captured output → the tail we store.
221
+ *
222
+ * Two transformations before truncating, both about spending the 16KB on
223
+ * things a human would read:
224
+ * - progress lines are the machine protocol — one fat JSON object per phase,
225
+ * marked by a `__progress__` field (progress.ts, and the same shape from the
226
+ * Python skills). Their payload is noise here, but WHICH PHASE a failed run
227
+ * reached is the single most useful fact about it, so the line collapses to
228
+ * its phase name instead of being dropped.
229
+ * - `__diagnostic_v1__` lines are our own structured log of this very run;
230
+ * keeping them would store the same verdict twice.
231
+ *
232
+ * Then keeps the TAIL, unlike ab-agent's head-first cleanText: a command's
233
+ * verdict — the error, or the URL it produced — is its last line.
234
+ */
235
+ export function condense(raw) {
236
+ if (!raw)
237
+ return '';
238
+ const compacted = raw
239
+ .split('\n')
240
+ .map((line) => {
241
+ const trimmed = line.trim();
242
+ if (trimmed.startsWith('__diagnostic_v1__'))
243
+ return null;
244
+ if (!trimmed.startsWith('{') || !trimmed.includes('"__progress__"'))
245
+ return line;
246
+ try {
247
+ const phase = JSON.parse(trimmed).phase;
248
+ return typeof phase === 'string' ? `· phase=${phase}` : '· progress';
249
+ }
250
+ catch {
251
+ return '· progress';
252
+ }
253
+ })
254
+ .filter((line) => line !== null)
255
+ .join('\n')
256
+ .trim();
257
+ // Slice the tail before cleaning: cleanDiagnosticText truncates head-first.
258
+ const tail = compacted.length > TOOL_RESULT_MAX ? compacted.slice(-TOOL_RESULT_MAX) : compacted;
259
+ return cleanDiagnosticText(tail, TOOL_RESULT_MAX);
260
+ }
261
+ /**
262
+ * The one-line verdict.
263
+ *
264
+ * A thrown SkillError carries it directly. A Python child does not throw — it
265
+ * just exits non-zero, so fall back to the last line it printed, which is where
266
+ * argparse errors, tracebacks and our own ❌ messages end up.
267
+ */
268
+ function summariseError(error, output) {
269
+ if (error instanceof Error && error.message) {
270
+ return cleanDiagnosticText(error.message, ERROR_SUMMARY_MAX);
271
+ }
272
+ const lastLine = output
273
+ .split('\n')
274
+ .map((l) => l.trim())
275
+ .filter((l) => l && !l.startsWith('· phase='))
276
+ .pop();
277
+ return lastLine ? cleanDiagnosticText(lastLine, ERROR_SUMMARY_MAX) : '';
278
+ }
package/dist/runner.js CHANGED
@@ -19,6 +19,7 @@ import { authChildEnv, ensureAuth } from './auth/ensure.js';
19
19
  import { emitBillingFooter } from './billing.js';
20
20
  import { resolveHttpContext } from './http.js';
21
21
  import { ensureTake, findTake } from './project/take.js';
22
+ import { InvocationTrace } from './project/trace.js';
22
23
  /** Read the token override accepted by both the TS handlers and the Python skills. */
23
24
  function tokenFlag(args) {
24
25
  const value = args.token ?? args.priv_token;
@@ -28,6 +29,7 @@ export async function runSkill(skillName, opts) {
28
29
  const started = performance.now();
29
30
  let exitCode = EXIT.ERROR;
30
31
  let failure;
32
+ let trace = null;
31
33
  traceHeaders();
32
34
  const skill = findSkill(skillName, opts.baseDir ?? SKILLS_DIR);
33
35
  if (!skill) {
@@ -41,25 +43,43 @@ export async function runSkill(skillName, opts) {
41
43
  apiBaseUrl: typeof opts.parsedArgs.api_base_url === 'string' ? opts.parsedArgs.api_base_url : undefined,
42
44
  flagToken: tokenFlag(opts.parsedArgs),
43
45
  });
44
- await applyTakeContext(skill, opts, auth);
46
+ const attached = await applyTakeContext(skill, opts, auth);
47
+ if (attached) {
48
+ // Only a take THIS process resolved gets reported — see trace.ts for why
49
+ // the cloud path and nested pipeline children must stay silent.
50
+ trace = InvocationTrace.begin({
51
+ skillName: skill.name,
52
+ args: opts.parsedArgs,
53
+ take: attached.take,
54
+ ctx: attached.ctx,
55
+ });
56
+ trace?.installCapture();
57
+ }
45
58
  switch (skill.entry.type) {
46
59
  case 'python':
47
60
  // Python children talk to ab-api themselves and print their own billing
48
61
  // footer; nothing was charged through this process.
49
- return exitCode = await runPython(skill, opts.rawArgs, auth);
62
+ exitCode = await runPython(skill, opts.rawArgs, auth, trace);
63
+ break;
50
64
  case 'http':
51
65
  case 'builtin':
52
- return exitCode = await runHandler(skill, opts.parsedArgs, auth);
66
+ exitCode = await runHandler(skill, opts.parsedArgs, auth);
67
+ break;
53
68
  }
54
69
  }
55
70
  catch (err) {
56
71
  failure = err;
57
72
  if (err instanceof SkillError) {
58
73
  process.stderr.write(cleanDiagnosticText(err.message) + '\n');
59
- return exitCode = err.exitCode;
74
+ exitCode = err.exitCode;
60
75
  }
61
- process.stderr.write(`❌ unexpected error: ${cleanDiagnosticText(err.stack ?? err.message)}\n`);
62
- return EXIT.ERROR;
76
+ else {
77
+ process.stderr.write(`❌ unexpected error: ${cleanDiagnosticText(err.stack ?? err.message)}\n`);
78
+ exitCode = EXIT.ERROR;
79
+ }
80
+ // Single exit point below: the trace flush in `finally` must run before the
81
+ // value leaves, and an `await` there defeats TS's reachability analysis on
82
+ // returns made from inside the try/catch.
63
83
  }
64
84
  finally {
65
85
  // In `finally` because a run can fail *after* a billed step (e.g. the image
@@ -67,7 +87,12 @@ export async function runSkill(skillName, opts) {
67
87
  // reported either way. No-ops when nothing was charged.
68
88
  diagnostic(exitCode, performance.now() - started, failure);
69
89
  emitBillingFooter();
90
+ // Last, so the billing footer is still inside the captured tail. Never
91
+ // throws and never changes the exit code — a lost trace row must not cost
92
+ // the user a render they already paid for.
93
+ await trace?.finish(exitCode, performance.now() - started, failure);
70
94
  }
95
+ return exitCode;
71
96
  }
72
97
  /**
73
98
  * Decide the take mode from the skill's declaration *and* what this particular
@@ -118,11 +143,11 @@ export function resolveTakeMode(skill, args) {
118
143
  async function applyTakeContext(skill, opts, auth) {
119
144
  const mode = resolveTakeMode(skill, opts.parsedArgs);
120
145
  if (mode === 'none')
121
- return;
146
+ return null;
122
147
  if ((process.env.CONVERSATION_ID ?? '').trim())
123
- return;
148
+ return null;
124
149
  if (!auth.token)
125
- return;
150
+ return null;
126
151
  try {
127
152
  const ctx = await resolveHttpContext(skill.name, {
128
153
  apiBaseUrl: auth.apiBaseUrl,
@@ -142,16 +167,32 @@ async function applyTakeContext(skill, opts, auth) {
142
167
  take = await findTake(ctx);
143
168
  }
144
169
  if (!take)
145
- return;
170
+ return null;
146
171
  process.env.CONVERSATION_ID = take.conversationId;
147
172
  process.stdout.write(`📁 take ${take.created ? 'created' : 'reused'}: ${take.videoProjectId} ` +
148
173
  `(project ${take.projectId}${take.attempt ? `, attempt ${take.attempt}` : ''})\n`);
174
+ return { take, ctx };
149
175
  }
150
176
  catch (err) {
151
177
  process.stderr.write(`⚠️ 无法归属本次产物(内容仍会正常生成,但不会出现在项目里): ${cleanDiagnosticText(err.message)}\n`);
178
+ return null;
152
179
  }
153
180
  }
154
- async function runPython(skill, rawArgs, auth) {
181
+ /**
182
+ * Spawn the Python skill.
183
+ *
184
+ * `stdio: 'inherit'` is the default and stays the default: the child writes
185
+ * straight to the terminal, which is exactly what a CLI should do. It does mean
186
+ * the parent sees nothing, so a failed render can only ever be reported as
187
+ * "exit code 1" — for the most valuable step in the pipeline.
188
+ *
189
+ * So when (and only when) we are tracing, stdout/stderr are piped and re-emitted
190
+ * verbatim, which routes them through the capture tap. stdin stays inherited so
191
+ * interactive prompts still work. Resolving on `close` rather than `exit` in
192
+ * that mode matters: `exit` can fire while the last chunk is still in the pipe,
193
+ * which is precisely the chunk holding the error message.
194
+ */
195
+ async function runPython(skill, rawArgs, auth, trace) {
155
196
  if (!skill.scriptAbsolutePath) {
156
197
  throw new SkillError(`skill ${skill.name} has entry.type=python but no scriptAbsolutePath`);
157
198
  }
@@ -159,10 +200,14 @@ async function runPython(skill, rawArgs, auth) {
159
200
  return await new Promise((resolve) => {
160
201
  const proc = spawn('python3', [skill.scriptAbsolutePath, ...rawArgs], {
161
202
  cwd,
162
- stdio: 'inherit',
203
+ stdio: trace ? ['inherit', 'pipe', 'pipe'] : 'inherit',
163
204
  env: { ...process.env, ...authChildEnv(auth), PYTHONUNBUFFERED: '1' },
164
205
  });
165
- proc.on('exit', (code) => resolve(code ?? EXIT.ERROR));
206
+ if (trace) {
207
+ proc.stdout?.on('data', (chunk) => process.stdout.write(chunk));
208
+ proc.stderr?.on('data', (chunk) => process.stderr.write(chunk));
209
+ }
210
+ proc.on(trace ? 'close' : 'exit', (code) => resolve(code ?? EXIT.ERROR));
166
211
  proc.on('error', (err) => {
167
212
  process.stderr.write(`❌ failed to spawn python3: ${err.message}\n`);
168
213
  resolve(EXIT.SPAWN_FAILED);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.35",
3
+ "version": "0.9.37",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,6 +26,7 @@
26
26
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
27
27
  "test:card-array-keys": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-card-array-keys.py",
28
28
  "test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
29
+ "test:narration-tts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-tts.py",
29
30
  "test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
30
31
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
31
32
  "test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
@@ -171,7 +171,7 @@ The agent should show the following in clear Markdown:
171
171
 
172
172
  ### Video info
173
173
  - Topic: 3 AI study hacks
174
- - Platform: douyin | Aspect ratio: 9:16 | Estimated duration: 45s
174
+ - Aspect ratio: 9:16 | Estimated duration: 45s
175
175
 
176
176
  ### Scene structure
177
177
  | # | Purpose | Duration | Narration |
@@ -374,7 +374,7 @@ Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is low
374
374
  ## Steps
375
375
 
376
376
  1. **Script path**: read the system-injected `Base directory for this skill: <path>` as `<SkillDir>`. Every command below uses `<SkillDir>/scripts/gen_script.py`; never hard-code paths.
377
- 2. **Understand the request**: extract topic, target platform, duration, style, audience, etc.
377
+ 2. **Understand the request**: extract topic, duration, style, audience, etc.
378
378
  3. **If the user specifies a template, read it first**: load the matching `template.json` via `template-registry` and confirm its template-specific fields and asset requirements. Do not skip this step.
379
379
  4. **Generate the DSL**: pick the right command or DSL shape for the target template (see the per-template commands below).
380
380
  5. **Schema validation**: make sure the output DSL conforms to v1alpha1.
@@ -385,7 +385,6 @@ Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is low
385
385
  ```bash
386
386
  python3 <SkillDir>/scripts/gen_script.py \
387
387
  --topic "3 AI hacks that double your study efficiency" \
388
- --platform douyin \
389
388
  --duration 30 \
390
389
  --style "tech" \
391
390
  --ratio "9:16"
@@ -416,7 +415,6 @@ python3 <SkillDir>/scripts/gen_script.py \
416
415
  | Flag | Description | Default |
417
416
  |------|-------------|---------|
418
417
  | `--topic` | Video topic (required unless `--validate`). | — |
419
- | `--platform` | Target platform: `douyin` / `xiaohongshu` / `bilibili` / `wechat` / `youtube` / `generic`. | `generic` |
420
418
  | `--headline` | On-screen main title (4–12 chars / ~3 words). Written to `meta.headline` + `textLayers[role=headline]`. **Pass it whenever the user gave a title** — otherwise headline falls back to the long-form topic and overflows the top text layer. | falls back to `--topic` |
421
419
  | `--subheadline` | On-screen subtitle / slogan. Written to `meta.subheadline` + `textLayers[role=subheadline]`. Multi-line via `\n`; how many lines survive and whether `**emphasis**` renders is declared per template — see its `llmHint`. Not the same thing as CC subtitles (`global.subtitle`). | `""` |
422
420
  | `--duration` | Target duration (seconds). Templates whose `durationStrategy` is `fit-caption` / `fit-narration` recompute the real duration and ignore this value. | `30` |
@@ -474,7 +472,6 @@ prefix and version — not a short alias:
474
472
 
475
473
  - **Schema validation failed**: check the DSL JSON shape and required fields against the schema.
476
474
  - **Scene duration mismatch**: adjust the narration length or the scene duration.
477
- - **Invalid platform**: look at the supported-platform list.
478
475
  - **`carousel-caption template ... needs visual or text content`**: you passed neither `--carousel-items` nor `--caption-lines`. Pass the user's media URLs (and caption lines where the template requires them).
479
476
  - **`template ... is typewriter-driven ... but --caption-lines is empty`**: a `fit-caption` template got no caption. Write the copy yourself if the user did not supply it, then pass one `--caption-lines` per line.
480
477
  - **`duration Ns is below/above template ... supportedDurations`**: the assembled DSL falls outside the range the template declares it was designed for. Below the minimum usually means the content is too thin (add caption lines / narration / scenes); above the maximum means trimming content or lowering `--duration`. This is enforced at generation time on purpose — a degenerate video still costs full render credits.
@@ -3,8 +3,8 @@
3
3
  Video script generator — turns a topic into Video DSL v1alpha1 JSON.
4
4
 
5
5
  Default behavior:
6
- Produces a structured Video DSL JSON to stdout based on the topic, platform,
7
- duration, and other constraints.
6
+ Produces a structured Video DSL JSON to stdout based on the topic, duration,
7
+ and other constraints.
8
8
 
9
9
  Usage:
10
10
  python gen_script.py --topic "3 AI hacks that double study efficiency" --duration 30 --ratio "9:16"
@@ -56,7 +56,6 @@ try:
56
56
  except ImportError:
57
57
  _HAS_REGISTRY_LOADER = False
58
58
 
59
- VALID_PLATFORMS = ["douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"]
60
59
  VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
61
60
  VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
62
61
 
@@ -761,7 +760,6 @@ def _build_carousel_caption_dsl(
761
760
  carousel_items: list[str],
762
761
  caption_lines: list[str],
763
762
  duration: int,
764
- platform: str,
765
763
  style: str,
766
764
  ratio: str,
767
765
  resolution: str,
@@ -910,7 +908,6 @@ def _build_carousel_caption_dsl(
910
908
  "topic": topic,
911
909
  "headline": effective_headline,
912
910
  "subheadline": effective_subheadline,
913
- "platform": platform,
914
911
  "targetDuration": effective_duration,
915
912
  "language": LANGUAGE_TO_META.get(output_language, "zh-CN"),
916
913
  "outputLanguage": output_language,
@@ -987,7 +984,6 @@ def _build_custom_payload(template_config: dict | None) -> dict:
987
984
 
988
985
  def build_dsl(
989
986
  topic: str,
990
- platform: str,
991
987
  duration: int,
992
988
  style: str,
993
989
  ratio: str,
@@ -1124,7 +1120,6 @@ def build_dsl(
1124
1120
  carousel_items=carousel_items or [],
1125
1121
  caption_lines=caption_lines or [],
1126
1122
  duration=duration,
1127
- platform=platform,
1128
1123
  style=style,
1129
1124
  ratio=ratio,
1130
1125
  resolution=resolution,
@@ -1419,7 +1414,6 @@ def build_dsl(
1419
1414
  "topic": topic,
1420
1415
  "headline": effective_headline,
1421
1416
  "subheadline": effective_subheadline,
1422
- "platform": platform,
1423
1417
  "targetDuration": duration,
1424
1418
  "language": LANGUAGE_TO_META[output_language],
1425
1419
  "outputLanguage": output_language,
@@ -1461,12 +1455,6 @@ Examples:
1461
1455
  """,
1462
1456
  )
1463
1457
  parser.add_argument("--topic", help="Video topic (required for generation mode)")
1464
- parser.add_argument(
1465
- "--platform",
1466
- default="generic",
1467
- choices=VALID_PLATFORMS,
1468
- help="Target platform (default: generic)",
1469
- )
1470
1458
  parser.add_argument("--duration", type=int, default=30, help="Target duration in seconds (default: 30)")
1471
1459
  parser.add_argument("--style", default="", help="Style tag")
1472
1460
  parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
@@ -1625,7 +1613,6 @@ Examples:
1625
1613
 
1626
1614
  print(f"📝 generating video script...", file=sys.stderr)
1627
1615
  print(f" topic: {args.topic}", file=sys.stderr)
1628
- print(f" platform: {args.platform}", file=sys.stderr)
1629
1616
  print(f" duration: {args.duration}s", file=sys.stderr)
1630
1617
  print(f" ratio: {args.ratio}", file=sys.stderr)
1631
1618
  if args.speed is not None:
@@ -1664,7 +1651,6 @@ Examples:
1664
1651
 
1665
1652
  dsl = build_dsl(
1666
1653
  topic=args.topic,
1667
- platform=args.platform,
1668
1654
  duration=args.duration,
1669
1655
  style=args.style,
1670
1656
  ratio=args.ratio,
@@ -20,18 +20,6 @@
20
20
  "type": "string",
21
21
  "description": "Video topic (required)"
22
22
  },
23
- "platform": {
24
- "type": "string",
25
- "enum": [
26
- "douyin",
27
- "xiaohongshu",
28
- "bilibili",
29
- "wechat",
30
- "youtube",
31
- "generic"
32
- ],
33
- "description": "Target platform"
34
- },
35
23
  "duration": {
36
24
  "type": "number",
37
25
  "description": "Target duration in seconds"
@@ -112,7 +100,6 @@
112
100
  ],
113
101
  "advanced": [],
114
102
  "hidden": [
115
- "platform",
116
103
  "duration",
117
104
  "style",
118
105
  "ratio",
@@ -84,6 +84,7 @@ remixmate gen-voice --text "<text-to-synthesize>" --json-output
84
84
  | `--text` | Text to synthesize (required, except with `--list-voices`) | — |
85
85
  | `--voice-id` | Voice id (use `--list-voices` to discover) | `Chinese (Mandarin)_Male_Announcer` |
86
86
  | `--speed` | Speech rate, 0.5–2.0 | `1.0` |
87
+ | `--language-boost` | CLI-only. Language hint for MiniMax (`auto` / `Chinese` / `English` / …). Mixed-script narration ("只有四个字:Agent Loop") mispronounces the Latin terms without one, so the service already applies its own default — pass this only to pin a specific language. Usually set from the DSL (`global.narration.languageBoost`, scene-level overrides) rather than by hand. Not exposed to the agent — see the handler comment. | service default |
87
88
  | `--list-voices` | List available voices and exit | — |
88
89
  | `--local` | CLI-only. With `--list-voices`: print the 2-entry hardcoded fallback list instead of querying the service (offline dev). Not exposed to the agent — see the handler comment. | off |
89
90
  | `--json-output` | Emit JSON (url, audio_length_ms, subtitles) | off |
@@ -175,6 +175,23 @@ def _narration_speed(narration: dict) -> Optional[float]:
175
175
  return float(raw) if raw > 0 else None
176
176
 
177
177
 
178
+ def _narration_language_boost(narration: dict) -> Optional[str]:
179
+ """Read a narration block's language hint, or None when unset.
180
+
181
+ Same tolerance as _narration_speed: a malformed value must not take down an
182
+ otherwise fine render. The service applies its own default when we send
183
+ nothing, so "unset" is always a safe outcome — this flag exists to pin a
184
+ language when auto-detection is not good enough (e.g. an English-only
185
+ template whose narration still carries a Chinese brand name).
186
+ """
187
+ if not isinstance(narration, dict):
188
+ return None
189
+ raw = narration.get("languageBoost")
190
+ if not isinstance(raw, str) or not raw.strip():
191
+ return None
192
+ return raw.strip()
193
+
194
+
178
195
  def extract_narration_lines(narration: dict) -> Optional[tuple[list[str], int, list[Optional[float]]]]:
179
196
  """If narration uses the structured {intro, items, outro} form, return
180
197
  (lines, intro_line_count, at_sec_list). `at_sec_list` is parallel to
@@ -650,6 +667,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
650
667
  dsl_assets_by_id = {a["assetId"]: a for a in dsl.get("assets", [])}
651
668
  global_narration = (dsl.get("global") or {}).get("narration") or {}
652
669
  global_speed = _narration_speed(global_narration)
670
+ global_language_boost = _narration_language_boost(global_narration)
653
671
  for scene in dsl.get("scenes", []):
654
672
  narration = (scene.get("audio") or {}).get("narration") or {}
655
673
  ref = narration.get("assetRef")
@@ -666,6 +684,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
666
684
  speed = global_speed
667
685
  if speed is not None:
668
686
  payload["speed"] = speed
687
+ # 语种提示走与语速完全相同的路线(场景覆盖全局,都不写就交给服务端默认):
688
+ # resolve_asset_audio 只看得见 asset,看不见 scene。
689
+ language_boost = _narration_language_boost(narration)
690
+ if language_boost is None:
691
+ language_boost = global_language_boost
692
+ if language_boost is not None:
693
+ payload["languageBoost"] = language_boost
669
694
  extracted = extract_narration_lines(narration)
670
695
  if extracted:
671
696
  lines, intro_lines, _ = extracted
@@ -905,6 +930,10 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
905
930
  speed = _narration_speed(payload)
906
931
  if speed is not None:
907
932
  cmd.extend(["--speed", str(speed)])
933
+ # 同 speed:不带就**不传**这个 flag,默认值归服务端一处持有。
934
+ language_boost = _narration_language_boost(payload)
935
+ if language_boost is not None:
936
+ cmd.extend(["--language-boost", language_boost])
908
937
  if private_token:
909
938
  cmd.extend(["--priv-token", private_token])
910
939
 
@@ -193,6 +193,7 @@ Rules:
193
193
  - Do not write `templateData.highlightMap`; `render_video.py` builds it automatically from the per-line timestamps that TTS returns.
194
194
  - If the author explicitly provides a `highlightMap`, the system keeps it as-is.
195
195
  - The legacy flat `narration.text` form still works but the author then has to keep `highlightMap` in sync with the subtitle segmentation (error-prone, not recommended).
196
+ - `narration.languageBoost` (optional, also accepted at `global.narration`) pins the TTS language hint (`auto` / `Chinese` / `English` / …). Leave it unset unless auto-detection gets it wrong — the service applies its own default, and a scene-level value overrides the global one, exactly like `speed`.
196
197
 
197
198
  ## Image sources (picture-book-en and every other template)
198
199
 
@@ -25,7 +25,6 @@ import re
25
25
  from typing import Iterable, Optional
26
26
 
27
27
  VALID_VERSIONS = {"v1alpha1"}
28
- VALID_PLATFORMS = {"douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"}
29
28
  VALID_RATIOS = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
30
29
  VALID_RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
31
30
  VALID_PURPOSES = {"opening", "intro", "point", "example", "explanation", "transition", "highlight", "cta", "ending"}
@@ -175,19 +174,6 @@ def _check_meta_presence(dsl: dict) -> list[ValidationError]:
175
174
  return errors
176
175
 
177
176
 
178
- def _check_meta_enums(dsl: dict) -> list[ValidationError]:
179
- """完整版额外:meta.platform 必须是已知枚举。"""
180
- errors: list[ValidationError] = []
181
- meta = dsl.get("meta")
182
- if isinstance(meta, dict):
183
- platform = meta.get("platform", "generic")
184
- if platform not in VALID_PLATFORMS:
185
- errors.append(ValidationError(
186
- "meta.platform", f"must be one of {sorted(VALID_PLATFORMS)}",
187
- ))
188
- return errors
189
-
190
-
191
177
  def _check_global_presence(dsl: dict) -> list[ValidationError]:
192
178
  """gen-script 历史:global 必须存在(只检查存在性)。"""
193
179
  if not isinstance(dsl.get("global"), dict):
@@ -358,6 +344,51 @@ def _check_assetbindings_deprecation(dsl: dict) -> list[ValidationError]:
358
344
  return errors
359
345
 
360
346
 
347
+ def _check_narration_items_text_coexist(dsl: dict) -> list[ValidationError]:
348
+ """分行旁白与整段 ``narration.text`` 并存 → warning(text 会被整个忽略)。
349
+
350
+ 渲染侧是 **items 优先、text 完全忽略**:``extract_narration_lines`` 一旦拿到
351
+ items,TTS 文本就是 ``"\\n".join(items)``,字幕也改走 ``split_subtitle_from_lines``,
352
+ ``narration.text`` 既不喂 TTS 也不切字幕。
353
+
354
+ 为什么值得报:gen_script 的骨架**总是**写一条 ``narration.text``,agent 之后为
355
+ 逐行高亮补 items 时不会删它,于是两者并存几乎是这类模板的默认结局。留着的那
356
+ 一份是死数据 —— 它曾经在旁白编辑面板里被当成"可以单独改、单独重新生成"的一
357
+ 行摆出来(实测一个场景因此显示三行,其中一行是另外两行拼起来的),改它不影响
358
+ 成片,生成它照样扣积分。
359
+
360
+ 只报 warning 不拒绝:两者并存不会让画面出错,它只是让人误以为 text 还有用。
361
+ """
362
+ errors: list[ValidationError] = []
363
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
364
+ sid = scene.get("id", f"scenes[{idx}]")
365
+ narration = (scene.get("audio") or {}).get("narration") or {}
366
+ if not isinstance(narration, dict):
367
+ continue
368
+ items = narration.get("items")
369
+ has_items = isinstance(items, list) and any(
370
+ (isinstance(x, str) and x.strip()) or isinstance(x, dict) for x in items
371
+ )
372
+ has_intro_outro = any(
373
+ isinstance(narration.get(k), str) and narration[k].strip()
374
+ for k in ("intro", "outro")
375
+ )
376
+ text = narration.get("text")
377
+ if not (has_items or has_intro_outro):
378
+ continue
379
+ if not (isinstance(text, str) and text.strip()):
380
+ continue
381
+ errors.append(ValidationError(
382
+ f"scenes[{idx}].audio.narration.text",
383
+ f"scene '{sid}': this scene uses per-line narration (intro/items/outro), so "
384
+ "narration.text is ignored end to end — it is neither sent to TTS nor split "
385
+ "into subtitles. Leaving it here makes it look like editable copy that no "
386
+ "longer affects the video. Drop the text field and keep the lines.",
387
+ severity="warning",
388
+ ))
389
+ return errors
390
+
391
+
361
392
  def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
362
393
  """render-video / 完整版:结构化 narration items 数与 templateData 的对应数组等长。
363
394
 
@@ -978,6 +1009,7 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
978
1009
  errors += _check_asset_duplicates(dsl)
979
1010
  errors += _check_scene_asset_refs(dsl)
980
1011
  errors += _check_narration_items_count(dsl)
1012
+ errors += _check_narration_items_text_coexist(dsl)
981
1013
  # 新增(非历史规则):多版式模板必须逐场景选定 slideId。放进 integrity 集合是因为
982
1014
  # render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
983
1015
  # 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
@@ -1001,7 +1033,6 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1001
1033
  errors: list[ValidationError] = []
1002
1034
  errors += _check_version(dsl)
1003
1035
  errors += _check_meta_presence(dsl)
1004
- errors += _check_meta_enums(dsl)
1005
1036
  errors += _check_global_presence(dsl)
1006
1037
  errors += _check_global_enums(dsl)
1007
1038
  errors += _check_scenes_min(dsl)
@@ -1014,6 +1045,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1014
1045
  errors += _check_scene_asset_refs(dsl)
1015
1046
  errors += _check_assetbindings_deprecation(dsl)
1016
1047
  errors += _check_narration_items_count(dsl)
1048
+ errors += _check_narration_items_text_coexist(dsl)
1017
1049
  errors += _check_narration_language_strict(dsl)
1018
1050
  errors += _check_slide_id_chosen(dsl)
1019
1051
  errors += _check_slide_payload_fields(dsl)
@@ -50,21 +50,70 @@ def resolve_dimensions(ratio: str, resolution: str) -> tuple[int, int]:
50
50
  return dims.get(resolution, dims.get("1080p", (1920, 1080)))
51
51
 
52
52
 
53
+ def _is_latin_word_char(ch: str) -> bool:
54
+ return ch.isascii() and ch.isalnum()
55
+
56
+
57
+ def _breakable(prev_token: str, next_token: str) -> bool:
58
+ """两个 token 之间断开是否安全 —— 拉丁↔拉丁即同一词组内部,不安全。
59
+
60
+ 拆与并共用这一个判据:断的时候躲开的边界,并的时候就得把空格补回去。
61
+ """
62
+ if not prev_token or not next_token:
63
+ return True
64
+ return not (_is_latin_word_char(prev_token[-1]) and _is_latin_word_char(next_token[0]))
65
+
66
+
67
+ def _concat_segments(left: str, right: str) -> str:
68
+ """拼接两个字幕段,必要时补回被切分吃掉的那个空格。
69
+
70
+ ``_split_at_spaces`` 在空格处断开,空格本身不属于任何一段;直接 ``+`` 拼回去
71
+ 会把 ``capabilities and`` + ``more`` 粘成 ``capabilities andmore`` —— 这串同样会
72
+ 原样进 TTS,读出来就是一个不存在的词。中文两侧不补空格(中文本来就不分词)。
73
+ """
74
+ if _breakable(left, right):
75
+ return left + right
76
+ return left + " " + right
77
+
78
+
53
79
  def _split_at_spaces(text: str, max_len: int) -> list[str]:
54
- """Split long text at space boundaries to protect English words."""
80
+ """Split long text at space boundaries, keeping multi-word Latin terms whole.
81
+
82
+ These boundaries are not only subtitle cuts: ``segment_narration`` joins the
83
+ result with ``\\n`` and that is what goes to TTS, where each line is a hard
84
+ segment boundary. Cutting between two Latin words therefore breaks the term
85
+ apart in the *audio* — Minimax loses both the liaison and the context that
86
+ told it this was English at all.
87
+
88
+ 实测(2026-09-20): ``最近刷爆科技圈的 AI Agent,`` 是 18 字、超过 16 的上限,而这
89
+ 段里仅有的两个空格都在 "AI Agent" 中间,于是旧实现只能从词组中间下刀,切成
90
+ ``最近刷爆科技圈的 AI`` + ``Agent,`` —— 合成出来 "AI" 收在上一段末尾、"Agent"
91
+ 孤零零起一段,那个词就读不准了。同句末尾的 "Agent Loop。" 因为没超上限反而完好,
92
+ 用户听到的正是"中间那个 Agent 不对"。
93
+
94
+ 所以断点优先选**中文↔拉丁**边界(回退到词组之前),找不到再退回"就近断开"。
95
+ 纯英文旁白里根本不存在中文↔拉丁边界,每个断点都会走回退分支,行为与旧实现逐字
96
+ 相同 —— picture-book-en 这类全英文模板不受影响。
97
+ """
55
98
  if len(text) <= max_len:
56
99
  return [text]
100
+
57
101
  parts: list[str] = []
58
- current = ""
102
+ buf: list[str] = [] # 当前段已收的 token
59
103
  for word in text.split(" "):
60
- candidate = f"{current} {word}" if current else word
61
- if len(candidate) > max_len and current:
62
- parts.append(current)
63
- current = word
64
- else:
65
- current = candidate
66
- if current:
67
- parts.append(current)
104
+ if buf and len(" ".join(buf + [word])) > max_len:
105
+ # 从最靠后的边界往回找第一个安全断点;整段都没有就按旧行为就近断开。
106
+ cut = len(buf)
107
+ for i in range(len(buf), 0, -1):
108
+ following = word if i == len(buf) else buf[i]
109
+ if _breakable(buf[i - 1], following):
110
+ cut = i
111
+ break
112
+ parts.append(" ".join(buf[:cut]))
113
+ buf = buf[cut:]
114
+ buf.append(word)
115
+ if buf:
116
+ parts.append(" ".join(buf))
68
117
  return parts
69
118
 
70
119
 
@@ -83,17 +132,17 @@ def _merge_short(segments: list[str], min_len: int) -> list[str]:
83
132
  prev_is_sentence_end = bool(_PRIMARY_TAIL.search(merged[-1]))
84
133
  if len(merged[-1]) < min_len:
85
134
  # Previous fragment is short — always merge forward into it
86
- merged[-1] += seg
135
+ merged[-1] = _concat_segments(merged[-1], seg)
87
136
  elif len(seg) < min_len and not prev_is_sentence_end:
88
137
  # Current fragment is short and prev is NOT a sentence end — merge backward
89
- merged[-1] += seg
138
+ merged[-1] = _concat_segments(merged[-1], seg)
90
139
  else:
91
140
  # Either both are long enough, or seg is short but prev ends a sentence
92
141
  merged.append(seg)
93
142
  # Handle trailing short segment (avoid merging across sentence boundary)
94
143
  if len(merged) > 1 and len(merged[-1]) < min_len:
95
144
  if not _PRIMARY_TAIL.search(merged[-2]):
96
- merged[-2] += merged.pop()
145
+ merged[-2] = _concat_segments(merged[-2], merged.pop())
97
146
  return merged
98
147
 
99
148
 
@@ -44,11 +44,6 @@
44
44
  "properties": {
45
45
  "title": { "type": "string" },
46
46
  "topic": { "type": "string" },
47
- "platform": {
48
- "type": "string",
49
- "enum": ["douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"],
50
- "default": "generic"
51
- },
52
47
  "targetDuration": {
53
48
  "type": "number",
54
49
  "description": "目标总时长(秒)"
@@ -95,7 +90,8 @@
95
90
  "properties": {
96
91
  "voiceId": { "type": "string", "description": "Minimax 音色 ID" },
97
92
  "voiceName": { "type": "string" },
98
- "speed": { "type": "number", "default": 1.0 }
93
+ "speed": { "type": "number", "default": 1.0 },
94
+ "languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;留空由服务端取默认值。" }
99
95
  }
100
96
  },
101
97
  "bgm": {
@@ -250,7 +246,8 @@
250
246
  "description": "每行一个卡片的叙述句。长度必须等于 templateData 里对应数组(concepts/pillars/eras/items)的长度"
251
247
  },
252
248
  "outro": { "type": "string", "description": "可选:收尾句(此期间无卡片高亮)" },
253
- "assetRef": { "type": "string", "description": "旁白音频 assetId" }
249
+ "assetRef": { "type": "string", "description": "旁白音频 assetId" },
250
+ "languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;覆盖 global.narration 的同名字段;留空则跟随全局/服务端默认。" }
254
251
  }
255
252
  },
256
253
  "bgm": {