@remixmate/cli 0.1.0 → 0.1.1

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.
Files changed (122) hide show
  1. package/README.md +100 -83
  2. package/README.zh-CN.md +183 -0
  3. package/dist/handlers/gen-digital-human.d.ts +12 -0
  4. package/dist/handlers/gen-digital-human.js +157 -0
  5. package/dist/handlers/gen-image.js +5 -41
  6. package/dist/handlers/gen-video.d.ts +18 -0
  7. package/dist/handlers/gen-video.js +153 -0
  8. package/dist/handlers/gen-voice.js +2 -12
  9. package/dist/handlers/index.js +4 -2
  10. package/dist/handlers/shared.d.ts +26 -0
  11. package/dist/handlers/shared.js +66 -0
  12. package/dist/http.d.ts +4 -2
  13. package/dist/http.js +5 -3
  14. package/dist/manifest.json +27 -24
  15. package/dist/registry.d.ts +5 -16
  16. package/dist/registry.js +11 -17
  17. package/dist/skill-schema.d.ts +47 -0
  18. package/dist/skill-schema.js +49 -0
  19. package/package.json +5 -4
  20. package/skills/export-jianying/scripts/gen_jianying_draft.py +4 -4
  21. package/skills/gen-digital-human/SKILL.md +23 -46
  22. package/skills/gen-digital-human/skill.json +3 -2
  23. package/skills/gen-digital-human/version.json +1 -1
  24. package/skills/gen-image/SKILL.md +2 -2
  25. package/skills/gen-image/skill.json +2 -1
  26. package/skills/gen-image/version.json +1 -1
  27. package/skills/gen-script/SKILL.md +13 -13
  28. package/skills/gen-script/scripts/gen_script.py +11 -12
  29. package/skills/gen-video/SKILL.md +22 -33
  30. package/skills/gen-video/skill.json +3 -2
  31. package/skills/gen-video/version.json +1 -1
  32. package/skills/gen-voice/SKILL.md +1 -1
  33. package/skills/gen-voice/version.json +1 -1
  34. package/skills/prepare-video-assets/SKILL.md +2 -2
  35. package/skills/prepare-video-assets/skill.json +1 -1
  36. package/skills/render-video/scripts/render_video.py +8 -8
  37. package/skills/{template-bind → template-registry}/README.md +4 -4
  38. package/skills/{template-bind → template-registry}/SKILL.md +14 -12
  39. package/skills/template-registry/scripts/list_templates.py +129 -0
  40. package/skills/{template-bind → template-registry}/scripts/match_template.py +4 -3
  41. package/skills/{template-bind → template-registry}/scripts/registry_loader.py +1 -1
  42. package/skills/{template-bind → template-registry}/scripts/template_paths.py +7 -7
  43. package/skills/{template-bind → template-registry}/skill.json +6 -5
  44. package/skills/template-registry/version.json +7 -0
  45. package/skills/{template-bind → template-registry}/video_dsl/README.md +2 -2
  46. package/skills/{template-bind → template-registry}/video_dsl/runtime/__init__.py +1 -1
  47. package/skills/video-parser/SKILL.md +47 -160
  48. package/skills/video-parser/skill.json +3 -4
  49. package/skills/video-parser/version.json +1 -1
  50. package/skills/web-capture/SKILL.md +407 -116
  51. package/skills/web-capture/scripts/_media_screenshot/__init__.py +21 -0
  52. package/skills/web-capture/scripts/_media_screenshot/bootstrap.py +34 -0
  53. package/skills/web-capture/scripts/_media_screenshot/browser.py +86 -0
  54. package/skills/web-capture/scripts/_media_screenshot/cli_args.py +111 -0
  55. package/skills/web-capture/scripts/_media_screenshot/js/arrow.js +86 -0
  56. package/skills/web-capture/scripts/_media_screenshot/js/caption.js +27 -0
  57. package/skills/web-capture/scripts/_media_screenshot/js/clear_overlay.js +5 -0
  58. package/skills/web-capture/scripts/_media_screenshot/js/cursor_bootstrap.js +26 -0
  59. package/skills/web-capture/scripts/_media_screenshot/js/cursor_hide.js +6 -0
  60. package/skills/web-capture/scripts/_media_screenshot/js/cursor_move.js +12 -0
  61. package/skills/web-capture/scripts/_media_screenshot/js/cursor_ripple.js +25 -0
  62. package/skills/web-capture/scripts/_media_screenshot/js/element_zoom.js +72 -0
  63. package/skills/web-capture/scripts/_media_screenshot/js/element_zoom_restore.js +21 -0
  64. package/skills/web-capture/scripts/_media_screenshot/js/fade_out_overlays.js +10 -0
  65. package/skills/web-capture/scripts/_media_screenshot/js/focus.js +38 -0
  66. package/skills/web-capture/scripts/_media_screenshot/js/highlight.js +97 -0
  67. package/skills/web-capture/scripts/_media_screenshot/js/overlay_bootstrap.js +19 -0
  68. package/skills/web-capture/scripts/_media_screenshot/js/page_zoom_in.js +54 -0
  69. package/skills/web-capture/scripts/_media_screenshot/js/page_zoom_out.js +11 -0
  70. package/skills/web-capture/scripts/_media_screenshot/js/page_zoom_restore.js +13 -0
  71. package/skills/web-capture/scripts/_media_screenshot/js/redact.js +33 -0
  72. package/skills/web-capture/scripts/_media_screenshot/js/scroll.js +26 -0
  73. package/skills/web-capture/scripts/_media_screenshot/js/scroll_through.js +18 -0
  74. package/skills/web-capture/scripts/_media_screenshot/js/sequence.js +76 -0
  75. package/skills/web-capture/scripts/_media_screenshot/js/settled_check.js +15 -0
  76. package/skills/web-capture/scripts/_media_screenshot/js/title_card.js +71 -0
  77. package/skills/web-capture/scripts/_media_screenshot/js_loader.py +14 -0
  78. package/skills/web-capture/scripts/_media_screenshot/overlay.py +17 -0
  79. package/skills/web-capture/scripts/_media_screenshot/recording.py +100 -0
  80. package/skills/web-capture/scripts/_media_screenshot/scenes/__init__.py +39 -0
  81. package/skills/web-capture/scripts/_media_screenshot/scenes/arrow.py +50 -0
  82. package/skills/web-capture/scripts/_media_screenshot/scenes/caption.py +20 -0
  83. package/skills/web-capture/scripts/_media_screenshot/scenes/click.py +35 -0
  84. package/skills/web-capture/scripts/_media_screenshot/scenes/code_lines.py +54 -0
  85. package/skills/web-capture/scripts/_media_screenshot/scenes/focus.py +24 -0
  86. package/skills/web-capture/scripts/_media_screenshot/scenes/highlight.py +54 -0
  87. package/skills/web-capture/scripts/_media_screenshot/scenes/hover.py +28 -0
  88. package/skills/web-capture/scripts/_media_screenshot/scenes/redact.py +32 -0
  89. package/skills/web-capture/scripts/_media_screenshot/scenes/scroll.py +32 -0
  90. package/skills/web-capture/scripts/_media_screenshot/scenes/sequence.py +43 -0
  91. package/skills/web-capture/scripts/_media_screenshot/scenes/title_card.py +20 -0
  92. package/skills/web-capture/scripts/_media_screenshot/scenes/type.py +42 -0
  93. package/skills/web-capture/scripts/_media_screenshot/scenes/wait.py +9 -0
  94. package/skills/web-capture/scripts/_media_screenshot/scenes/zoom.py +72 -0
  95. package/skills/web-capture/scripts/_media_screenshot/screenshot.py +166 -0
  96. package/skills/web-capture/scripts/_media_screenshot/storyboard.py +88 -0
  97. package/skills/web-capture/scripts/_media_screenshot/template.py +119 -0
  98. package/skills/web-capture/scripts/_media_screenshot/trim.py +82 -0
  99. package/skills/web-capture/scripts/record.py +186 -0
  100. package/skills/web-capture/scripts/screenshot.py +94 -0
  101. package/skills/web-capture/skill.json +14 -14
  102. package/skills/web-capture/templates/github-code-tour.json +59 -0
  103. package/skills/web-capture/templates/github-readme-tour.json +77 -0
  104. package/skills/web-capture/templates/github-repo-intro.json +74 -0
  105. package/skills/web-capture/version.json +2 -2
  106. package/dist/handlers/template-bind.d.ts +0 -15
  107. package/dist/handlers/template-bind.js +0 -149
  108. package/skills/gen-digital-human/scripts/gen_digital_human_video.py +0 -574
  109. package/skills/gen-video/scripts/gen_video.py +0 -591
  110. package/skills/template-bind/version.json +0 -7
  111. package/skills/web-capture/scripts/web_capture.py +0 -461
  112. /package/skills/{template-bind → template-registry}/scripts/check_contracts.py +0 -0
  113. /package/skills/{template-bind → template-registry}/scripts/render_job_client.py +0 -0
  114. /package/skills/{template-bind → template-registry}/scripts/sync_registry.py +0 -0
  115. /package/skills/{template-bind → template-registry}/video_dsl/runtime/dsl_validator.py +0 -0
  116. /package/skills/{template-bind → template-registry}/video_dsl/runtime/prompt_enhancer.py +0 -0
  117. /package/skills/{template-bind → template-registry}/video_dsl/runtime/template_binder.py +0 -0
  118. /package/skills/{template-bind → template-registry}/video_dsl/runtime/timeline_compiler.py +0 -0
  119. /package/skills/{template-bind → template-registry}/video_dsl/schema/render-plan-v1alpha1.json +0 -0
  120. /package/skills/{template-bind → template-registry}/video_dsl/schema/template-binding-v1alpha1.json +0 -0
  121. /package/skills/{template-bind → template-registry}/video_dsl/schema/template-definition-v1alpha1.json +0 -0
  122. /package/skills/{template-bind → template-registry}/video_dsl/schema/video-dsl-v1alpha1.json +0 -0
