@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.
- package/dist/diagnostics.d.ts +21 -1
- package/dist/diagnostics.js +36 -5
- package/dist/handlers/gen-voice.js +14 -1
- package/dist/install.d.ts +1 -0
- package/dist/install.js +1 -1
- package/dist/manifest.json +2 -15
- package/dist/project/take.d.ts +4 -0
- package/dist/project/take.js +4 -0
- package/dist/project/trace.d.ts +98 -0
- package/dist/project/trace.js +278 -0
- package/dist/runner.js +58 -13
- package/package.json +2 -1
- package/skills/gen-script/SKILL.md +2 -5
- package/skills/gen-script/scripts/gen_script.py +2 -16
- package/skills/gen-script/skill.json +0 -13
- package/skills/gen-voice/SKILL.md +1 -0
- package/skills/render-video/scripts/render_video.py +29 -0
- package/skills/template-registry/SKILL.md +1 -0
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +47 -15
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +62 -13
- package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +4 -7
package/dist/diagnostics.d.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
export declare function traceHeaders(): Record<string, string>;
|
|
2
|
-
|
|
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;
|
package/dist/diagnostics.js
CHANGED
|
@@ -8,8 +8,22 @@ export function traceHeaders() {
|
|
|
8
8
|
process.env.AB_OPERATION_ID ||= randomUUID();
|
|
9
9
|
return { traceparent: header };
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
})
|
|
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,
|
|
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 =
|
|
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', {
|
|
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';
|
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
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",
|
package/dist/project/take.d.ts
CHANGED
|
@@ -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.
|
package/dist/project/take.js
CHANGED
|
@@ -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
|
-
|
|
62
|
+
exitCode = await runPython(skill, opts.rawArgs, auth, trace);
|
|
63
|
+
break;
|
|
50
64
|
case 'http':
|
|
51
65
|
case 'builtin':
|
|
52
|
-
|
|
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
|
-
|
|
74
|
+
exitCode = err.exitCode;
|
|
60
75
|
}
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
-
|
|
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,
|
|
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,
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
102
|
+
buf: list[str] = [] # 当前段已收的 token
|
|
59
103
|
for word in text.split(" "):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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": {
|