@remixmate/cli 0.9.30 → 0.9.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.30",
4
- "generatedAt": "2026-09-14T14:44:43.104Z",
3
+ "version": "0.9.32",
4
+ "generatedAt": "2026-09-15T01:33:27.056Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -833,7 +833,7 @@
833
833
  "tier": "orchestration",
834
834
  "category": "authoring",
835
835
  "title": "Video Template Registry",
836
- "summary": "List all available video templates (templateId / name / aspect ratio / style tags). Template-to-DSL binding is no longer exposed as a separate step — once prepare_video_assets receives a template_id it runs the full DSL→RenderPlan pipeline internally.",
836
+ "summary": "List all available video templates (templateId / name / aspect ratio / style tags), or read one template's full definition with template_id. Listing returns summaries; pass template_id to get that template's authoring contract (llmHint / customPayloadSchema / slotMapping / variants). Template-to-DSL binding is no longer exposed as a separate step — once prepare_video_assets receives a template_id it runs the full DSL→RenderPlan pipeline internally.",
837
837
  "triggers": [
838
838
  "View available templates / list every template"
839
839
  ],
@@ -853,11 +853,19 @@
853
853
  "properties": {
854
854
  "list_templates": {
855
855
  "type": "boolean",
856
- "description": "List available templates (default behavior; also implied when other flags are passed)."
856
+ "description": "List available templates as summaries — templateId / name / description / aspect ratios / language / status / styleTags / variantIds / key capabilities / a truncated llmHint (default behavior; also implied when other flags are passed)."
857
+ },
858
+ "template_id": {
859
+ "type": "string",
860
+ "description": "Return this template's FULL definition instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. This is the per-template authoring contract — read it before writing DSL for that template."
861
+ },
862
+ "full": {
863
+ "type": "boolean",
864
+ "description": "List mode only: emit full definitions for every listed template instead of summaries. Rejected when the result would be too large for one tool result — prefer template_id, or narrow with the filter_* parameters."
857
865
  },
858
866
  "list_examples": {
859
867
  "type": "boolean",
860
- "description": "List the *.dsl.json / *.binding.json reference examples shipped under template-registry/video_dsl/schema/examples/, grouped by templateId. Useful for downstream agents (e.g. a creation agent) that want to read a template's reference shape before producing new DSL."
868
+ "description": "List the *.dsl.json / *.binding.json reference examples under template-registry/video_dsl/schema/examples/, grouped by templateId. That directory is optional and is not part of the published package, so this is normally empty — to read a template's reference shape, use template_id instead (the registry's own contract)."
861
869
  },
862
870
  "filter_tag": {
863
871
  "type": "string",
@@ -877,7 +885,7 @@
877
885
  },
878
886
  "json_output": {
879
887
  "type": "boolean",
880
- "description": "Emit a JSON result ({ templates: [...] } or { examples: [...] }) instead of the human-readable table."
888
+ "description": "Emit a JSON result instead of the human-readable table: { templates: [...] } — summaries, or full definitions when template_id / full is set — or { examples: [...] }."
881
889
  }
882
890
  },
883
891
  "required": []
@@ -886,6 +894,8 @@
886
894
  "primary": [],
