hyper-animator-codex 0.3.0 → 0.4.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 +21 -0
- package/package.json +1 -1
- package/skills/hyper-animator-codex/SKILL.md +14 -3
- package/skills/hyper-animator-codex/references/preview-controls-workflow.md +54 -0
- package/skills/hyper-animator-codex/scripts/inject_preview_controls.mjs +338 -0
- package/skills/hyper-animator-codex/scripts/validate_hyperframes_html.py +34 -2
package/README.md
CHANGED
|
@@ -68,6 +68,27 @@ ${CODEX_HOME:-$HOME/.codex}/skills/hyper-animator-codex/config/minimax.json
|
|
|
68
68
|
|
|
69
69
|
Do not commit that installed config file. The npm package does not include local MiniMax credentials.
|
|
70
70
|
|
|
71
|
+
## HTML Preview Controls
|
|
72
|
+
|
|
73
|
+
Preview HTML can include a bottom page indicator and thin draggable progress bar:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
node skills/hyper-animator-codex/scripts/inject_preview_controls.mjs composition.html -o composition.preview.html --force
|
|
77
|
+
python3 skills/hyper-animator-codex/scripts/validate_hyperframes_html.py composition.preview.html --preview-controls
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The injected controls support dragging the progress bar and using ArrowLeft or ArrowRight to move between pages.
|
|
81
|
+
|
|
82
|
+
Use `data-preview-pages="0,3,6,9"` for explicit page starts, or `data-preview-page-count="4"` to divide the duration evenly.
|
|
83
|
+
|
|
84
|
+
When rendering video, hide preview controls with any one of:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
?render=1
|
|
88
|
+
?preview=0
|
|
89
|
+
<html data-render-mode="video">
|
|
90
|
+
```
|
|
91
|
+
|
|
71
92
|
## Contents
|
|
72
93
|
|
|
73
94
|
- `skills/hyper-animator-codex/SKILL.md`: Codex skill instructions.
|
package/package.json
CHANGED
|
@@ -23,9 +23,10 @@ Turn a natural-language animation or video brief into a validated HyperFrames HT
|
|
|
23
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
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
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.
|
|
28
|
-
13.
|
|
26
|
+
11. Run pre-render quality gates on the base HTML.
|
|
27
|
+
12. 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`.
|
|
28
|
+
13. Show a concise plan summary and preview path or HTML file to the user. Ask for confirmation before video render.
|
|
29
|
+
14. 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
30
|
|
|
30
31
|
## Interactive Questions
|
|
31
32
|
|
|
@@ -45,6 +46,7 @@ Do not ask everything upfront when the brief is already specific. Ask only for m
|
|
|
45
46
|
- Read `references/hyperframes-agent-pseudocode.ts` when implementing the end-to-end loop or when the correct sequence is ambiguous.
|
|
46
47
|
- Read `references/minimax-music-workflow.md` when generated background music, MiniMax, music prompt, instrumental/vocal choice, or provider fallback is mentioned.
|
|
47
48
|
- 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.
|
|
49
|
+
- Read `references/preview-controls-workflow.md` before showing HTML previews or rendering after preview approval.
|
|
48
50
|
- Use `references/examples/*.json` for sanity checks against common request shapes.
|
|
49
51
|
|
|
50
52
|
## Catalog Rules
|
|
@@ -76,6 +78,12 @@ Generated block HTML must include:
|
|
|
76
78
|
- no wall-clock `Date.now()` or `setInterval()` driving primary timeline progress;
|
|
77
79
|
- readable text at the target video dimensions.
|
|
78
80
|
|
|
81
|
+
Preview HTML must pass:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python3 scripts/validate_hyperframes_html.py path/to/composition.preview.html --preview-controls
|
|
85
|
+
```
|
|
86
|
+
|
|
79
87
|
If the validator fails, fix the HTML before asking the user to approve render.
|
|
80
88
|
|
|
81
89
|
## Render Handoff
|
|
@@ -87,6 +95,9 @@ Before rendering, summarize:
|
|
|
87
95
|
- sound effects and background music choices;
|
|
88
96
|
- MiniMax provider status, model, generated audio path, metadata path, and redacted config source when MiniMax is used;
|
|
89
97
|
- beat map path, BPM, duration, and timing assumptions when beat-sync is enabled;
|
|
98
|
+
- base HTML path and preview HTML path;
|
|
99
|
+
- preview controls injection status and page count source;
|
|
100
|
+
- render-hidden signal used to hide preview controls during video render;
|
|
90
101
|
- dimensions and duration;
|
|
91
102
|
- content assumptions;
|
|
92
103
|
- preview location;
|
|
@@ -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,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(
|
|
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(
|
|
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)
|