@@ -0,0 +1,24 @@
1
+ """Focus scene: dim the page with a spotlight cut-out around the target."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ @register("focus")
10
+ def scene_focus(page, scene: dict) -> None:
11
+ locator = resolve_locator(page, scene["selector"])
12
+ box = locator.bounding_box()
13
+ if not box:
14
+ raise RuntimeError(f"focus: no bounding box for {scene['selector']}")
15
+ page.evaluate(
16
+ js_loader.load("focus"),
17
+ {
18
+ "box": box,
19
+ "pad": scene.get("padding", 12),
20
+ "label": scene.get("label", ""),
21
+ "color": scene.get("color", "#ff3b30"),
22
+ },
23
+ )
24
+ page.wait_for_timeout(scene.get("duration", 3000))
@@ -0,0 +1,54 @@
1
+ """Highlight scene: rectangle around a target element (or union of several)."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ def _union_box(page, selectors: list[str]) -> dict | None:
10
+ """Compute the union bounding box of every matching selector. Skips misses."""
11
+ # Make sure the first one is visible / on-screen before measuring.
12
+ resolve_locator(page, selectors[0])
13
+ boxes: list[dict] = []
14
+ for sel in selectors:
15
+ try:
16
+ loc = page.locator(sel).first
17
+ b = loc.bounding_box()
18
+ except Exception:
19
+ continue
20
+ if b:
21
+ boxes.append(b)
22
+ if not boxes:
23
+ return None
24
+ x = min(b["x"] for b in boxes)
25
+ y = min(b["y"] for b in boxes)
26
+ right = max(b["x"] + b["width"] for b in boxes)
27
+ bottom = max(b["y"] + b["height"] for b in boxes)
28
+ return {"x": x, "y": y, "width": right - x, "height": bottom - y}
29
+
30
+
31
+ @register("highlight")
32
+ def scene_highlight(page, scene: dict) -> None:
33
+ selectors = scene.get("selectors")
34
+ if selectors:
35
+ box = _union_box(page, list(selectors))
36
+ if not box:
37
+ raise RuntimeError(f"highlight: no bounding boxes for {selectors}")
38
+ else:
39
+ locator = resolve_locator(page, scene["selector"])
40
+ box = locator.bounding_box()
41
+ if not box:
42
+ raise RuntimeError(f"highlight: no bounding box for {scene['selector']}")
43
+ page.evaluate(
44
+ js_loader.load("highlight"),
45
+ {
46
+ "box": box,
47
+ "pad": scene.get("padding", 8),
48
+ "color": scene.get("color", "#ff3b30"),
49
+ "lw": scene.get("lineWidth", 4),
50
+ "label": scene.get("label", ""),
51
+ "drawOnMs": int(scene.get("drawOnMs", 0)),
52
+ },
53
+ )
54
+ page.wait_for_timeout(scene.get("duration", 3000))
@@ -0,0 +1,28 @@
1
+ """Hover scene: move cursor onto target and hold so tooltips/cards reveal."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ @register("hover")
10
+ def scene_hover(page, scene: dict) -> None:
11
+ locator = resolve_locator(page, scene["selector"])
12
+ box = locator.bounding_box()
13
+ if not box:
14
+ raise RuntimeError(f"hover: no bounding box for {scene['selector']}")
15
+ cx = box["x"] + box["width"] / 2
16
+ cy = box["y"] + box["height"] / 2
17
+
18
+ move_ms = int(scene.get("moveMs", 700))
19
+ hold_ms = int(scene.get("holdMs", 2500))
20
+
21
+ page.evaluate(js_loader.load("cursor_bootstrap"))
22
+ page.evaluate(js_loader.load("cursor_move"), {"x": cx, "y": cy, "ms": move_ms})
23
+ page.wait_for_timeout(move_ms + 80)
24
+ try:
25
+ locator.hover()
26
+ except Exception:
27
+ pass
28
+ page.wait_for_timeout(hold_ms)
@@ -0,0 +1,32 @@
1
+ """Redact scene: blur / pixelate / solid-cover sensitive regions."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ @register("redact")
10
+ def scene_redact(page, scene: dict) -> None:
11
+ selectors: list[str] = list(scene.get("selectors") or [])
12
+ if not selectors and scene.get("selector"):
13
+ selectors.append(scene["selector"])
14
+ items: list[dict] = []
15
+ for sel in selectors:
16
+ try:
17
+ loc = resolve_locator(page, sel)
18
+ box = loc.bounding_box()
19
+ except Exception:
20
+ continue
21
+ if box:
22
+ items.append({"box": box, "color": scene.get("color", "#222")})
23
+ if not items:
24
+ raise RuntimeError("redact: no resolvable targets")
25
+ page.evaluate(
26
+ js_loader.load("redact"),
27
+ {
28
+ "items": items,
29
+ "mode": scene.get("mode", "blur"),
30
+ },
31
+ )
32
+ page.wait_for_timeout(scene.get("duration", 2500))
@@ -0,0 +1,32 @@
1
+ """Scroll scene: step-scroll for ``duration`` or until ``toSelector`` is hit."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ from .. import js_loader
7
+ from ..browser import resolve_locator
8
+ from . import register
9
+
10
+
11
+ @register("scroll")
12
+ def scene_scroll(page, scene: dict) -> None:
13
+ step = scene.get("step", 50)
14
+ interval = scene.get("interval", 60)
15
+ if scene.get("fromSelector"):
16
+ from_loc = resolve_locator(page, scene["fromSelector"])
17
+ from_loc.scroll_into_view_if_needed()
18
+ page.wait_for_timeout(300)
19
+ elif scene.get("startTop"):
20
+ page.evaluate("() => window.scrollTo(0, 0)")
21
+ page.wait_for_timeout(200)
22
+ duration = scene.get("duration", 8000)
23
+ page.evaluate(
24
+ js_loader.load("scroll"),
25
+ {
26
+ "step": step,
27
+ "interval": interval,
28
+ "endTime": int(time.time() * 1000) + duration,
29
+ "toSelector": scene.get("toSelector"),
30
+ },
31
+ )
32
+ page.wait_for_timeout(scene.get("holdAtEnd", 600))
@@ -0,0 +1,43 @@
1
+ """Sequence scene: multiple targets get numbered outlines, revealed in order."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ @register("sequence")
10
+ def scene_sequence(page, scene: dict) -> None:
11
+ raw = scene.get("items") or []
12
+ items: list[dict] = []
13
+ for it in raw:
14
+ if isinstance(it, str):
15
+ sel, label = it, ""
16
+ else:
17
+ sel = it.get("selector")
18
+ label = it.get("label", "")
19
+ if not sel:
20
+ continue
21
+ try:
22
+ loc = resolve_locator(page, sel)
23
+ box = loc.bounding_box()
24
+ except Exception:
25
+ continue
26
+ if not box:
27
+ continue
28
+ items.append({"box": box, "label": label})
29
+ if not items:
30
+ raise RuntimeError("sequence: no resolvable items")
31
+ step_ms = int(scene.get("stepMs", 500))
32
+ page.evaluate(
33
+ js_loader.load("sequence"),
34
+ {
35
+ "items": items,
36
+ "color": scene.get("color", "#ff3b30"),
37
+ "lineWidth": scene.get("lineWidth", 3),
38
+ "padding": scene.get("padding", 6),
39
+ "stepMs": step_ms,
40
+ },
41
+ )
42
+ total = step_ms * len(items) + int(scene.get("holdMs", 1500))
43
+ page.wait_for_timeout(total)
@@ -0,0 +1,20 @@
1
+ """Title card scene: full-screen opener/outro with title + subtitle + accent."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from . import register
6
+
7
+
8
+ @register("title-card")
9
+ def scene_title_card(page, scene: dict) -> None:
10
+ page.evaluate(
11
+ js_loader.load("title_card"),
12
+ {
13
+ "title": scene.get("title", ""),
14
+ "subtitle": scene.get("subtitle", ""),
15
+ "bg": scene.get("background", "rgba(15,17,21,0.96)"),
16
+ "fg": scene.get("color", "#fff"),
17
+ "accent": scene.get("accent", "#ff3b30"),
18
+ },
19
+ )
20
+ page.wait_for_timeout(scene.get("duration", 2500))
@@ -0,0 +1,42 @@
1
+ """Type scene: move cursor to an input, focus, then typewriter the text."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ @register("type")
10
+ def scene_type(page, scene: dict) -> None:
11
+ locator = resolve_locator(page, scene["selector"])
12
+ text = scene.get("text", "")
13
+ delay = int(scene.get("delay", 80))
14
+ clear_first = bool(scene.get("clearFirst", True))
15
+ move_cursor = bool(scene.get("moveCursor", True))
16
+ move_ms = int(scene.get("moveMs", 600))
17
+ pre_hold = int(scene.get("preHoldMs", 200))
18
+ post_hold = int(scene.get("postHoldMs", 800))
19
+
20
+ if move_cursor:
21
+ box = locator.bounding_box()
22
+ if box:
23
+ cx = box["x"] + box["width"] / 2
24
+ cy = box["y"] + box["height"] / 2
25
+ page.evaluate(js_loader.load("cursor_bootstrap"))
26
+ page.evaluate(js_loader.load("cursor_move"), {"x": cx, "y": cy, "ms": move_ms})
27
+ page.wait_for_timeout(move_ms + pre_hold)
28
+
29
+ try:
30
+ locator.click()
31
+ except Exception:
32
+ pass
33
+ if clear_first:
34
+ try:
35
+ locator.fill("")
36
+ except Exception:
37
+ pass
38
+ try:
39
+ locator.type(text, delay=delay)
40
+ except Exception:
41
+ page.keyboard.type(text, delay=delay)
42
+ page.wait_for_timeout(post_hold)
@@ -0,0 +1,9 @@
1
+ """Wait scene: a simple pause between other scenes."""
2
+ from __future__ import annotations
3
+
4
+ from . import register
5
+
6
+
7
+ @register("wait")
8
+ def scene_wait(page, scene: dict) -> None:
9
+ page.wait_for_timeout(scene.get("duration", 1000))
@@ -0,0 +1,72 @@
1
+ """Zoom scene: element-level OR page-level (browser-like) zoom animation."""
2
+ from __future__ import annotations
3
+
4
+ from .. import js_loader
5
+ from ..browser import resolve_locator
6
+ from . import register
7
+
8
+
9
+ def _run_page_zoom(page, scene: dict) -> None:
10
+ target_scale = scene.get("scale", 2.5)
11
+ ramp_in_ms = scene.get("rampInMs", scene.get("rampMs", 700))
12
+ ramp_out_ms = scene.get("rampOutMs", 0)
13
+ hold_ms = scene.get("holdMs", 2500)
14
+ color = scene.get("color", "#ff9500")
15
+ label = scene.get("label", "")
16
+ page.evaluate(
17
+ js_loader.load("page_zoom_in"),
18
+ {
19
+ "selector": scene["selector"],
20
+ "targetScale": target_scale,
21
+ "rampInMs": ramp_in_ms,
22
+ "label": label,
23
+ "color": color,
24
+ },
25
+ )
26
+ page.wait_for_timeout(ramp_in_ms + hold_ms)
27
+ if ramp_out_ms > 0:
28
+ page.evaluate(js_loader.load("page_zoom_out"), {"rampOutMs": ramp_out_ms})
29
+ page.wait_for_timeout(ramp_out_ms + 100)
30
+ page.evaluate(js_loader.load("page_zoom_restore"))
31
+
32
+
33
+ def _run_element_zoom(page, scene: dict) -> None:
34
+ target_scale = scene.get("scale", 2.5)
35
+ ramp_ms = scene.get("rampMs", 700)
36
+ hold_ms = scene.get("holdMs", 2500)
37
+ color = scene.get("color", "#ff9500")
38
+ label = scene.get("label", "")
39
+ dim = scene.get("dim", True)
40
+ line_width = scene.get("lineWidth", 3)
41
+
42
+ page.evaluate(
43
+ js_loader.load("element_zoom"),
44
+ {
45
+ "selector": scene["selector"],
46
+ "targetScale": target_scale,
47
+ "rampMs": ramp_ms,
48
+ "color": color,
49
+ "label": label,
50
+ "dim": dim,
51
+ "lineWidth": line_width,
52
+ },
53
+ )
54
+ page.wait_for_timeout(ramp_ms + hold_ms)
55
+ page.evaluate(js_loader.load("element_zoom_restore"), scene["selector"])
56
+
57
+
58
+ @register("zoom")
59
+ def scene_zoom(page, scene: dict) -> None:
60
+ target = scene.get("target", "element")
61
+ locator = resolve_locator(page, scene["selector"])
62
+ locator.scroll_into_view_if_needed()
63
+ # Center in viewport so the element has room to grow.
64
+ locator.evaluate(
65
+ "el => el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })"
66
+ )
67
+ page.wait_for_timeout(250)
68
+
69
+ if target == "page":
70
+ _run_page_zoom(page, scene)
71
+ else:
72
+ _run_element_zoom(page, scene)
@@ -0,0 +1,166 @@
1
+ """Screenshot operation: full-page / element / region, with optional
2
+ hide-before / native mask / drawn annotations.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import scenes
11
+ from .bootstrap import ensure_runtime
12
+ from .browser import (
13
+ apply_pre_action_waits,
14
+ build_context_options,
15
+ launch_with_browser_install,
16
+ )
17
+ from .overlay import ensure_overlay
18
+
19
+
20
+ def _apply_hide(page, selectors: list[str]) -> None:
21
+ """display:none every match of every selector (idempotent, scoped to this page)."""
22
+ if not selectors:
23
+ return
24
+ page.evaluate(
25
+ """(sels) => {
26
+ sels.forEach((s) => {
27
+ try {
28
+ document.querySelectorAll(s).forEach((el) => {
29
+ el.style.setProperty('display', 'none', 'important');
30
+ });
31
+ } catch (e) { /* invalid selector — skip */ }
32
+ });
33
+ }""",
34
+ selectors,
35
+ )
36
+
37
+
38
+ def _apply_annotations(page, annotations: list[dict], settle_ms: int) -> None:
39
+ """Reuse scene handlers to draw static overlays before snapshotting."""
40
+ if not annotations:
41
+ return
42
+ ensure_overlay(page)
43
+ for ann in annotations:
44
+ handler = scenes.get_handler(ann.get("type", ""))
45
+ if not handler:
46
+ print(
47
+ f"[annotate] skip unknown type: {ann.get('type')!r} "
48
+ f"(known: {', '.join(scenes.known_types())})",
49
+ file=sys.stderr,
50
+ )
51
+ continue
52
+ # Scene handlers wait on `duration`; for static annotations we want
53
+ # 0-wait so they all stack up before the snapshot fires.
54
+ ann_copy = dict(ann)
55
+ ann_copy.setdefault("duration", 0)
56
+ ann_copy.setdefault("holdMs", 0)
57
+ try:
58
+ handler(page, ann_copy)
59
+ except Exception as e:
60
+ print(f"[annotate] {ann.get('type')} failed: {e}", file=sys.stderr)
61
+ if settle_ms > 0:
62
+ page.wait_for_timeout(settle_ms)
63
+
64
+
65
+ def _load_annotations(spec) -> tuple[list[dict], int]:
66
+ """Accept either a list, a dict with 'annotations' key, or a path to such."""
67
+ if spec is None:
68
+ return [], 0
69
+ settle_ms = 900
70
+ if isinstance(spec, (str, Path)):
71
+ p = Path(spec)
72
+ if p.exists():
73
+ data = json.loads(p.read_text())
74
+ else:
75
+ data = json.loads(str(spec))
76
+ else:
77
+ data = spec
78
+ if isinstance(data, list):
79
+ return data, settle_ms
80
+ if isinstance(data, dict):
81
+ return list(data.get("annotations") or []), int(data.get("settleMs", settle_ms))
82
+ return [], settle_ms
83
+
84
+
85
+ def _build_mask_locators(page, selectors: list[str]):
86
+ out = []
87
+ for s in selectors or []:
88
+ try:
89
+ out.append(page.locator(s))
90
+ except Exception:
91
+ continue
92
+ return out
93
+
94
+
95
+ def do_screenshot(cfg: dict) -> Path:
96
+ """Take a screenshot per cfg. Returns the absolute output path."""
97
+ ensure_runtime()
98
+ from playwright.sync_api import sync_playwright
99
+
100
+ output = Path(cfg["output"]).resolve()
101
+ output.parent.mkdir(parents=True, exist_ok=True)
102
+
103
+ annotations, settle_ms = _load_annotations(cfg.get("annotate"))
104
+
105
+ with sync_playwright() as p:
106
+ browser = launch_with_browser_install(p, cfg.get("browser"))
107
+ try:
108
+ context = browser.new_context(**build_context_options(p, cfg, None))
109
+ if cfg.get("timeout"):
110
+ context.set_default_timeout(cfg["timeout"])
111
+ page = context.new_page()
112
+ try:
113
+ page.goto(cfg["url"], wait_until="domcontentloaded")
114
+ apply_pre_action_waits(page, cfg)
115
+
116
+ _apply_hide(page, cfg.get("hideSelectors") or [])
117
+ _apply_annotations(page, annotations, settle_ms)
118
+
119
+ mask_locators = _build_mask_locators(page, cfg.get("maskSelectors") or [])
120
+ mask_kwargs: dict = {}
121
+ if mask_locators:
122
+ mask_kwargs["mask"] = mask_locators
123
+ if cfg.get("maskColor"):
124
+ mask_kwargs["mask_color"] = cfg["maskColor"]
125
+
126
+ if cfg.get("selector"):
127
+ locator = page.locator(cfg["selector"]).first
128
+ if cfg.get("scrollIntoView", True):
129
+ locator.scroll_into_view_if_needed()
130
+ if isinstance(cfg.get("clip"), (list, tuple)) and len(cfg["clip"]) == 4:
131
+ box = locator.bounding_box()
132
+ if not box:
133
+ raise SystemExit(
134
+ f'selector "{cfg["selector"]}" has no bounding box'
135
+ )
136
+ dx, dy, w, h = cfg["clip"]
137
+ page.screenshot(
138
+ path=str(output),
139
+ clip={
140
+ "x": box["x"] + dx,
141
+ "y": box["y"] + dy,
142
+ "width": w,
143
+ "height": h,
144
+ },
145
+ **mask_kwargs,
146
+ )
147
+ else:
148
+ locator.screenshot(path=str(output), **mask_kwargs)
149
+ elif isinstance(cfg.get("clip"), (list, tuple)) and len(cfg["clip"]) == 4:
150
+ x, y, w, h = cfg["clip"]
151
+ page.screenshot(
152
+ path=str(output),
153
+ clip={"x": x, "y": y, "width": w, "height": h},
154
+ **mask_kwargs,
155
+ )
156
+ else:
157
+ page.screenshot(
158
+ path=str(output),
159
+ full_page=bool(cfg.get("fullPage")),
160
+ **mask_kwargs,
161
+ )
162
+ finally:
163
+ context.close()
164
+ finally:
165
+ browser.close()
166
+ return output
@@ -0,0 +1,88 @@
1
+ """Storyboard runner: orchestrate a sequence of scenes with smooth transitions."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from .bootstrap import ensure_runtime
9
+ from .browser import (
10
+ apply_pre_action_waits,
11
+ build_context_options,
12
+ launch_with_browser_install,
13
+ wait_until_settled,
14
+ )
15
+ from .overlay import clear_overlay, ensure_overlay, fade_out_overlays
16
+ from . import scenes
17
+
18
+
19
+ def _run_storyboard(page, cfg: dict) -> None:
20
+ ensure_overlay(page)
21
+ scene_list = cfg.get("scenes") or []
22
+ transition = cfg.get("transition") or {}
23
+ fade_out_ms = transition.get("fadeOutMs", transition.get("duration", 350))
24
+ gap_ms = transition.get("gapMs", 150)
25
+ for i, scene in enumerate(scene_list):
26
+ handler = scenes.get_handler(scene.get("type", ""))
27
+ if not handler:
28
+ print(
29
+ f"[runner] skip unknown scene type: {scene.get('type')!r} "
30
+ f"(known: {', '.join(scenes.known_types())})",
31
+ file=sys.stderr,
32
+ )
33
+ continue
34
+ print(
35
+ f"[runner] scene {i + 1}/{len(scene_list)}: {scene['type']}",
36
+ file=sys.stderr,
37
+ )
38
+ handler(page, scene)
39
+ if i < len(scene_list) - 1:
40
+ fade_out_overlays(page, fade_out_ms)
41
+ clear_overlay(page)
42
+ ensure_overlay(page)
43
+ if gap_ms > 0:
44
+ page.wait_for_timeout(gap_ms)
45
+
46
+
47
+ def do_storyboard(cfg: dict) -> tuple[Path, int, int]:
48
+ """Run a storyboard recording. Returns (output_path, start_ms, end_ms)."""
49
+ ensure_runtime()
50
+ from playwright.sync_api import sync_playwright
51
+
52
+ output = Path(cfg["output"]).resolve()
53
+ output.parent.mkdir(parents=True, exist_ok=True)
54
+ out_dir = str(output.parent)
55
+
56
+ with sync_playwright() as p:
57
+ browser = launch_with_browser_install(p, cfg.get("browser"))
58
+ try:
59
+ t0 = time.time()
60
+ context = browser.new_context(**build_context_options(p, cfg, out_dir))
61
+ if cfg.get("timeout"):
62
+ context.set_default_timeout(cfg["timeout"])
63
+ page = context.new_page()
64
+ video_path: str | None = None
65
+ start_ms = 0
66
+ end_ms = 0
67
+ try:
68
+ page.goto(cfg["url"], wait_until="domcontentloaded")
69
+ apply_pre_action_waits(page, cfg)
70
+ wait_until_settled(page, cfg)
71
+ start_ms = int((time.time() - t0) * 1000)
72
+ _run_storyboard(page, cfg)
73
+ end_ms = int((time.time() - t0) * 1000)
74
+ video = page.video
75
+ context.close()
76
+ if video:
77
+ video_path = video.path()
78
+ finally:
79
+ pass
80
+ finally:
81
+ browser.close()
82
+
83
+ if not video_path or not Path(video_path).exists():
84
+ raise SystemExit("storyboard finished but no video file was produced")
85
+ src = Path(video_path)
86
+ if src.resolve() != output:
87
+ src.replace(output)
88
+ return output, start_ms, end_ms