@remixmate/cli 0.9.23 → 0.9.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.24",
|
|
4
|
+
"generatedAt": "2026-09-07T14:49:54.095Z",
|
|
5
5
|
"skills": [
|
|
6
6
|
{
|
|
7
7
|
"id": "export-jianying",
|
|
@@ -388,25 +388,25 @@
|
|
|
388
388
|
},
|
|
389
389
|
"headline": {
|
|
390
390
|
"type": "string",
|
|
391
|
-
"description": "On-screen headline
|
|
391
|
+
"description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
|
|
392
392
|
},
|
|
393
393
|
"subheadline": {
|
|
394
394
|
"type": "string",
|
|
395
|
-
"description": "On-screen subheadline
|
|
395
|
+
"description": "On-screen subheadline: the line under the headline, saying why the viewer should care. Multi-line via \\n. Stored at meta.subheadline and pushed into every scene's textLayers[role=subheadline] as the top small-text. **Must** be set when the user explicitly provided a subtitle / slogan. **Never a bare URL** — text layers are painted on screen, where a link is neither readable nor clickable; say what the thing is instead. How many lines the template keeps, and whether it parses **emphasis**, is declared in that template's llmHint (gen_script prints it in full on stderr). Note: this is the on-screen subheadline, not the CC subtitle (global.subtitle) — they are independent."
|
|
396
396
|
},
|
|
397
397
|
"carousel_items": {
|
|
398
398
|
"type": "array",
|
|
399
399
|
"items": {
|
|
400
400
|
"type": "string"
|
|
401
401
|
},
|
|
402
|
-
"description": "Media URLs for the template's image/video carousel
|
|
402
|
+
"description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
|
|
403
403
|
},
|
|
404
404
|
"caption_lines": {
|
|
405
405
|
"type": "array",
|
|
406
406
|
"items": {
|
|
407
407
|
"type": "string"
|
|
408
408
|
},
|
|
409
|
-
"description": "
|
|
409
|
+
"description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). Each element is one line. Line-count limits and whether **emphasis** is parsed are declared by the template — read its llmHint, which gen_script prints in full on stderr. **Must** pass when the user explicitly provides bullet-point text / bottom copy. **Also must pass — written by you — when the user did NOT provide any copy but the template is typewriter-driven** (capabilities.durationStrategy=fit-caption): such templates have no narration, so these lines are both the video's content and the thing that decides its duration. Draft them from the material you researched (repo README, page screenshots, the topic). Leaving this empty for a fit-caption template is rejected: nothing auto-generates caption text, and an empty caption renders a titled carousel with no words at all."
|
|
410
410
|
},
|
|
411
411
|
"stub_image_url": {
|
|
412
412
|
"type": "string",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.24",
|
|
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",
|
|
@@ -45,7 +45,7 @@ The full schema is `template-registry` skill's `video_dsl/schema/video-dsl-v1alp
|
|
|
45
45
|
|
|
46
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:
|
|
47
47
|
|
|
48
|
-
- `
|
|
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
49
|
- Different templates' `binding.json` may reflect different `slotMapping` choices, asset-binding styles, and scene organization.
|
|
50
50
|
|
|
51
51
|
### Mandatory steps
|
|
@@ -58,6 +58,15 @@ The intent is to reuse the validated example shapes already in the repo and to a
|
|
|
58
58
|
6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `templateData.slideId`, `visuals.avatar.assetRef`.
|
|
59
59
|
7. After generation, run the schema check and show the script summary to the user for confirmation.
|
|
60
60
|
|
|
61
|
+
### On-screen text: the rules live in the template, not here
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
Two consequences for the agent:
|
|
66
|
+
|
|
67
|
+
- `list_templates`' table truncates `llmHint` to 200 chars for browsing, so the summary you picked the template from is usually **not** the whole rule. `gen_script.py` re-prints the chosen template's `llmHint` in full on stderr before it assembles the DSL — read it there and correct yourself before you show the user a confirmation summary.
|
|
68
|
+
- Multi-line on-screen text uses real newlines. A literal `\n` typed inside shell single quotes arrives as two characters, so `gen_script.py` folds `\n` back into a newline for `--headline` / `--subheadline`; both `'a\nb'` and `$'a\nb'` therefore work.
|
|
69
|
+
|
|
61
70
|
### Hard constraints
|
|
62
71
|
|
|
63
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/`.
|
|
@@ -159,7 +168,7 @@ The agent should show the following in clear Markdown:
|
|
|
159
168
|
|
|
160
169
|
## Agent behavior: user-supplied media for carousel/image-driven templates (`--carousel-items` / `--caption-lines`)
|
|
161
170
|
|
|
162
|
-
Some templates are **media-driven, not prompt-driven**: their on-screen content comes entirely from media URLs the user already has, and the skill does **not** generate any image. These are the templates whose `capabilities.payloadStyle` is `carousel-caption
|
|
171
|
+
Some templates are **media-driven, not prompt-driven**: their on-screen content comes entirely from media URLs the user already has, and the skill does **not** generate any image. These are the templates whose `capabilities.payloadStyle` is `carousel-caption`.
|
|
163
172
|
|
|
164
173
|
For these templates the picture comes from `customPayload.carousel.items`, which is filled **only** from the `--carousel-items` flag. If the user gives you images but you do not pass `--carousel-items`, the carousel is empty and the result is a **black, 2-second clip** (with `durationStrategy: fit-caption`, an empty carousel + empty caption degrades to the 1s headline-intro + 1s tail minimum). `gen_script.py` now hard-fails in this case instead of producing the degenerate video.
|
|
165
174
|
|
|
@@ -169,26 +178,26 @@ When the user selects a `carousel-caption` template (or any template whose `asse
|
|
|
169
178
|
|
|
170
179
|
1. **Extract every media URL the user provided** (image or video links in the prompt) and pass each one as a separate `--carousel-items <url>` flag — preserve the user's order, and pass the URLs **verbatim** (do not rewrite host/path/query).
|
|
171
180
|
2. Pass each on-screen caption line as `--caption-lines '<text>'`. Whether this is optional depends on the template's `capabilities.durationStrategy`, **not** on `needsNarration` (every `carousel-caption` template has `needsNarration: false`):
|
|
172
|
-
- `durationStrategy: fit-caption`
|
|
173
|
-
- `durationStrategy: fit-images`
|
|
181
|
+
- `durationStrategy: fit-caption` → **caption lines are mandatory.** The template has no narration and the typewriter copy is both the content and the clock: it is what the video says *and* what decides how long it runs. **If the user did not supply the copy, write it yourself** from the material you researched (repo README, page screenshots, the topic) and pass it. `gen_script.py` hard-fails on an empty caption for these templates.
|
|
182
|
+
- `durationStrategy: fit-images` → purely visual, captions genuinely optional; duration comes from the image count.
|
|
174
183
|
3. **Never call gen_script for a `carousel-caption` template without `--carousel-items`.** If the user picked such a template but provided no media, ask them for the image/video URLs first — do not generate an empty carousel.
|
|
175
184
|
4. Do **not** route these user-provided images through `gen-image`; they are existing assets and go straight into the carousel.
|
|
176
185
|
5. Nothing downstream fills these in for you. `gen_script.py` routes on `capabilities.payloadStyle` alone — omitting the flags does **not** fall back to a generic path that generates images or writes copy; it assembles an empty carousel / empty caption. There is no auto-generation of caption text anywhere in the pipeline.
|
|
177
186
|
|
|
178
187
|
### Command example
|
|
179
188
|
|
|
180
|
-
User: "用模版
|
|
189
|
+
User: "用模版 <template-id> 生成视频,图片链接为:https://cdn.example.com/a.jpg,https://cdn.example.com/b.jpg,https://cdn.example.com/c.jpg"
|
|
181
190
|
|
|
182
191
|
```bash
|
|
183
192
|
python3 <SkillDir>/scripts/gen_script.py \
|
|
184
193
|
--topic "图片轮播视频" \
|
|
185
|
-
--template-id
|
|
194
|
+
--template-id <template-id> \
|
|
186
195
|
--carousel-items "https://cdn.example.com/a.jpg" \
|
|
187
196
|
--carousel-items "https://cdn.example.com/b.jpg" \
|
|
188
197
|
--carousel-items "https://cdn.example.com/c.jpg"
|
|
189
198
|
```
|
|
190
199
|
|
|
191
|
-
|
|
200
|
+
Every URL the user gave becomes one carousel item, in order. How long each item holds is the template's business — `fit-images` templates derive it from the item count, `fit-caption` templates from `capabilities.durationModel`; either way you pass the URLs and let the template decide.
|
|
192
201
|
|
|
193
202
|
|
|
194
203
|
## Test mode: skip asset generation (`--stub-image-url` / `--stub-video-url`)
|
|
@@ -244,7 +253,7 @@ Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is low
|
|
|
244
253
|
5. **Schema validation**: make sure the output DSL conforms to v1alpha1.
|
|
245
254
|
6. **User confirmation**: show the script summary and wait for confirmation, per the rules above.
|
|
246
255
|
|
|
247
|
-
### Generic
|
|
256
|
+
### Generic DSL (no template id, or a template with no special payload)
|
|
248
257
|
|
|
249
258
|
```bash
|
|
250
259
|
python3 <SkillDir>/scripts/gen_script.py \
|
|
@@ -282,7 +291,7 @@ python3 <SkillDir>/scripts/gen_script.py \
|
|
|
282
291
|
| `--topic` | Video topic (required unless `--validate`). | — |
|
|
283
292
|
| `--platform` | Target platform: `douyin` / `xiaohongshu` / `bilibili` / `wechat` / `youtube` / `generic`. | `generic` |
|
|
284
293
|
| `--headline` | On-screen main title (4–12 chars / ~3 words). Written to `meta.headline` + `textLayers[role=headline]`. **Pass it whenever the user gave a title** — otherwise headline falls back to the long-form topic and overflows the top text layer. | falls back to `--topic` |
|
|
285
|
-
| `--subheadline` | On-screen subtitle /
|
|
294
|
+
| `--subheadline` | On-screen subtitle / slogan. Written to `meta.subheadline` + `textLayers[role=subheadline]`. Multi-line via `\n`; how many lines survive and whether `**emphasis**` renders is declared per template — see its `llmHint`. Not the same thing as CC subtitles (`global.subtitle`). | `""` |
|
|
286
295
|
| `--duration` | Target duration (seconds). Templates whose `durationStrategy` is `fit-caption` / `fit-narration` recompute the real duration and ignore this value. | `30` |
|
|
287
296
|
| `--style` | Style tag. | — |
|
|
288
297
|
| `--ratio` | Aspect ratio. | `16:9` |
|
|
@@ -295,8 +304,8 @@ python3 <SkillDir>/scripts/gen_script.py \
|
|
|
295
304
|
| `-o` / `--output` | Output DSL file path. | stdout |
|
|
296
305
|
| `--stub-image-url` | Test mode: every image AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_IMAGE_URL`). | — |
|
|
297
306
|
| `--stub-video-url` | Test mode: every video AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_VIDEO_URL`). | — |
|
|
298
|
-
| `--carousel-items` | Repeatable. Media URL placed directly into `customPayload.carousel.items` for `carousel-caption` templates
|
|
299
|
-
| `--caption-lines` | Repeatable. On-screen
|
|
307
|
+
| `--carousel-items` | Repeatable. Media URL placed directly into `customPayload.carousel.items` for `carousel-caption` templates. Bypasses gen-image. **Required** for `carousel-caption` templates when the user supplies images. | — |
|
|
308
|
+
| `--caption-lines` | Repeatable. On-screen caption line for `carousel-caption` templates → `customPayload.caption.lines`. Line limits and `**emphasis**` support are declared per template (see its `llmHint`). **Required** for `durationStrategy: fit-caption` templates — write the lines yourself if the user did not supply them. Optional only for `fit-images` templates. | — |
|
|
300
309
|
|
|
301
310
|
## DSL generation principles
|
|
302
311
|
|
|
@@ -338,7 +347,7 @@ prefix and version — not a short alias:
|
|
|
338
347
|
- **Scene duration mismatch**: adjust the narration length or the scene duration.
|
|
339
348
|
- **Invalid platform**: look at the supported-platform list.
|
|
340
349
|
- **`carousel-caption template ... needs visual or text content`**: you passed neither `--carousel-items` nor `--caption-lines`. Pass the user's media URLs (and caption lines where the template requires them).
|
|
341
|
-
- **`template ... is typewriter-driven ... but --caption-lines is empty`**: a `fit-caption` template
|
|
350
|
+
- **`template ... is typewriter-driven ... but --caption-lines is empty`**: a `fit-caption` template got no caption. Write the copy yourself if the user did not supply it, then pass one `--caption-lines` per line.
|
|
342
351
|
- **`duration Ns is below/above template ... supportedDurations`**: the assembled DSL falls outside the range the template declares it was designed for. Below the minimum usually means the content is too thin (add caption lines / narration / scenes); above the maximum means trimming content or lowering `--duration`. This is enforced at generation time on purpose — a degenerate video still costs full render credits.
|
|
343
352
|
|
|
344
353
|
## scripts/ contents
|
|
@@ -207,9 +207,10 @@ def validate_dsl(dsl: dict) -> list:
|
|
|
207
207
|
Historical rule set (preserved verbatim by ``validate_structural``):
|
|
208
208
|
version + meta(title) + global presence + scene count 1–20 + each
|
|
209
209
|
scene's ``id`` and ``purpose``. Asset integrity, enum membership and
|
|
210
|
-
|
|
211
|
-
live in ``validate_dsl`` /
|
|
212
|
-
their respective historical
|
|
210
|
+
capabilities-driven checks (e.g. narrationLanguageStrict) are
|
|
211
|
+
intentionally NOT enforced here — those live in ``validate_dsl`` /
|
|
212
|
+
``validate_integrity`` and only run at their respective historical
|
|
213
|
+
call sites.
|
|
213
214
|
"""
|
|
214
215
|
from video_dsl.runtime.dsl_validator import ( # noqa: E402
|
|
215
216
|
validate_structural,
|
|
@@ -365,7 +366,7 @@ def _template_primary_visual_type(template_config: dict | None) -> str:
|
|
|
365
366
|
返回值之一:
|
|
366
367
|
- "image" : 模板要求图片素材(默认通用 DSL 也是图片)
|
|
367
368
|
- "video" : 模板要求视频素材(assetRequirements 含 video 但不含 image)
|
|
368
|
-
- "none" :
|
|
369
|
+
- "none" : 模板不需要任何视觉素材(assetRequirements 只有 audio)
|
|
369
370
|
"""
|
|
370
371
|
if not template_config:
|
|
371
372
|
return "image" # 默认:通用 DSL 走图片
|
|
@@ -388,8 +389,8 @@ def _template_needs_narration(template_config: dict | None) -> bool:
|
|
|
388
389
|
- 不生成 gen-voice 素材;
|
|
389
390
|
- 场景不写 ``audio.narration``。
|
|
390
391
|
于是骨架的 narrationSceneCount=0,ab-agent 的 prepare_video_assets 预校验
|
|
391
|
-
|
|
392
|
-
走的是同一条 pass-through
|
|
392
|
+
会直接放行,不再强制用户为每个场景填旁白 —— 所有 needsNarration=false 的模板
|
|
393
|
+
走的是同一条 pass-through 路径。
|
|
393
394
|
|
|
394
395
|
兼容历史脏数据:部分模板的 ``capabilities`` 是空列表 ``[]``(而非 dict),
|
|
395
396
|
统一按"未声明"处理 → 返回 True。仅当显式为 JSON ``false`` 时才关闭旁白。
|
|
@@ -426,6 +427,8 @@ def _template_needs_image(template_config: dict | None) -> bool:
|
|
|
426
427
|
# - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
|
|
427
428
|
# - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
|
|
428
429
|
# - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
|
|
430
|
+
# - duration_model dict|None capabilities.durationModel(fit-caption 估时用的节奏数值,
|
|
431
|
+
# 由模板声明;缺省则不估时,见 _estimate_fit_caption_duration)
|
|
429
432
|
# - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导
|
|
430
433
|
|
|
431
434
|
|
|
@@ -469,12 +472,78 @@ def _resolve_contract(template_config: dict | None) -> dict:
|
|
|
469
472
|
"fixed_scenes": fixed_scenes,
|
|
470
473
|
"payload_style": payload_style,
|
|
471
474
|
"duration_strategy": duration_strategy,
|
|
475
|
+
"duration_model": caps.get("durationModel"),
|
|
472
476
|
"payload_defaults": payload_defaults,
|
|
473
477
|
"primary_visual_type": primary_visual_type,
|
|
474
478
|
"default_layout": caps.get("defaultLayout"),
|
|
475
479
|
}
|
|
476
480
|
|
|
477
481
|
|
|
482
|
+
def _normalize_linebreaks(value: str | None) -> str:
|
|
483
|
+
"""把画面文字里的**字面量** ``\\n`` 折成真换行。
|
|
484
|
+
|
|
485
|
+
``textLayers[].content`` 用真换行分行,但调用方是在拼 shell 命令行的模型:
|
|
486
|
+
``--subheadline 'A\\nB'`` 在 POSIX 单引号里传进来的是**反斜杠 + n 两个字符**,
|
|
487
|
+
不是换行。不折的话它会原样渲进画面(副标题上真的印着一个 ``\\n``),而这既不
|
|
488
|
+
报错也不出现在确认摘要里 —— 属于"只有渲完看片才发现"的那类坑。
|
|
489
|
+
|
|
490
|
+
反过来说,画面文案里没有任何理由需要一个字面 ``\\n``,所以这个折叠是无损的。
|
|
491
|
+
真换行(``$'a\\nb'`` 或参数里直接带换行)本来就能用,不受影响。
|
|
492
|
+
"""
|
|
493
|
+
return (value or "").replace("\\n", "\n").strip()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _echo_template_llm_hint(template_config: dict | None, template_id: str | None) -> None:
|
|
497
|
+
"""把模板自己声明的 ``llmHint`` 原样打到 stderr。
|
|
498
|
+
|
|
499
|
+
**本函数刻意不认识任何一个具体模板。** 每个模板的画面文字该怎么写(标题写钩子
|
|
500
|
+
还是写产品名、副标题几行、支持不支持 ``**强调**``)是模板的事实,真源在它自己的
|
|
501
|
+
``template.json`` —— 与 ``imageStyleGuide`` 由 ``prompt_enhancer`` 通用消费、
|
|
502
|
+
``capabilities`` 由 ``_resolve_contract`` 通用归一化是同一条纪律:**CLI 出机制,
|
|
503
|
+
模板出内容**。把某个模板的版式规则抄进 skill 文档或这里的分支,等于在 registry
|
|
504
|
+
之外开第二份会漂移的事实。
|
|
505
|
+
|
|
506
|
+
之所以要在这里再打一遍:``list_templates`` 的表格视图把 llmHint 截到 200 字
|
|
507
|
+
(便于浏览),而真正的写作规范往往长得多(现网最长的已有 1400+ 字)。调用方
|
|
508
|
+
多半是照着那份被截断的摘要选完模板就直接来调 gen_script 的,规则的后半截它从没
|
|
509
|
+
见过。这里在装配 DSL 之前把全文补给它,让它能在确认摘要之前自己纠偏。
|
|
510
|
+
"""
|
|
511
|
+
hint = (template_config or {}).get("llmHint")
|
|
512
|
+
if isinstance(hint, str) and hint.strip():
|
|
513
|
+
print(
|
|
514
|
+
f"ℹ️ template '{template_id}' llmHint (authoring rules declared by the "
|
|
515
|
+
f"template itself — follow them):\n {hint.strip()}",
|
|
516
|
+
file=sys.stderr,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _warn_on_url_text_layer(headline: str, subheadline: str) -> None:
|
|
521
|
+
"""画面文字整条是一个裸 URL 时出声提醒(不阻断)。
|
|
522
|
+
|
|
523
|
+
这是 DSL 层的事实、不是某个模板的版式:``textLayers`` 是**画在画面上**的字,
|
|
524
|
+
而观众没法点视频里的链接、也很少有人会照着念一串 URL 抄下来。链接的正确去处
|
|
525
|
+
因模板而异(打字机行、结尾 CTA、简介),所以这里只说"这不该出现在画面标题里",
|
|
526
|
+
不替模板规定它该去哪 —— 那句话由模板的 llmHint 自己讲。
|
|
527
|
+
|
|
528
|
+
只 warn 不 exit:这是文风问题,照样渲得出一条能看的片子,与"caption 为空渲出
|
|
529
|
+
黑屏"那种结构性失败不同级。
|
|
530
|
+
"""
|
|
531
|
+
for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
|
|
532
|
+
lines = [ln.strip() for ln in (value or "").split("\n") if ln.strip()]
|
|
533
|
+
if lines and all(
|
|
534
|
+
ln.startswith(("http://", "https://", "www.", "github.com/")) for ln in lines
|
|
535
|
+
):
|
|
536
|
+
print(
|
|
537
|
+
f"⚠️ {flag} is nothing but a URL ({value.strip()!r}). "
|
|
538
|
+
"textLayers are drawn on screen — a link there is unreadable and "
|
|
539
|
+
"unclickable, and it spends the frame's most-read text on something "
|
|
540
|
+
"the viewer cannot act on.\n"
|
|
541
|
+
" Write what the thing is / why it matters instead, and see the "
|
|
542
|
+
"template's llmHint above for where the link belongs.",
|
|
543
|
+
file=sys.stderr,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
478
547
|
def _enforce_supported_duration(
|
|
479
548
|
dsl: dict, template_config: dict | None, template_id: str | None
|
|
480
549
|
) -> None:
|
|
@@ -482,8 +551,8 @@ def _enforce_supported_duration(
|
|
|
482
551
|
|
|
483
552
|
这条约束此前只在 template-library 的 CI(``check-dsl-examples.mjs``)对仓库里的
|
|
484
553
|
示例 DSL 生效,运行时链路(gen_script → prepare_video_assets → render_video)没有
|
|
485
|
-
|
|
486
|
-
|
|
554
|
+
任何一环校验它。于是一个声明了 ``supportedDurations.min`` 的模板照样可以静默产出
|
|
555
|
+
低于下限的成片 —— 模板自己说了"我不是为这么短设计的",却没人拦。这里在 DSL 出厂前补上同一道栅栏。
|
|
487
556
|
|
|
488
557
|
越界即报错退出,而不是静默出片:时长不足通常意味着内容(打字机文案 / 旁白 / 场景)
|
|
489
558
|
根本没填够,继续往下走只会烧掉渲染积分换一条废片。
|
|
@@ -556,64 +625,93 @@ def _plain_len(text: str) -> int:
|
|
|
556
625
|
return len((text or "").replace("**", ""))
|
|
557
626
|
|
|
558
627
|
|
|
559
|
-
# fit-caption
|
|
560
|
-
#
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
628
|
+
# ── fit-caption 估时 ───────────────────────────────────────────────────────
|
|
629
|
+
#
|
|
630
|
+
# 这里以前钉着一组常量(打字收尾 1.5s、标题入场 1.0s、每张图最少 2.0s …),注释写
|
|
631
|
+
# 明"与 template-library 里那个模板组件的 timing.ts 保持同一套数值"。也就是说
|
|
632
|
+
# **一个具体模板的组件内部节奏被手抄进了 CLI**:模板调一次 tailHold,这边不跟着改
|
|
633
|
+
# 就开始漂,而漂移的表现是"估时和组件实际耗时对不上",没有任何门禁看得见。
|
|
634
|
+
#
|
|
635
|
+
# 现在数值由模板在 ``capabilities.durationModel`` 里自己声明,本文件只实现算法:
|
|
636
|
+
# 各分量并行、取 max、加收尾留白。没声明的模板不估时(见 _estimate_fit_caption_duration)。
|
|
637
|
+
# 与 ``payloadDefaults`` / ``imageStyleGuide`` 同一条纪律:模板出数值,CLI 出机制。
|
|
567
638
|
|
|
568
639
|
|
|
569
|
-
def _estimate_caption_natural_sec(caption: dict) -> float:
|
|
640
|
+
def _estimate_caption_natural_sec(caption: dict, model: dict) -> float:
|
|
570
641
|
"""打字机自然节奏耗时(startDelay + 打字 + 行间停顿 + 收尾),秒。"""
|
|
571
|
-
|
|
642
|
+
max_lines = model.get("maxLines")
|
|
643
|
+
lines = caption.get("lines") or []
|
|
644
|
+
if isinstance(max_lines, int) and max_lines > 0:
|
|
645
|
+
lines = lines[:max_lines]
|
|
572
646
|
if not lines:
|
|
573
647
|
return 0.0
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
648
|
+
cps = caption.get("charsPerSec", model.get("charsPerSec"))
|
|
649
|
+
if not cps:
|
|
650
|
+
return 0.0
|
|
651
|
+
lo, hi = model.get("charsPerSecMin"), model.get("charsPerSecMax")
|
|
652
|
+
if lo is not None:
|
|
653
|
+
cps = max(lo, cps)
|
|
654
|
+
if hi is not None:
|
|
655
|
+
cps = min(hi, cps)
|
|
656
|
+
line_gap_sec = caption.get("lineGapMs", model.get("lineGapMs", 0)) / 1000.0
|
|
657
|
+
start_delay_sec = caption.get("startDelayMs", model.get("startDelayMs", 0)) / 1000.0
|
|
577
658
|
total_chars = sum(_plain_len(t) for t in lines)
|
|
578
659
|
inter_line_sec = line_gap_sec * max(0, len(lines) - 1)
|
|
579
|
-
fixed_overhead = start_delay_sec + inter_line_sec +
|
|
580
|
-
return fixed_overhead + total_chars /
|
|
660
|
+
fixed_overhead = start_delay_sec + inter_line_sec + model.get("tailHoldSec", 0)
|
|
661
|
+
return fixed_overhead + total_chars / cps
|
|
581
662
|
|
|
582
663
|
|
|
583
|
-
def _estimate_carousel_min_sec(carousel: dict) -> float:
|
|
664
|
+
def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
|
|
584
665
|
"""轮播至少需要的秒数(每张图/视频的最小停留,减去重叠的过渡)。"""
|
|
585
666
|
items = carousel.get("items") or []
|
|
586
667
|
n = len(items)
|
|
587
668
|
if n == 0:
|
|
588
669
|
return 0.0
|
|
589
|
-
transition_sec = carousel.get("transitionMs",
|
|
670
|
+
transition_sec = carousel.get("transitionMs", model.get("transitionMs", 0)) / 1000.0
|
|
590
671
|
explicit_pacing = carousel.get("pacing")
|
|
591
672
|
any_explicit = any(it.get("holdSec") is not None for it in items)
|
|
592
673
|
has_default_hold = carousel.get("defaultHoldSec") is not None
|
|
593
674
|
pacing = explicit_pacing or ("fixed" if (any_explicit or has_default_hold) else "auto")
|
|
594
675
|
if pacing == "fixed":
|
|
595
|
-
default_hold = carousel.get("defaultHoldSec",
|
|
676
|
+
default_hold = carousel.get("defaultHoldSec", model.get("defaultHoldSec", 0))
|
|
596
677
|
total = sum(it.get("holdSec", default_hold) for it in items)
|
|
597
678
|
return total - (n - 1) * transition_sec
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
)
|
|
679
|
+
per_image = model.get("minPerImageSec", 0)
|
|
680
|
+
per_video = model.get("minPerVideoSec", per_image)
|
|
681
|
+
total = sum(per_video if it.get("kind") == "video" else per_image for it in items)
|
|
602
682
|
return total - (n - 1) * transition_sec
|
|
603
683
|
|
|
604
684
|
|
|
605
|
-
def _estimate_fit_caption_duration(
|
|
685
|
+
def _estimate_fit_caption_duration(
|
|
686
|
+
custom_payload: dict, duration_model: dict | None, template_id: str | None
|
|
687
|
+
) -> float:
|
|
606
688
|
"""fit-caption 策略:按打字机 + 轮播 + 标题入场推算建议时长(秒)。
|
|
607
689
|
|
|
608
|
-
|
|
609
|
-
|
|
690
|
+
算法与模板导出的 ``capabilities.durationEstimator`` 等价(各分量并行、取 max、
|
|
691
|
+
加收尾留白),但**每一个数值都来自模板声明的 ``capabilities.durationModel``**。
|
|
692
|
+
模板没声明就不估 —— 返回 0,调用方沿用传入的 targetDuration 并收到一条 warning。
|
|
693
|
+
宁可退回调用方的时长,也不拿一组从别的模板抄来的数字去替这个模板做决定。
|
|
610
694
|
"""
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
695
|
+
if not isinstance(duration_model, dict) or not duration_model:
|
|
696
|
+
print(
|
|
697
|
+
f"⚠️ template '{template_id}' declares durationStrategy=fit-caption but no "
|
|
698
|
+
"capabilities.durationModel, so gen_script cannot compute the fitted "
|
|
699
|
+
"duration and falls back to the requested --duration.\n"
|
|
700
|
+
" Fix in the template (not here): add capabilities.durationModel with the "
|
|
701
|
+
"component's own pacing numbers (headlineIntroSec / tailPaddingSec / "
|
|
702
|
+
"caption.{charsPerSec,lineGapMs,startDelayMs,tailHoldSec,maxLines} / "
|
|
703
|
+
"carousel.{transitionMs,defaultHoldSec,minPerImageSec,minPerVideoSec}).",
|
|
704
|
+
file=sys.stderr,
|
|
705
|
+
)
|
|
706
|
+
return 0.0
|
|
707
|
+
caption_sec = _estimate_caption_natural_sec(
|
|
708
|
+
custom_payload.get("caption") or {}, duration_model.get("caption") or {}
|
|
709
|
+
)
|
|
710
|
+
carousel_sec = _estimate_carousel_min_sec(
|
|
711
|
+
custom_payload.get("carousel") or {}, duration_model.get("carousel") or {}
|
|
712
|
+
)
|
|
713
|
+
headline_intro = duration_model.get("headlineIntroSec", 0)
|
|
714
|
+
return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)
|
|
617
715
|
|
|
618
716
|
|
|
619
717
|
def _build_carousel_caption_dsl(
|
|
@@ -636,8 +734,9 @@ def _build_carousel_caption_dsl(
|
|
|
636
734
|
narration_enabled: bool,
|
|
637
735
|
payload_defaults: dict,
|
|
638
736
|
duration_strategy: str | None,
|
|
737
|
+
duration_model: dict | None,
|
|
639
738
|
) -> dict:
|
|
640
|
-
"""carousel-caption
|
|
739
|
+
"""carousel-caption 装配(单场景、图/视频轮播 + 打字机文字)。
|
|
641
740
|
|
|
642
741
|
所有差异来自归一化 contract(capabilities.payloadDefaults / durationStrategy /
|
|
643
742
|
needsNarration),不再读 input-schema.json,也没有 if template_id==X 分支。
|
|
@@ -702,12 +801,21 @@ def _build_carousel_caption_dsl(
|
|
|
702
801
|
caption_config["lines"] = caption_lines
|
|
703
802
|
|
|
704
803
|
# ── Build customPayload ───────────────────────────────────────────────
|
|
804
|
+
#
|
|
805
|
+
# background / headlineStyle 只在模板自己声明了 payloadDefaults 时才写进去。
|
|
806
|
+
# 这里原本硬编码着 ``{"preset": "grid-particles"}`` / ``{"pill": True}`` 作为
|
|
807
|
+
# 兜底 —— 那两个值是**某一个**模板的词汇(一个背景 preset 枚举成员 + 一个它
|
|
808
|
+
# 顶部装饰的开关)。同为 carousel-caption 但没声明这两个键的模板既不认识也不读
|
|
809
|
+
# 它们,等于往它的 DSL 里塞两坨死配置(payload 契约门禁只查"声明了却没人消费",
|
|
810
|
+
# 查不到"没声明却被硬塞进来");而下一个模板如果恰好也有 background、枚举却不同,
|
|
811
|
+
# 拿到的就是一个它不认识的值。模板的默认值归模板声明,CLI 只负责透传。
|
|
705
812
|
custom_payload = {
|
|
706
|
-
"background": defaults.get("background", {"preset": "grid-particles"}),
|
|
707
|
-
"headlineStyle": defaults.get("headlineStyle", {"pill": True}),
|
|
708
813
|
"carousel": carousel_config,
|
|
709
814
|
"caption": caption_config,
|
|
710
815
|
}
|
|
816
|
+
for key in ("background", "headlineStyle"):
|
|
817
|
+
if defaults.get(key):
|
|
818
|
+
custom_payload[key] = defaults[key]
|
|
711
819
|
|
|
712
820
|
# ── Narration skeleton ────────────────────────────────────────────────
|
|
713
821
|
narration_text = _localize_label(output_language, "narration_opening",
|
|
@@ -715,11 +823,11 @@ def _build_carousel_caption_dsl(
|
|
|
715
823
|
|
|
716
824
|
# ── Effective scene duration ──────────────────────────────────────────
|
|
717
825
|
# fit-caption 策略:时长由打字机自然节奏决定,而不是盲取调用方传入的 targetDuration。
|
|
718
|
-
#
|
|
826
|
+
# 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长,
|
|
719
827
|
# 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
|
|
720
828
|
effective_duration = duration
|
|
721
829
|
if duration_strategy == "fit-caption":
|
|
722
|
-
est = _estimate_fit_caption_duration(custom_payload)
|
|
830
|
+
est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
|
|
723
831
|
if est > 0:
|
|
724
832
|
effective_duration = int(math.ceil(est))
|
|
725
833
|
print(
|
|
@@ -728,7 +836,7 @@ def _build_carousel_caption_dsl(
|
|
|
728
836
|
file=sys.stderr,
|
|
729
837
|
)
|
|
730
838
|
|
|
731
|
-
# ── Build scene (
|
|
839
|
+
# ── Build scene (sceneStrategy=single: one scene carries everything) ──
|
|
732
840
|
text_layers = [
|
|
733
841
|
{"role": "headline", "content": headline, "animation": "slide-up"},
|
|
734
842
|
]
|
|
@@ -883,8 +991,10 @@ def build_dsl(
|
|
|
883
991
|
)
|
|
884
992
|
|
|
885
993
|
# 缺省值:headline 退回 topic,subheadline 默认空字符串
|
|
886
|
-
effective_headline = (headline
|
|
887
|
-
effective_subheadline = (subheadline
|
|
994
|
+
effective_headline = _normalize_linebreaks(headline) or topic
|
|
995
|
+
effective_subheadline = _normalize_linebreaks(subheadline)
|
|
996
|
+
_echo_template_llm_hint(template_config, template_id)
|
|
997
|
+
_warn_on_url_text_layer(effective_headline, effective_subheadline)
|
|
888
998
|
|
|
889
999
|
# ── carousel-caption 模板(单场景、图/视频轮播 + 打字机)单独装配并直接返回 ──
|
|
890
1000
|
if payload_style == "carousel-caption":
|
|
@@ -911,13 +1021,13 @@ def build_dsl(
|
|
|
911
1021
|
file=sys.stderr,
|
|
912
1022
|
)
|
|
913
1023
|
sys.exit(1)
|
|
914
|
-
# 兜底校验 ②:durationStrategy=fit-caption
|
|
1024
|
+
# 兜底校验 ②:durationStrategy=fit-caption 的模板由打字机
|
|
915
1025
|
# 文案驱动节奏 —— caption 就是内容本体,不是可选装饰。caption_lines 为空时上面的
|
|
916
1026
|
# "两者皆空" 检查放行,产出的却是「顶部标题 + 轮播、底部一个字都没有」的退化片:
|
|
917
1027
|
# 估时掉到轮播地板值(2 张图 ≈ 5s),而调用方往往还在确认摘要里描述了一段
|
|
918
1028
|
# 根本没进 DSL 的文案,用户在确认环节也看不出来。所以这里必须硬失败。
|
|
919
1029
|
# 注意:只卡 fit-caption。carousel-caption 里 durationStrategy=fit-images 的
|
|
920
|
-
#
|
|
1030
|
+
# 纯视觉模板本来就允许无文案,不受影响。
|
|
921
1031
|
if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
|
|
922
1032
|
print(
|
|
923
1033
|
"❌ template "
|
|
@@ -955,6 +1065,7 @@ def build_dsl(
|
|
|
955
1065
|
narration_enabled=needs_narration,
|
|
956
1066
|
payload_defaults=contract["payload_defaults"],
|
|
957
1067
|
duration_strategy=contract["duration_strategy"],
|
|
1068
|
+
duration_model=contract["duration_model"],
|
|
958
1069
|
)
|
|
959
1070
|
_enforce_supported_duration(carousel_dsl, template_config, template_id)
|
|
960
1071
|
return carousel_dsl
|
|
@@ -1068,8 +1179,9 @@ def build_dsl(
|
|
|
1068
1179
|
text_layers = [
|
|
1069
1180
|
{"role": "badge", "content": plan["label"], "animation": "slide-up"},
|
|
1070
1181
|
]
|
|
1071
|
-
# 给所有 point 场景补一个 headline textLayer
|
|
1072
|
-
#
|
|
1182
|
+
# 给所有 point 场景补一个 headline textLayer:凡是把 titleText 从
|
|
1183
|
+
# textLayers[role=headline] 抽出来的 propExtractors 都能直接拿到主标题,
|
|
1184
|
+
# 不再依赖 binding 的兜底逻辑。
|
|
1073
1185
|
text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
|
|
1074
1186
|
if effective_subheadline:
|
|
1075
1187
|
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
@@ -1103,18 +1215,25 @@ def build_dsl(
|
|
|
1103
1215
|
}
|
|
1104
1216
|
scenes.append(scene)
|
|
1105
1217
|
else:
|
|
1106
|
-
# The "no-visual" branch labels each scene with a layout hint
|
|
1107
|
-
# Historical fallback was hardcoded "html-slide" — when no template
|
|
1108
|
-
# was specified, every audio-only scene defaulted to html-slide
|
|
1109
|
-
# styling. We replace the hardcoded fallback chain with:
|
|
1218
|
+
# The "no-visual" branch labels each scene with a layout hint:
|
|
1110
1219
|
# 1. template.capabilities.defaultLayout ⇐ the template tells
|
|
1111
1220
|
# remixmate what layout name it wants on its no-visual scenes
|
|
1112
1221
|
# 2. template_id ⇐ legacy: pass id as
|
|
1113
1222
|
# layout name (existing behavior when no capability declared)
|
|
1114
|
-
# 3. "
|
|
1115
|
-
#
|
|
1223
|
+
# 3. "text-overlay" ⇐ no-template case
|
|
1224
|
+
#
|
|
1225
|
+
# ⚠️ 第 3 档以前硬编码着模板库里某个具体模板的 id —— 没选模板时,每个纯
|
|
1226
|
+
# 音频场景都被贴上那个名字。那是这份脚本唯一一处"认识某个模板"的可执行代码:
|
|
1227
|
+
# 没有模板参与的通用 DSL 不该知道模板库里有谁,模板换名 / 下架都会让这个
|
|
1228
|
+
# 字面量变成谎话。改成 schema 自己的枚举值(Scene.layout 的合法取值是
|
|
1229
|
+
# full-visual / text-overlay / … 这一组,模板 id 从来就不在里面,写进去反而
|
|
1230
|
+
# 会让 dsl_validator 报一条 layout 枚举 warning)。
|
|
1231
|
+
#
|
|
1232
|
+
# 这个字段是纯描述性的:match_template 只把它抄进 binding 的 layoutVariant,
|
|
1233
|
+
# 而 layoutVariant 在 ab-render 与 template-library 里都没有任何读取方
|
|
1234
|
+
# (已核对),渲染走的是 slotMapping / compositionId。所以换值不改成片。
|
|
1116
1235
|
_layout_cfg = (template_config or {}).get("capabilities") or {}
|
|
1117
|
-
layout = _layout_cfg.get("defaultLayout") or template_id or "
|
|
1236
|
+
layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
|
|
1118
1237
|
text_layers = []
|
|
1119
1238
|
if plan["purpose"] == "opening":
|
|
1120
1239
|
text_layers = [
|
|
@@ -1260,19 +1379,20 @@ Examples:
|
|
|
1260
1379
|
default=None,
|
|
1261
1380
|
help="Media URL for the template's carousel/gallery. Can be repeated: "
|
|
1262
1381
|
"--carousel-items url1 --carousel-items url2. "
|
|
1263
|
-
"For
|
|
1264
|
-
"
|
|
1382
|
+
"For templates declaring capabilities.payloadStyle=carousel-caption, "
|
|
1383
|
+
"these URLs are placed directly into "
|
|
1265
1384
|
"customPayload.carousel.items as existing assets, bypassing AI image generation entirely.",
|
|
1266
1385
|
)
|
|
1267
1386
|
parser.add_argument(
|
|
1268
1387
|
"--caption-lines",
|
|
1269
1388
|
action="append",
|
|
1270
1389
|
default=None,
|
|
1271
|
-
help="
|
|
1390
|
+
help="On-screen caption line. Can be repeated: "
|
|
1272
1391
|
"--caption-lines 'line1' --caption-lines 'line2'. "
|
|
1273
|
-
"For
|
|
1274
|
-
"
|
|
1275
|
-
"
|
|
1392
|
+
"For templates declaring capabilities.payloadStyle=carousel-caption, "
|
|
1393
|
+
"these are placed into customPayload.caption.lines. "
|
|
1394
|
+
"Line count limits and whether **emphasis** is parsed are declared per "
|
|
1395
|
+
"template — see the chosen template's llmHint.",
|
|
1276
1396
|
)
|
|
1277
1397
|
|
|
1278
1398
|
args = parser.parse_args()
|
|
@@ -58,25 +58,25 @@
|
|
|
58
58
|
},
|
|
59
59
|
"headline": {
|
|
60
60
|
"type": "string",
|
|
61
|
-
"description": "On-screen headline
|
|
61
|
+
"description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
|
|
62
62
|
},
|
|
63
63
|
"subheadline": {
|
|
64
64
|
"type": "string",
|
|
65
|
-
"description": "On-screen subheadline
|
|
65
|
+
"description": "On-screen subheadline: the line under the headline, saying why the viewer should care. Multi-line via \\n. Stored at meta.subheadline and pushed into every scene's textLayers[role=subheadline] as the top small-text. **Must** be set when the user explicitly provided a subtitle / slogan. **Never a bare URL** — text layers are painted on screen, where a link is neither readable nor clickable; say what the thing is instead. How many lines the template keeps, and whether it parses **emphasis**, is declared in that template's llmHint (gen_script prints it in full on stderr). Note: this is the on-screen subheadline, not the CC subtitle (global.subtitle) — they are independent."
|
|
66
66
|
},
|
|
67
67
|
"carousel_items": {
|
|
68
68
|
"type": "array",
|
|
69
69
|
"items": {
|
|
70
70
|
"type": "string"
|
|
71
71
|
},
|
|
72
|
-
"description": "Media URLs for the template's image/video carousel
|
|
72
|
+
"description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
|
|
73
73
|
},
|
|
74
74
|
"caption_lines": {
|
|
75
75
|
"type": "array",
|
|
76
76
|
"items": {
|
|
77
77
|
"type": "string"
|
|
78
78
|
},
|
|
79
|
-
"description": "
|
|
79
|
+
"description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). Each element is one line. Line-count limits and whether **emphasis** is parsed are declared by the template — read its llmHint, which gen_script prints in full on stderr. **Must** pass when the user explicitly provides bullet-point text / bottom copy. **Also must pass — written by you — when the user did NOT provide any copy but the template is typewriter-driven** (capabilities.durationStrategy=fit-caption): such templates have no narration, so these lines are both the video's content and the thing that decides its duration. Draft them from the material you researched (repo README, page screenshots, the topic). Leaving this empty for a fit-caption template is rejected: nothing auto-generates caption text, and an empty caption renders a titled carousel with no words at all."
|
|
80
80
|
},
|
|
81
81
|
"stub_image_url": {
|
|
82
82
|
"type": "string",
|