@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.
@@ -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}): ${text}`, EXIT.BACKEND_UNREACHABLE);
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}): ${text}`);
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: ${text.slice(0, 200)}`);
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
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.28",
4
- "generatedAt": "2026-09-13T13:40:09.148Z",
3
+ "version": "0.9.30",
4
+ "generatedAt": "2026-09-14T14:44:43.104Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
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.28",
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`; examples live in `video_dsl/schema/examples/` (locate them through `template-registry`'s `Base directory for this skill`).
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 the existing `*.dsl.json` and `*.binding.json` examples in `template-registry`'s `video_dsl/schema/examples/`, then generate the DSL in the template's native shape using both those examples and the template's `template.json`. The agent must not generate the DSL from scratch ignoring the examples, and must not produce a generic DSL first and rely on `template-registry` to force-match the template later.**
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 reuse the validated example shapes already in the repo and 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:
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) — its own `dsl-example.json` is the only place that shows which.
49
- - Different templates' `binding.json` may reflect different `slotMapping` choices, asset-binding styles, and scene organization.
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. Visit `template-registry/video_dsl/schema/examples/` and locate the closest existing `*.dsl.json` and `*.binding.json` for the requested template.
54
- 2. Read those examples and understand the actual DSL shape, the `templateData` fields, the scene organization, and the binding style the template uses.
55
- 3. Read the template's `template.json`.
56
- 4. Combine the examples and `template.json` to understand the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, and `scenePatterns`.
57
- 5. Generate the DSL using the example's shape and the template's native requirements — not the generic DSL shape.
58
- 6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `templateData.slideId`, `visuals.avatar.assetRef`.
59
- 7. After generation, run the schema check and show the script summary to the user for confirmation.
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 any of the existing examples under `template-registry/video_dsl/schema/examples/`.
73
- - **Forbidden**: only reading `template.json` while ignoring the existing `*.dsl.json` and `*.binding.json` examples.
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
- If `template-registry/video_dsl/schema/examples/` has no matching example for the requested template, or `gen_script.py` cannot yet build the required DSL shape, the agent must clearly tell the user "this template is not yet supported for auto-generation from existing examples" and explain what key fields, structure, or binding info are missing. Possible next steps:
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 that has an example and is already supported.
83
- 2. Have the agent take the closest `*.dsl.json` / `*.binding.json` as a reference and hand-craft the required DSL structure for this template, then show it to the user for confirmation.
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 based on the existing examples in `template-registry`".
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 DSL examples live under `template-registry/video_dsl/schema/examples/`; list them with `template_registry list_examples=true`.
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
- "zh": "关于{topic}的要点内容",
201
- "en": "A key point about {topic}",
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
- def _build_custom_payload(purpose: str, topic: str, label: str, idx: int, total_scenes: int, language: str = DEFAULT_LANGUAGE) -> dict:
942
- """Build customPayload with slideId and templateData for template-native scenes."""
943
- if purpose == "opening":
944
- return {
945
- "slideId": "demo-concept-overview",
946
- "templateData": {
947
- "title": topic,
948
- },
949
- }
950
- elif purpose == "cta":
951
- return {
952
- "slideId": "demo-concept-overview",
953
- "templateData": {
954
- "title": topic,
955
- },
956
- }
957
- else:
958
- palette = ["#6C5CE7", "#00B894", "#E17055", "#0984E3", "#FDCB6E", "#A29BFE", "#FF7675"]
959
- return {
960
- "slideId": "demo-single-concept",
961
- "templateData": {
962
- "icon": "💡",
963
- "title": label,
964
- "description": _localize_label(language, "point_description", topic=topic),
965
- "accentColor": palette[idx % len(palette)],
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": topic, "animation": "fade-in"},
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(plan["purpose"], topic, plan["label"], idx, len(scene_plans), language=output_language)
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": "V20",
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"⚠️ No examples found under {_EXAMPLES_DIR}.\n"
156
- " This package may have shipped without reference examples; "
157
- "check the cli source repo or upgrade @remixmate/cli.",
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": "V13",
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