@remixmate/cli 0.9.40 → 0.9.43

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.40",
4
- "generatedAt": "2026-09-22T08:04:46.896Z",
3
+ "version": "0.9.43",
4
+ "generatedAt": "2026-09-22T14:20:35.434Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -464,7 +464,7 @@
464
464
  "items": {
465
465
  "type": "string"
466
466
  },
467
- "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."
467
+ "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 into customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise 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."
468
468
  },
469
469
  "scene_images": {
470
470
  "type": "array",
@@ -478,7 +478,7 @@
478
478
  "items": {
479
479
  "type": "string"
480
480
  },
481
- "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."
481
+ "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). For image sequences (payloadDefaults.sequence), lines map in order to sequence.items[].caption, with at most one line per image. Otherwise 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."
482
482
  },
483
483
  "stub_image_url": {
484
484
  "type": "string",
@@ -1043,7 +1043,7 @@
1043
1043
  "tier": "tool",
1044
1044
  "category": "consuming",
1045
1045
  "title": "Web Page Reader",
1046
- "summary": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
1046
+ "summary": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. GitHub repository pages also return observed Stars/Forks/Watchers before sidebar cleanup (missing counters stay unknown). Reuse successful reads across script and template fields; do not repeat the same URL just for counters already returned. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
1047
1047
  "triggers": [
1048
1048
  "Read this link / what does this page say / summarize this article",
1049
1049
  "Fetch page content, extract article text, get the text of a URL",
@@ -1083,7 +1083,7 @@
1083
1083
  },
1084
1084
  "selector": {
1085
1085
  "type": "string",
1086
- "description": "Read only inside this CSS selector. Leave empty to auto-detect the article container — only reach for this when the auto-detected container was wrong."
1086
+ "description": "Read only inside this CSS selector, preserving the selected region's sidebar/button text instead of pruning boilerplate. Scripts/styles are still removed. Leave empty to auto-detect the article container; use a targeted selector only for a concrete missing required fact."
1087
1087
  },
1088
1088
  "include_links": {
1089
1089
  "type": "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.40",
3
+ "version": "0.9.43",
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",
@@ -22,6 +22,7 @@
22
22
  "test:cli": "npm run build && node --test test/*.test.mjs",
23
23
  "test:validators": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-validators.py",
24
24
  "test:web-read": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-web-read.py",
25
+ "test:web-read:browser": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-web-read-browser.py",
25
26
  "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
26
27
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
27
28
  "test:card-array-keys": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-card-array-keys.py",
@@ -37,6 +38,8 @@
37
38
  "check:render": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/render-template-check.py",
38
39
  "clean": "node scripts/clean.mjs",
39
40
  "test:countdown": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-countdown-strategy.py",
41
+ "test:prepare-contracts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-prepare-contracts.py",
42
+ "test:image-sequence": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-sequence.py",
40
43
  "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
41
44
  },
42
45
  "keywords": [
@@ -22,6 +22,8 @@ import math
22
22
  import os
23
23
  import sys
24
24
  import uuid
25
+ from copy import deepcopy
26
+ from urllib.parse import urlsplit
25
27
 
26
28
  SCHEMA_PATH = os.environ.get(
27
29
  "VIDEO_DSL_SCHEMA_PATH",
@@ -66,12 +68,16 @@ except ImportError:
66
68
  try:
67
69
  from registry_loader import (
68
70
  load_registry_data as _load_registry_data,
69
- get_template as _get_template,
71
+ require_visible_template as _get_template,
72
+ TemplateStatusGatedError,
70
73
  )
71
74
  _HAS_REGISTRY_LOADER = True
72
75
  except ImportError:
73
76
  _HAS_REGISTRY_LOADER = False
74
77
 
78
+ class TemplateStatusGatedError(RuntimeError): # type: ignore[no-redef]
79
+ """占位:共享模块不可导入时(单测 / 裁剪安装)永远不会被抛出。"""
80
+
75
81
  VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
76
82
  VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
77
83
 
@@ -376,11 +382,19 @@ def _load_template_config(template_id: str) -> dict | None:
376
382
  (仅在仓库还残留旧目录时使用)。
377
383
  返回完整 template dict(含 supportedAspectRatios / assetRequirements / slotMapping 等),
378
384
  找不到时返回 None。
385
+
386
+ ⚠️ 一个例外**不走**兜底:模板存在、但被状态门控挡在可见集合外(``beta`` 而本进程
387
+ 没开 ENABLE_BETA_TEMPLATES)时 ``TemplateStatusGatedError`` 原样上抛。下面那些
388
+ ``_template_*`` / ``_resolve_contract`` 读到 None 会一路退回通用默认值 —— arc 三段式、
389
+ 默认 layout、空 customPayload,而且 llmHint 根本不会打给模型。对「id 不存在」这是
390
+ 合理容错,对「模板在线但本进程看不见」则是把配置问题伪装成一条能跑通的废片。
379
391
  """
380
392
  # 优先使用 registry_loader(这是模板元数据的唯一权威来源)
381
393
  if _HAS_REGISTRY_LOADER:
382
394
  try:
383
395
  return _get_template(template_id)
396
+ except TemplateStatusGatedError:
397
+ raise
384
398
  except Exception as exc:
385
399
  print(
386
400
  f"⚠️ registry_loader failed, falling back to local template.json: {exc}",
@@ -480,7 +494,7 @@ def _template_needs_image(template_config: dict | None) -> bool:
480
494
  # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
481
495
  # 非 countdown 模板为 None)
482
496
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
483
- # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
497
+ # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption, fit-images}
484
498
  # - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
485
499
  # - duration_model dict|None capabilities.durationModel(fit-caption 估时用的节奏数值,
486
500
  # 由模板声明;缺省则不估时,见 _estimate_fit_caption_duration)
@@ -524,7 +538,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
524
538
  payload_style = "slide" if primary_visual_type == "none" else "visual-overlay"
525
539
 
526
540
  duration_strategy = caps.get("durationStrategy")
527
- if duration_strategy not in ("explicit", "fit-caption"):
541
+ if duration_strategy not in ("explicit", "fit-caption", "fit-images"):
528
542
  duration_strategy = "explicit"
529
543
 
530
544
  fixed_scenes = caps.get("fixedScenes")
@@ -786,6 +800,29 @@ def _estimate_fit_caption_duration(
786
800
  return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)
787
801
 
788
802
 
803
+ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int = 30) -> float:
804
+ """Apply template-declared image timing, in frames just like the renderer.
805
+
806
+ Hold tiers and transition margins belong to durationModel.sequence; never
807
+ infer them from a template id or silently use the caller's target duration.
808
+ """
809
+ timing = (model or {}).get("sequence", {})
810
+ tiers = timing.get("holdSecByCount", [])
811
+ count = len(sequence["items"])
812
+ rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
813
+ if rule_hold is None:
814
+ raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
815
+ base_hold = sequence.get("defaultHoldSec", rule_hold)
816
+ transition_ms = sequence.get("transitionMs", timing.get("transitionMs"))
817
+ margin = timing.get("minHoldMarginFrames")
818
+ if transition_ms is None or margin is None:
819
+ raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
820
+ # Python round uses ties-to-even; JS Math.round uses floor(x + 0.5).
821
+ min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
822
+ return sum(max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
823
+ for item in sequence["items"]) / fps
824
+
825
+
789
826
  def _build_carousel_caption_dsl(
790
827
  *,
791
828
  template_id: str,
@@ -814,7 +851,12 @@ def _build_carousel_caption_dsl(
814
851
  needsNarration),不再读 input-schema.json,也没有 if template_id==X 分支。
815
852
  轮播素材由调用方在 carousel_items 里直接给 URL(existing 资产),不触发 AI 生图。
816
853
  """
817
- defaults = payload_defaults or {}
854
+ defaults = deepcopy(payload_defaults or {})
855
+ sequence_mode = isinstance(defaults.get("sequence"), dict)
856
+ if sequence_mode and not carousel_items:
857
+ raise ValueError("Image sequence needs --carousel-items; caption text alone cannot supply images")
858
+ if sequence_mode and len(caption_lines) > len(carousel_items):
859
+ raise ValueError("Image sequence has more --caption-lines than images; use at most one caption per image")
818
860
 
819
861
  # ── Build assets from carousel_items ──────────────────────────────────
820
862
  assets = []
@@ -822,7 +864,9 @@ def _build_carousel_caption_dsl(
822
864
  for idx, url in enumerate(carousel_items):
823
865
  asset_id = f"carousel-img-{idx + 1:02d}"
824
866
  # Determine if URL is a video (basic heuristic on extension)
825
- is_video = any(url.lower().endswith(ext) for ext in (".mp4", ".mov", ".webm", ".avi"))
867
+ is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
868
+ if sequence_mode and is_video:
869
+ raise ValueError("Image sequence requires image URLs in --carousel-items, not video URLs")
826
870
  assets.append({
827
871
  "assetId": asset_id,
828
872
  "type": "video" if is_video else "image",
@@ -889,6 +933,18 @@ def _build_carousel_caption_dsl(
889
933
  if defaults.get(key):
890
934
  custom_payload[key] = defaults[key]
891
935
 
936
+ # The carousel-caption capability also covers full-frame image sequences.
937
+ # Its declared sequence defaults select that payload shape; do not send
938
+ # carousel entrance/exit or a typewriter caption block to such components.
939
+ if sequence_mode:
940
+ sequence_config = defaults["sequence"]
941
+ sequence_config["items"] = [
942
+ {"assetRef": asset_id,
943
+ **({"caption": caption_lines[index]} if index < len(caption_lines) else {})}
944
+ for index, (asset_id, _) in enumerate(carousel_asset_ids)
945
+ ]
946
+ custom_payload = {"sequence": sequence_config}
947
+
892
948
  # ── Narration skeleton ────────────────────────────────────────────────
893
949
  narration_text = _localize_label(output_language, "narration_opening",
894
950
  label="", topic=topic, duration=duration)
@@ -898,6 +954,10 @@ def _build_carousel_caption_dsl(
898
954
  # 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长,
899
955
  # 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
900
956
  effective_duration = duration
957
+ if duration_strategy == "fit-images":
958
+ if not sequence_mode:
959
+ raise ValueError("fit-images requires capabilities.payloadDefaults.sequence")
960
+ effective_duration = _estimate_fit_images_duration(custom_payload["sequence"], duration_model)
901
961
  if duration_strategy == "fit-caption":
902
962
  est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
903
963
  if est > 0:
@@ -1631,7 +1691,8 @@ Examples:
1631
1691
  "--carousel-items url1 --carousel-items url2. "
1632
1692
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
1633
1693
  "these URLs are placed directly into "
1634
- "customPayload.carousel.items as existing assets, bypassing AI image generation entirely.",
1694
+ "customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise "
1695
+ "customPayload.carousel.items, as existing assets without AI image generation.",
1635
1696
  )
1636
1697
  parser.add_argument(
1637
1698
  "--scene-images",
@@ -1654,7 +1715,8 @@ Examples:
1654
1715
  help="On-screen caption line. Can be repeated: "
1655
1716
  "--caption-lines 'line1' --caption-lines 'line2'. "
1656
1717
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
1657
- "these are placed into customPayload.caption.lines. "
1718
+ "these are placed into customPayload.caption.lines, or sequence.items[].caption "
1719
+ "in image order when payloadDefaults.sequence is declared. "
1658
1720
  "Line count limits and whether **emphasis** is parsed are declared per "
1659
1721
  "template — see the chosen template's llmHint.",
1660
1722
  )
@@ -1833,4 +1895,11 @@ Examples:
1833
1895
 
1834
1896
 
1835
1897
  if __name__ == "__main__":
1836
- main()
1898
+ try:
1899
+ main()
1900
+ except TemplateStatusGatedError as exc:
1901
+ # 状态门控挡住了一个确实在线的模板。这是配置问题,不是调用方参数错误,
1902
+ # 所以给一条可执行的提示而不是 traceback —— 并且**必须**非零退出:静默
1903
+ # 退回通用骨架正是这次要消灭的失败形态。
1904
+ print(f"❌ {exc}", file=sys.stderr)
1905
+ sys.exit(1)
@@ -61,7 +61,7 @@
61
61
  "items": {
62
62
  "type": "string"
63
63
  },
64
- "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."
64
+ "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 into customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise 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."
65
65
  },
66
66
  "scene_images": {
67
67
  "type": "array",
@@ -75,7 +75,7 @@
75
75
  "items": {
76
76
  "type": "string"
77
77
  },
78
- "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."
78
+ "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). For image sequences (payloadDefaults.sequence), lines map in order to sequence.items[].caption, with at most one line per image. Otherwise 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."
79
79
  },
80
80
  "stub_image_url": {
81
81
  "type": "string",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V13",
5
+ "version": "V15",
6
6
  "skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
7
7
  }
@@ -331,11 +331,18 @@ def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
331
331
 
332
332
  Falls back to ``MainVideo16x9`` / ``MainVideo`` when the template isn't
333
333
  found, the registry can't be loaded, or the aspect ratio isn't keyed.
334
+
335
+ ``include_all_statuses=True`` —— 状态门控是**选型策略**,不是渲染策略。
336
+ 它要回答的是"列表里该不该出现这个模板、agent 该不该挑它",一旦模板已经被选定并
337
+ 通过校验,渲染就必须读到它真实的定义。把门控带到这里的后果是静默换成另一个
338
+ composition / 丢掉封面,成片与模板声明不符,而日志里只字未提。
339
+ 「能不能被选中」由 template-registry 的 ``--include-beta`` 与
340
+ ``dsl_validator._check_template_status_gate`` 负责把关。
334
341
  """
335
342
  if template_id:
336
343
  try:
337
344
  from registry_loader import get_template # type: ignore
338
- tpl = get_template(template_id)
345
+ tpl = get_template(template_id, include_all_statuses=True)
339
346
  if tpl:
340
347
  entry_map = tpl.get("remotionEntry") or {}
341
348
  hit = entry_map.get(aspect_ratio)
@@ -356,6 +363,9 @@ def resolve_cover_composition_id(template_id: str) -> Optional[str]:
356
363
  Reading from the registry rather than the template.json file directly so
357
364
  behaviour matches what ab-render's manifest exposes (the registry is the
358
365
  aggregated truth used by both ab-render and ab-skill).
366
+
367
+ ``include_all_statuses=True`` 的理由同 ``resolve_remotion_entry``:门控管选型,
368
+ 不管渲染。带上门控的话 beta 模板的封面会无声无息地不渲。
359
369
  """
360
370
  if not template_id:
361
371
  return None
@@ -363,7 +373,7 @@ def resolve_cover_composition_id(template_id: str) -> Optional[str]:
363
373
  # sys.path setup happens once at module top — see header.
364
374
  from registry_loader import get_template # type: ignore
365
375
 
366
- tpl = get_template(template_id)
376
+ tpl = get_template(template_id, include_all_statuses=True)
367
377
  if not tpl:
368
378
  return None
369
379
  for comp in tpl.get("compositions", []) or []:
@@ -554,7 +564,8 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
554
564
  if template_id:
555
565
  try:
556
566
  from registry_loader import get_template # type: ignore
557
- _tpl = get_template(template_id)
567
+ # include_all_statuses:门控管选型不管渲染(理由见 resolve_remotion_entry)。
568
+ _tpl = get_template(template_id, include_all_statuses=True)
558
569
  needs_video_duration = bool(
559
570
  ((_tpl or {}).get("capabilities") or {}).get("needsVideoDuration")
560
571
  )
@@ -1790,7 +1801,10 @@ def render_with_remote_api(
1790
1801
  if template_id:
1791
1802
  try:
1792
1803
  from registry_loader import get_template # type: ignore
1793
- _meta = get_template(template_id)
1804
+ # include_all_statuses:门控管选型不管渲染(理由见 resolve_remotion_entry)。
1805
+ # 漏掉它的话私有 beta 模板会丢 sourceOssKey,转而按内置模板渲——渲出来的
1806
+ # 是另一个模板的画面。
1807
+ _meta = get_template(template_id, include_all_statuses=True)
1794
1808
  if _meta and not _meta.get("isBuiltin"):
1795
1809
  source_oss_key = _meta.get("sourceOssKey")
1796
1810
  except Exception:
@@ -2086,6 +2100,26 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
2086
2100
  LogPrint(f"❌ template not found: {template_id} (available: {available})", file=sys.stderr)
2087
2101
  sys.exit(1)
2088
2102
 
2103
+ # 选型门控在这里落地,而不是在上面的查表上:查表刻意用未门控的列表,因为绑定
2104
+ # 必须读到模板真实的定义(slotMapping / compositions)。门控管的是"允许不允许
2105
+ # 选中它",而 auto_bind 正是"一个 id 变成本次成片所用模板"的那一刻。
2106
+ #
2107
+ # dsl_validator 已经在更早的位置报同一件事,但它只看 meta.templateId;手写的
2108
+ # DSL 可能没有那个字段,只靠 --template-id 传进来 —— 这里补上那条路。
2109
+ try:
2110
+ from registry_loader import gated_status # type: ignore
2111
+ _gated = gated_status(template_id)
2112
+ except Exception:
2113
+ _gated = None
2114
+ if _gated:
2115
+ LogPrint(
2116
+ f"❌ template '{template_id}' exists but its status is '{_gated}', so this "
2117
+ f"process is not allowed to select it. Set ENABLE_BETA_TEMPLATES=1 to admit "
2118
+ f"beta templates, or promote the template to stable.",
2119
+ file=sys.stderr,
2120
+ )
2121
+ sys.exit(1)
2122
+
2089
2123
  selected = match_template.load_full_template(selected)
2090
2124
  LogPrint(f"📋 auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
2091
2125
  binding = match_template.build_binding(selected, dsl)
@@ -2363,6 +2397,14 @@ Examples:
2363
2397
  LogPrint(f"❌ {exc}", file=sys.stderr)
2364
2398
  sys.exit(2)
2365
2399
 
2400
+ from video_dsl.runtime.payload_contract import plan_asset_errors
2401
+ reference_errors = plan_asset_errors(render_plan)
2402
+ if reference_errors:
2403
+ LogPrint("❌ RenderPlan asset references are invalid:", file=sys.stderr)
2404
+ for error in reference_errors[:10]:
2405
+ LogPrint(f" - {error}", file=sys.stderr)
2406
+ sys.exit(2)
2407
+
2366
2408
  if not args.skip_asset_resolve:
2367
2409
  # Stub mode: short-circuit gen-image / gen-video before counting pending.
2368
2410
  # CLI flag takes precedence; env vars act as fallback with a visible warning
@@ -17,6 +17,8 @@ from pathlib import Path
17
17
  sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))
18
18
 
19
19
  from countdown_spec import resolve_countdown_spec # noqa: E402
20
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry"))
21
+ from video_dsl.runtime.payload_contract import required_payload_errors # noqa: E402
20
22
 
21
23
 
22
24
  def _payload_prop(props: dict, key: str):
@@ -84,6 +86,7 @@ def validate_required_props(template: dict, rows: list[dict]) -> None:
84
86
  schema = template.get("customPayloadSchema") or {}
85
87
  missing = [error for key in sorted(required)
86
88
  for error in _required_value_errors(_payload_prop(props, key), schema.get(key, {}), key)]
89
+ missing += required_payload_errors(template, props)
87
90
  if missing:
88
91
  failures.append(f"scene {row.get('sceneId', '?')}: {', '.join(missing)}")
89
92
 
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V23",
5
+ "version": "V25",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }
@@ -53,10 +53,13 @@ __all__ = [
53
53
  "load_registry_data",
54
54
  "load_registry_indexed",
55
55
  "get_template",
56
+ "require_visible_template",
57
+ "gated_status",
56
58
  "list_templates",
57
59
  "invalidate_cache",
58
60
  "RegistryAuthError",
59
61
  "RegistryUnreachableError",
62
+ "TemplateStatusGatedError",
60
63
  "EXIT_NOT_AUTHENTICATED",
61
64
  "EXIT_BACKEND_UNREACHABLE",
62
65
  ]
@@ -78,6 +81,15 @@ class RegistryAuthError(RuntimeError):
78
81
  class RegistryUnreachableError(RuntimeError):
79
82
  """网络不通 / 超时 / 5xx —— 用户无需重新登录,重试或修配置即可。"""
80
83
 
84
+
85
+ class TemplateStatusGatedError(RuntimeError):
86
+ """模板存在,但它的 ``status`` 不在当前进程的可见集合里(见 ``_allowed_statuses``)。
87
+
88
+ 与「id 不存在」分开的独立失败模式:前者要改 id,后者要开门控开关
89
+ (``ENABLE_BETA_TEMPLATES=1``)或把模板转正。压成同一个 ``None`` 返回值的后果见
90
+ ``require_visible_template`` 的文档。
91
+ """
92
+
81
93
  # 默认缓存 TTL(秒)。HTTP 拉取后该时间内不再重新请求。
82
94
  _DEFAULT_TTL_SECONDS = 300
83
95
 
@@ -386,6 +398,46 @@ def get_template(template_id: str, *, include_all_statuses: bool = False) -> Opt
386
398
  return load_registry_indexed(include_all_statuses=include_all_statuses).get(template_id)
387
399
 
388
400
 
401
+ def gated_status(template_id: str) -> Optional[str]:
402
+ """模板存在、但被状态门控挡在可见集合外时返回它的真实 status;否则 None。
403
+
404
+ ``get_template`` 用 ``None`` 同时表达「registry 里没有这个 id」和「有,但被
405
+ 门控挡了」两件完全不同的事。前者是调用方拼错了 id,后者是运行时配置问题
406
+ (模板确实在线,只是当前进程没开 beta)—— 两者的处置方式不同,却在一个返回
407
+ 值里混成一样。这个函数把后者单独识别出来。
408
+ """
409
+ if not template_id:
410
+ return None
411
+ if get_template(template_id) is not None:
412
+ return None
413
+ tpl = get_template(template_id, include_all_statuses=True)
414
+ return _resolve_status(tpl) if tpl is not None else None
415
+
416
+
417
+ def require_visible_template(template_id: str) -> Optional[dict]:
418
+ """与 ``get_template`` 相同,但模板**被状态门控挡住**时抛异常而不是返回 None。
419
+
420
+ 为什么需要它:``get_template`` 返回 None 之后,调用方普遍的写法是「拿不到就用
421
+ 默认值继续」—— 对"这个 id 不存在"是合理的容错,对"模板在线但本进程看不到"却是
422
+ 灾难性的静默降级:模板的 llmHint / customPayloadSchema / capabilities 全部蒸发,
423
+ 脚本按通用骨架生成、payload 契约整段跳过,最后烧掉渲染积分换一条废片,而日志里
424
+ 一个字都没有。
425
+
426
+ 返回 None 只剩一个含义:registry 里没有这个 templateId。
427
+ """
428
+ tpl = get_template(template_id)
429
+ if tpl is not None:
430
+ return tpl
431
+ status = gated_status(template_id)
432
+ if status is None:
433
+ return None
434
+ raise TemplateStatusGatedError(
435
+ f"模板 '{template_id}' 存在,但状态是 '{status}',当前进程的可见集合只有 "
436
+ f"{sorted(_allowed_statuses())}。设置 {_BETA_GATE_ENV}=1 放行 beta,"
437
+ f"或把该模板转为 stable —— 继续往下走只会拿不到它的契约并产出通用结果。"
438
+ )
439
+
440
+
389
441
  def list_templates(*, include_all_statuses: bool = False) -> list[dict]:
390
442
  """Return the visible templates as a list (status-filtered)."""
391
443
  return list(load_registry_indexed(include_all_statuses=include_all_statuses).values())
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V15",
5
+ "version": "V18",
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
  }
@@ -131,6 +131,10 @@ def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
131
131
  shared-lib sys.path)
132
132
  - the registry can't be loaded (network/file/etc.)
133
133
  - the template isn't in the visible set (e.g. beta gate hides it)
134
+
135
+ 最后那一条是**静默跳过整套模板契约校验**的入口 —— payload 必填字段、版式字段名、
136
+ 逐句对齐全部形同不存在。它由 ``_check_template_status_gate`` 单独报一条错来兜底,
137
+ 所以这里保持返回 None 不变(否则每个 ``_check_*`` 都会为同一件事各报一遍)。
134
138
  """
135
139
  if not template_id:
136
140
  return None
@@ -144,6 +148,39 @@ def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
144
148
  return None
145
149
 
146
150
 
151
+ def _check_template_status_gate(dsl: dict) -> list[ValidationError]:
152
+ """模板存在、却被状态门控挡在可见集合外 → 一条 error。
153
+
154
+ 为什么必须报错而不是继续:下面所有读 ``_get_template_cfg`` 的检查(payload 必填、
155
+ slideSchemas 字段名、lineSyncSlides)拿到 None 之后都是 ``return []`` —— 于是一个
156
+ beta 模板的 DSL 能"零错误"通过校验,再一路烧掉 TTS 与渲染积分产出空壳画面。
157
+ contracts 测试之所以没发现这一点,是因为它们 patch 掉了 ``_get_template_cfg``,
158
+ 生产里的门控在测试里从不生效。
159
+ """
160
+ template_id = _pick_template_id(dsl)
161
+ if not template_id:
162
+ return []
163
+ try:
164
+ from registry_loader import gated_status # type: ignore
165
+ except ImportError:
166
+ return []
167
+ try:
168
+ status = gated_status(template_id)
169
+ except Exception:
170
+ # registry 不可达等问题由各自的加载路径报,这里不重复成第二条噪声。
171
+ return []
172
+ if not status:
173
+ return []
174
+ return [ValidationError(
175
+ "meta.templateId",
176
+ f"template '{template_id}' exists but its status is '{status}', so it is hidden "
177
+ f"from this process — every template-specific contract check (payload required "
178
+ f"fields, slide schemas, line-sync) would be skipped instead of enforced. "
179
+ f"Set ENABLE_BETA_TEMPLATES=1 to admit beta templates, or promote the template "
180
+ f"to stable.",
181
+ )]
182
+
183
+
147
184
  def _contains_cjk(text: str) -> bool:
148
185
  return bool(text) and bool(_CJK_PATTERN.search(text))
149
186
 
@@ -300,29 +337,17 @@ def _check_asset_enums(dsl: dict) -> list[ValidationError]:
300
337
  def _check_scene_asset_refs(dsl: dict) -> list[ValidationError]:
301
338
  """render-video 历史:scene 引用的 assetRef 必须在 assets[] 声明过。
302
339
 
303
- 检查范围:
304
- - scene.audio.narration.assetRef
305
- - scene.visuals.background.assetRef
340
+ 包括 customPayload / templateData 中的嵌套 assetRef 与 global 引用。
306
341
  """
307
342
  errors: list[ValidationError] = []
308
343
  declared = {
309
344
  a.get("assetId") for a in (dsl.get("assets") or []) if a.get("assetId")
310
345
  }
311
- for idx, scene in enumerate(dsl.get("scenes", []) or []):
312
- sid = scene.get("id", f"scenes[{idx}]")
313
- nar = (scene.get("audio") or {}).get("narration") or {}
314
- nar_ref = nar.get("assetRef")
315
- if nar_ref and nar_ref not in declared:
316
- errors.append(ValidationError(
317
- f"scenes[{idx}].audio.narration.assetRef",
318
- f"scene '{sid}': narration.assetRef '{nar_ref}' not found in assets[]",
319
- ))
320
- bg_ref = ((scene.get("visuals") or {}).get("background") or {}).get("assetRef")
321
- if bg_ref and bg_ref not in declared:
322
- errors.append(ValidationError(
323
- f"scenes[{idx}].visuals.background.assetRef",
324
- f"scene '{sid}': visuals.background.assetRef '{bg_ref}' not found in assets[]",
325
- ))
346
+ from .payload_contract import asset_references
347
+ for key in ("scenes", "global"):
348
+ for path, ref in asset_references(dsl.get(key), key):
349
+ if ref not in declared:
350
+ errors.append(ValidationError(path, "assetRef not found in assets[]"))
326
351
  return errors
327
352
 
328
353
 
@@ -742,14 +767,22 @@ def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
742
767
  tpl = _get_template_cfg(_pick_template_id(dsl))
743
768
  if not tpl:
744
769
  return []
770
+ from .payload_contract import required_payload_errors
771
+ errors: list[ValidationError] = []
772
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
773
+ custom = scene.get("customPayload")
774
+ custom = dict(custom) if isinstance(custom, dict) else {}
775
+ if not custom.get("templateData") and isinstance(scene.get("templateData"), dict):
776
+ custom["templateData"] = scene["templateData"]
777
+ errors += [ValidationError(f"scenes[{idx}].customPayload", message)
778
+ for message in required_payload_errors(tpl, custom)]
745
779
  if isinstance(tpl.get("slideSchemas"), dict):
746
- return [] # 多版式模板走 _check_slide_payload_fields
780
+ return errors # 多版式字段走 _check_slide_payload_fields
747
781
  cps = tpl.get("customPayloadSchema")
748
782
  if not isinstance(cps, dict) or not cps:
749
- return []
783
+ return errors
750
784
 
751
785
  root = {"type": "object", "properties": cps}
752
- errors: list[ValidationError] = []
753
786
  for idx, scene in enumerate(dsl.get("scenes", []) or []):
754
787
  custom = scene.get("customPayload")
755
788
  if not isinstance(custom, dict) or not custom:
@@ -1014,6 +1047,9 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
1014
1047
  # render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
1015
1048
  # 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
1016
1049
  errors += _check_slide_id_chosen(dsl)
1050
+ # 必须排在下面那批模板契约检查**之前**:模板被状态门控挡住时它们会集体空转,
1051
+ # 于是"零错误通过"其实是"一条都没查"。见 _check_template_status_gate。
1052
+ errors += _check_template_status_gate(dsl)
1017
1053
  # 同理:字段名写错同样是「渲染成功但画面是空壳」,必须在烧掉 TTS / 渲染积分
1018
1054
  # 之前拦住。只对声明了 slideSchemas 的模板生效,见 _check_slide_payload_fields。
1019
1055
  errors += _check_slide_payload_fields(dsl)
@@ -1048,10 +1084,9 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1048
1084
  errors += _check_narration_items_text_coexist(dsl)
1049
1085
  errors += _check_narration_language_strict(dsl)
1050
1086
  errors += _check_slide_id_chosen(dsl)
1087
+ errors += _check_template_status_gate(dsl)
1051
1088
  errors += _check_slide_payload_fields(dsl)
1052
1089
  errors += _check_custom_payload_fields(dsl)
1053
1090
  errors += _check_line_sync_narration(dsl)
1054
1091
  errors += _check_slide_cross_field(dsl)
1055
1092
  return errors
1056
-
1057
-
@@ -0,0 +1,82 @@
1
+ """Template-declared content requirements and recursive asset references.
2
+
3
+ Shared by DSL preparation and saved RenderPlan validation. Requirements use a
4
+ small JSON Schema subset; values never appear in diagnostic messages.
5
+ """
6
+
7
+
8
+ def payload_values(props: dict) -> dict:
9
+ """Flatten templateData wrappers, with non-null outer props taking precedence."""
10
+ layers, seen = [], set()
11
+ while isinstance(props, dict) and id(props) not in seen and len(layers) < 10:
12
+ layers.append(props)
13
+ seen.add(id(props))
14
+ props = props.get("templateData")
15
+ result = {}
16
+ for layer in reversed(layers):
17
+ result.update({k: v for k, v in layer.items() if k != "templateData" and v is not None})
18
+ return result
19
+
20
+
21
+ def requirement_errors(value, schema: dict, path: str = "customPayload") -> list[str]:
22
+ """Enforce type/const/enum/required/properties/items/minItems/anyOf."""
23
+ errors = []
24
+ kind = schema.get("type")
25
+ matches = {"object": isinstance(value, dict), "array": isinstance(value, list),
26
+ "string": isinstance(value, str)}
27
+ if kind in matches and not matches[kind]:
28
+ return [f"{path} (expected {kind})"]
29
+ if "const" in schema and value != schema["const"]:
30
+ return [f"{path} (unexpected value)"]
31
+ if "enum" in schema and value not in schema["enum"]:
32
+ return [f"{path} (unexpected value)"]
33
+ if isinstance(value, dict):
34
+ for key in schema.get("required", []):
35
+ if value.get(key) is None or value.get(key) == "":
36
+ errors.append(f"{path}.{key} (required)")
37
+ for key, child in schema.get("properties", {}).items():
38
+ if key in value:
39
+ errors += requirement_errors(value[key], child, f"{path}.{key}")
40
+ if isinstance(value, list):
41
+ if len(value) < schema.get("minItems", 0):
42
+ errors.append(f"{path} (too few items)")
43
+ if "items" in schema:
44
+ for index, item in enumerate(value):
45
+ errors += requirement_errors(item, schema["items"], f"{path}[{index}]")
46
+ if "anyOf" in schema:
47
+ branches = [requirement_errors(value, branch, path) for branch in schema["anyOf"]]
48
+ if not branches or all(branches):
49
+ alternatives = " OR ".join("; ".join(branch) for branch in branches)
50
+ errors.append(f"{path} must satisfy one content alternative: {alternatives}")
51
+ return errors
52
+
53
+
54
+ def required_payload_errors(template: dict, props: dict) -> list[str]:
55
+ caps = template.get("capabilities")
56
+ schema = caps.get("payloadRequirements") if isinstance(caps, dict) else None
57
+ return requirement_errors(payload_values(props), schema) if isinstance(schema, dict) else []
58
+
59
+
60
+ def asset_references(value, path: str = "", *, bound_props: bool = False):
61
+ """Walk nested assetRef fields; bound props also use *AssetId names."""
62
+ if isinstance(value, list):
63
+ for index, item in enumerate(value):
64
+ yield from asset_references(item, f"{path}[{index}]", bound_props=bound_props)
65
+ elif isinstance(value, dict):
66
+ for key, child in value.items():
67
+ child_path = f"{path}.{key}" if path else key
68
+ if key == "assetRef" or (bound_props and key.endswith("AssetId")):
69
+ if isinstance(child, str) and child.strip():
70
+ yield child_path, child
71
+ else:
72
+ yield from asset_references(child, child_path, bound_props=bound_props)
73
+
74
+
75
+ def plan_asset_errors(plan: dict) -> list[str]:
76
+ declared = {asset.get("assetId") for asset in plan.get("resolvedAssets", []) if isinstance(asset, dict)}
77
+ errors = []
78
+ for index, row in enumerate(plan.get("timeline", [])):
79
+ for path, ref in asset_references(row.get("props", {}), f"timeline[{index}].props", bound_props=True):
80
+ if ref not in declared:
81
+ errors.append(f"{path} references an asset missing from resolvedAssets[]")
82
+ return errors
@@ -50,6 +50,10 @@ python3 <ReadScript> --url "https://example.com/article/123"
50
50
 
51
51
  Prints a Markdown document: an `# title` line, a `site · author · date · url` line, then the body. A one-line extraction diagnostic (`blocks / chars / container`) goes to **stderr**, so piping stdout gives you clean text.
52
52
 
53
+ GitHub repository pages also include Stars / Forks / Watchers found on that page, captured before sidebar cleanup. JSON exposes them as `metadata.repository`; Markdown and plain text include a statistics line. Counts retain the page's precision (e.g. `47.2k`); missing counters are unknown, not zero.
54
+
55
+ Reuse a successful read for both the script and template fields. Re-read only for a specific missing required fact, an extraction failure/truncation, or an explicit request for fresh data. Change the relevant selector/limit/wait setting for corrective reads. Do not repeatedly fetch the same page for optional popularity text: reuse returned counters or use factual non-numeric wording.
56
+
53
57
  ## Output formats
54
58
 
55
59
  ```bash
@@ -84,6 +88,8 @@ python3 <ReadScript> --url "https://example.com/post" --max-chars 0
84
88
 
85
89
  The container is picked by paragraph-density scoring, which is right on ordinary article/docs/blog pages and can miss on unusual layouts. In order of what to try:
86
90
 
91
+ An explicit `--selector` opts out of boilerplate pruning inside that scope, so a sidebar, button, or counter can be read intentionally. Non-content elements such as scripts and styles are still removed.
92
+
87
93
  ```bash
88
94
  # 1. Content renders late (SPA): wait for the real element
89
95
  python3 <ReadScript> --url "https://app.example.com/doc/1" --wait-for-selector "article.body"
@@ -4,7 +4,7 @@
4
4
  "tier": "tool",
5
5
  "category": "consuming",
6
6
  "title": "Web Page Reader",
7
- "description": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
7
+ "description": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. GitHub repository pages also return observed Stars/Forks/Watchers before sidebar cleanup (missing counters stay unknown). Reuse successful reads across script and template fields; do not repeat the same URL just for counters already returned. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
8
8
  "auth": "none",
9
9
  "envVars": [
10
10
  "WEB_CAPTURE_BROWSER",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "selector": {
35
35
  "type": "string",
36
- "description": "Read only inside this CSS selector. Leave empty to auto-detect the article container — only reach for this when the auto-detected container was wrong."
36
+ "description": "Read only inside this CSS selector, preserving the selected region's sidebar/button text instead of pruning boilerplate. Scripts/styles are still removed. Leave empty to auto-detect the article container; use a targeted selector only for a concrete missing required fact."
37
37
  },
38
38
  "include_links": {
39
39
  "type": "boolean",
@@ -48,6 +48,41 @@
48
48
 
49
49
  const bodyCharCount = rawLen(document.body);
50
50
 
51
+ // Repository counters live outside the README. Capture them before the
52
+ // readability pass removes forms / sidebars, without another page request.
53
+ // Keep GitHub's displayed precision (e.g. 47.2k); never invent missing counts.
54
+ const repoPath = location.pathname.replace(/\/+$/, '');
55
+ if (location.hostname === 'github.com' && /^\/[^/]+\/[^/]+$/.test(repoPath)) {
56
+ const repository = { name: repoPath.slice(1), url: `${location.origin}${repoPath}` };
57
+ const countPattern = /^\d[\d,.]*(?:[kmb])?$/i;
58
+ const links = Array.from(document.querySelectorAll('a[href]'));
59
+ for (const [key, suffix] of [['stars', 'stargazers'], ['forks', 'forks'], ['watchers', 'watchers']]) {
60
+ const candidates = links.filter((a) => {
61
+ try {
62
+ const url = new URL(a.href, location.href);
63
+ return url.origin === location.origin && url.pathname.replace(/\/+$/, '') === `${repoPath}/${suffix}`;
64
+ } catch { return false; }
65
+ });
66
+ for (const link of candidates) {
67
+ const exact = [link, ...link.querySelectorAll('[title]')]
68
+ .map((el) => squash(el.getAttribute('title'))).find((value) => countPattern.test(value));
69
+ const displayed = squash(link.textContent).match(/(?:^|\s)(\d[\d,.]*(?:[kmb])?)(?=\s|$)/i);
70
+ if (exact || displayed) {
71
+ repository[key] = exact || displayed[1];
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ if (Object.keys(repository).length > 2) metadata.repository = repository;
77
+ }
78
+
79
+ // Resolve explicit scopes on the original DOM. A caller may intentionally
80
+ // request a sidebar, a button, or a node nested inside one.
81
+ const selected = opts.selector ? document.querySelector(opts.selector) : null;
82
+ if (opts.selector && !selected) {
83
+ return { error: 'selector-not-found', selector: opts.selector, metadata };
84
+ }
85
+
51
86
  // ── 1. strip boilerplate (structural only, see below) ────────────────────
52
87
  const JUNK_TAGS = [
53
88
  'script', 'style', 'noscript', 'template', 'svg', 'canvas', 'iframe',
@@ -65,7 +100,10 @@
65
100
  return typeof tokens === 'string' && JUNK_WORDS.test(tokens);
66
101
  };
67
102
 
68
- document.querySelectorAll(JUNK_TAGS).forEach((el) => {
103
+ const cleanupSelector = selected
104
+ ? 'script,style,noscript,template,svg,canvas,iframe'
105
+ : JUNK_TAGS;
106
+ document.querySelectorAll(cleanupSelector).forEach((el) => {
69
107
  try { el.remove(); } catch (e) { /* already detached */ }
70
108
  });
71
109
 
@@ -102,10 +140,7 @@
102
140
  let containerReason = '';
103
141
 
104
142
  if (opts.selector) {
105
- container = document.querySelector(opts.selector);
106
- if (!container) {
107
- return { error: 'selector-not-found', selector: opts.selector, metadata };
108
- }
143
+ container = selected;
109
144
  containerReason = `selector:${opts.selector}`;
110
145
  } else {
111
146
  // Readability's scoring:每个够长的段落给自己父/祖父/曾祖父记分(1/2、1/3 衰减),
@@ -165,6 +200,7 @@
165
200
  Array.prototype.slice.call(container.querySelectorAll('div,section,ul,ol,span,header,p,figure'))
166
201
  .forEach((el) => {
167
202
  if (!container.contains(el) || el === container) return;
203
+ if (selected) return; // Explicit scopes opt out of boilerplate pruning.
168
204
  if (!isJunkClass(el)) return;
169
205
  if (rawLen(el) / containerChars > 0.4) return;
170
206
  try { el.remove(); } catch (e) { /* already detached */ }
@@ -328,7 +364,7 @@
328
364
  const flush = () => {
329
365
  const t = squash(buffer);
330
366
  buffer = '';
331
- if (t.length >= 2) push({ type: 'paragraph', text: t });
367
+ if (t.length >= (selected ? 1 : 2)) push({ type: 'paragraph', text: t });
332
368
  };
333
369
  el.childNodes.forEach((child) => {
334
370
  if (child.nodeType === 3) { buffer += child.nodeValue; return; }
@@ -354,7 +390,8 @@
354
390
  flush();
355
391
  };
356
392
 
357
- walk(container, 0);
393
+ if (EMIT.has(container.tagName)) emit(container);
394
+ else walk(container, 0);
358
395
 
359
396
  const charCount = blocks.reduce((n, b) => {
360
397
  if (b.type === 'list') return n + b.items.reduce((m, i) => m + i.text.length, 0);
@@ -150,6 +150,12 @@ def _header(data: dict, fmt: str) -> str:
150
150
  trailer.append(meta["publishedTime"])
151
151
  trailer.append(data.get("finalUrl") or meta.get("url") or "")
152
152
  lines.append(" · ".join(t for t in trailer if t))
153
+ repository = meta.get("repository") or {}
154
+ stats = [f"{label}: {repository[key]}" for key, label in
155
+ (("stars", "Stars"), ("forks", "Forks"), ("watchers", "Watchers"))
156
+ if repository.get(key) is not None]
157
+ if stats:
158
+ lines.append("GitHub repository statistics (observed on page): " + " · ".join(stats))
153
159
  return "\n".join(lines)
154
160
 
155
161
 
@@ -98,6 +98,9 @@ def main() -> None:
98
98
  chars = result["charCount"]
99
99
  body_chars = result["bodyCharCount"]
100
100
  status = result.get("status")
101
+ # A targeted read of a counter/button can legitimately be one character.
102
+ # Empty scopes still deserve a warning; HTTP warnings remain independent.
103
+ unexpectedly_short = chars < 100 and (not args.selector or chars == 0)
101
104
  if isinstance(status, int) and status >= 400:
102
105
  # 错误页也有正文("404 Not Found"),照读不误——但不说一声的话,调用方
103
106
  # 会把一张错误页当成文章内容去写脚本。
@@ -105,14 +108,14 @@ def main() -> None:
105
108
  f"⚠️ 服务器返回 HTTP {status},下面读到的很可能是错误页而不是目标内容。",
106
109
  file=sys.stderr,
107
110
  )
108
- if chars < 100 and body_chars >= 2000:
111
+ if unexpectedly_short and body_chars >= 2000:
109
112
  print(
110
113
  f"⚠️ 整页有 {body_chars} 字符,却只抽到 {chars} 字符"
111
114
  f"(容器 {result.get('container')})——正文容器多半没认对。"
112
115
  "可用 --selector <正文容器> 指定,或 --format json 看抽到了哪些块。",
113
116
  file=sys.stderr,
114
117
  )
115
- elif chars < 100:
118
+ elif unexpectedly_short:
116
119
  print(
117
120
  f"⚠️ 这个页面几乎没有文本(整页 {body_chars} 字符,HTTP {result.get('status')})。"
118
121
  "多半是登录墙、反爬拦截,或正文由 JS 延迟渲染还没出来。"
@@ -2,6 +2,6 @@
2
2
  "skillName": "web-screenshot",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "555",
5
- "version": "V5",
5
+ "version": "V6",
6
6
  "skillDescription": "Web-page screenshot skill (still images only, png/jpg), built on Playwright Python — captures any URL to a local image file.\nSupports: full-page / viewport / element / region capture, device emulation, waiting for dynamic content, hiding or masking distracting elements, and static annotations (highlight box / arrow / caption / numbered sequence / redact / code-line highlight).\n\nImages only (`.png` / `.jpg`). For video (`.webm` / screen recording / storyboard clips) use web-record / `web_record`.\n\nUse this skill immediately whenever the user asks for any of:\n- Web page screenshot, screen capture, full-page screenshot, long screenshot\n- Capture a specific element / region, partial screenshot, focus on an area\n- Hide or mask elements before capturing (cookie banner, sensitive information)\n- Annotate a still screenshot (highlight box / arrow / label / caption bar / numbering)\n- Highlight lines L5-L20 of a file and screenshot it\n- Wait for an element to appear, then capture\n- Mobile / device-emulated screenshot (iPhone / Pixel, etc.)\n- Capture with cookies / a logged-in session / storageState\n\nEven when the user does not say \"playwright\", any request to screenshot a URL and get a local image file should route here."
7
7
  }