@remixmate/cli 0.9.62 → 0.9.64

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.62",
4
- "generatedAt": "2026-09-30T08:05:32.139Z",
3
+ "version": "0.9.64",
4
+ "generatedAt": "2026-10-04T11:52:39.175Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -940,7 +940,8 @@
940
940
  "title": "Video Template Registry",
941
941
  "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.",
942
942
  "triggers": [
943
- "View available templates / list every template"
943
+ "View available templates / list every template",
944
+ "Watch a template's sample videos / open its detail page"
944
945
  ],
945
946
  "entry": {
946
947
  "type": "python",
@@ -993,7 +994,11 @@
993
994
  },
994
995
  "json_output": {
995
996
  "type": "boolean",
996
- "description": "Emit a JSON result instead of the human-readable table: { templates: [...] } — summaries, or full definitions when template_id / full is set — or { examples: [...] }."
997
+ "description": "Emit a JSON result instead of the human-readable table: { templates: [...] } — summaries, or full definitions when template_id / full is set — or { examples: [...] }. List summaries carry pageUrl (the template's web detail page — show it to the user as a link) and, when the backend serves samples, showcases { count, pageUrl, items }; the template_id view carries top-level pageUrls / showcases maps."
998
+ },
999
+ "open": {
1000
+ "type": "string",
1001
+ "description": "Print the web detail page URL of <templateId> (playable samples, layouts, copy-ready prompt) and open it in the browser when run from an interactive terminal. Relay the printed URL to the user."
997
1002
  }
998
1003
  },
999
1004
  "required": []
@@ -1009,7 +1014,8 @@
1009
1014
  "filter_aspect",
1010
1015
  "filter_language",
1011
1016
  "include_beta",
1012
- "json_output"
1017
+ "json_output",
1018
+ "open"
1013
1019
  ],
1014
1020
  "hidden": []
1015
1021
  }
@@ -15,7 +15,7 @@
15
15
  * which would silently group takes wrong). A host that hands us nothing simply
16
16
  * degrades one rung; see resolveTakeKey().
17
17
  *
18
- * See docs/cli-project-binding-design.md §5.1 and appendix A.
18
+ * See docs/product/cli/cli-project-binding-design.md §5.1 and appendix A.
19
19
  */
20
20
  export interface HostSessionProvider {
21
21
  /** Host identifier; becomes the namespace of the grouping key. */
@@ -15,7 +15,7 @@
15
15
  * which would silently group takes wrong). A host that hands us nothing simply
16
16
  * degrades one rung; see resolveTakeKey().
17
17
  *
18
- * See docs/cli-project-binding-design.md §5.1 and appendix A.
18
+ * See docs/product/cli/cli-project-binding-design.md §5.1 and appendix A.
19
19
  */
20
20
  /**
21
21
  * Adding a host is a data-only change: append a row, touch nothing else.
@@ -15,7 +15,7 @@
15
15
  * remotes, monorepos and repos with no origin — each misfire silently filing
16
16
  * output under a project the user never made.
17
17
  *
18
- * See docs/cli-project-binding-design.md §3.
18
+ * See docs/product/cli/cli-project-binding-design.md §3.
19
19
  */
20
20
  import { type HttpContext } from '../http.js';
21
21
  export interface ResolvedProject {
@@ -15,7 +15,7 @@
15
15
  * remotes, monorepos and repos with no origin — each misfire silently filing
16
16
  * output under a project the user never made.
17
17
  *
18
- * See docs/cli-project-binding-design.md §3.
18
+ * See docs/product/cli/cli-project-binding-design.md §3.
19
19
  */
20
20
  import { mmPost } from '../http.js';
21
21
  import { readBinding } from './store.js';
@@ -14,7 +14,7 @@
14
14
  * ~/WorkBuddy/<timestamp>) would never hit a byPath entry, so a
15
15
  * cwd-only design silently degrades to nothing for them.
16
16
  *
17
- * See docs/cli-project-binding-design.md §3.
17
+ * See docs/product/cli/cli-project-binding-design.md §3.
18
18
  */
19
19
  export declare const CONFIG_DIR: string;
20
20
  export declare const PROJECTS_FILE: string;
@@ -14,7 +14,7 @@
14
14
  * ~/WorkBuddy/<timestamp>) would never hit a byPath entry, so a
15
15
  * cwd-only design silently degrades to nothing for them.
16
16
  *
17
- * See docs/cli-project-binding-design.md §3.
17
+ * See docs/product/cli/cli-project-binding-design.md §3.
18
18
  */
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join, resolve } from 'node:path';
@@ -14,7 +14,7 @@
14
14
  * separate CLI processes, sometimes concurrent, and a local cache would have to
15
15
  * solve staleness and write races that a unique index solves for free.
16
16
  *
17
- * See docs/cli-project-binding-design.md §5.
17
+ * See docs/product/cli/cli-project-binding-design.md §5.
18
18
  */
19
19
  import { type HttpContext } from '../http.js';