887
895
  "advanced": [
888
896
  "list_templates",
897
+ "template_id",
898
+ "full",
889
899
  "list_examples",
890
900
  "filter_tag",
891
901
  "filter_aspect",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
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",
@@ -52,9 +52,10 @@ The intent is to avoid the failure mode where "the generic DSL looks compatible
52
52
 
53
53
  ### Mandatory steps
54
54
 
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.
55
+ 1. Read the requested template's full definition from the registry — `template_registry` with `template_id=<the id>` and `json_output=true` emits that one template whole. This is the shipping source of truth, and unlike the summary list it truncates nothing. Do **not** ask for every template's full definition (`list_templates=true` + `full=true`): the whole registry is far more JSON than one tool result can carry, and the call fails instead of returning the contract.
56
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
57
  3. Read its `customPayloadSchema`: every template-specific field lives there, including the enum of legal `customPayload.slideId` values for multi-layout templates.
58
+ 3b. If the template ships `slideSchemas` (multi-layout templates do), **author `templateData` straight from `slideSchemas[slideId]`** — it is the only place the per-layout field names exist. Do not infer them: the same concept is named `name` in one layout, `label` in another and `era` in a third, and a wrong name is dropped in silence (see below).
58
59
  4. Combine that with the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, `supportedDurations`, `constraints`, and `scenePatterns`.
59
60
  5. Generate the DSL in the template's native shape — not the generic DSL shape.
60
61
  6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `customPayload.slideId`, `visuals.avatar.assetRef`.
@@ -69,6 +70,14 @@ When a template's `customPayloadSchema.slideId.enum` holds more than one value (
69
70
 
70
71
  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.
71
72
 
73
+ ### Multi-layout templates: field names are per-layout, never guessable
74
+
75
+ Picking the layout is only half of it. Each layout reads its own field names out of `templateData`, and a field the layout does not read is **dropped without a word** — that slot renders empty, or shows the component's built-in placeholder text, and the render still reports success.
76
+
77
+ This has shipped broken videos: a 7-scene deck wrote `items[].title` where `html-slide`'s `feature-grid` reads `name`, `items[].description` where `timeline-axis` reads `detail`, and `title` / `subtitle` / `ctaText` where `closing-cta` reads `headline` / `actionLine` / `ctaLabel` / `ctaUrl`. Five of seven scenes rendered as empty cards and placeholder copy — after paying for TTS and the render.
78
+
79
+ So: **copy the field names out of `slideSchemas[slideId]`.** The DSL validator now rejects unknown field names before any asset is generated and tells you the declared ones, but that check only fires for templates that ship `slideSchemas` — reading the contract is still the primary move, not the fallback.
80
+
72
81
  ### On-screen text: the rules live in the template, not here
73
82
 
74
83
  How the on-screen text layers should be *written* — whether the headline is a hook or a product name, how many subheadline lines survive, whether `**emphasis**` is parsed, where a link is allowed to appear — is a property of each template's layout, and its single source of truth is that template's `llmHint` in `template.json`. This skill deliberately does not restate any of it: a copy here would drift from the registry, and the registry is what actually renders.
@@ -476,4 +485,4 @@ prefix and version — not a short alias:
476
485
  |------|---------|
477
486
  | `gen_script.py` | Core script — produces the Video DSL JSON from a topic. |
478
487
 
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.
488
+ The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slideSchemas` + `slotMapping`) — everything you need to author against is in there, so read it rather than inferring field names from the template's name or from another template. `template_registry list_examples=true` lists local reference DSLs when any exist, but that directory does not ship — see *DSL schema* above.
@@ -77,8 +77,9 @@ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDE
77
77
  > The Python binding logic that maps DSL → TemplateBinding lives in `scripts/match_template.py` but is **not exposed as a CLI** — it is only consumed as a Python library by `render-video`'s `render_video.py` via `import match_template`.
78
78
 
79
79
  1. **List available templates**: run `remixmate template-registry --list-templates` to view the templates in the registry along with their supported aspect ratios / style tags, and decide which `templateId` to pick.
80
- 2. **Write the DSL**: when generating the Video DSL, put the chosen `templateId` into `meta.templateId` (the canonical location). Use `meta.templateVariant` / `renderHints.templateVariant` to explicitly select a variant. The legacy `renderHints.templatePreference[0]` is still tolerated by `match_template.py` and `dsl_validator._pick_template_id` during transition, but new authors should write `meta.templateId`.
81
- 3. **Produce the TemplateBinding**: there is no standalone CLI for DSL TemplateBinding; `prepare_video_assets` calls `match_template.build_binding(template, dsl)` inline during the asset-resolution pipeline and embeds the binding into the RenderPlan it hands to the renderer no separate `*.binding.json` file is written.
80
+ 2. **Read that template's contract**: run `remixmate template-registry --template-id <id> --json-output` for the full definition (llmHint, `customPayloadSchema` with the legal `slideId` values, `slotMapping`, `variants`). The list view only carries a 200-char llmHint preview, which is not enough to author against.
81
+ 3. **Write the DSL**: when generating the Video DSL, put the chosen `templateId` into `meta.templateId` (the canonical location). Use `meta.templateVariant` / `renderHints.templateVariant` to explicitly select a variant. The legacy `renderHints.templatePreference[0]` is still tolerated by `match_template.py` and `dsl_validator._pick_template_id` during transition, but new authors should write `meta.templateId`.
82
+ 4. **Produce the TemplateBinding**: there is no standalone CLI for DSL → TemplateBinding; `prepare_video_assets` calls `match_template.build_binding(template, dsl)` inline during the asset-resolution pipeline and embeds the binding into the RenderPlan it hands to the renderer — no separate `*.binding.json` file is written.
82
83
 
83
84
  > Design trade-off: collapsing the binding step into the asset-prep pipeline (no CLI, no on-disk artifact) avoids binding files drifting between the agent, the database, and the file system; any hand-edited `.binding.json` would never be consumed by the renderer anyway. For local debugging you can still `import match_template.build_binding` from Python.
84
85
 
@@ -119,12 +120,25 @@ print(json.dumps(binding, ensure_ascii=False, indent=2))
119
120
 
120
121
  | Flag | Description | Default |
121
122
  |------|-------------|---------|
122
- | `--list-templates` | List every available template (currently the only CLI verb). | — |
123
+ | `--list-templates` | List every available template as a **summary** (templateId / name / description / aspect ratios / language / status / styleTags / variantIds / the `capabilities` keys that drive authoring — `payloadStyle` / `needsNarration` / `durationStrategy` / `narrationDriver` — and llmHint truncated to 200 chars). | — |
124
+ | `--template-id <id>` | Print that template's **full definition** — llmHint in full, `customPayloadSchema`, `slotMapping`, `compositions`, `variants`. Repeatable. | — |
125
+ | `--full` | List mode: emit full definitions instead of summaries. Refuses when the result would exceed 60 K characters. | off |
123
126
  | `--filter-tag` | Keep only templates whose `styleTags` match this substring (case-insensitive). | none |
124
127
  | `--filter-aspect` | Keep only templates declaring this aspect ratio (e.g. `9:16`). | none |
125
128
  | `--filter-language` | Keep only templates whose `contentLanguage` includes this code (`zh`/`en`); language-agnostic templates always show. | none |
126
129
  | `--include-beta` | Also show `status: beta` templates (same effect as `ENABLE_BETA_TEMPLATES=1`). | off |
127
- | `--json-output` | Emit `{ "templates": [...] }` instead of the table. | off |
130
+ | `--json-output` | Emit `{ "templates": [...] }` instead of the table — summaries, or full definitions under `--template-id` / `--full`. | off |
131
+
132
+ ### List vs. detail
133
+
134
+ A registry row is 4–17 KB of JSON, so dumping every full definition at once overflows an LLM tool result (the caller sees "exceeds maximum allowed tokens" instead of the contract it asked for). The list verb therefore returns summaries — enough to *choose* a template — and `--template-id` returns the one definition you need to *author* for it:
135
+
136
+ ```bash
137
+ remixmate template-registry --list-templates # choose
138
+ remixmate template-registry --template-id html-slide --json-output # then read its contract
139
+ ```
140
+
141
+ This mirrors the list/detail split described in `scripts/registry_loader.py` (P1.2), realized CLI-side so it holds even while the backend still serves one merged registry payload.
128
142
 
129
143
  ## Props extraction rules
130
144
 
@@ -13,8 +13,32 @@ TS↔Python duplication; this is now the only place the list verb is realized.
13
13
  The DSL → TemplateBinding logic stays in ``match_template.py`` and is invoked
14
14
  in-process by ``render-video``; it is intentionally NOT exposed as a CLI verb.
15
15
 
16
+ List vs. detail (why ``--json-output`` no longer dumps everything):
17
+ A registry row is 4–17 KB of JSON (customPayloadSchema + slotMapping +
18
+ variants dominate), so ``--list-templates --json-output`` over a real
19
+ registry produced a six-figure-character blob. Every caller of this CLI is
20
+ an LLM tool call, and that blob overflowed the tool-result budget outright —
21
+ the agent got an "exceeds maximum allowed tokens" error instead of the
22
+ template definition it asked for, i.e. the mode that existed *to* read a
23
+ template's contract was the one mode that could never be read.
24
+
25
+ So the list verb emits **summaries** (identity + how to choose: name,
26
+ description, aspects, language, status, tags, variant ids, the capability
27
+ keys that drive authoring, a truncated llmHint) and ``--template-id <id>``
28
+ emits the **full definition** of the one
29
+ template the agent picked. That is the shape ``registry_loader``'s P1.2 note
30
+ already anticipated, realized CLI-side so it works before the backend
31
+ list/detail endpoints exist. ``--full`` still dumps whole definitions for
32
+ programmatic callers, guarded by a size check so it can't silently recreate
33
+ the overflow.
34
+
16
35
  Flags:
17
- --list-templates (accepted; listing is the only verb)
36
+ --list-templates (accepted; listing is the default verb)
37
+ --template-id <id> print the FULL definition of this template
38
+ (repeatable); this is the detail verb
39
+ --full in list mode, emit full definitions instead
40
+ of summaries (size-guarded; prefer
41
+ --template-id)
18
42
  --list-examples list *.dsl.json / *.binding.json reference
19
43
  examples grouped by templateId (consumed
20
44
  by downstream agents that want to read a
@@ -44,9 +68,19 @@ from registry_loader import ( # noqa: E402
44
68
  EXIT_NOT_AUTHENTICATED,
45
69
  RegistryAuthError,
46
70
  RegistryUnreachableError,
71
+ get_template,
47
72
  list_templates as load_visible_templates,
48
73
  )
49
74
 
75
+ # Summary llmHint budget: enough to tell templates apart when choosing, far
76
+ # short of the multi-KB authoring contract (read that with --template-id).
77
+ _SUMMARY_HINT_CHARS = 200
78
+
79
+ # Upper bound for a multi-template full dump (--full). ~60 K chars ≈ 20 K
80
+ # tokens: still large, but inside a tool-result budget, and the message it
81
+ # fails with names the flag that replaces it.
82
+ _MAX_FULL_DUMP_CHARS = 60_000
83
+
50
84
 
51
85
  def _matches(tpl: dict, tag: str | None, aspect: str | None, language: str | None) -> bool:
52
86
  if tag:
@@ -74,6 +108,57 @@ def _status_of(tpl: dict) -> str:
74
108
  return "stable"
75
109
 
76
110
 
111
+ def _truncate(text: object, limit: int) -> str | None:
112
+ if not isinstance(text, str) or not text.strip():
113
+ return None
114
+ text = text.strip()
115
+ return text if len(text) <= limit else text[: limit - 3] + "..."
116
+
117
+
118
+ def _summarize(tpl: dict) -> dict:
119
+ """The "choose a template" view: identity + selection criteria only.
120
+
121
+ Deliberately excludes the authoring contract (llmHint in full,
122
+ customPayloadSchema, slideSchemas, slotMapping, compositions, variants' style bodies) —
123
+ that is what ``--template-id`` returns, one template at a time.
124
+ """
125
+ variants = tpl.get("variants")
126
+ # Only the scalar capability keys that change how a caller *drives* the
127
+ # template (narration vs typewriter caption, who decides duration). The
128
+ # rest of `capabilities` — payloadDefaults above all — is bulk that scales
129
+ # with the registry, so it stays in the detail view.
130
+ caps = tpl.get("capabilities") if isinstance(tpl.get("capabilities"), dict) else {}
131
+ caps_summary = {
132
+ k: caps[k]
133
+ for k in ("payloadStyle", "needsNarration", "durationStrategy", "narrationDriver")
134
+ if k in caps
135
+ }
136
+ summary = {
137
+ "templateId": tpl.get("templateId"),
138
+ "name": tpl.get("name"),
139
+ "description": tpl.get("description"),
140
+ "status": _status_of(tpl),
141
+ "supportedAspectRatios": tpl.get("supportedAspectRatios") or [],
142
+ "contentLanguage": tpl.get("contentLanguage") or [],
143
+ "styleTags": tpl.get("styleTags") or [],
144
+ "variantIds": sorted(variants.keys()) if isinstance(variants, dict) else [],
145
+ "capabilities": caps_summary,
146
+ "llmHintPreview": _truncate(tpl.get("llmHint"), _SUMMARY_HINT_CHARS),
147
+ }
148
+ hint = tpl.get("llmHint")
149
+ if isinstance(hint, str) and len(hint.strip()) > _SUMMARY_HINT_CHARS:
150
+ summary["llmHintTruncated"] = True
151
+ return summary
152
+
153
+
154
+ def _detail_hint(template_id: str = "<templateId>") -> str:
155
+ return (
156
+ f"Summaries only. Run --template-id {template_id} --json-output for one "
157
+ "template's full definition (llmHint / customPayloadSchema / slideSchemas / "
158
+ "slotMapping / variants) — that is the per-template authoring contract."
159
+ )
160
+
161
+
77
162
  # ----- Examples discovery (--list-examples) ---------------------------------
78
163
  #
79
164
  # Examples live under <SkillDir>/video_dsl/schema/examples/ as `*.dsl.json` and
@@ -129,7 +214,12 @@ def main() -> None:
129
214
  description="List available video templates from the registry.",
130
215
  )
131
216
  ap.add_argument("--list-templates", action="store_true",
132
- help="List every available template (the only CLI verb).")
217
+ help="List every available template (summaries; the default verb).")
218
+ ap.add_argument("--template-id", action="append", dest="template_ids", metavar="ID",
219
+ help="Print this template's FULL definition (repeatable).")
220
+ ap.add_argument("--full", action="store_true",
221
+ help="List mode: emit full definitions instead of summaries "
222
+ "(size-guarded; prefer --template-id).")
133
223
  ap.add_argument("--list-examples", action="store_true",
134
224
  help="List reference examples (*.dsl.json / *.binding.json) grouped by templateId.")
135
225
  ap.add_argument("--filter-tag", help="Keep templates whose styleTags match this substring.")
@@ -168,9 +258,12 @@ def main() -> None:
168
258
  "contract that does ship is the registry's template.json:\n"
169
259
  " • llmHint — how this template's on-screen text and layouts must be authored\n"
170
260
  " • customPayloadSchema — every template-specific field, incl. the legal slideId values\n"
261
+ " • slideSchemas — for multi-layout templates: what templateData each slideId eats\n"
171
262
  " • 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).",
263
+ " Read it with `--template-id <id> --json-output` (one template's full "
264
+ "definition). Run `--list-templates` first if you need the ids;\n"
265
+ " that view is summaries only — pointing it at the whole registry "
266
+ "returns more JSON than a tool result can carry.",
174
267
  )
175
268
  return
176
269
  print(f"\n{'Template ID':<28} Files")
@@ -201,13 +294,74 @@ def main() -> None:
201
294
  print(f"❌ {exc}", file=sys.stderr)
202
295
  sys.exit(1)
203
296
 
297
+ # ----- detail mode (--template-id) ---------------------------------------
298
+ #
299
+ # Explicit ids win over --filter-*: the caller already chose, and silently
300
+ # returning nothing because a stale filter excluded the pick would read as
301
+ # "template does not exist".
302
+ if args.template_ids:
303
+ by_id = {t.get("templateId"): t for t in templates}
304
+ picked: list[dict] = []
305
+ missing: list[str] = []
306
+ for tid in args.template_ids:
307
+ tpl = by_id.get(tid)
308
+ if tpl is None:
309
+ missing.append(tid)
310
+ else:
311
+ picked.append(tpl)
312
+ if missing:
313
+ for tid in missing:
314
+ # Distinguish "gated out by status" from "does not exist" —
315
+ # otherwise a beta template reads as a typo and the caller
316
+ # retries the id instead of passing --include-beta.
317
+ gated = get_template(tid, include_all_statuses=True)
318
+ if gated is not None:
319
+ print(
320
+ f"❌ Template '{tid}' exists but its status is "
321
+ f"'{_status_of(gated)}' — pass --include-beta to read it.",
322
+ file=sys.stderr,
323
+ )
324
+ else:
325
+ print(f"❌ Unknown template id: '{tid}'", file=sys.stderr)
326
+ known = ", ".join(sorted(str(t.get("templateId")) for t in templates))
327
+ print(f" Available ids: {known}", file=sys.stderr)
328
+ sys.exit(1)
329
+ if args.json_output:
330
+ print(json.dumps({"templates": picked}, ensure_ascii=False))
331
+ else:
332
+ # Pretty-printed rather than tabular: the whole point of detail mode
333
+ # is the nested contract (customPayloadSchema / slotMapping), which
334
+ # a table cannot show.
335
+ print(json.dumps({"templates": picked}, ensure_ascii=False, indent=2))
336
+ return
337
+
204
338
  visible = [
205
339
  t for t in templates
206
340
  if _matches(t, args.filter_tag, args.filter_aspect, args.filter_language)
207
341
  ]
208
342
 
209
343
  if args.json_output:
210
- print(json.dumps({"templates": visible}, ensure_ascii=False))
344
+ if args.full:
345
+ payload = json.dumps({"templates": visible}, ensure_ascii=False)
346
+ if len(payload) > _MAX_FULL_DUMP_CHARS and len(visible) > 1:
347
+ print(
348
+ f"❌ Full definitions for {len(visible)} template(s) are "
349
+ f"{len(payload):,} characters — past the {_MAX_FULL_DUMP_CHARS:,}-char "
350
+ "cap, and past what a tool result can carry.\n"
351
+ " Read one template at a time with `--template-id <id> --json-output`, "
352
+ "or narrow with --filter-tag / --filter-aspect / --filter-language.",
353
+ file=sys.stderr,
354
+ )
355
+ sys.exit(1)
356
+ print(payload)
357
+ return
358
+ print(json.dumps(
359
+ {
360
+ "templates": [_summarize(t) for t in visible],
361
+ "detailHint": _detail_hint(),
362
+ },
363
+ ensure_ascii=False,
364
+ ))
211
365
  return
212
366
 
213
367
  if not visible:
@@ -233,12 +387,16 @@ def main() -> None:
233
387
  status = _status_of(tpl).ljust(8)
234
388
  tags = ", ".join((tpl.get("styleTags") or [])[:5])
235
389
  print(f"{tid} {name} {ratios} {lang} {status} {tags}")
236
- hint = tpl.get("llmHint")
390
+ hint = _truncate(tpl.get("llmHint"), _SUMMARY_HINT_CHARS)
237
391
  if hint:
238
- hint = hint if len(hint) <= 200 else hint[:197] + "..."
239
392
  print(f"{' ' * 22} ↳ {hint}")
240
393
 
241
394
  print(f"\n{len(visible)} template(s) shown")
395
+ first_id = str(visible[0].get("templateId", "<templateId>"))
396
+ print(
397
+ f"Full definition of one template: --template-id {first_id} --json-output "
398
+ "(llmHint / customPayloadSchema / slideSchemas / slotMapping / variants)",
399
+ )
242
400
 
243
401
 
244
402
  if __name__ == "__main__":
@@ -4,20 +4,22 @@
4
4
  "tier": "orchestration",
5
5
  "category": "authoring",
6
6
  "title": "Video Template Registry",
7
- "description": "List all available video templates (templateId / name / aspect ratio / style tags). Template-to-DSL binding is no longer exposed as a separate step — once prepare_video_assets receives a template_id it runs the full DSL→RenderPlan pipeline internally.",
7
+ "description": "List all available video templates (templateId / name / aspect ratio / style tags), or read one template's full definition with template_id. Listing returns summaries; pass template_id to get that template's authoring contract (llmHint / customPayloadSchema / slotMapping / variants). Template-to-DSL binding is no longer exposed as a separate step — once prepare_video_assets receives a template_id it runs the full DSL→RenderPlan pipeline internally.",
8
8
  "auth": "required",
9
9
  "envVars": ["PRIV_TOKEN", "VIDEO_TEMPLATE_REGISTRY_URL", "MM_API_BASE_URL", "VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD"],
10
10
  "entry": { "type": "python", "scriptPath": "scripts/list_templates.py" },
11
11
  "parameters": {
12
12
  "type": "object",
13
13
  "properties": {
14
- "list_templates": { "type": "boolean", "description": "List available templates (default behavior; also implied when other flags are passed)." },
15
- "list_examples": { "type": "boolean", "description": "List the *.dsl.json / *.binding.json reference examples shipped under template-registry/video_dsl/schema/examples/, grouped by templateId. Useful for downstream agents (e.g. a creation agent) that want to read a template's reference shape before producing new DSL." },
14
+ "list_templates": { "type": "boolean", "description": "List available templates as summaries — templateId / name / description / aspect ratios / language / status / styleTags / variantIds / key capabilities / a truncated llmHint (default behavior; also implied when other flags are passed)." },
15
+ "template_id": { "type": "string", "description": "Return this template's FULL definition instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. This is the per-template authoring contract read it before writing DSL for that template." },
16
+ "full": { "type": "boolean", "description": "List mode only: emit full definitions for every listed template instead of summaries. Rejected when the result would be too large for one tool result — prefer template_id, or narrow with the filter_* parameters." },
17
+ "list_examples": { "type": "boolean", "description": "List the *.dsl.json / *.binding.json reference examples under template-registry/video_dsl/schema/examples/, grouped by templateId. That directory is optional and is not part of the published package, so this is normally empty — to read a template's reference shape, use template_id instead (the registry's own contract)." },
16
18
  "filter_tag": { "type": "string", "description": "Show only templates whose styleTags contain (or are contained in) this string. Case-insensitive. E.g. 'tech' / '科普' / 'walkthrough'." },
17
19
  "filter_aspect": { "type": "string", "description": "Show only templates that declare this aspect ratio. E.g. '9:16' / '16:9' / '1:1'." },
18
20
  "filter_language": { "type": "string", "description": "Show only templates whose contentLanguage includes this code ('zh' or 'en'). Templates with no declared language are always shown (treated as language-agnostic)." },
19
21
  "include_beta": { "type": "boolean", "description": "Also list templates with status='beta'. Default off (only 'stable' shows). The ENABLE_BETA_TEMPLATES env var has the same effect process-wide." },
20
- "json_output": { "type": "boolean", "description": "Emit a JSON result ({ templates: [...] } or { examples: [...] }) instead of the human-readable table." }
22
+ "json_output": { "type": "boolean", "description": "Emit a JSON result instead of the human-readable table: { templates: [...] } — summaries, or full definitions when template_id / full is set — or { examples: [...] }." }
21
23
  },
22
24
  "required": []
23
25
  }
@@ -20,6 +20,7 @@ DSL 校验器 - 对 Video DSL v1alpha1 做结构校验与标准化。
20
20
  处理的模板时**不需要再改 remixmate 代码**。
21
21
  """
22
22
 
23
+ import difflib
23
24
  import re
24
25
  from typing import Iterable, Optional
25
26
 
@@ -32,6 +33,22 @@ VALID_LAYOUTS = {"full-visual", "split-left-right", "split-top-bottom", "picture
32
33
  VALID_ASSET_TYPES = {"image", "video", "audio", "avatar", "subtitle", "bgm"}
33
34
  VALID_ASSET_SOURCES = {"existing", "gen-image", "gen-video", "gen-voice", "gen-digital-human"}
34
35
 
36
+ # 由 Composition 消费、而非 slide 组件消费的字段。前八个与模板侧
37
+ # utils/slidePayload.ts 的 FRAME_LEVEL_KEYS 同源 ——「整包态」binder 会把它们混进
38
+ # templateData,出现在那里是正当的,不该被当成未声明字段。highlightMap 同理(模板
39
+ # 一般在 slideSchemas._shared 里声明它,这里兜底没声明的情况)。
40
+ _SLIDE_FRAME_LEVEL_KEYS = frozenset({
41
+ "slideId",
42
+ "titleText",
43
+ "subtitleText",
44
+ "watermarkText",
45
+ "narrationAssetId",
46
+ "narrationSrc",
47
+ "background",
48
+ "templateData",
49
+ "highlightMap",
50
+ })
51
+
35
52
  # CJK 汉字范围(简繁通用)+ 常见中文标点
36
53
  _CJK_PATTERN = re.compile(r"[一-鿿㐀-䶿＀-￯ -〿]")
37
54
 
@@ -438,6 +455,137 @@ def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
438
455
  return errors
439
456
 
440
457
 
458
+ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
459
+ """逐版式校验 ``customPayload.templateData`` 的字段名。
460
+
461
+ 为什么需要这道门禁:多版式模板里「slideId 决定 templateData 吃哪些字段」这条
462
+ 契约,此前只存在于模板的 React 源码里 —— 而写 DSL 的 agent 只能看到 registry
463
+ 下发的 ``template.json``。字段名猜错(把 ``name`` 写成 ``title``、把 ``detail``
464
+ 写成 ``description``)时组件读到 undefined,要么渲染出内置的占位文案、要么整块
465
+ 空白,**渲染成功、零日志、退出码 0**。真实案例:一份 7 场景的 DSL 里 5 个场景
466
+ 中招,TTS 和渲染的钱全花完才在成片里看出来。
467
+
468
+ 契约现在写在 ``template.json`` 的 ``slideSchemas`` 里(键 = slideId,值 = 该版式
469
+ 的 JSON Schema),由 template-library 的 ``check:slide-schemas`` 与组件源码双向
470
+ 对账。本函数按它核对每个场景,**未声明的字段名带 "did you mean" 提示** ——
471
+ 收到这条错误的多半正是那个猜错了名字的 agent,直接把正确的名字给它。
472
+
473
+ 没有声明 ``slideSchemas`` 的模板(全部单版式模板)一律跳过,不产生噪音。
474
+ """
475
+ tpl = _get_template_cfg(_pick_template_id(dsl))
476
+ if not tpl:
477
+ return []
478
+ schemas = tpl.get("slideSchemas")
479
+ if not isinstance(schemas, dict):
480
+ return []
481
+
482
+ tid = tpl.get("templateId", "<unknown>")
483
+ shared = ((schemas.get("_shared") or {}).get("properties") or {})
484
+ known_slides = [k for k in schemas if not k.startswith("_")]
485
+
486
+ errors: list[ValidationError] = []
487
+
488
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
489
+ sid = scene.get("id", f"scenes[{idx}]")
490
+ custom = scene.get("customPayload") or {}
491
+ if not isinstance(custom, dict):
492
+ continue
493
+ data = custom.get("templateData")
494
+ if not isinstance(data, dict):
495
+ data = scene.get("templateData")
496
+ if not isinstance(data, dict):
497
+ continue
498
+
499
+ slide_id = custom.get("slideId") or data.get("slideId")
500
+ if not isinstance(slide_id, str) or not slide_id:
501
+ continue # 「没选版式」由 _check_slide_id_chosen 负责报
502
+
503
+ schema = schemas.get(slide_id)
504
+ if not isinstance(schema, dict):
505
+ # 版式名写错 —— 同样静默回落到 DefaultSlide,和没写一样致命。
506
+ errors.append(ValidationError(
507
+ f"scenes[{idx}].customPayload.slideId",
508
+ f"scene '{sid}' uses slideId '{slide_id}', which template '{tid}' does not "
509
+ "register — the frame silently falls back to a single centred title.\n"
510
+ f" Available layouts: {', '.join(sorted(known_slides))}",
511
+ ))
512
+ continue
513
+
514
+ base = f"scenes[{idx}].customPayload.templateData"
515
+ errors += _check_payload_object(
516
+ data,
517
+ schema,
518
+ path=base,
519
+ scene=sid,
520
+ extra_allowed=shared,
521
+ # frame 级字段由 Composition 消费,「整包态」binder 会把它们混进
522
+ # templateData —— 出现在这里是正当的,不算未声明字段。
523
+ skip_keys=_SLIDE_FRAME_LEVEL_KEYS,
524
+ )
525
+
526
+ # 卡片数组:用户真正会写错的地方在这一层(items[].title vs items[].name)。
527
+ for key, sub in (schema.get("properties") or {}).items():
528
+ if not isinstance(sub, dict) or sub.get("type") != "array":
529
+ continue
530
+ item_schema = sub.get("items")
531
+ if not isinstance(item_schema, dict) or not item_schema.get("properties"):
532
+ continue
533
+ value = data.get(key)
534
+ if not isinstance(value, list):
535
+ continue
536
+ for i, item in enumerate(value):
537
+ if not isinstance(item, dict):
538
+ continue
539
+ errors += _check_payload_object(
540
+ item, item_schema, path=f"{base}.{key}[{i}]", scene=sid,
541
+ )
542
+
543
+ return errors
544
+
545
+
546
+ def _check_payload_object(
547
+ data: dict,
548
+ schema: dict,
549
+ *,
550
+ path: str,
551
+ scene: str,
552
+ extra_allowed: Optional[dict] = None,
553
+ skip_keys: frozenset = frozenset(),
554
+ ) -> list[ValidationError]:
555
+ """一层对象的「未声明字段 + 缺必填字段」核对。数组元素与顶层共用这一份。"""
556
+ props = schema.get("properties")
557
+ if not isinstance(props, dict):
558
+ return []
559
+ allowed = set(props) | set(extra_allowed or {})
560
+
561
+ errors: list[ValidationError] = []
562
+
563
+ for key in data:
564
+ if key in allowed or key in skip_keys:
565
+ continue
566
+ hint = difflib.get_close_matches(key, sorted(allowed), n=1, cutoff=0.6)
567
+ suggestion = (
568
+ f" Did you mean '{hint[0]}'?" if hint
569
+ else f" Declared fields: {', '.join(sorted(allowed))}."
570
+ )
571
+ errors.append(ValidationError(
572
+ f"{path}.{key}",
573
+ f"scene '{scene}': the slide does not read '{key}' — the value is silently "
574
+ f"dropped and that slot renders empty (or shows the component's placeholder)."
575
+ f"{suggestion}",
576
+ ))
577
+
578
+ for key in schema.get("required") or []:
579
+ if data.get(key) is None:
580
+ errors.append(ValidationError(
581
+ f"{path}.{key}",
582
+ f"scene '{scene}': required field '{key}' is missing — without it the slide "
583
+ "renders its built-in placeholder instead of your content.",
584
+ ))
585
+
586
+ return errors
587
+
588
+
441
589
  def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
442
590
  """完整版:模板若声明 ``capabilities.narrationLanguageStrict`` 则强约束旁白语言。
443
591
 
@@ -525,6 +673,9 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
525
673
  # render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
526
674
  # 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
527
675
  errors += _check_slide_id_chosen(dsl)
676
+ # 同理:字段名写错同样是「渲染成功但画面是空壳」,必须在烧掉 TTS / 渲染积分
677
+ # 之前拦住。只对声明了 slideSchemas 的模板生效,见 _check_slide_payload_fields。
678
+ errors += _check_slide_payload_fields(dsl)
528
679
  return errors
529
680
 
530
681
 
@@ -553,6 +704,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
553
704
  errors += _check_narration_items_count(dsl)
554
705
  errors += _check_narration_language_strict(dsl)
555
706
  errors += _check_slide_id_chosen(dsl)
707
+ errors += _check_slide_payload_fields(dsl)
556
708
  return errors
557
709
 
558
710