hyper-animator-codex 0.3.0 → 0.5.0

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/README.md CHANGED
@@ -37,6 +37,21 @@ Use a custom Codex skills directory:
37
37
  npx hyper-animator-codex install --target /path/to/codex/skills
38
38
  ```
39
39
 
40
+ ## Git Checkpoints
41
+
42
+ During `/hyper-animator` runs, the installed skill initializes a normal Git repository in a new output directory and creates rollback-friendly stage commits. It does not create branches or worktrees.
43
+
44
+ The bundled helper can be run from an installed skill directory or repository checkout:
45
+
46
+ ```bash
47
+ node skills/hyper-animator-codex/scripts/git_checkpoint.mjs \
48
+ --phase init \
49
+ --message "chore: initialize hyper animator workspace" \
50
+ --allow-empty
51
+ ```
52
+
53
+ Existing Git repositories with uncommitted changes are protected: the helper stops instead of mixing user edits into automatic commits. Large video and audio files above 10 MB are skipped by default and recorded in a manifest under `hyper-animator-output/git-checkpoints/`.
54
+
40
55
  ## MiniMax Music Configuration
41
56
 
42
57
  MiniMax is the preferred generated-background-music provider. Configure it during install so the copied Codex skill can generate music without asking for credentials later:
@@ -68,10 +83,32 @@ ${CODEX_HOME:-$HOME/.codex}/skills/hyper-animator-codex/config/minimax.json
68
83
 
69
84
  Do not commit that installed config file. The npm package does not include local MiniMax credentials.
70
85
 
86
+ ## HTML Preview Controls
87
+
88
+ Preview HTML can include a bottom page indicator and thin draggable progress bar:
89
+
90
+ ```bash
91
+ node skills/hyper-animator-codex/scripts/inject_preview_controls.mjs composition.html -o composition.preview.html --force
92
+ python3 skills/hyper-animator-codex/scripts/validate_hyperframes_html.py composition.preview.html --preview-controls
93
+ ```
94
+
95
+ The injected controls support dragging the progress bar and using ArrowLeft or ArrowRight to move between pages.
96
+
97
+ Use `data-preview-pages="0,3,6,9"` for explicit page starts, or `data-preview-page-count="4"` to divide the duration evenly.
98
+
99
+ When rendering video, hide preview controls with any one of:
100
+
101
+ ```text
102
+ ?render=1
103
+ ?preview=0
104
+ <html data-render-mode="video">
105
+ ```
106
+
71
107
  ## Contents
72
108
 
73
109
  - `skills/hyper-animator-codex/SKILL.md`: Codex skill instructions.
74
110
  - `skills/hyper-animator-codex/references/`: HyperFrames catalog map, workflow guide, pseudocode, and request examples.
111
+ - `skills/hyper-animator-codex/scripts/git_checkpoint.mjs`: Git initialization and stage-commit helper for `/hyper-animator` runs.
75
112
  - `skills/hyper-animator-codex/scripts/validate_hyperframes_html.py`: static pre-render HTML quality gate.
76
113
 
77
114
  ## Optional Beat Detection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyper-animator-codex",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Install the Hyper Animator Codex skill for Codex.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,21 +11,24 @@ Turn a natural-language animation or video brief into a validated HyperFrames HT
11
11
 
12
12
  ## Required Flow
13
13
 
14
- 1. Capture the raw request and extract an initial intent profile: purpose, format, duration, content inputs, style tags, motion tags, and needed roles.
15
- 2. If purpose, format, or core content is unclear, ask the first clarification round before choosing catalog items.
16
- 3. Read `references/hyperframes-catalog-map.json` and score candidates by keyword, intent domain, format, role, style, motion, and constraints.
17
- 4. Pick candidate blocks/components for main scene, caption, effects, transitions, and outro.
18
- 5. Decide generation mode:
14
+ 1. Read `references/git-checkpoint-workflow.md` and run `scripts/git_checkpoint.mjs --phase init --message "chore: initialize hyper animator workspace" --allow-empty` before requirement capture. If the helper reports an existing dirty repository or linked worktree, stop and ask the user to resolve it.
15
+ 2. Capture the raw request and extract an initial intent profile: purpose, format, duration, content inputs, style tags, motion tags, and needed roles.
16
+ 3. If purpose, format, or core content is unclear, ask the first clarification round before choosing catalog items.
17
+ 4. Read `references/hyperframes-catalog-map.json` and score candidates by keyword, intent domain, format, role, style, motion, and constraints.
18
+ 5. Pick candidate blocks/components for main scene, caption, effects, transitions, and outro.
19
+ 6. Decide generation mode:
19
20
  - `generate_new_hyperframes_html` when the user asks to write HTML, create a new effect, customize style, match a brand, use complex animation, or when component snippets are only paste placeholders.
20
21
  - `assemble_existing_catalog_items` only when the user asks to use existing catalog items or quickly compose installed blocks/components.
21
- 6. Clarify audio: ask whether to add animation/transition sound effects and whether to add background music.
22
- 7. If background music is requested or undecided, read `references/minimax-music-workflow.md` and prefer MiniMax generation when configured. If MiniMax is unavailable or declined, ask for a local audio path or continue without BGM.
23
- 8. If background music is used, read `references/beat-sync-workflow.md`, generate or obtain the audio, and run `scripts/analyze_music_beats.py` when the file is available.
24
- 9. Ask the second clarification round with candidate context: visual direction, motion rhythm, generation mode, audio choices, music prompt/model when MiniMax is used, and beat-sync assumptions when background music is present.
25
- 10. Write or assemble HTML. When beat-sync is enabled, align major reveals, cuts, transitions, camera moves, and visual accents to the beat map instead of arbitrary timestamps.
26
- 11. Run pre-render quality gates.
27
- 12. Show a concise plan summary and preview path or HTML file to the user. Ask for confirmation before video render.
28
- 13. Render only after user confirmation, then report output path and any caveats.
22
+ 7. Clarify audio: ask whether to add animation/transition sound effects and whether to add background music.
23
+ 8. If background music is requested or undecided, read `references/minimax-music-workflow.md` and prefer MiniMax generation when configured. If MiniMax is unavailable or declined, ask for a local audio path or continue without BGM.
24
+ 9. If background music is used, read `references/beat-sync-workflow.md`, generate or obtain the audio, and run `scripts/analyze_music_beats.py` when the file is available.
25
+ 10. Ask the second clarification round with candidate context: visual direction, motion rhythm, generation mode, audio choices, music prompt/model when MiniMax is used, and beat-sync assumptions when background music is present.
26
+ 11. Write or assemble HTML. When beat-sync is enabled, align major reveals, cuts, transitions, camera moves, and visual accents to the beat map instead of arbitrary timestamps.
27
+ 12. After each file-writing stage, run `scripts/git_checkpoint.mjs` with the phase-specific allowlist from `references/git-checkpoint-workflow.md`. Do not create, switch, or recommend Git branches or worktrees.
28
+ 13. Run pre-render quality gates on the base HTML.
29
+ 14. Read `references/preview-controls-workflow.md`, run `scripts/inject_preview_controls.mjs` to create a preview HTML copy, and run `scripts/validate_hyperframes_html.py preview.html --preview-controls`.
30
+ 15. Show a concise plan summary and preview path or HTML file to the user. Ask for confirmation before video render.
31
+ 16. Render only after user confirmation. Prefer rendering the base HTML; if rendering the preview copy, use `?render=1`, `?preview=0`, or `<html data-render-mode="video">` so preview controls are hidden. Then report output path and any caveats.
29
32
 
30
33
  ## Interactive Questions
31
34
 
@@ -40,11 +43,13 @@ Do not ask everything upfront when the brief is already specific. Ask only for m
40
43
 
41
44
  ## References
42
45
 
46
+ - Read `references/git-checkpoint-workflow.md` before any `/hyper-animator` run writes files or initializes output.
43
47
  - Read `references/hyperframes-intent-workflow.md` for the full AskUserQuestion workflow, generation-mode rules, scoring model, and render confirmation requirements.
44
48
  - Read `references/hyperframes-catalog-map.json` whenever selecting catalog candidates or determining visual references.
45
49
  - Read `references/hyperframes-agent-pseudocode.ts` when implementing the end-to-end loop or when the correct sequence is ambiguous.
46
50
  - Read `references/minimax-music-workflow.md` when generated background music, MiniMax, music prompt, instrumental/vocal choice, or provider fallback is mentioned.
47
51
  - Read `references/beat-sync-workflow.md` when sound effects, background music, soundtrack, beat sync, rhythm, BPM, audio-reactive animation, or transition timing to music is mentioned.
52
+ - Read `references/preview-controls-workflow.md` before showing HTML previews or rendering after preview approval.
48
53
  - Use `references/examples/*.json` for sanity checks against common request shapes.
49
54
 
50
55
  ## Catalog Rules
@@ -76,6 +81,12 @@ Generated block HTML must include:
76
81
  - no wall-clock `Date.now()` or `setInterval()` driving primary timeline progress;
77
82
  - readable text at the target video dimensions.
78
83
 
84
+ Preview HTML must pass:
85
+
86
+ ```bash
87
+ python3 scripts/validate_hyperframes_html.py path/to/composition.preview.html --preview-controls
88
+ ```
89
+
79
90
  If the validator fails, fix the HTML before asking the user to approve render.
80
91
 
81
92
  ## Render Handoff
@@ -87,9 +98,13 @@ Before rendering, summarize:
87
98
  - sound effects and background music choices;
88
99
  - MiniMax provider status, model, generated audio path, metadata path, and redacted config source when MiniMax is used;
89
100
  - beat map path, BPM, duration, and timing assumptions when beat-sync is enabled;
101
+ - base HTML path and preview HTML path;
102
+ - preview controls injection status and page count source;
103
+ - render-hidden signal used to hide preview controls during video render;
90
104
  - dimensions and duration;
91
105
  - content assumptions;
92
106
  - preview location;
93
107
  - validator result.
108
+ - Git checkpoint status, latest commit hash, skipped large media manifest path, and any dirty-worktree caveats;
94
109
 
95
110
  Ask the user whether to render or revise. If rendering tools or project-specific commands are unavailable, stop at validated HTML and report the exact missing render command or dependency.
@@ -0,0 +1,87 @@
1
+ # Git Checkpoint Workflow
2
+
3
+ Use this workflow whenever `/hyper-animator` writes files. The goal is rollback-friendly stage commits without creating branches or worktrees.
4
+
5
+ ## Start Of Run
6
+
7
+ Run this before requirement capture or file generation:
8
+
9
+ ```bash
10
+ node scripts/git_checkpoint.mjs \
11
+ --phase init \
12
+ --message "chore: initialize hyper animator workspace" \
13
+ --allow-empty
14
+ ```
15
+
16
+ If the helper reports an existing repository with uncommitted changes, stop and ask the user to resolve those files before continuing.
17
+
18
+ ## Phase Commits
19
+
20
+ Use explicit allowlists for every checkpoint:
21
+
22
+ ```bash
23
+ node scripts/git_checkpoint.mjs \
24
+ --phase brief \
25
+ --message "feat: capture animation brief" \
26
+ --allow "hyper-animator-output/brief/**" \
27
+ --allow "hyper-animator-output/catalog/**"
28
+ ```
29
+
30
+ ```bash
31
+ node scripts/git_checkpoint.mjs \
32
+ --phase base-html \
33
+ --message "feat: create base animation html" \
34
+ --allow "*.html" \
35
+ --allow "hyper-animator-output/html/**" \
36
+ --allow "hyper-animator-output/assets/**"
37
+ ```
38
+
39
+ ```bash
40
+ node scripts/git_checkpoint.mjs \
41
+ --phase audio \
42
+ --message "feat: add audio assets" \
43
+ --allow "hyper-animator-output/music/**" \
44
+ --allow "hyper-animator-output/beat-maps/**"
45
+ ```
46
+
47
+ ```bash
48
+ node scripts/git_checkpoint.mjs \
49
+ --phase preview \
50
+ --message "feat: add preview controls" \
51
+ --allow "*.preview.html" \
52
+ --allow "hyper-animator-output/preview/**"
53
+ ```
54
+
55
+ ```bash
56
+ node scripts/git_checkpoint.mjs \
57
+ --phase render \
58
+ --message "chore: render animation video" \
59
+ --allow "hyper-animator-output/render/**" \
60
+ --allow "*.mp4" \
61
+ --allow "*.webm"
62
+ ```
63
+
64
+ For preview feedback revisions:
65
+
66
+ ```bash
67
+ node scripts/git_checkpoint.mjs \
68
+ --phase refine \
69
+ --message "refine: update animation from preview feedback" \
70
+ --allow "*.html" \
71
+ --allow "hyper-animator-output/**"
72
+ ```
73
+
74
+ ## Media Policy
75
+
76
+ The default large media threshold is 10 MB. Large audio/video files are skipped unless `--include-large-media` is passed. When a file is skipped, the helper commits a manifest under:
77
+
78
+ ```text
79
+ hyper-animator-output/git-checkpoints/<phase>-skipped-media.json
80
+ ```
81
+
82
+ ## Hard Rules
83
+
84
+ - Do not run `git switch`, `git checkout -b`, `git branch`, or `git worktree add`.
85
+ - Do not create or recommend branches or worktrees for `/hyper-animator` output.
86
+ - Do not commit MiniMax credentials, `.firecrawl/`, `node_modules/`, or render caches.
87
+ - If unrelated dirty files are reported, stop and ask the user before continuing.
@@ -0,0 +1,54 @@
1
+ # Preview Controls Workflow
2
+
3
+ Use this whenever a generated or assembled HyperFrames HTML animation will be shown to the user for preview before video render.
4
+
5
+ ## Required Behavior
6
+
7
+ HTML previews should include a bottom page indicator and thin progress bar. Users can drag the progress bar to seek, or press ArrowLeft and ArrowRight to move between pages.
8
+
9
+ Video renders must hide those controls. Use one of these render-hidden signals:
10
+
11
+ - `?render=1`
12
+ - `?preview=0`
13
+ - `<html data-render-mode="video">`
14
+
15
+ ## Page Model
16
+
17
+ Prefer explicit page starts:
18
+
19
+ ```html
20
+ data-preview-pages="0,3,6,9"
21
+ ```
22
+
23
+ If exact page starts are not known, use:
24
+
25
+ ```html
26
+ data-preview-page-count="4"
27
+ ```
28
+
29
+ If neither attribute exists, the injector falls back to one page.
30
+
31
+ ## Generate Preview HTML
32
+
33
+ After the base HTML passes normal quality gates, create a preview copy:
34
+
35
+ ```bash
36
+ node scripts/inject_preview_controls.mjs composition.html -o composition.preview.html --force
37
+ python3 scripts/validate_hyperframes_html.py composition.preview.html --preview-controls
38
+ ```
39
+
40
+ Show the preview copy to the user for validation.
41
+
42
+ ## Render HTML
43
+
44
+ Prefer rendering the base `composition.html` without preview controls. If the renderer must use the preview copy, render it with `?render=1`, `?preview=0`, or `<html data-render-mode="video">` so controls are hidden.
45
+
46
+ ## Handoff Summary
47
+
48
+ Before rendering, report:
49
+
50
+ - base HTML path;
51
+ - preview HTML path;
52
+ - whether preview controls were injected;
53
+ - page count source: `data-preview-pages`, `data-preview-page-count`, or one-page fallback;
54
+ - render-hidden signal to be used.
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+
6
+ const FALLBACK_USER_NAME = "Hyper Animator Codex";
7
+ const FALLBACK_USER_EMAIL = "hyper-animator-codex@local";
8
+ const DEFAULT_MAX_BINARY_MB = 10;
9
+ const SKIPPED_MANIFEST_DIR = join("hyper-animator-output", "git-checkpoints");
10
+ const MEDIA_EXTENSIONS = new Set([
11
+ ".mp4",
12
+ ".mov",
13
+ ".webm",
14
+ ".mkv",
15
+ ".avi",
16
+ ".m4v",
17
+ ".mp3",
18
+ ".wav",
19
+ ".m4a",
20
+ ".aac",
21
+ ".flac",
22
+ ".ogg",
23
+ ".wma",
24
+ ]);
25
+ const DEFAULT_EXCLUDES = [
26
+ ".firecrawl/",
27
+ ".gitconfig-global",
28
+ ".git/",
29
+ "node_modules/",
30
+ "config/minimax.json",
31
+ "skills/hyper-animator-codex/config/minimax.json",
32
+ "tmp/",
33
+ ".tmp/",
34
+ ];
35
+
36
+ let repositoryRootCache = null;
37
+ let gitPrefixCache = null;
38
+
39
+ function requireValue(argv, index, flag) {
40
+ const value = argv[index + 1];
41
+
42
+ if (!value || value.startsWith("--")) {
43
+ throw new Error(`${flag} requires a value`);
44
+ }
45
+
46
+ return value;
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ const options = {
51
+ phase: "",
52
+ message: "",
53
+ allow: [],
54
+ exclude: [],
55
+ maxBinaryMb: DEFAULT_MAX_BINARY_MB,
56
+ includeLargeMedia: false,
57
+ allowEmpty: false,
58
+ dryRun: false,
59
+ };
60
+
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const arg = argv[index];
63
+
64
+ if (arg === "--phase") {
65
+ options.phase = requireValue(argv, index, arg);
66
+ index += 1;
67
+ } else if (arg === "--message") {
68
+ options.message = requireValue(argv, index, arg);
69
+ index += 1;
70
+ } else if (arg === "--allow") {
71
+ options.allow.push(requireValue(argv, index, arg));
72
+ index += 1;
73
+ } else if (arg === "--exclude") {
74
+ options.exclude.push(requireValue(argv, index, arg));
75
+ index += 1;
76
+ } else if (arg === "--max-binary-mb") {
77
+ options.maxBinaryMb = Number(requireValue(argv, index, arg));
78
+ index += 1;
79
+ } else if (arg === "--include-large-media") {
80
+ options.includeLargeMedia = true;
81
+ } else if (arg === "--allow-empty") {
82
+ options.allowEmpty = true;
83
+ } else if (arg === "--dry-run") {
84
+ options.dryRun = true;
85
+ } else {
86
+ throw new Error(`Unknown option: ${arg}`);
87
+ }
88
+ }
89
+
90
+ if (!options.phase) {
91
+ throw new Error("--phase is required");
92
+ }
93
+
94
+ if (!options.message) {
95
+ throw new Error("--message is required");
96
+ }
97
+
98
+ if (!Number.isFinite(options.maxBinaryMb) || options.maxBinaryMb < 0) {
99
+ throw new Error("--max-binary-mb must be a non-negative number");
100
+ }
101
+
102
+ return options;
103
+ }
104
+
105
+ function git(args, { check = true } = {}) {
106
+ const result = spawnSync("git", args, {
107
+ cwd: process.cwd(),
108
+ encoding: "utf8",
109
+ });
110
+
111
+ if (check && result.status !== 0) {
112
+ const detail = result.stderr.trim() || result.stdout.trim() || `git ${args.join(" ")}`;
113
+ throw new Error(detail);
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ function normalizeGitPath(value) {
120
+ return value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
121
+ }
122
+
123
+ function repositoryRoot() {
124
+ if (repositoryRootCache !== null) {
125
+ return repositoryRootCache;
126
+ }
127
+
128
+ const result = git(["rev-parse", "--show-toplevel"], { check: false });
129
+ repositoryRootCache = result.status === 0 && result.stdout.trim() ? result.stdout.trim() : process.cwd();
130
+
131
+ return repositoryRootCache;
132
+ }
133
+
134
+ function gitPrefix() {
135
+ if (gitPrefixCache !== null) {
136
+ return gitPrefixCache;
137
+ }
138
+
139
+ const result = git(["rev-parse", "--show-prefix"], { check: false });
140
+ gitPrefixCache = result.status === 0 ? normalizeGitPath(result.stdout) : "";
141
+
142
+ return gitPrefixCache;
143
+ }
144
+
145
+ function cwdRelativeToGitPath(path) {
146
+ const prefix = gitPrefix();
147
+
148
+ return normalizeGitPath(prefix ? join(prefix, path) : path);
149
+ }
150
+
151
+ function resolveGitDirPath(value) {
152
+ const trimmed = value.trim();
153
+ const absolute = isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
154
+
155
+ try {
156
+ return normalizeGitPath(realpathSync(absolute));
157
+ } catch {
158
+ return normalizeGitPath(absolute);
159
+ }
160
+ }
161
+
162
+ function isDefaultExcluded(path) {
163
+ const normalized = normalizeGitPath(path);
164
+
165
+ return DEFAULT_EXCLUDES.some((entry) => {
166
+ const cleaned = entry.endsWith("/") ? entry.slice(0, -1) : entry;
167
+ return normalized === cleaned || normalized.startsWith(`${cleaned}/`);
168
+ });
169
+ }
170
+
171
+ function isInsideGitRepo() {
172
+ const result = git(["rev-parse", "--is-inside-work-tree"], { check: false });
173
+
174
+ return result.status === 0 && result.stdout.trim() === "true";
175
+ }
176
+
177
+ function isLinkedWorktree() {
178
+ const gitDir = resolveGitDirPath(git(["rev-parse", "--git-dir"]).stdout);
179
+ const commonDir = resolveGitDirPath(git(["rev-parse", "--git-common-dir"]).stdout);
180
+
181
+ return gitDir !== commonDir;
182
+ }
183
+
184
+ function hasAnyCommit() {
185
+ return git(["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
186
+ }
187
+
188
+ function parsePorcelainLine(line) {
189
+ const path = line.slice(3).replace(/^"|"$/g, "");
190
+ const target = path.includes(" -> ") ? path.split(" -> ").pop() : path;
191
+
192
+ return normalizeGitPath(target);
193
+ }
194
+
195
+ function porcelainLines(pathspecs = []) {
196
+ const args = ["status", "--porcelain=v1", "--untracked-files=all"];
197
+
198
+ if (pathspecs.length > 0) {
199
+ args.push("--", ...pathspecs);
200
+ }
201
+
202
+ return git(args, { check: true }).stdout
203
+ .split("\n")
204
+ .map((line) => line.trimEnd())
205
+ .filter(Boolean);
206
+ }
207
+
208
+ function dirtyLines() {
209
+ return porcelainLines().filter((line) => !isDefaultExcluded(parsePorcelainLine(line)));
210
+ }
211
+
212
+ function allDirtyFiles(pathspecs = []) {
213
+ const seen = new Set();
214
+
215
+ return porcelainLines(pathspecs)
216
+ .map(parsePorcelainLine)
217
+ .filter((path) => !isDefaultExcluded(path))
218
+ .filter((path) => {
219
+ if (seen.has(path)) {
220
+ return false;
221
+ }
222
+
223
+ seen.add(path);
224
+ return true;
225
+ });
226
+ }
227
+
228
+ function trackedOrUntrackedMatching(pathspecs) {
229
+ return allDirtyFiles(pathspecs);
230
+ }
231
+
232
+ function stagedFiles() {
233
+ return git(["diff", "--cached", "--name-only", "--diff-filter=ACDMR"], { check: true }).stdout
234
+ .split("\n")
235
+ .map((line) => line.trim())
236
+ .filter(Boolean)
237
+ .map(normalizeGitPath);
238
+ }
239
+
240
+ function patternToRegExp(pattern) {
241
+ let escaped = "";
242
+
243
+ for (let index = 0; index < pattern.length; index += 1) {
244
+ const char = pattern[index];
245
+
246
+ if (char === "*") {
247
+ if (pattern[index + 1] === "*") {
248
+ escaped += ".*";
249
+ index += 1;
250
+ } else {
251
+ escaped += "[^/]*";
252
+ }
253
+ } else if (/[.+^${}()|[\]\\]/.test(char)) {
254
+ escaped += `\\${char}`;
255
+ } else {
256
+ escaped += char;
257
+ }
258
+ }
259
+
260
+ return new RegExp(`^${escaped}$`);
261
+ }
262
+
263
+ function isExplicitlyExcluded(path, patterns) {
264
+ return patterns.some((pattern) => {
265
+ const normalized = normalizeGitPath(pattern);
266
+
267
+ if (normalized.endsWith("/")) {
268
+ return path.startsWith(normalized);
269
+ }
270
+
271
+ if (normalized.includes("*")) {
272
+ return patternToRegExp(normalized).test(path);
273
+ }
274
+
275
+ return path === normalized;
276
+ });
277
+ }
278
+
279
+ function ensureIdentity() {
280
+ const name = git(["config", "--get", "user.name"], { check: false }).stdout.trim();
281
+ const email = git(["config", "--get", "user.email"], { check: false }).stdout.trim();
282
+
283
+ if (!name) {
284
+ git(["config", "--local", "user.name", FALLBACK_USER_NAME]);
285
+ }
286
+
287
+ if (!email) {
288
+ git(["config", "--local", "user.email", FALLBACK_USER_EMAIL]);
289
+ }
290
+ }
291
+
292
+ function extensionOf(path) {
293
+ const index = path.lastIndexOf(".");
294
+
295
+ return index === -1 ? "" : path.slice(index).toLowerCase();
296
+ }
297
+
298
+ function fileSizeBytes(path) {
299
+ const fsPath = isAbsolute(path) ? path : join(repositoryRoot(), path);
300
+
301
+ try {
302
+ return statSync(fsPath).size;
303
+ } catch (error) {
304
+ if (error?.code === "ENOENT") {
305
+ return 0;
306
+ }
307
+
308
+ throw error;
309
+ }
310
+ }
311
+
312
+ function isLargeMedia(path, maxBinaryMb) {
313
+ return MEDIA_EXTENSIONS.has(extensionOf(path)) && fileSizeBytes(path) > maxBinaryMb * 1024 * 1024;
314
+ }
315
+
316
+ function writeSkippedMediaManifest(phase, skipped) {
317
+ if (skipped.length === 0) {
318
+ return null;
319
+ }
320
+
321
+ mkdirSync(SKIPPED_MANIFEST_DIR, { recursive: true });
322
+ const manifestPath = join(SKIPPED_MANIFEST_DIR, `${phase}-skipped-media.json`);
323
+ const manifest = {
324
+ phase,
325
+ skipped: skipped.map((path) => ({
326
+ path,
327
+ size_bytes: fileSizeBytes(path),
328
+ reason: "larger than max-binary-mb",
329
+ })),
330
+ };
331
+
332
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
333
+ return cwdRelativeToGitPath(manifestPath);
334
+ }
335
+
336
+ function readKnownSkippedMedia() {
337
+ const known = new Set();
338
+
339
+ if (!existsSync(SKIPPED_MANIFEST_DIR)) {
340
+ return known;
341
+ }
342
+
343
+ for (const entry of readdirSync(SKIPPED_MANIFEST_DIR, { withFileTypes: true })) {
344
+ if (!entry.isFile() || !entry.name.endsWith("-skipped-media.json")) {
345
+ continue;
346
+ }
347
+
348
+ const manifestPath = join(SKIPPED_MANIFEST_DIR, entry.name);
349
+ let manifest;
350
+
351
+ try {
352
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
353
+ } catch {
354
+ continue;
355
+ }
356
+
357
+ if (!Array.isArray(manifest?.skipped)) {
358
+ continue;
359
+ }
360
+
361
+ for (const item of manifest.skipped) {
362
+ const path = typeof item === "string" ? item : item?.path;
363
+
364
+ if (typeof path === "string" && path.trim()) {
365
+ known.add(normalizeGitPath(path));
366
+ }
367
+ }
368
+ }
369
+
370
+ return known;
371
+ }
372
+
373
+ function stageFiles(paths) {
374
+ if (paths.length > 0) {
375
+ git(["add", "--", ...paths.map((path) => `:(top)${normalizeGitPath(path)}`)]);
376
+ }
377
+ }
378
+
379
+ function planStageFiles(options) {
380
+ const dirty = allDirtyFiles();
381
+ const selected = trackedOrUntrackedMatching(options.allow);
382
+ const selectedSet = new Set(selected);
383
+ const knownSkippedMedia = readKnownSkippedMedia();
384
+ const explicitExcluded = new Set();
385
+ const stageable = [];
386
+ const skippedMedia = [];
387
+
388
+ for (const path of dirty) {
389
+ if (isExplicitlyExcluded(path, options.exclude)) {
390
+ explicitExcluded.add(path);
391
+ }
392
+ }
393
+
394
+ for (const path of selected) {
395
+ if (isDefaultExcluded(path) || isExplicitlyExcluded(path, options.exclude)) {
396
+ continue;
397
+ }
398
+
399
+ if (!options.includeLargeMedia && isLargeMedia(path, options.maxBinaryMb)) {
400
+ skippedMedia.push(path);
401
+ continue;
402
+ }
403
+
404
+ stageable.push(path);
405
+ }
406
+
407
+ const skippedSet = new Set(skippedMedia);
408
+ const unrelated = dirty.filter((path) => {
409
+ if (selectedSet.has(path) || explicitExcluded.has(path) || skippedSet.has(path)) {
410
+ return false;
411
+ }
412
+
413
+ return options.includeLargeMedia || !knownSkippedMedia.has(path) || !isLargeMedia(path, options.maxBinaryMb);
414
+ });
415
+
416
+ return { stageable, skippedMedia, unrelated };
417
+ }
418
+
419
+ function ensureOnlyPlannedFilesStaged(expectedPaths) {
420
+ const expected = new Set(expectedPaths.map(normalizeGitPath));
421
+ const unexpected = stagedFiles().filter((path) => !expected.has(path));
422
+
423
+ if (unexpected.length > 0) {
424
+ throw new Error(`Existing repository has staged files outside the planned set:\n${unexpected.join("\n")}`);
425
+ }
426
+ }
427
+
428
+ function createCommit(message, allowEmpty) {
429
+ const args = ["commit"];
430
+
431
+ if (allowEmpty) {
432
+ args.push("--allow-empty");
433
+ }
434
+
435
+ args.push("-m", message);
436
+
437
+ const result = git(args, { check: false });
438
+ const output = `${result.stdout}\n${result.stderr}`;
439
+
440
+ if (result.status !== 0 && /nothing to commit|no changes added/i.test(output)) {
441
+ return false;
442
+ }
443
+
444
+ if (result.status !== 0) {
445
+ throw new Error(output.trim() || `git ${args.join(" ")}`);
446
+ }
447
+
448
+ return true;
449
+ }
450
+
451
+ function main(argv = process.argv.slice(2)) {
452
+ const options = parseArgs(argv);
453
+
454
+ if (options.phase !== "init" && options.allow.length === 0) {
455
+ throw new Error("--allow is required for non-init phases");
456
+ }
457
+
458
+ if (options.dryRun) {
459
+ console.log(`dry run: phase=${options.phase} message=${options.message}`);
460
+ console.log(isInsideGitRepo() ? "dry run: existing Git repository detected" : "dry run: would initialize Git repository");
461
+ return;
462
+ }
463
+
464
+ const startedInsideRepo = isInsideGitRepo();
465
+
466
+ if (startedInsideRepo && isLinkedWorktree()) {
467
+ throw new Error("Refusing to run in a linked worktree. Use a normal repository directory for /hyper-animator.");
468
+ }
469
+
470
+ if (startedInsideRepo) {
471
+ if (options.phase === "init" && hasAnyCommit()) {
472
+ const dirty = dirtyLines();
473
+
474
+ if (dirty.length > 0) {
475
+ throw new Error(`Existing repository has uncommitted changes:\n${dirty.join("\n")}`);
476
+ }
477
+
478
+ ensureIdentity();
479
+ console.log(`checkpoint ${options.phase}: ${git(["rev-parse", "--short", "HEAD"]).stdout.trim()}`);
480
+ return;
481
+ }
482
+ } else {
483
+ git(["init"]);
484
+ }
485
+
486
+ ensureIdentity();
487
+
488
+ if (!hasAnyCommit()) {
489
+ const plan = planStageFiles({ ...options, allow: [] });
490
+ ensureOnlyPlannedFilesStaged(plan.stageable);
491
+ const manifestPath = writeSkippedMediaManifest(options.phase, plan.skippedMedia);
492
+ const stageable = manifestPath ? [...plan.stageable, manifestPath] : plan.stageable;
493
+ stageFiles(stageable);
494
+ ensureOnlyPlannedFilesStaged(stageable);
495
+ if (!createCommit(options.message, options.allowEmpty)) {
496
+ throw new Error("nothing to commit");
497
+ }
498
+ } else {
499
+ const plan = planStageFiles(options);
500
+
501
+ if (plan.unrelated.length > 0) {
502
+ throw new Error(`Existing repository has unrelated dirty files outside allowlist:\n${plan.unrelated.join("\n")}`);
503
+ }
504
+
505
+ ensureOnlyPlannedFilesStaged(plan.stageable);
506
+ const manifestPath = writeSkippedMediaManifest(options.phase, plan.skippedMedia);
507
+ const stageable = manifestPath ? [...plan.stageable, manifestPath] : plan.stageable;
508
+ stageFiles(stageable);
509
+ ensureOnlyPlannedFilesStaged(stageable);
510
+
511
+ if (!createCommit(options.message, options.allowEmpty)) {
512
+ throw new Error("nothing to commit");
513
+ }
514
+ }
515
+
516
+ console.log(`checkpoint ${options.phase}: ${git(["rev-parse", "--short", "HEAD"]).stdout.trim()}`);
517
+ }
518
+
519
+ try {
520
+ main();
521
+ } catch (error) {
522
+ console.error(`Error: ${error.message}`);
523
+ process.exitCode = 1;
524
+ }
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+
4
+ const MARKER = "<!-- hyper-animator-preview-controls:start -->";
5
+ const PREVIEW_UI_MARKER = /<[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*?)?\sdata-hyper-preview-ui(?:\s|=|\/?>)/i;
6
+
7
+ function parseArgs(args) {
8
+ const parsed = { input: undefined, output: undefined, force: false, componentId: undefined };
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index];
11
+ if (arg === "-o" || arg === "--output") {
12
+ parsed.output = requireValue(args, index, arg);
13
+ index += 1;
14
+ } else if (arg === "--force") {
15
+ parsed.force = true;
16
+ } else if (arg === "--component-id") {
17
+ parsed.componentId = requireValue(args, index, arg);
18
+ index += 1;
19
+ } else if (arg === "--help" || arg === "-h") {
20
+ parsed.help = true;
21
+ } else if (!parsed.input) {
22
+ parsed.input = arg;
23
+ } else {
24
+ throw new Error(`Unknown argument: ${arg}`);
25
+ }
26
+ }
27
+ return parsed;
28
+ }
29
+
30
+ function requireValue(args, index, flag) {
31
+ const value = args[index + 1];
32
+ if (!value || value.startsWith("-")) {
33
+ throw new Error(`${flag} requires a value`);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ function printHelp() {
39
+ console.log(`Usage:
40
+ node scripts/inject_preview_controls.mjs input.html [-o preview.html] [--force] [--component-id <id>]
41
+
42
+ Options:
43
+ -o, --output <file> Write preview HTML to a separate file
44
+ --force Allow overwriting the output file
45
+ --component-id <id> Use a specific data-composition-id for timeline seeking
46
+ `);
47
+ }
48
+
49
+ async function pathExists(path) {
50
+ try {
51
+ await stat(path);
52
+ return true;
53
+ } catch (error) {
54
+ if (error && error.code === "ENOENT") return false;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ function findCompositionId(html, explicitId) {
60
+ if (explicitId) return explicitId;
61
+ const match = html.match(/data-composition-id\s*=\s*["']([^"']+)["']/i);
62
+ if (!match) {
63
+ throw new Error("Cannot inject preview controls: missing data-composition-id. Pass --component-id to override.");
64
+ }
65
+ return match[1];
66
+ }
67
+
68
+ function serializeJsString(value) {
69
+ return JSON.stringify(value)
70
+ .replace(/</g, "\\u003C")
71
+ .replace(/\u2028/g, "\\u2028")
72
+ .replace(/\u2029/g, "\\u2029");
73
+ }
74
+
75
+ function findTagEnd(html, startIndex) {
76
+ let quote = null;
77
+ for (let index = startIndex + 1; index < html.length; index += 1) {
78
+ const char = html[index];
79
+ if (quote) {
80
+ if (char === quote) {
81
+ quote = null;
82
+ }
83
+ continue;
84
+ }
85
+ if (char === '"' || char === "'") {
86
+ quote = char;
87
+ continue;
88
+ }
89
+ if (char === ">") {
90
+ return index;
91
+ }
92
+ }
93
+ return -1;
94
+ }
95
+
96
+ function scanLastClosingBodyTagIndex(html) {
97
+ const lower = html.toLowerCase();
98
+ let index = 0;
99
+ let lastClosingBodyTagIndex = -1;
100
+ let rawTextTag = "";
101
+
102
+ while (index < html.length) {
103
+ if (rawTextTag) {
104
+ const closingTag = `</${rawTextTag}`;
105
+ const closingTagIndex = lower.indexOf(closingTag, index);
106
+ if (closingTagIndex === -1) {
107
+ return lastClosingBodyTagIndex;
108
+ }
109
+ const closingTagEndIndex = findTagEnd(html, closingTagIndex);
110
+ if (closingTagEndIndex === -1) {
111
+ return lastClosingBodyTagIndex;
112
+ }
113
+ index = closingTagEndIndex + 1;
114
+ rawTextTag = "";
115
+ continue;
116
+ }
117
+
118
+ const nextTagIndex = html.indexOf("<", index);
119
+ if (nextTagIndex === -1) {
120
+ return lastClosingBodyTagIndex;
121
+ }
122
+
123
+ if (lower.startsWith("<!--", nextTagIndex)) {
124
+ const commentEndIndex = lower.indexOf("-->", nextTagIndex + 4);
125
+ if (commentEndIndex === -1) {
126
+ return lastClosingBodyTagIndex;
127
+ }
128
+ index = commentEndIndex + 3;
129
+ continue;
130
+ }
131
+
132
+ const tagEndIndex = findTagEnd(html, nextTagIndex);
133
+ if (tagEndIndex === -1) {
134
+ return lastClosingBodyTagIndex;
135
+ }
136
+
137
+ const tagContent = lower.slice(nextTagIndex + 1, tagEndIndex).trimStart();
138
+ if (/^\/\s*body\b/.test(tagContent)) {
139
+ lastClosingBodyTagIndex = nextTagIndex;
140
+ } else if (/^(script|style|textarea|title)\b/.test(tagContent)) {
141
+ rawTextTag = tagContent.match(/^(script|style|textarea|title)\b/)[1];
142
+ }
143
+
144
+ index = tagEndIndex + 1;
145
+ }
146
+
147
+ return lastClosingBodyTagIndex;
148
+ }
149
+
150
+ function findLastClosingBodyTagIndex(html) {
151
+ return scanLastClosingBodyTagIndex(html);
152
+ }
153
+
154
+ function injectPreviewControls(html, componentId) {
155
+ if (PREVIEW_UI_MARKER.test(html)) {
156
+ return html;
157
+ }
158
+
159
+ const escapedId = serializeJsString(componentId);
160
+ const controls = `<!-- hyper-animator-preview-controls:start -->
161
+ <style data-hyper-preview-style>
162
+ .hyper-preview-ui {
163
+ position: fixed;
164
+ left: 0;
165
+ right: 0;
166
+ bottom: 0;
167
+ z-index: 2147483647;
168
+ display: grid;
169
+ grid-template-columns: auto minmax(0, 1fr);
170
+ align-items: center;
171
+ gap: 10px;
172
+ padding: 6px 10px 8px;
173
+ color: rgba(255, 255, 255, 0.86);
174
+ font: 12px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
175
+ pointer-events: none;
176
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.34), rgba(0, 0, 0, 0));
177
+ }
178
+ .hyper-preview-ui[hidden] {
179
+ display: none !important;
180
+ }
181
+ .hyper-preview-page {
182
+ min-width: 42px;
183
+ text-align: center;
184
+ font-variant-numeric: tabular-nums lining-nums;
185
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
186
+ }
187
+ .hyper-preview-progress {
188
+ width: 100%;
189
+ height: 3px;
190
+ accent-color: rgba(255, 255, 255, 0.92);
191
+ cursor: pointer;
192
+ pointer-events: auto;
193
+ }
194
+ </style>
195
+ <div class="hyper-preview-ui" data-hyper-preview-ui hidden aria-label="Animation preview controls">
196
+ <div class="hyper-preview-page" data-hyper-preview-page aria-live="polite">1 / 1</div>
197
+ <input class="hyper-preview-progress" data-hyper-preview-progress type="range" min="0" max="1000" step="1" value="0" aria-label="Preview progress" />
198
+ </div>
199
+ <script data-hyper-preview-script>
200
+ (function () {
201
+ const componentId = ${escapedId};
202
+ const params = new URLSearchParams(window.location.search);
203
+ const renderMode = params.get("render") === "1" || params.get("preview") === "0" || document.documentElement.dataset.renderMode === "video";
204
+ const root = document.querySelector("[data-hyper-preview-ui]");
205
+ if (!root) return;
206
+ if (renderMode) {
207
+ root.hidden = true;
208
+ root.style.display = "none";
209
+ return;
210
+ }
211
+
212
+ const pageLabel = root.querySelector("[data-hyper-preview-page]");
213
+ const progress = root.querySelector("[data-hyper-preview-progress]");
214
+ const composition = findComposition();
215
+ const duration = readDuration(composition);
216
+ const pages = readPages(composition, duration);
217
+ let pageIndex = 0;
218
+
219
+ root.hidden = false;
220
+ root.style.display = "";
221
+ updateForTime(0);
222
+
223
+ progress.addEventListener("input", function () {
224
+ const seconds = (Number(progress.value) / Number(progress.max)) * duration;
225
+ seek(seconds);
226
+ updateForTime(seconds);
227
+ });
228
+
229
+ document.addEventListener("keydown", function (event) {
230
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || renderMode) return;
231
+ if (event.key === "ArrowLeft") {
232
+ event.preventDefault();
233
+ goToPage(Math.max(0, pageIndex - 1));
234
+ } else if (event.key === "ArrowRight") {
235
+ event.preventDefault();
236
+ goToPage(Math.min(pages.length - 1, pageIndex + 1));
237
+ }
238
+ });
239
+
240
+ function readDuration(element) {
241
+ const raw = element ? Number(element.dataset.duration) : NaN;
242
+ const timelineDuration = getTimeline() && typeof getTimeline().duration === "function" ? Number(getTimeline().duration()) : NaN;
243
+ const value = Number.isFinite(raw) && raw > 0 ? raw : timelineDuration;
244
+ return Number.isFinite(value) && value > 0 ? value : 1;
245
+ }
246
+
247
+ function readPages(element, totalDuration) {
248
+ if (element && element.dataset.previewPages) {
249
+ const parsed = element.dataset.previewPages.split(",").map((value) => Number(value.trim())).filter((value) => Number.isFinite(value) && value >= 0 && value <= totalDuration);
250
+ if (parsed.length > 0) return parsed;
251
+ }
252
+ const count = element && Number.parseInt(element.dataset.previewPageCount || "", 10);
253
+ if (Number.isFinite(count) && count > 1) {
254
+ return Array.from({ length: count }, (_, index) => (totalDuration / count) * index);
255
+ }
256
+ return [0];
257
+ }
258
+
259
+ function getTimeline() {
260
+ return window.__timelines && window.__timelines[componentId];
261
+ }
262
+
263
+ function findComposition() {
264
+ const compositions = document.querySelectorAll("[data-composition-id]");
265
+ for (const element of compositions) {
266
+ if (element.dataset.compositionId === componentId) {
267
+ return element;
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+
273
+ function seek(seconds) {
274
+ const timeline = getTimeline();
275
+ if (!timeline) return;
276
+ if (typeof timeline.time === "function") {
277
+ timeline.time(Math.max(0, Math.min(duration, seconds)));
278
+ } else if (typeof timeline.progress === "function") {
279
+ timeline.progress(Math.max(0, Math.min(1, seconds / duration)));
280
+ }
281
+ }
282
+
283
+ function goToPage(nextIndex) {
284
+ pageIndex = nextIndex;
285
+ const seconds = pages[pageIndex] || 0;
286
+ seek(seconds);
287
+ updateForTime(seconds);
288
+ }
289
+
290
+ function updateForTime(seconds) {
291
+ pageIndex = activePageIndex(seconds);
292
+ pageLabel.textContent = String(pageIndex + 1) + " / " + String(pages.length);
293
+ progress.value = String(Math.round((Math.max(0, Math.min(duration, seconds)) / duration) * Number(progress.max)));
294
+ }
295
+
296
+ function activePageIndex(seconds) {
297
+ let active = 0;
298
+ for (let index = 0; index < pages.length; index += 1) {
299
+ if (seconds + 0.0001 >= pages[index]) active = index;
300
+ }
301
+ return active;
302
+ }
303
+ })();
304
+ </script>
305
+ <!-- hyper-animator-preview-controls:end -->`;
306
+
307
+ const closingBodyTagIndex = findLastClosingBodyTagIndex(html);
308
+ if (closingBodyTagIndex !== -1) {
309
+ return `${html.slice(0, closingBodyTagIndex)}${controls}\n${html.slice(closingBodyTagIndex)}`;
310
+ }
311
+ return `${html}\n${controls}\n`;
312
+ }
313
+
314
+ async function main() {
315
+ const options = parseArgs(process.argv.slice(2));
316
+ if (options.help) {
317
+ printHelp();
318
+ return;
319
+ }
320
+ if (!options.input) {
321
+ throw new Error("input.html is required");
322
+ }
323
+
324
+ const output = options.output || options.input;
325
+ if (options.output && !options.force && await pathExists(output)) {
326
+ throw new Error(`Output already exists: ${output}. Re-run with --force to overwrite.`);
327
+ }
328
+
329
+ const html = await readFile(options.input, "utf8");
330
+ const componentId = findCompositionId(html, options.componentId);
331
+ await writeFile(output, injectPreviewControls(html, componentId), "utf8");
332
+ console.log(`Preview controls written to: ${output}`);
333
+ }
334
+
335
+ main().catch((error) => {
336
+ console.error(`Error: ${error.message}`);
337
+ process.exitCode = 1;
338
+ });
@@ -13,7 +13,12 @@ def has_attr(html: str, name: str) -> bool:
13
13
  return re.search(rf"\b{name}\s*=\s*['\"]?[^'\"\s>]+", html, re.IGNORECASE) is not None
14
14
 
15
15
 
16
- def check_html(html: str, *, component: bool = False) -> tuple[list[str], list[str]]:
16
+ def check_html(
17
+ html: str,
18
+ *,
19
+ component: bool = False,
20
+ preview_controls: bool = False,
21
+ ) -> tuple[list[str], list[str]]:
17
22
  failures: list[str] = []
18
23
  warnings: list[str] = []
19
24
 
@@ -51,6 +56,24 @@ def check_html(html: str, *, component: bool = False) -> tuple[list[str], list[s
51
56
  if len(re.findall(r"data-duration\s*=\s*['\"]?(\d+(?:\.\d+)?)", html)) > 1:
52
57
  warnings.append("multiple data-duration values found; verify the intended render duration")
53
58
 
59
+ if preview_controls:
60
+ if "data-hyper-preview-ui" not in html:
61
+ failures.append("missing preview controls: data-hyper-preview-ui")
62
+ if "data-hyper-preview-page" not in html:
63
+ failures.append("missing preview controls: page indicator")
64
+ if "data-hyper-preview-progress" not in html:
65
+ failures.append("missing preview controls: progress input")
66
+ if not re.search(r"<input[^>]+type=[\"']range[\"']", html, re.IGNORECASE | re.DOTALL):
67
+ failures.append("missing preview controls: range input")
68
+ if "ArrowLeft" not in html or "ArrowRight" not in html:
69
+ failures.append("missing preview controls: left/right keyboard handlers")
70
+ if 'params.get("render") === "1"' not in html:
71
+ failures.append("missing preview controls: ?render=1 hidden-mode check")
72
+ if 'params.get("preview") === "0"' not in html:
73
+ failures.append("missing preview controls: ?preview=0 hidden-mode check")
74
+ if 'dataset.renderMode === "video"' not in html:
75
+ failures.append("missing preview controls: data-render-mode video hidden-mode check")
76
+
54
77
  return failures, warnings
55
78
 
56
79
 
@@ -62,6 +85,11 @@ def main() -> int:
62
85
  action="store_true",
63
86
  help="Validate a component snippet instead of a full block composition",
64
87
  )
88
+ parser.add_argument(
89
+ "--preview-controls",
90
+ action="store_true",
91
+ help="Require injected Hyper Animator preview controls",
92
+ )
65
93
  args = parser.parse_args()
66
94
 
67
95
  path = Path(args.html_file)
@@ -70,7 +98,11 @@ def main() -> int:
70
98
  return 2
71
99
 
72
100
  html = path.read_text(encoding="utf-8")
73
- failures, warnings = check_html(html, component=args.component)
101
+ failures, warnings = check_html(
102
+ html,
103
+ component=args.component,
104
+ preview_controls=args.preview_controls,
105
+ )
74
106
 
75
107
  for warning in warnings:
76
108
  print(f"WARN: {warning}", file=sys.stderr)