20
20
  export interface EnsuredTake {
@@ -14,7 +14,7 @@
14
14
  * separate CLI processes, sometimes concurrent, and a local cache would have to
15
15
  * solve staleness and write races that a unique index solves for free.
16
16
  *
17
- * See docs/cli-project-binding-design.md §5.
17
+ * See docs/product/cli/cli-project-binding-design.md §5.
18
18
  */
19
19
  import { mmPost } from '../http.js';
20
20
  import { resolveTakeKey } from './host.js';
@@ -13,7 +13,7 @@
13
13
  * reply — those live in the host's own transcript. So this module reports the
14
14
  * one thing it genuinely observes: the invocation itself.
15
15
  *
16
- * See docs/cli-session-record-design.md §5.
16
+ * See docs/product/cli/cli-session-record-design.md §5.
17
17
  */
18
18
  import { type HttpContext } from '../http.js';
19
19
  import type { ParsedArgs } from '../argv.js';
@@ -13,7 +13,7 @@
13
13
  * reply — those live in the host's own transcript. So this module reports the
14
14
  * one thing it genuinely observes: the invocation itself.
15
15
  *
16
- * See docs/cli-session-record-design.md §5.
16
+ * See docs/product/cli/cli-session-record-design.md §5.
17
17
  */
18
18
  import os from 'node:os';
19
19
  import { cleanDiagnosticText, outcomeOf } from '../diagnostics.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.62",
3
+ "version": "0.9.64",
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,7 +37,8 @@
37
37
  "test:payload-hydration": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-payload-hydration.py",
38
38
  "test:sentence-alignment": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-sentence-alignment.py",
39
39
  "test:authoring-budget": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-authoring-budget.py",
40
- "test:unit": "npm run -s test:narration-speed && npm run -s test:template-look && npm run -s test:narration-tts && npm run -s test:render-resume && npm run -s test:required-props && npm run -s test:countdown && npm run -s test:prepare-contracts && npm run -s test:image-sequence && npm run -s test:image-prompt-compose && npm run -s test:page-contracts && npm run -s test:declared-keys && npm run -s test:payload-hydration && npm run -s test:sentence-alignment && npm run -s test:authoring-budget",
40
+ "test:showcase-links": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-showcase-links.py",
41
+ "test:unit": "npm run -s test:narration-speed && npm run -s test:template-look && npm run -s test:narration-tts && npm run -s test:render-resume && npm run -s test:required-props && npm run -s test:countdown && npm run -s test:prepare-contracts && npm run -s test:image-sequence && npm run -s test:image-prompt-compose && npm run -s test:page-contracts && npm run -s test:declared-keys && npm run -s test:payload-hydration && npm run -s test:sentence-alignment && npm run -s test:authoring-budget && npm run -s test:showcase-links",
41
42
  "integration": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/integration/regress.py",
42
43
  "integration:render": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/integration/render-template-check.py"
43
44
  },
