@remixmate/cli 0.9.28 → 0.9.30
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 +4 -0
- package/dist/diagnostics.js +39 -0
- package/dist/http.js +6 -5
- package/dist/manifest.json +2 -2
- package/dist/runner.js +14 -6
- package/package.json +1 -1
- package/skills/gen-script/SKILL.md +33 -19
- package/skills/gen-script/scripts/gen_script.py +66 -36
- package/skills/render-video/scripts/remote_renderer_client.py +4 -0
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/list_templates.py +19 -3
- package/skills/template-registry/scripts/log_diagnostics.py +32 -0
- package/skills/template-registry/scripts/render_job_client.py +2 -0
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +63 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function traceHeaders(): Record<string, string>;
|
|
2
|
+
export declare function cleanDiagnosticText(value: string): string;
|
|
3
|
+
/** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
|
|
4
|
+
export declare function diagnostic(exitCode: number, durationMs: number, error?: unknown): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
export function traceHeaders() {
|
|
3
|
+
const parent = process.env.AB_TRACEPARENT ?? '';
|
|
4
|
+
const match = /^00-([a-f0-9]{32})-([a-f0-9]{16})-[a-f0-9]{2}$/.exec(parent);
|
|
5
|
+
const trace = match && !/^0+$/.test(match[1]) && !/^0+$/.test(match[2]) ? match[1] : randomBytes(16).toString('hex');
|
|
6
|
+
const header = `00-${trace}-${randomBytes(8).toString('hex')}-01`;
|
|
7
|
+
process.env.AB_TRACEPARENT = header;
|
|
8
|
+
process.env.AB_OPERATION_ID ||= randomUUID();
|
|
9
|
+
return { traceparent: header };
|
|
10
|
+
}
|
|
11
|
+
export function cleanDiagnosticText(value) {
|
|
12
|
+
let text = value.replace(/https?:\/\/[^\s"'<>]+/g, raw => {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(raw);
|
|
15
|
+
return url.origin + url.pathname;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return '[url]';
|
|
19
|
+
}
|
|
20
|
+
}).replace(/(Bearer\s+|(?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;}]+/gi, '$1[redacted]');
|
|
21
|
+
for (const [key, secret] of Object.entries(process.env)) {
|
|
22
|
+
if (/TOKEN|SECRET|PASSWORD|API_KEY/i.test(key) && secret && secret.length >= 6)
|
|
23
|
+
text = text.split(secret).join('[redacted]');
|
|
24
|
+
}
|
|
25
|
+
return Buffer.from(text).subarray(0, 2048).toString('utf8');
|
|
26
|
+
}
|
|
27
|
+
/** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
|
|
28
|
+
export function diagnostic(exitCode, durationMs, error) {
|
|
29
|
+
if (process.env.AB_DIAGNOSTICS !== '1')
|
|
30
|
+
return;
|
|
31
|
+
traceHeaders();
|
|
32
|
+
const outcome = exitCode === 0 ? 'success' : exitCode === 130 ? 'cancelled' : [2, 3, 4].includes(exitCode) ? 'rejected' : 'failure';
|
|
33
|
+
const record = { schema_version: 1, timestamp: new Date().toISOString(), service: 'remixmate-cli', environment: process.env.APP_ENV ?? 'dev',
|
|
34
|
+
level: outcome === 'failure' ? 'error' : 'info', event: 'cli.execution.completed', message: 'CLI execution completed', outcome,
|
|
35
|
+
trace_id: process.env.AB_TRACEPARENT?.split('-')[1], operation_id: process.env.AB_OPERATION_ID,
|
|
36
|
+
duration_ms: durationMs, attributes: { exit_code: exitCode },
|
|
37
|
+
error: error instanceof Error ? { type: error.name, message: cleanDiagnosticText(error.message), stack: cleanDiagnosticText(error.stack ?? '') } : undefined };
|
|
38
|
+
process.stderr.write('__diagnostic_v1__ ' + JSON.stringify(record) + '\n');
|
|
39
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* 3. process.env.MM_BACKEND_API_URL — ab-agent's convention (back-compat)
|
|
19
19
|
* 4. https://api.remixmate.com/api — production default (zero-config)
|
|
20
20
|
*/
|
|
21
|
+
import { traceHeaders, cleanDiagnosticText } from './diagnostics.js';
|
|
21
22
|
import { recordBilling } from './billing.js';
|
|
22
23
|
import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './auth/resolve.js';
|
|
23
24
|
import { attemptAutoLogin } from './auth/auto-login.js';
|
|
@@ -73,7 +74,7 @@ function buildHeaders(ctx, extra) {
|
|
|
73
74
|
headers['x-invoke-agent'] = ctx.agentName;
|
|
74
75
|
if (ctx.conversationId)
|
|
75
76
|
headers['x-conversation-id'] = ctx.conversationId;
|
|
76
|
-
return { ...headers, ...extra };
|
|
77
|
+
return { ...headers, ...extra, ...traceHeaders() };
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
79
80
|
* POST JSON to ab-api and return the parsed business payload.
|
|
@@ -110,16 +111,16 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
|
|
|
110
111
|
throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 HTTP ${resp.status})`, EXIT.NOT_AUTHENTICATED);
|
|
111
112
|
}
|
|
112
113
|
if (resp.status >= 500) {
|
|
113
|
-
throw new SkillError(`❌ API request failed (HTTP ${resp.status}):
|
|
114
|
+
throw new SkillError(`❌ API request failed (HTTP ${resp.status}): [response omitted]`, EXIT.BACKEND_UNREACHABLE);
|
|
114
115
|
}
|
|
115
|
-
throw new SkillError(`❌ API request failed (HTTP ${resp.status}):
|
|
116
|
+
throw new SkillError(`❌ API request failed (HTTP ${resp.status}): [response omitted]`);
|
|
116
117
|
}
|
|
117
118
|
let parsed;
|
|
118
119
|
try {
|
|
119
120
|
parsed = JSON.parse(text);
|
|
120
121
|
}
|
|
121
122
|
catch {
|
|
122
|
-
throw new SkillError(`❌ failed to parse response, body is not JSON:
|
|
123
|
+
throw new SkillError(`❌ failed to parse response, body is not JSON: [response omitted]`);
|
|
123
124
|
}
|
|
124
125
|
// Before the code check: a charged response is always code=0 today, but a
|
|
125
126
|
// partial-failure envelope that still billed must not lose its billing line.
|
|
@@ -130,7 +131,7 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
|
|
|
130
131
|
if (parsed.code === 401 || parsed.code === 403) {
|
|
131
132
|
throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 code=${parsed.code}${parsed.msg ? `: ${parsed.msg}` : ''})`, EXIT.NOT_AUTHENTICATED);
|
|
132
133
|
}
|
|
133
|
-
throw new SkillError(`❌ API returned a business error: ${parsed.msg ?? 'unknown error'} (code=${parsed.code})`);
|
|
134
|
+
throw new SkillError(`❌ API returned a business error: ${cleanDiagnosticText(parsed.msg ?? 'unknown error')} (code=${parsed.code})`);
|
|
134
135
|
}
|
|
135
136
|
return parsed.data ?? undefined;
|
|
136
137
|
}
|
package/dist/manifest.json
CHANGED
package/dist/runner.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Resolves to the child process's exit code so cli.ts can propagate it.
|
|
11
11
|
*/
|
|
12
|
+
import { diagnostic, cleanDiagnosticText, traceHeaders } from './diagnostics.js';
|
|
12
13
|
import { spawn } from 'node:child_process';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { findSkill, SKILLS_DIR } from './registry.js';
|
|
@@ -24,9 +25,14 @@ function tokenFlag(args) {
|
|
|
24
25
|
return typeof value === 'string' ? value : undefined;
|
|
25
26
|
}
|
|
26
27
|
export async function runSkill(skillName, opts) {
|
|
28
|
+
const started = performance.now();
|
|
29
|
+
let exitCode = EXIT.ERROR;
|
|
30
|
+
let failure;
|
|
31
|
+
traceHeaders();
|
|
27
32
|
const skill = findSkill(skillName, opts.baseDir ?? SKILLS_DIR);
|
|
28
33
|
if (!skill) {
|
|
29
34
|
process.stderr.write(`❌ skill not found: ${skillName}\n`);
|
|
35
|
+
diagnostic(EXIT.USAGE, performance.now() - started);
|
|
30
36
|
return EXIT.USAGE;
|
|
31
37
|
}
|
|
32
38
|
try {
|
|
@@ -40,24 +46,26 @@ export async function runSkill(skillName, opts) {
|
|
|
40
46
|
case 'python':
|
|
41
47
|
// Python children talk to ab-api themselves and print their own billing
|
|
42
48
|
// footer; nothing was charged through this process.
|
|
43
|
-
return await runPython(skill, opts.rawArgs, auth);
|
|
49
|
+
return exitCode = await runPython(skill, opts.rawArgs, auth);
|
|
44
50
|
case 'http':
|
|
45
51
|
case 'builtin':
|
|
46
|
-
return await runHandler(skill, opts.parsedArgs, auth);
|
|
52
|
+
return exitCode = await runHandler(skill, opts.parsedArgs, auth);
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
catch (err) {
|
|
56
|
+
failure = err;
|
|
50
57
|
if (err instanceof SkillError) {
|
|
51
|
-
process.stderr.write(err.message + '\n');
|
|
52
|
-
return err.exitCode;
|
|
58
|
+
process.stderr.write(cleanDiagnosticText(err.message) + '\n');
|
|
59
|
+
return exitCode = err.exitCode;
|
|
53
60
|
}
|
|
54
|
-
process.stderr.write(`❌ unexpected error: ${err.message}\n`);
|
|
61
|
+
process.stderr.write(`❌ unexpected error: ${cleanDiagnosticText(err.stack ?? err.message)}\n`);
|
|
55
62
|
return EXIT.ERROR;
|
|
56
63
|
}
|
|
57
64
|
finally {
|
|
58
65
|
// In `finally` because a run can fail *after* a billed step (e.g. the image
|
|
59
66
|
// generated and was charged, then the upload timed out) — spend gets
|
|
60
67
|
// reported either way. No-ops when nothing was charged.
|
|
68
|
+
diagnostic(exitCode, performance.now() - started, failure);
|
|
61
69
|
emitBillingFooter();
|
|
62
70
|
}
|
|
63
71
|
}
|
|
@@ -140,7 +148,7 @@ async function applyTakeContext(skill, opts, auth) {
|
|
|
140
148
|
`(project ${take.projectId}${take.attempt ? `, attempt ${take.attempt}` : ''})\n`);
|
|
141
149
|
}
|
|
142
150
|
catch (err) {
|
|
143
|
-
process.stderr.write(`⚠️ 无法归属本次产物(内容仍会正常生成,但不会出现在项目里): ${err.message}\n`);
|
|
151
|
+
process.stderr.write(`⚠️ 无法归属本次产物(内容仍会正常生成,但不会出现在项目里): ${cleanDiagnosticText(err.message)}\n`);
|
|
144
152
|
}
|
|
145
153
|
}
|
|
146
154
|
async function runPython(skill, rawArgs, auth) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.30",
|
|
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",
|
|
@@ -37,26 +37,37 @@ Turns a user-supplied **topic** into a **Video DSL v1alpha1** JSON describing wh
|
|
|
37
37
|
|
|
38
38
|
## DSL schema
|
|
39
39
|
|
|
40
|
-
The full schema is `template-registry` skill's `video_dsl/schema/video-dsl-v1alpha1.json
|
|
40
|
+
The full schema is `template-registry` skill's `video_dsl/schema/video-dsl-v1alpha1.json`.
|
|
41
|
+
|
|
42
|
+
**Per-template reference DSLs do not ship with this package.** `video_dsl/schema/examples/` is a local-only directory: it is absent from the published npm package (and has never existed in the repo), so `template_registry list_examples=true` normally returns nothing. Do not read "no examples found" as "this template is unsupported" — the authoritative per-template contract ships inside the registry's `template.json`, see below.
|
|
41
43
|
|
|
42
44
|
## Agent behavior: DSL generation when a template is selected
|
|
43
45
|
|
|
44
|
-
**When the user explicitly specifies a template id, the agent must first read
|
|
46
|
+
**When the user explicitly specifies a template id, the agent must first read that template's full definition from the registry, then generate the DSL in the template's native shape. The agent must not generate the DSL from scratch ignoring the template definition, and must not produce a generic DSL first and rely on `template-registry` to force-match the template later.**
|
|
45
47
|
|
|
46
|
-
The intent is to
|
|
48
|
+
The intent is to avoid the failure mode where "the generic DSL looks compatible on the surface, but is missing template-specific fields, has the wrong nesting, or binds incorrectly — only to fail later at binding or render time". Examples:
|
|
47
49
|
|
|
48
|
-
- A template may require fields the generic DSL never emits (a slide id, a word list, an avatar assetRef)
|
|
49
|
-
-
|
|
50
|
+
- A template may require fields the generic DSL never emits (a slide id, a word list, an avatar assetRef). `customPayloadSchema` is what declares them.
|
|
51
|
+
- Templates differ in `slotMapping` choices, asset-binding styles, and scene organization.
|
|
50
52
|
|
|
51
53
|
### Mandatory steps
|
|
52
54
|
|
|
53
|
-
1.
|
|
54
|
-
2. Read
|
|
55
|
-
3. Read
|
|
56
|
-
4. Combine
|
|
57
|
-
5. Generate the DSL
|
|
58
|
-
6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `
|
|
59
|
-
7.
|
|
55
|
+
1. Read the requested template's full definition from the registry — `template_registry` with `list_templates=true` and `json_output=true` emits each template whole. This is the shipping source of truth, and unlike the table view it truncates nothing.
|
|
56
|
+
2. Read its `llmHint` end to end — that is where the template states how its on-screen text and its layouts must be authored.
|
|
57
|
+
3. Read its `customPayloadSchema`: every template-specific field lives there, including the enum of legal `customPayload.slideId` values for multi-layout templates.
|
|
58
|
+
4. Combine that with the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, `supportedDurations`, `constraints`, and `scenePatterns`.
|
|
59
|
+
5. Generate the DSL in the template's native shape — not the generic DSL shape.
|
|
60
|
+
6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `customPayload.slideId`, `visuals.avatar.assetRef`.
|
|
61
|
+
7. If `template_registry list_examples=true` does return files locally, read them as an extra sample — never as a substitute for step 1.
|
|
62
|
+
8. After generation, run the schema check and show the script summary to the user for confirmation.
|
|
63
|
+
|
|
64
|
+
### Multi-layout templates: pick a layout per scene
|
|
65
|
+
|
|
66
|
+
When a template's `customPayloadSchema.slideId.enum` holds more than one value (today `html-slide` and `html-slide-blackboard`, 16 layouts each), **every scene must name one**, chosen to fit that scene's information shape — a comparison, a timeline, a code walkthrough and a set of numbers are four different layouts.
|
|
67
|
+
|
|
68
|
+
`gen_script.py` cannot make that choice: it emits the placeholder `"slideId": "__CHOOSE_SLIDE__"`, and the DSL validator rejects any DSL that still carries it, listing the legal values in the error. Replace every placeholder, and do not reuse one layout for the whole video.
|
|
69
|
+
|
|
70
|
+
A missing `slideId` is the quietest failure in the pipeline: the renderer drops the entire `templateData` and draws a single centred title — no error, no log, exit code 0.
|
|
60
71
|
|
|
61
72
|
### On-screen text: the rules live in the template, not here
|
|
62
73
|
|
|
@@ -69,23 +80,26 @@ Two consequences for the agent:
|
|
|
69
80
|
|
|
70
81
|
### Hard constraints
|
|
71
82
|
|
|
72
|
-
- **Forbidden**: the user specified a template, but the agent generated the DSL without reading
|
|
73
|
-
- **Forbidden**:
|
|
83
|
+
- **Forbidden**: the user specified a template, but the agent generated the DSL without reading that template's full `template.json` (`llmHint` + `customPayloadSchema` included).
|
|
84
|
+
- **Forbidden**: leaving any `"__CHOOSE_SLIDE__"` placeholder in the DSL, or shipping scenes with no `customPayload.slideId` on a multi-layout template.
|
|
85
|
+
- **Forbidden**: reusing a single `slideId` across the whole video on a multi-layout template because it was the first one in the enum.
|
|
74
86
|
- **Forbidden**: the user specified a template, but the agent first generated a generic DSL and then passed `--template-id` to `template-registry` to force-bind it.
|
|
75
87
|
- **Forbidden**: continuing into binding / rendering despite knowing that template-specific fields, scene shapes, or binding details are missing.
|
|
76
88
|
- **Forbidden**: silently degrading to a generic DSL because the current script cannot support a template, leaving the failure to the downstream stage.
|
|
77
89
|
|
|
78
90
|
### When a template is not yet supported
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
"No examples found" is **not** this case — see the note under *DSL schema*; examples do not ship, and `template.json` is the contract.
|
|
93
|
+
|
|
94
|
+
This case is: the template's `customPayloadSchema` / `slotMapping` demands a shape `gen_script.py` cannot build and the agent cannot hand-assemble. Then say clearly that the template is not yet supported for auto-generation, and name the fields, structure or binding info that are missing. Possible next steps:
|
|
81
95
|
|
|
82
|
-
1. Ask the user to switch to a template
|
|
83
|
-
2.
|
|
96
|
+
1. Ask the user to switch to a template whose shape is already supported.
|
|
97
|
+
2. Hand-craft the required DSL structure from `customPayloadSchema` and `slotMapping`, then show it to the user for confirmation.
|
|
84
98
|
3. Stop the flow and wait for the user to decide, rather than emitting a DSL that "looks like it matches but cannot render".
|
|
85
99
|
|
|
86
100
|
### Design principle
|
|
87
101
|
|
|
88
|
-
When the user specifies a template, `gen-script`'s goal is no longer "produce a generally-compatible DSL" but "produce a template-native DSL
|
|
102
|
+
When the user specifies a template, `gen-script`'s goal is no longer "produce a generally-compatible DSL" but "produce a template-native DSL that satisfies that template's own declared contract".
|
|
89
103
|
|
|
90
104
|
## Authentication & environment
|
|
91
105
|
|
|
@@ -462,4 +476,4 @@ prefix and version — not a short alias:
|
|
|
462
476
|
|------|---------|
|
|
463
477
|
| `gen_script.py` | Core script — produces the Video DSL JSON from a topic. |
|
|
464
478
|
|
|
465
|
-
The authoritative
|
|
479
|
+
The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slotMapping`). `template_registry list_examples=true` lists local reference DSLs when any exist, but that directory does not ship — see *DSL schema* above.
|
|
@@ -196,12 +196,11 @@ def _localize_label(language, key, **fmt):
|
|
|
196
196
|
"zh": "[骨架待填充] 关于{topic}的内容({duration}s)",
|
|
197
197
|
"en": "[skeleton placeholder] Content about {topic} ({duration}s)",
|
|
198
198
|
},
|
|
199
|
-
"point_description"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
# NOTE: 这里曾有 "point_description"("关于{topic}的要点内容")与
|
|
200
|
+
# "more_about"("更多关于{topic}的内容")两条,用来给 no-visual 场景拼画面
|
|
201
|
+
# 文案。两条都已删除:topic 长度不受控,拼进画面必然溢出,而正确的文案来源
|
|
202
|
+
# 是调用方给的 --headline / --subheadline。
|
|
203
203
|
"follow_us": {"zh": "关注我们", "en": "Follow us"},
|
|
204
|
-
"more_about": {"zh": "更多关于{topic}的内容", "en": "More about {topic}"},
|
|
205
204
|
"video_description": {
|
|
206
205
|
"zh": "关于「{topic}」的{duration}秒短视频",
|
|
207
206
|
"en": "A {duration}s short video about \"{topic}\"",
|
|
@@ -938,33 +937,52 @@ def _build_carousel_caption_dsl(
|
|
|
938
937
|
return dsl
|
|
939
938
|
|
|
940
939
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
940
|
+
# 「作者还没选版式」的显式标记。
|
|
941
|
+
#
|
|
942
|
+
# 为什么是哨兵而不是留空:slideId 缺失会静默回落到 DefaultSlide —— 渲染成功、零日志、
|
|
943
|
+
# 退出码 0,画面只剩一行居中标题,正是要消灭的失效形态。哨兵让下游能区分「作者没选」
|
|
944
|
+
# 与「作者选了但拼错」,两者的修法不同。dsl_validator 见到它会硬拒绝(同名常量在
|
|
945
|
+
# video_dsl/runtime/dsl_validator.py,两处字面量必须一致)。
|
|
946
|
+
SLIDE_ID_SENTINEL = "__CHOOSE_SLIDE__"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _is_multi_slide_template(template_config: dict | None) -> bool:
|
|
950
|
+
"""模板是否注册了多个可选版式 —— 由模板自己声明,不看模板 id。
|
|
951
|
+
|
|
952
|
+
判据是 ``customPayloadSchema.slideId.enum`` 有没有超过一个取值:有得挑才谈得上
|
|
953
|
+
「该挑哪个」。dsl_validator 的 ``_check_slide_id_chosen`` 用的是同一条判据,两侧
|
|
954
|
+
因此不会对同一个模板给出相反的结论。
|
|
955
|
+
|
|
956
|
+
**刻意不写死模板 id**:哪个模板有几种版式是模板自己的事实,真源在它的
|
|
957
|
+
``template.json``;把名单抄进 CLI,模板改名 / 新增多版式模板都要跟着发一次 npm,
|
|
958
|
+
而漏发的表现是"这个模板又开始只出一种版式了"——正是本次要修的那个形态。
|
|
959
|
+
"""
|
|
960
|
+
schema = (template_config or {}).get("customPayloadSchema")
|
|
961
|
+
if not isinstance(schema, dict):
|
|
962
|
+
return False
|
|
963
|
+
slide_schema = schema.get("slideId")
|
|
964
|
+
enum = slide_schema.get("enum") if isinstance(slide_schema, dict) else None
|
|
965
|
+
return isinstance(enum, list) and len(enum) > 1
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _build_custom_payload(template_config: dict | None) -> dict:
|
|
969
|
+
"""骨架阶段的 customPayload —— **不猜版式、不拼画面文案**。
|
|
970
|
+
|
|
971
|
+
这个函数曾经按 purpose 写死两种 slideId(opening / cta → demo-concept-overview,
|
|
972
|
+
其余 → demo-single-concept),于是 16 种注册版式里只有 2 种会被用到,且与内容
|
|
973
|
+
形态完全无关 —— 线上「不论选题版式永远一样」就是这么来的。它还往
|
|
974
|
+
templateData.description 里拼 "关于{topic}的要点内容",topic 长度不受控,画面
|
|
975
|
+
溢出是必然的。
|
|
976
|
+
|
|
977
|
+
现在分两档:
|
|
978
|
+
|
|
979
|
+
- 多版式模板 → 只放哨兵,等 agent 按内容挑版式并填 templateData;
|
|
980
|
+
- 其余 no-visual 模板 → 空载荷。这些模板只有一种版式,没有可挑的东西,而注入
|
|
981
|
+
它们 schema 不认识的字段只会把真正要的 slide / statement / item 挤掉。
|
|
982
|
+
"""
|
|
983
|
+
if _is_multi_slide_template(template_config):
|
|
984
|
+
return {"slideId": SLIDE_ID_SENTINEL, "templateData": {}}
|
|
985
|
+
return {}
|
|
968
986
|
|
|
969
987
|
|
|
970
988
|
def build_dsl(
|
|
@@ -1346,11 +1364,19 @@ def build_dsl(
|
|
|
1346
1364
|
# (已核对),渲染走的是 slotMapping / compositionId。所以换值不改成片。
|
|
1347
1365
|
_layout_cfg = (template_config or {}).get("capabilities") or {}
|
|
1348
1366
|
layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
|
|
1367
|
+
# ⚠️ 画面文字用 effective_headline / effective_subheadline,与上面的
|
|
1368
|
+
# has_visual 分支同源。这两条分支长期分叉:visual 分支一直用的是
|
|
1369
|
+
# --headline,no-visual 分支却渲染裸 topic —— SKILL.md 写着"用户给了标题
|
|
1370
|
+
# 就传 --headline,否则 headline 会回落成长 topic 并撑破顶部文字层",
|
|
1371
|
+
# agent 照做了,这条分支没兑现。调用方把整篇提纲当 topic 传进来时,成片
|
|
1372
|
+
# 必然是一屏文字墙,而 meta.headline 里存的又是对的,只看 meta 查不出来。
|
|
1349
1373
|
text_layers = []
|
|
1350
1374
|
if plan["purpose"] == "opening":
|
|
1351
1375
|
text_layers = [
|
|
1352
|
-
{"role": "headline", "content":
|
|
1376
|
+
{"role": "headline", "content": effective_headline, "animation": "fade-in"},
|
|
1353
1377
|
]
|
|
1378
|
+
if effective_subheadline:
|
|
1379
|
+
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
1354
1380
|
elif plan["purpose"] == "point":
|
|
1355
1381
|
text_layers = [
|
|
1356
1382
|
{"role": "headline", "content": plan["label"], "animation": "slide-up"},
|
|
@@ -1358,10 +1384,11 @@ def build_dsl(
|
|
|
1358
1384
|
elif plan["purpose"] == "cta":
|
|
1359
1385
|
text_layers = [
|
|
1360
1386
|
{"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "zoom-in"},
|
|
1361
|
-
{"role": "subheadline", "content": _localize_label(output_language, "more_about", topic=topic), "animation": "fade-in"},
|
|
1362
1387
|
]
|
|
1388
|
+
if effective_subheadline:
|
|
1389
|
+
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
1363
1390
|
|
|
1364
|
-
custom_payload = _build_custom_payload(
|
|
1391
|
+
custom_payload = _build_custom_payload(template_config)
|
|
1365
1392
|
|
|
1366
1393
|
scene = {
|
|
1367
1394
|
"id": scene_id,
|
|
@@ -1369,8 +1396,11 @@ def build_dsl(
|
|
|
1369
1396
|
"duration": plan["duration"],
|
|
1370
1397
|
"layout": layout,
|
|
1371
1398
|
"textLayers": text_layers,
|
|
1372
|
-
"customPayload": custom_payload,
|
|
1373
1399
|
}
|
|
1400
|
+
# 单版式模板的载荷是空 dict —— 写一个空 customPayload 只会让 agent 以为
|
|
1401
|
+
# 这里有个需要填的结构,干脆不写这个键。
|
|
1402
|
+
if custom_payload:
|
|
1403
|
+
scene["customPayload"] = custom_payload
|
|
1374
1404
|
if needs_narration:
|
|
1375
1405
|
scene["audio"] = {
|
|
1376
1406
|
"narration": {
|
|
@@ -34,6 +34,9 @@ import urllib.error
|
|
|
34
34
|
import urllib.parse
|
|
35
35
|
import urllib.request
|
|
36
36
|
from typing import Callable, Optional
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))
|
|
39
|
+
from log_diagnostics import trace_headers
|
|
37
40
|
|
|
38
41
|
DEFAULT_API_BASE_URL = "https://api-render.remixmate.com"
|
|
39
42
|
API_BASE_URL = (
|
|
@@ -87,6 +90,7 @@ def _build_headers(private_token: str, content_type: str = "application/json", c
|
|
|
87
90
|
headers["x-invoke-agent"] = AGENT_NAME
|
|
88
91
|
if conversation_id:
|
|
89
92
|
headers["x-conversation-id"] = conversation_id
|
|
93
|
+
headers.update(trace_headers())
|
|
90
94
|
return headers
|
|
91
95
|
|
|
92
96
|
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "render-video",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "473",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V21",
|
|
6
6
|
"skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
|
|
7
7
|
}
|
|
@@ -151,10 +151,26 @@ def main() -> None:
|
|
|
151
151
|
print(json.dumps({"examples": examples}, ensure_ascii=False))
|
|
152
152
|
return
|
|
153
153
|
if not examples:
|
|
154
|
+
# 这**不是**异常状态,而是常态:该目录从未进过仓库,也就从未随包发布过。
|
|
155
|
+
#
|
|
156
|
+
# 旧文案("This package may have shipped without reference examples;
|
|
157
|
+
# check the cli source repo or upgrade @remixmate/cli")把常态说成了事故,
|
|
158
|
+
# 而 gen-script 的 SKILL.md 又把「先读这里的参考 DSL」列为强制第一步 ——
|
|
159
|
+
# 于是线上 agent 每次都在这里撞空,然后退回 gen_script 的骨架照抄,连模板
|
|
160
|
+
# 自己声明的版式都不去看。升级 CLI 修不了它,因为没有哪一版带过这些文件。
|
|
161
|
+
#
|
|
162
|
+
# 真正随包到达调用方的逐模板契约是 registry 里的 template.json,所以这里
|
|
163
|
+
# 直接把人指过去,而不是让它以为"该模板不受支持"。
|
|
154
164
|
print(
|
|
155
|
-
f"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
165
|
+
f"ℹ️ No local reference DSLs under {_EXAMPLES_DIR} — that directory is "
|
|
166
|
+
"optional and is not part of the published package.\n"
|
|
167
|
+
" This does NOT mean the template is unsupported. The per-template "
|
|
168
|
+
"contract that does ship is the registry's template.json:\n"
|
|
169
|
+
" • llmHint — how this template's on-screen text and layouts must be authored\n"
|
|
170
|
+
" • customPayloadSchema — every template-specific field, incl. the legal slideId values\n"
|
|
171
|
+
" • slotMapping — propExtractors / requiredProps / optionalProps\n"
|
|
172
|
+
" Read it with `--list-templates --json-output` (that mode emits each "
|
|
173
|
+
"template's full definition; the table view truncates llmHint to 200 chars).",
|
|
158
174
|
)
|
|
159
175
|
return
|
|
160
176
|
print(f"\n{'Template ID':<28} Files")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Portable stdlib-only context and opt-in stderr diagnostics; never writes stdout."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
_started = time.monotonic()
|
|
12
|
+
|
|
13
|
+
def trace_headers():
|
|
14
|
+
match = re.fullmatch(r'00-([a-f0-9]{32})-([a-f0-9]{16})-[a-f0-9]{2}', os.getenv('AB_TRACEPARENT', ''))
|
|
15
|
+
tid = match[1] if match and match[1] != '0'*32 and match[2] != '0'*16 else secrets.token_hex(16)
|
|
16
|
+
parent = f'00-{tid}-{secrets.token_hex(8)}-01'
|
|
17
|
+
os.environ['AB_TRACEPARENT'] = parent
|
|
18
|
+
os.environ.setdefault('AB_OPERATION_ID', str(uuid.uuid4()))
|
|
19
|
+
return {'traceparent': parent}
|
|
20
|
+
|
|
21
|
+
def diagnostic(exit_code, kind='none', service='remixmate-studio-cli'):
|
|
22
|
+
if os.getenv('AB_DIAGNOSTICS') != '1':
|
|
23
|
+
return
|
|
24
|
+
trace_headers()
|
|
25
|
+
outcome = 'success' if exit_code == 0 else 'failure' if exit_code == 2 else 'rejected'
|
|
26
|
+
record = dict(schema_version=1, timestamp=datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
|
|
27
|
+
level='error' if outcome == 'failure' else 'info', service=service, environment=os.getenv('APP_ENV', 'dev'),
|
|
28
|
+
event='cli.execution.completed', message='CLI execution completed', outcome=outcome,
|
|
29
|
+
duration_ms=(time.monotonic()-_started)*1000, trace_id=os.environ['AB_TRACEPARENT'].split('-')[1],
|
|
30
|
+
operation_id=os.environ['AB_OPERATION_ID'], attributes={'exit_code': exit_code, 'error_kind': kind if re.fullmatch('[a-z_]{1,80}', str(kind)) else 'unknown'})
|
|
31
|
+
sys.stderr.write('__diagnostic_v1__ ' + json.dumps(record, ensure_ascii=False) + '\n')
|
|
32
|
+
sys.stderr.flush()
|
|
@@ -11,6 +11,7 @@ gen_jianying_draft.py 使用。
|
|
|
11
11
|
两个变量语义相同,保留回退是为兼容只配了其中一个的部署环境。
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
+
from log_diagnostics import trace_headers
|
|
14
15
|
import json
|
|
15
16
|
import os
|
|
16
17
|
import sys
|
|
@@ -34,6 +35,7 @@ def _make_headers(priv_token: str) -> dict:
|
|
|
34
35
|
agent_name = os.environ.get("AGENT_NAME", "")
|
|
35
36
|
if agent_name:
|
|
36
37
|
headers["x-invoke-agent"] = agent_name
|
|
38
|
+
headers.update(trace_headers())
|
|
37
39
|
return headers
|
|
38
40
|
|
|
39
41
|
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "template-registry",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "475",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V14",
|
|
6
6
|
"skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
|
|
7
7
|
}
|
|
@@ -380,6 +380,64 @@ def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
|
|
|
380
380
|
return errors
|
|
381
381
|
|
|
382
382
|
|
|
383
|
+
# gen_script 写进骨架的「作者还没选版式」哨兵。
|
|
384
|
+
#
|
|
385
|
+
# 两处必须是同一个字面量,而 gen-script 与 template-registry 是两个独立 skill 目录、
|
|
386
|
+
# 没有共享模块可以 import,所以这里复制一份。改动需同步 gen_script.py 的
|
|
387
|
+
# ``SLIDE_ID_SENTINEL``(那边的注释也指回这里)。
|
|
388
|
+
SLIDE_ID_SENTINEL = "__CHOOSE_SLIDE__"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
|
|
392
|
+
"""多版式模板的每个场景都必须显式选一个 ``customPayload.slideId``。
|
|
393
|
+
|
|
394
|
+
为什么需要这道门禁:slideId 缺失(或还是哨兵)时,渲染端会静默回落到
|
|
395
|
+
``DefaultSlide`` —— 渲染成功、零日志、退出码 0,画面只剩一行居中标题。这是整条
|
|
396
|
+
管线里最安静的失效方式,看起来"像是模板本来就长这样",只有付完渲染的钱才会发现。
|
|
397
|
+
|
|
398
|
+
**判定「是不是多版式模板」不写死模板 id**,而是看模板自己声明的
|
|
399
|
+
``customPayloadSchema.slideId.enum`` 有没有超过一个取值 —— 有得挑才要求挑。
|
|
400
|
+
单版式模板(枚举只有一项或压根没声明)不拦:那里没有可选的东西,报错只会变成
|
|
401
|
+
修不掉的常驻噪音。
|
|
402
|
+
|
|
403
|
+
报错文案直接把该模板全部可选 slideId 列出来,因为收到这条错误的调用方多半
|
|
404
|
+
正是那个"不知道有哪些版式"的 agent。
|
|
405
|
+
"""
|
|
406
|
+
tpl = _get_template_cfg(_pick_template_id(dsl))
|
|
407
|
+
if not tpl:
|
|
408
|
+
return []
|
|
409
|
+
schema = tpl.get("customPayloadSchema")
|
|
410
|
+
if not isinstance(schema, dict):
|
|
411
|
+
return []
|
|
412
|
+
slide_schema = schema.get("slideId")
|
|
413
|
+
enum = slide_schema.get("enum") if isinstance(slide_schema, dict) else None
|
|
414
|
+
if not isinstance(enum, list) or len(enum) <= 1:
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
tid = tpl.get("templateId", "<unknown>")
|
|
418
|
+
choices = ", ".join(str(x) for x in enum)
|
|
419
|
+
errors: list[ValidationError] = []
|
|
420
|
+
for idx, scene in enumerate(dsl.get("scenes", []) or []):
|
|
421
|
+
sid = scene.get("id", f"scenes[{idx}]")
|
|
422
|
+
custom = scene.get("customPayload") or {}
|
|
423
|
+
slide_id = custom.get("slideId") or (custom.get("templateData") or {}).get("slideId")
|
|
424
|
+
if isinstance(slide_id, str) and slide_id and slide_id != SLIDE_ID_SENTINEL:
|
|
425
|
+
continue
|
|
426
|
+
reason = (
|
|
427
|
+
"still holds the gen_script placeholder"
|
|
428
|
+
if slide_id == SLIDE_ID_SENTINEL
|
|
429
|
+
else "has no slideId"
|
|
430
|
+
)
|
|
431
|
+
errors.append(ValidationError(
|
|
432
|
+
f"scenes[{idx}].customPayload.slideId",
|
|
433
|
+
f"scene '{sid}' {reason} — template '{tid}' registers several layouts and "
|
|
434
|
+
"one must be chosen per scene, otherwise the whole templateData is dropped "
|
|
435
|
+
"and the frame silently renders a single centred title.\n"
|
|
436
|
+
f" Pick the one that matches this scene's information shape: {choices}",
|
|
437
|
+
))
|
|
438
|
+
return errors
|
|
439
|
+
|
|
440
|
+
|
|
383
441
|
def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
|
|
384
442
|
"""完整版:模板若声明 ``capabilities.narrationLanguageStrict`` 则强约束旁白语言。
|
|
385
443
|
|
|
@@ -463,6 +521,10 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
|
|
|
463
521
|
errors += _check_asset_duplicates(dsl)
|
|
464
522
|
errors += _check_scene_asset_refs(dsl)
|
|
465
523
|
errors += _check_narration_items_count(dsl)
|
|
524
|
+
# 新增(非历史规则):多版式模板必须逐场景选定 slideId。放进 integrity 集合是因为
|
|
525
|
+
# render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
|
|
526
|
+
# 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
|
|
527
|
+
errors += _check_slide_id_chosen(dsl)
|
|
466
528
|
return errors
|
|
467
529
|
|
|
468
530
|
|
|
@@ -490,6 +552,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
490
552
|
errors += _check_assetbindings_deprecation(dsl)
|
|
491
553
|
errors += _check_narration_items_count(dsl)
|
|
492
554
|
errors += _check_narration_language_strict(dsl)
|
|
555
|
+
errors += _check_slide_id_chosen(dsl)
|
|
493
556
|
return errors
|
|
494
557
|
|
|
495
558
|
|