@@ -1477,7 +1477,7 @@ def build_dsl(
1477
1477
 
1478
1478
  scene_images 是用户**自带的配图**(visual-overlay 模板专用),按顺序占位:第 i 张
1479
1479
  图给第 i 个场景,没被占到的场景照常走 gen-image 补图。这条规则不是随便定的——
1480
- 见 docs/asset-annotation-design.md §6:另外两种立场(模型自由挑选 / 严格一一对应)
1480
+ 见 docs/product/creation/asset-annotation-design.md §6:另外两种立场(模型自由挑选 / 严格一一对应)
1481
1481
  都会让"只传两张图"要么失去可预期性,要么直接跑不通。
1482
1482
 
1483
1483
  speed 是旁白语速倍率,落在 global.narration.speed(不给 = 1.0)。它**不写进
@@ -2184,7 +2184,7 @@ def render_with_remote_api(
2184
2184
  if source_oss_key:
2185
2185
  # 只传 templateKey:由 ab-render 带本次的 X-Priv-Token 向 ab-api 换源码包 URL
2186
2186
  # 并自行下载(所有权校验在 ab-api)。源码包 URL 不经过客户端 ——
2187
- # 见 ab-platform/docs/template-source-protection-design.md §4.3。
2187
+ # 见 ab-platform/docs/templates/template-source-protection-design.md §4.3。
2188
2188
  payload = {
2189
2189
  "templateKey": template_id,
2190
2190
  "inputProps": input_props,
@@ -9,6 +9,7 @@ description: |
9
9
  Note: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally.
10
10
  triggers:
11
11
  - View available templates / list every template
12
+ - Watch a template's sample videos / open its detail page
12
13
  ---
13
14
 
14
15
  # Video Template Registry & Binding Skill
@@ -48,7 +49,8 @@ The skill itself only reads the registry; whether a token is needed depends on t
48
49
  |---------|-------------|---------|
49
50
  | `VIDEO_TEMPLATE_REGISTRY_URL` | ab-api endpoint returning the registry (the single source of truth). | derived from `MM_API_BASE_URL` |
50
51
  | `MM_API_BASE_URL` | ab-api base URL the CLI talks to. When `VIDEO_TEMPLATE_REGISTRY_URL` is unset, the registry endpoint is derived as `<base>/remotionTemplate/registry`. | `https://api.remixmate.ai/api` |
51
- | `PRIV_TOKEN` | Sent as `X-Priv-Token` when hitting the registry endpoint. Falls back to `~/.config/remixmate/credentials.json`. | unset |
52
+ | `PRIV_TOKEN` | Sent as `X-Priv-Token` to the registry and sample endpoints. Injected by the `remixmate` runner after `remixmate login`; the Python side reads only this env var. | unset |
53
+ | `VIDEO_TEMPLATE_SHOWCASE_URL` | Sample endpoint override. Default `<MM_API_BASE_URL>/templateShowcase/listByTemplate`; not called when the registry is a `file://` URL. | derived |
52
54
  | `VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD` | `POST` (default) or `GET`. POST shape matches ab-api `{code,msg,data}`. | `POST` |
53
55
 
54
56
  Resolution (single source — see `scripts/registry_loader.py` for the canonical implementation):
@@ -74,6 +76,16 @@ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDE
74
76
  remixmate template-registry --list-templates
75
77
  ```
76
78
 
79
+ ### Show templates to the user
80
+
81
+ When the user asks to see / browse / pick templates, they decide by *watching* samples, not by reading definitions. The list adds, per template, a link to its web detail page (playable samples, layouts, a copy-ready prompt) and the number of samples whenever the backend serves them — table: `↳ detail: <url>` and `↳ samples: N`; JSON: each summary's `pageUrl` and `showcases.count`; `--template-id`: top-level `pageUrls`.
82
+
83
+ - Present each template as: name, aspect ratios, sample count, and its `pageUrl` as a clickable Markdown link. Keep the URL exactly as printed.
84
+ - When the user wants to see what one template looks like, give its link, or run `remixmate template-registry --open <templateId>` (prints the URL; opens the browser only from an interactive terminal — relay the printed URL either way).
85
+ - If the host renders images, you may add a sample's `coverImage` as a Markdown image next to the link; the link stays the primary content.
86
+ - No sample info (older backend, or a template with no samples yet) is not an error: show the template without it.
87
+ - The detail page's prompt is meant to be pasted back into this chat; treat it like any user request.
88
+
77
89
  ### Build a binding locally (debug only, not part of the render pipeline)
78
90
 
79
91
  ```python
@@ -99,7 +111,8 @@ print(json.dumps(binding, ensure_ascii=False, indent=2))
99
111
  | `--filter-aspect` | Keep only templates declaring this aspect ratio (e.g. `9:16`). | none |
100
112
  | `--filter-language` | Keep only templates whose `contentLanguage` includes this code (`zh`/`en`); language-agnostic templates always show. | none |
101
113
  | `--include-beta` | Also show `status: beta` templates (same effect as `ENABLE_BETA_TEMPLATES=1`). | off |
102
- | `--json-output` | List mode: emit `{ "templates": [...] }` summaries (or full definitions with `--full`) instead of the table. Detail mode (`--template-id`) is always compact JSON. | off |
114
+ | `--json-output` | List mode: emit `{ "templates": [...] }` summaries (or full definitions with `--full`) instead of the table. Detail mode (`--template-id`) is always compact JSON. Summaries carry `pageUrl` (web detail page) and `showcases: { count, pageUrl, items }`; detail mode carries top-level `pageUrls` and `showcases` maps. Each is absent when the backend does not serve it. | off |
115
+ | `--open <id>` | Print the template's web detail page URL (samples, layouts) and open the browser when run from an interactive terminal. Exits 1 when the backend has no link for it. Cannot be combined with `--template-id`. | — |
103
116
 
104
117
  ### List vs. detail
105
118
 
@@ -68,6 +68,14 @@ Flags:
68
68
  --include-beta also show beta templates (else stable-only)
69
69
  --json-output emit {"templates": [...]} or {"examples": [...]}
70
70
  instead of a table
71
+ --open <id> print the template's web detail page URL
72
+ (samples, layouts); also opens the browser
73
+ when stdout is an interactive terminal
74
+
75
+ Links & samples: list and detail views add each template's web detail-page
76
+ link (registry document-level ``pageUrls``, falling back to the samples
77
+ endpoint) and sample count (showcase_loader.py) when ab-api serves them; when
78
+ it does not, the views are unchanged apart from one stderr warning.
71
79
  """
72
80
 
73
81
  from __future__ import annotations
@@ -87,7 +95,14 @@ from registry_loader import ( # noqa: E402
87
95
  RegistryUnreachableError,
88
96
  get_template,
89
97
  list_templates as load_visible_templates,
98
+ registry_page_urls,
90
99
  )
100
+ from showcase_loader import load_showcases, summarize_group # noqa: E402
101
+
102
+ # Samples per template in list views (table count + JSON items). The detail
103
+ # view (--template-id) returns up to _DETAIL_SHOWCASES.
104
+ _LIST_SHOWCASES = 3
105
+ _DETAIL_SHOWCASES = 20
91
106
 
92
107
  # Summary llmHint budget: enough to tell templates apart when choosing, far
93
108
  # short of the multi-KB authoring contract (read that with --template-id).
@@ -168,6 +183,58 @@ def _summarize(tpl: dict) -> dict:
168
183
  return summary
169
184
 
170
185
 
186
+ def _open_in_browser(url: str) -> None:
187
+ """Best-effort browser open after the URL has already been printed.
188
+
189
+ Only when stdout is a TTY (a human at a terminal) and REMIXMATE_NO_BROWSER
190
+ is unset: agent hosts run commands through pipes / sandboxes, where the
191
+ printed URL is what they relay to the user. Failures are swallowed.
192
+ """
193
+ if not sys.stdout.isatty() or os.environ.get("REMIXMATE_NO_BROWSER", "").strip():
194
+ return
195
+ try:
196
+ import webbrowser
197
+
198
+ webbrowser.open(url, new=2)
199
+ except Exception: # noqa: BLE001
200
+ pass
201
+
202
+
203
+ def _page_url(template_id: object, page_urls: dict[str, str], groups: dict | None) -> str | None:
204
+ """Web detail page link for one template.
205
+
206
+ The registry's document-level ``pageUrls`` comes first: it rides on the call
207
+ every list makes anyway, so the link survives the samples endpoint being
208
+ down. The samples group's ``pageUrl`` is the fallback (same URL, built by
209
+ the same backend helper).
210
+ """
211
+ if not isinstance(template_id, str):
212
+ return None
213
+ if page_urls.get(template_id):
214
+ return page_urls[template_id]
215
+ group = (groups or {}).get(template_id)
216
+ url = group.get("pageUrl") if isinstance(group, dict) else None
217
+ return url if isinstance(url, str) and url else None
218
+
219
+
220
+ def _report_unknown_ids(missing: list[str], templates: list[dict]) -> None:
221
+ for tid in missing:
222
+ # Distinguish "gated out by status" from "does not exist" —
223
+ # otherwise a beta template reads as a typo and the caller
224
+ # retries the id instead of passing --include-beta.
225
+ gated = get_template(tid, include_all_statuses=True)
226
+ if gated is not None:
227
+ print(
228
+ f"❌ Template '{tid}' exists but its status is "
229
+ f"'{_status_of(gated)}' — pass --include-beta to read it.",
230
+ file=sys.stderr,
231
+ )
232
+ else:
233
+ print(f"❌ Unknown template id: '{tid}'", file=sys.stderr)
234
+ known = ", ".join(sorted(str(t.get("templateId")) for t in templates))
235
+ print(f" Available ids: {known}", file=sys.stderr)
236
+
237
+
171
238
  def _detail_hint(template_id: str = "<templateId>") -> str:
172
239
  return (
173
240
  f"Summaries only. Run --template-id {template_id} --json-output for one "
@@ -337,8 +404,14 @@ def main() -> None:
337
404
  ap.add_argument("--filter-language", help="Keep templates tagged with this content language (zh|en|...).")
338
405
  ap.add_argument("--include-beta", action="store_true", help="Also show beta templates (default: stable only).")
339
406
  ap.add_argument("--json-output", action="store_true", help='Emit JSON ({"templates":[...]} or {"examples":[...]}) instead of a table.')
407
+ ap.add_argument("--open", dest="open_id", metavar="ID",
408
+ help="Print the web detail page URL of this template (samples, layouts) "
409
+ "and open it in the browser when run from an interactive terminal.")
340
410
  args = ap.parse_args()
341
411
 
412
+ if args.open_id and (args.template_ids or args.list_examples or args.list_templates):
413
+ ap.error("--open cannot be combined with --template-id / --list-examples / --list-templates")
414
+
342
415
  # --include-beta flips registry_loader's documented status gate via env, so
343
416
  # the CLI and in-process callers share one gating implementation.
344
417
  if args.include_beta:
@@ -404,13 +477,37 @@ def main() -> None:
404
477
  print(f"❌ {exc}", file=sys.stderr)
405
478
  sys.exit(1)
406
479
 
480
+ by_id = {t.get("templateId"): t for t in templates}
481
+ page_urls = registry_page_urls()
482
+
483
+ # ----- open mode (--open) -------------------------------------------------
484
+ #
485
+ # The URL goes to stdout first, unconditionally: in Codex / Claude Code the
486
+ # command runs through a pipe and the printed link is what the agent relays.
487
+ if args.open_id:
488
+ if args.open_id not in by_id:
489
+ _report_unknown_ids([args.open_id], templates)
490
+ sys.exit(1)
491
+ page_url = _page_url(args.open_id, page_urls, None)
492
+ if not page_url:
493
+ page_url = _page_url(args.open_id, {}, load_showcases([args.open_id], 1))
494
+ if not page_url:
495
+ print(
496
+ "❌ No web detail page link for this template: the backend does not "
497
+ "serve sample links yet, or its site address is not configured.",
498
+ file=sys.stderr,
499
+ )
500
+ sys.exit(1)
501
+ print(page_url)
502
+ _open_in_browser(page_url)
503
+ return
504
+
407
505
  # ----- detail mode (--template-id) ---------------------------------------
408
506
  #
409
507
  # Explicit ids win over --filter-*: the caller already chose, and silently
410
508
  # returning nothing because a stale filter excluded the pick would read as
411
509
  # "template does not exist".
412
510
  if args.template_ids:
413
- by_id = {t.get("templateId"): t for t in templates}
414
511
  picked: list[dict] = []
415
512
  missing: list[str] = []
416
513
  for tid in args.template_ids:
@@ -420,21 +517,7 @@ def main() -> None:
420
517
  else:
421
518
  picked.append(tpl)
422
519
  if missing:
423
- for tid in missing:
424
- # Distinguish "gated out by status" from "does not exist" —
425
- # otherwise a beta template reads as a typo and the caller
426
- # retries the id instead of passing --include-beta.
427
- gated = get_template(tid, include_all_statuses=True)
428
- if gated is not None:
429
- print(
430
- f"❌ Template '{tid}' exists but its status is "
431
- f"'{_status_of(gated)}' — pass --include-beta to read it.",
432
- file=sys.stderr,
433
- )
434
- else:
435
- print(f"❌ Unknown template id: '{tid}'", file=sys.stderr)
436
- known = ", ".join(sorted(str(t.get("templateId")) for t in templates))
437
- print(f" Available ids: {known}", file=sys.stderr)
520
+ _report_unknown_ids(missing, templates)
438
521
  sys.exit(1)
439
522
  if args.slide_ids:
440
523
  wanted = [s for s in args.slide_ids if s != _SLIDE_EXPAND_ALL]
@@ -457,7 +540,22 @@ def main() -> None:
457
540
  # tool call: indent=2 nearly doubled the size (html-slide 37 K → 65 K
458
541
  # chars), which alone pushed the plain call past the tool-result cap and
459
542
  # got it spilled to a file the agent cannot read. Humans can `| jq`.
460
- print(json.dumps({"templates": picked}, ensure_ascii=False))
543
+ detail: dict = {"templates": picked}
544
+ # Samples and links sit beside the definitions, never inside them: the
545
+ # definition is the template's authoring contract, these are web content.
546
+ groups = load_showcases([str(t.get("templateId")) for t in picked], _DETAIL_SHOWCASES)
547
+ links = {
548
+ str(t.get("templateId")): url
549
+ for t in picked
550
+ if (url := _page_url(t.get("templateId"), page_urls, groups))
551
+ }
552
+ if links:
553
+ detail["pageUrls"] = links
554
+ if groups is not None:
555
+ detail["showcases"] = {
556
+ tid: summarize_group(g, _DETAIL_SHOWCASES) for tid, g in groups.items()
557
+ }
558
+ print(json.dumps(detail, ensure_ascii=False))
461
559
  return
462
560
 
463
561
  visible = [
@@ -480,9 +578,20 @@ def main() -> None:
480
578
  sys.exit(1)
481
579
  print(payload)
482
580
  return
581
+ groups = load_showcases([str(t.get("templateId")) for t in visible], _LIST_SHOWCASES) if visible else {}
582
+ summaries = []
583
+ for t in visible:
584
+ s = _summarize(t)
585
+ if url := _page_url(t.get("templateId"), page_urls, groups):
586
+ s["pageUrl"] = url
587
+ # Absent (not null) when the endpoint is unavailable, so a consumer
588
+ # never mistakes "unknown" for "zero samples".
589
+ if groups is not None and t.get("templateId") in groups:
590
+ s["showcases"] = summarize_group(groups[t["templateId"]], _LIST_SHOWCASES)
591
+ summaries.append(s)
483
592
  print(json.dumps(
484
593
  {
485
- "templates": [_summarize(t) for t in visible],
594
+ "templates": summaries,
486
595
  "detailHint": _detail_hint(),
487
596
  },
488
597
  ensure_ascii=False,
@@ -496,6 +605,9 @@ def main() -> None:
496
605
  )
497
606
  return
498
607
 
608
+ groups = load_showcases([str(t.get("templateId")) for t in visible], _LIST_SHOWCASES)
609
+ any_link = False
610
+
499
611
  header = (
500
612
  f"\n{'Template ID':<22} {'Name':<18} {'Aspect':<12} "
501
613
  f"{'Lang':<8} {'Status':<8} Tags / LLM hint"
@@ -515,6 +627,14 @@ def main() -> None:
515
627
  hint = _truncate(tpl.get("llmHint"), _SUMMARY_HINT_CHARS)
516
628
  if hint:
517
629
  print(f"{' ' * 22} ↳ {hint}")
630
+ # Two lines, link on its own: an agent summarizing the table keeps a
631
+ # labelled `detail:` line, a URL trailing a count reads as an aside.
632
+ group = (groups or {}).get(tpl.get("templateId"))
633
+ if group is not None:
634
+ print(f"{' ' * 22} ↳ samples: {int(group.get('showcaseCount') or 0)}")
635
+ if url := _page_url(tpl.get("templateId"), page_urls, groups):
636
+ print(f"{' ' * 22} ↳ detail: {url}")
637
+ any_link = True
518
638
 
519
639
  print(f"\n{len(visible)} template(s) shown")
520
640
  first_id = str(visible[0].get("templateId", "<templateId>"))
@@ -522,6 +642,8 @@ def main() -> None:
522
642
  f"Full definition of one template: --template-id {first_id} --json-output "
523
643
  "(llmHint / customPayloadSchema / slideSchemas / slotMapping / variants)",
524
644
  )
645
+ if any_link:
646
+ print(f"Watch samples / layouts in the browser: open a detail link, or --open {first_id}")
525
647
 
526
648
 
527
649
  if __name__ == "__main__":
@@ -186,7 +186,7 @@ def build_binding(template: dict, dsl: dict) -> dict:
186
186
  # 用户选中的字体(DSL 的 global.font)。这里只是把不透明 key 折进 typography,
187
187
  # 真正的解析(uniqId → 排印家族名 + 字重文件)在 ab-render 侧完成。
188
188
  #
189
- # 优先级见 ab-platform/docs/font-selection-design.md §13.3:
189
+ # 优先级见 ab-platform/docs/product/creation/font-selection-design.md §13.3:
190
190
  # 用户选择 > variant.defaultFontId(预留档位,当前恒为空)
191
191
  # > template.defaultTypography.bodyFont(现状裸 family 名)> 模板 theme 字栈
192
192
  # 下面的 variant 合并在此之后执行,所以 fontId 单独在合并后再写回,
@@ -106,6 +106,18 @@ _CACHE_DIR = os.environ.get(
106
106
  _DEFAULT_API_BASE_URL = "https://api.remixmate.ai/api"
107
107
 
108
108
 
109
+ def api_base_url() -> str:
110
+ """ab-api base(不带尾斜杠)。registry 与其它挂在 ab-api 上的只读接口共用这一份解析。
111
+
112
+ 顺序:MM_API_BASE_URL → MM_BACKEND_API_URL → 生产默认,见 ``_default_registry_url``。
113
+ """
114
+ return (
115
+ os.environ.get("MM_API_BASE_URL", "").strip()
116
+ or os.environ.get("MM_BACKEND_API_URL", "").strip()
117
+ or _DEFAULT_API_BASE_URL
118
+ ).rstrip("/")
119
+
120
+
109
121
  def _default_registry_url() -> str:
110
122
  """未显式配置 VIDEO_TEMPLATE_REGISTRY_URL 时,推导默认 ab-api registry endpoint。
111
123
 
@@ -118,12 +130,7 @@ def _default_registry_url() -> str:
118
130
  → https://api.remixmate.ai/api(生产默认,零配置)
119
131
  路径段固定为 ``/remotionTemplate/registry``(POST,返回 ``{code,msg,data}``)。
120
132
  """
121
- base = (
122
- os.environ.get("MM_API_BASE_URL", "").strip()
123
- or os.environ.get("MM_BACKEND_API_URL", "").strip()
124
- or _DEFAULT_API_BASE_URL
125
- ).rstrip("/")
126
- return f"{base}/remotionTemplate/registry"
133
+ return f"{api_base_url()}/remotionTemplate/registry"
127
134
 
128
135
 
129
136
  def _cache_path_for(url: str) -> str:
@@ -299,6 +306,23 @@ def load_registry_data(
299
306
  return _load_from_url(url, ttl=ttl_seconds, timeout=http_timeout)
300
307
 
301
308
 
309
+ def registry_page_urls() -> dict[str, str]:
310
+ """Document-level ``pageUrls`` (templateId → web detail page), built by ab-api.
311
+
312
+ Kept beside ``templates`` rather than inside each definition: a definition is
313
+ the template's authoring contract, a link is site information. Absent on
314
+ older backends, offline registries, or when the backend has no site address
315
+ configured — returns ``{}`` then (never raises: it is display-only).
316
+ """
317
+ try:
318
+ raw = load_registry_data().get("pageUrls")
319
+ except Exception: # noqa: BLE001 — the caller already loaded the registry once
320
+ return {}
321
+ if not isinstance(raw, dict):
322
+ return {}
323
+ return {k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str) and v}
324
+
325
+
302
326
  # ════════════════════════════════════════════════════════════════════════════
303
327
  # Indexed views — O(1) template-id lookup + status / beta gating
304
328
  #
@@ -101,7 +101,7 @@ def create_job(priv_token: str, dsl: str = "", dsl_meta: dict = None) -> int:
101
101
  CONVERSATION_ID 已被注入而整段跳过它;兜底补绑的 jobId 末位来源是从 stdout
102
102
  正则抠的)。归属断了,成片在旁白面板里就查不到已生成的音频。
103
103
  而 CONVERSATION_ID 在这一刻本来就已经在环境里了——信息在场,以前只是没传。
104
- 见 ab-platform/docs/render-job-binding-fix.md。
104
+ 见 ab-platform/docs/engineering/render-job-binding-fix.md。
105
105
 
106
106
  服务端对该字段是软失败:绑不上只记日志,不影响任务创建。
107
107
 
@@ -0,0 +1,175 @@
1
+ """Showcase (sample video) loader — per-template samples + web detail-page links.
2
+
3
+ Used by ``list_templates.py`` so an agent host (Codex CLI / Codex Desktop /
4
+ Claude Code) can show the user how many samples a template has and a link to
5
+ the web detail page where the samples play.
6
+
7
+ Contract (ab-api ``POST /templateShowcase/listByTemplate``, X-Priv-Token):
8
+
9
+ request {"templateKeys": [...], "limitPerTemplate": N}
10
+ response {code, msg, data: {"templates": [
11
+ {"templateKey", "pageUrl"?, "showcaseCount",
12
+ "showcases": [{"showcaseId", "name", "coverImage", "videoUrl",
13
+ "aspectRatio", "duration", "hasDemo", "pageUrl"?, ...}]}
14
+ ]}}
15
+
16
+ Links are built server-side; the CLI never knows the web origin or routes.
17
+
18
+ Unlike the registry, samples are *enhancement* data: every failure (network,
19
+ older ab-api without the endpoint, business error, bad JSON) returns ``None``
20
+ with one stderr warning, and the caller keeps printing the template list.
21
+ Auth failures are also degraded here — the registry call that precedes this
22
+ one already surfaces them with the proper exit code.
23
+
24
+ Env:
25
+ VIDEO_TEMPLATE_SHOWCASE_URL explicit endpoint (tests point it at file://)
26
+ MM_API_BASE_URL / MM_BACKEND_API_URL base, same resolution as the registry
27
+ PRIV_TOKEN injected by the CLI runner
28
+ VIDEO_TEMPLATE_REGISTRY_CACHE_DIR shared cache dir
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import os
36
+ import sys
37
+ import tempfile
38
+ import time
39
+ import urllib.request
40
+ from typing import Optional
41
+
42
+ from registry_loader import api_base_url
43
+
44
+ # Never longer than the registry TTL: a sample added in ShowcaseManager should
45
+ # show up about as fast as a template change does.
46
+ _TTL_SECONDS = 300
47
+ _HTTP_TIMEOUT = 5
48
+
49
+ # Item fields kept in CLI output. The endpoint returns the full wizard item
50
+ # (replicateConfig, layoutPreview, tags …) for the web; an agent only needs
51
+ # enough to describe and link a sample.
52
+ SUMMARY_ITEM_FIELDS = (
53
+ "showcaseId", "name", "coverImage", "videoUrl",
54
+ "aspectRatio", "duration", "hasDemo", "pageUrl",
55
+ )
56
+
57
+
58
+ def _endpoint() -> str:
59
+ """Endpoint URL, or "" when samples should not be fetched at all.
60
+
61
+ An offline registry (``VIDEO_TEMPLATE_REGISTRY_URL=file://…``, fixtures /
62
+ air-gapped dev) never implies a network call for samples: the registry
63
+ there is not ab-api's, so neither are its templates.
64
+ """
65
+ explicit = os.environ.get("VIDEO_TEMPLATE_SHOWCASE_URL", "").strip()
66
+ if explicit:
67
+ return explicit
68
+ if os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL", "").strip().startswith("file://"):
69
+ return ""
70
+ return f"{api_base_url()}/templateShowcase/listByTemplate"
71
+
72
+
73
+ def _cache_dir() -> str:
74
+ return os.environ.get(
75
+ "VIDEO_TEMPLATE_REGISTRY_CACHE_DIR",
76
+ os.path.join(tempfile.gettempdir(), "ab-template-registry"),
77
+ )
78
+
79
+
80
+ def _cache_file(url: str, body: bytes, token: str) -> str:
81
+ # Token identity is part of the key (hashed twice, never stored): two
82
+ # accounts on one machine see different private templates.
83
+ token_id = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8]
84
+ digest = hashlib.sha256(url.encode("utf-8") + b"\0" + body + b"\0" + token_id.encode()).hexdigest()[:16]
85
+ return os.path.join(_cache_dir(), f"showcases-{digest}.json")
86
+
87
+
88
+ def _read_cache(path: str) -> Optional[dict]:
89
+ try:
90
+ with open(path, "r", encoding="utf-8") as fh:
91
+ cached = json.load(fh)
92
+ if time.time() - float(cached.get("fetchedAt", 0)) < _TTL_SECONDS:
93
+ return cached.get("data")
94
+ except (OSError, ValueError, TypeError):
95
+ pass
96
+ return None
97
+
98
+
99
+ def _write_cache(path: str, data: dict) -> None:
100
+ try:
101
+ os.makedirs(os.path.dirname(path), exist_ok=True)
102
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path))
103
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
104
+ json.dump({"fetchedAt": time.time(), "data": data}, fh, ensure_ascii=False)
105
+ os.replace(tmp, path)
106
+ except OSError:
107
+ pass # cache is best-effort
108
+
109
+
110
+ def _warn(reason: str) -> None:
111
+ print(f"⚠️ showcase info unavailable: {reason}", file=sys.stderr)
112
+
113
+
114
+ def load_showcases(template_keys: list[str], limit_per_template: int) -> Optional[dict[str, dict]]:
115
+ """Return ``{templateKey: group}`` for the visible templates, or ``None`` on any failure.
116
+
117
+ Templates the caller cannot see are simply absent from the dict.
118
+ """
119
+ keys = [k for k in dict.fromkeys(template_keys) if isinstance(k, str) and k]
120
+ if not keys:
121
+ return {}
122
+
123
+ url = _endpoint()
124
+ token = os.environ.get("PRIV_TOKEN", "").strip()
125
+ # Silent skips (not failures): offline registry, or no credential — the
126
+ # endpoint is authenticated and the registry step already reported auth.
127
+ if not url or (not token and not url.startswith("file://")):
128
+ return None
129
+ body = json.dumps(
130
+ {"templateKeys": keys, "limitPerTemplate": max(1, min(int(limit_per_template), 50))},
131
+ separators=(",", ":"),
132
+ ).encode("utf-8")
133
+ cache_path = _cache_file(url, body, token)
134
+
135
+ data = _read_cache(cache_path)
136
+ if data is None:
137
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
138
+ if token:
139
+ headers["X-Priv-Token"] = token
140
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
141
+ try:
142
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
143
+ parsed = json.loads(resp.read().decode("utf-8"))
144
+ except Exception as exc: # noqa: BLE001 — enhancement data, degrade on anything
145
+ # Message only: exception text never includes the token header.
146
+ _warn(type(exc).__name__ + (f" {exc.code}" if hasattr(exc, "code") else ""))
147
+ return None
148
+ if not isinstance(parsed, dict) or parsed.get("code") not in (0, "0"):
149
+ code = parsed.get("code") if isinstance(parsed, dict) else "?"
150
+ _warn(f"code={code}")
151
+ return None
152
+ data = parsed.get("data")
153
+ if not isinstance(data, dict) or not isinstance(data.get("templates"), list):
154
+ _warn("unexpected response shape")
155
+ return None
156
+ _write_cache(cache_path, data)
157
+
158
+ wanted = set(keys)
159
+ out: dict[str, dict] = {}
160
+ for group in data.get("templates", []):
161
+ if isinstance(group, dict) and group.get("templateKey") in wanted:
162
+ out[group["templateKey"]] = group
163
+ return out
164
+
165
+
166
+ def summarize_group(group: dict, max_items: int) -> dict:
167
+ """The CLI's view of one template's samples: count, link, a few slim items."""
168
+ items = []
169
+ for sc in (group.get("showcases") or [])[:max_items]:
170
+ if isinstance(sc, dict):
171
+ items.append({k: sc[k] for k in SUMMARY_ITEM_FIELDS if k in sc and sc[k] not in (None, "")})
172
+ out: dict = {"count": int(group.get("showcaseCount") or 0), "items": items}
173
+ if group.get("pageUrl"):
174
+ out["pageUrl"] = group["pageUrl"]
175
+ return out
@@ -19,7 +19,8 @@
19
19
  "filter_aspect": { "type": "string", "description": "Show only templates that declare this aspect ratio. E.g. '9:16' / '16:9' / '1:1'." },
20
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)." },
21
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." },
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: [...] }." }
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: [...] }. List summaries carry pageUrl (the template's web detail page — show it to the user as a link) and, when the backend serves samples, showcases { count, pageUrl, items }; the template_id view carries top-level pageUrls / showcases maps." },
23
+ "open": { "type": "string", "description": "Print the web detail page URL of <templateId> (playable samples, layouts, copy-ready prompt) and open it in the browser when run from an interactive terminal. Relay the printed URL to the user." }
23
24
  },
24
25
  "required": []
25
26
  }
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V26",
5
+ "version": "V27",
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
  }
@@ -108,7 +108,7 @@
108
108
  "properties": {
109
109
  "fontId": {
110
110
  "type": "string",
111
- "description": "字体库的不透明 key(font.uniq_id)。**不要在这里写 CSS family 名**:family 名是渲染细节,由 ab-render 调 /font/resolve 解析;写成 family 名一旦拼错就静默退回默认字体,没有任何报错。见 ab-platform/docs/font-selection-design.md §8.1。"
111
+ "description": "字体库的不透明 key(font.uniq_id)。**不要在这里写 CSS family 名**:family 名是渲染细节,由 ab-render 调 /font/resolve 解析;写成 family 名一旦拼错就静默退回默认字体,没有任何报错。见 ab-platform/docs/product/creation/font-selection-design.md §8.1。"
112
112
  },
113
113
  "fontName": { "type": "string", "description": "展示名,仅供人读与日志,渲染不使用" }
114
114
  }
@@ -271,7 +271,7 @@ def annotate_asset(
271
271
  """把录屏的语义写进 ab-api 素材库(POST /asset/annotate):分镜文本 + 来源页面。
272
272
 
273
273
  分镜里没有逐场景时间戳,整条录屏作为一个片段。失败抛 FinalizeError,调用方只记日志——
274
- 语义是附加信息,不能让「录屏已完成」失败。见 ab-platform/docs/asset-semantic-library-design.md §6.1。
274
+ 语义是附加信息,不能让「录屏已完成」失败。见 ab-platform/docs/product/creation/asset-semantic-library-design.md §6.1。
275
275
  """
276
276
  if not private_token or not (file_id or file_url):
277
277
  return