hyperframes 0.4.23 → 0.4.25

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.
@@ -0,0 +1,423 @@
1
+ (function () {
2
+ const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
3
+
4
+ function toRect(rect) {
5
+ return {
6
+ left: round(rect.left),
7
+ top: round(rect.top),
8
+ right: round(rect.right),
9
+ bottom: round(rect.bottom),
10
+ width: round(rect.width),
11
+ height: round(rect.height),
12
+ };
13
+ }
14
+
15
+ function rectFromOrigin(left, top, width, height) {
16
+ return {
17
+ left: round(left),
18
+ top: round(top),
19
+ right: round(left + width),
20
+ bottom: round(top + height),
21
+ width: round(width),
22
+ height: round(height),
23
+ };
24
+ }
25
+
26
+ function round(value) {
27
+ return Math.round(value * 100) / 100;
28
+ }
29
+
30
+ function overflowFor(subject, container, tolerance) {
31
+ const overflow = {};
32
+ if (subject.left < container.left - tolerance)
33
+ overflow.left = round(container.left - subject.left);
34
+ if (subject.right > container.right + tolerance)
35
+ overflow.right = round(subject.right - container.right);
36
+ if (subject.top < container.top - tolerance) overflow.top = round(container.top - subject.top);
37
+ if (subject.bottom > container.bottom + tolerance)
38
+ overflow.bottom = round(subject.bottom - container.bottom);
39
+ return Object.keys(overflow).length > 0 ? overflow : null;
40
+ }
41
+
42
+ function escapeCss(value) {
43
+ if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
44
+ return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
45
+ }
46
+
47
+ function escapeAttr(value) {
48
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
49
+ }
50
+
51
+ function selectorFor(element) {
52
+ if (element.id) return `#${escapeCss(element.id)}`;
53
+ const dataName =
54
+ element.getAttribute("data-layout-name") ||
55
+ element.getAttribute("data-composition-id") ||
56
+ element.getAttribute("data-start");
57
+ if (dataName) {
58
+ const attr = element.hasAttribute("data-layout-name")
59
+ ? "data-layout-name"
60
+ : element.hasAttribute("data-composition-id")
61
+ ? "data-composition-id"
62
+ : "data-start";
63
+ const attrSelector = `[${attr}="${escapeAttr(dataName)}"]`;
64
+ if (document.querySelectorAll(attrSelector).length === 1) return attrSelector;
65
+ return `${element.tagName.toLowerCase()}${attrSelector}`;
66
+ }
67
+ const classes = Array.from(element.classList).slice(0, 2);
68
+ if (classes.length > 0) {
69
+ return `${element.tagName.toLowerCase()}.${classes.map(escapeCss).join(".")}`;
70
+ }
71
+ const parent = element.parentElement;
72
+ if (!parent) return element.tagName.toLowerCase();
73
+ const siblings = Array.from(parent.children).filter(
74
+ (child) => child.tagName === element.tagName,
75
+ );
76
+ const index = siblings.indexOf(element) + 1;
77
+ return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
78
+ }
79
+
80
+ function hasIgnoreFlag(element) {
81
+ return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
82
+ }
83
+
84
+ function hasAllowOverflowFlag(element) {
85
+ return !!element.closest("[data-layout-allow-overflow]");
86
+ }
87
+
88
+ function opacityChain(element) {
89
+ let opacity = 1;
90
+ for (let current = element; current; current = current.parentElement) {
91
+ const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
92
+ if (Number.isFinite(parsed)) opacity *= parsed;
93
+ }
94
+ return opacity;
95
+ }
96
+
97
+ function isVisibleElement(element) {
98
+ if (IGNORE_TAGS.has(element.tagName)) return false;
99
+ if (hasIgnoreFlag(element)) return false;
100
+ const style = getComputedStyle(element);
101
+ if (
102
+ style.display === "none" ||
103
+ style.visibility === "hidden" ||
104
+ style.visibility === "collapse"
105
+ ) {
106
+ return false;
107
+ }
108
+ if (opacityChain(element) < 0.2) return false;
109
+ const rect = element.getBoundingClientRect();
110
+ return rect.width > 0.5 && rect.height > 0.5;
111
+ }
112
+
113
+ function textContentFor(element) {
114
+ return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim();
115
+ }
116
+
117
+ function hasOwnTextCandidate(element) {
118
+ const text = textContentFor(element);
119
+ if (!text) return false;
120
+ for (const child of Array.from(element.children)) {
121
+ if (isVisibleElement(child) && textContentFor(child)) return false;
122
+ }
123
+ return true;
124
+ }
125
+
126
+ function textRectFor(element) {
127
+ const range = document.createRange();
128
+ range.selectNodeContents(element);
129
+ const rects = Array.from(range.getClientRects()).filter(
130
+ (rect) => rect.width > 0.5 && rect.height > 0.5,
131
+ );
132
+ range.detach();
133
+ if (rects.length === 0) return null;
134
+
135
+ const union = rects.reduce(
136
+ (acc, rect) => ({
137
+ left: Math.min(acc.left, rect.left),
138
+ top: Math.min(acc.top, rect.top),
139
+ right: Math.max(acc.right, rect.right),
140
+ bottom: Math.max(acc.bottom, rect.bottom),
141
+ }),
142
+ {
143
+ left: Number.POSITIVE_INFINITY,
144
+ top: Number.POSITIVE_INFINITY,
145
+ right: Number.NEGATIVE_INFINITY,
146
+ bottom: Number.NEGATIVE_INFINITY,
147
+ },
148
+ );
149
+
150
+ return toRect({
151
+ ...union,
152
+ width: union.right - union.left,
153
+ height: union.bottom - union.top,
154
+ });
155
+ }
156
+
157
+ function parsePx(value) {
158
+ const parsed = Number.parseFloat(value);
159
+ return Number.isFinite(parsed) ? parsed : 0;
160
+ }
161
+
162
+ function hasMeaningfulBoxStyle(style) {
163
+ return (
164
+ parsePx(style.paddingTop) +
165
+ parsePx(style.paddingRight) +
166
+ parsePx(style.paddingBottom) +
167
+ parsePx(style.paddingLeft) +
168
+ parsePx(style.borderTopWidth) +
169
+ parsePx(style.borderRightWidth) +
170
+ parsePx(style.borderBottomWidth) +
171
+ parsePx(style.borderLeftWidth) +
172
+ parsePx(style.borderTopLeftRadius) +
173
+ parsePx(style.borderTopRightRadius) +
174
+ parsePx(style.borderBottomRightRadius) +
175
+ parsePx(style.borderBottomLeftRadius) >
176
+ 0
177
+ );
178
+ }
179
+
180
+ function hasPaint(style) {
181
+ const backgroundColor = style.backgroundColor || "";
182
+ const hasBackground =
183
+ backgroundColor !== "" &&
184
+ backgroundColor !== "transparent" &&
185
+ !backgroundColor.endsWith(", 0)") &&
186
+ backgroundColor !== "rgba(0, 0, 0, 0)";
187
+ const hasImage = style.backgroundImage && style.backgroundImage !== "none";
188
+ const hasBorder =
189
+ parsePx(style.borderTopWidth) +
190
+ parsePx(style.borderRightWidth) +
191
+ parsePx(style.borderBottomWidth) +
192
+ parsePx(style.borderLeftWidth) >
193
+ 0;
194
+ const hasRadius =
195
+ parsePx(style.borderTopLeftRadius) +
196
+ parsePx(style.borderTopRightRadius) +
197
+ parsePx(style.borderBottomRightRadius) +
198
+ parsePx(style.borderBottomLeftRadius) >
199
+ 0;
200
+ return hasBackground || hasImage || hasBorder || hasRadius;
201
+ }
202
+
203
+ function clipsOverflow(style) {
204
+ return [style.overflowX, style.overflowY, style.overflow].some(
205
+ (value) => value && value !== "visible" && value !== "clip visible",
206
+ );
207
+ }
208
+
209
+ function rootRectFor(root) {
210
+ const measured = toRect(root.getBoundingClientRect());
211
+ const authoredWidth = Number.parseFloat(root.getAttribute("data-width") || "");
212
+ const authoredHeight = Number.parseFloat(root.getAttribute("data-height") || "");
213
+ const hasAuthoredSize =
214
+ Number.isFinite(authoredWidth) &&
215
+ authoredWidth > 0 &&
216
+ Number.isFinite(authoredHeight) &&
217
+ authoredHeight > 0;
218
+
219
+ if (!hasAuthoredSize) return measured;
220
+ if (measured.width > 0.5 && measured.height > 0.5) return measured;
221
+ return rectFromOrigin(measured.left, measured.top, authoredWidth, authoredHeight);
222
+ }
223
+
224
+ function isConstraintCandidate(element, root, rootRect) {
225
+ if (element === root) return true;
226
+ const style = getComputedStyle(element);
227
+ if (clipsOverflow(style)) return true;
228
+ if (element.hasAttribute("data-layout-boundary")) return true;
229
+ if (!hasPaint(style)) return false;
230
+ if (!hasMeaningfulBoxStyle(style)) return false;
231
+ const rect = element.getBoundingClientRect();
232
+ const rootArea = rootRect.width * rootRect.height;
233
+ const area = rect.width * rect.height;
234
+ return area > 0 && area < rootArea * 0.95;
235
+ }
236
+
237
+ function nearestConstraint(element, root, rootRect) {
238
+ for (
239
+ let current = element;
240
+ current && current !== document.body;
241
+ current = current.parentElement
242
+ ) {
243
+ if (!isVisibleElement(current)) continue;
244
+ if (isConstraintCandidate(current, root, rootRect)) return current;
245
+ if (current === root) return current;
246
+ }
247
+ return root;
248
+ }
249
+
250
+ function formatPx(value) {
251
+ return `${Math.round(value)}px`;
252
+ }
253
+
254
+ function maxOverflow(overflow) {
255
+ return Math.max(...Object.values(overflow).filter((value) => typeof value === "number"));
256
+ }
257
+
258
+ function textOverflowFixHint(textRect, containerRect, overflow, fontSize, targetName) {
259
+ const horizontalOverflow = (overflow.left || 0) + (overflow.right || 0);
260
+ const verticalOverflow = (overflow.top || 0) + (overflow.bottom || 0);
261
+ const neededWidth = containerRect.width + horizontalOverflow;
262
+ const neededHeight = containerRect.height + verticalOverflow;
263
+ const widthRatio = containerRect.width > 0 ? containerRect.width / textRect.width : 0;
264
+ const heightRatio = containerRect.height > 0 ? containerRect.height / textRect.height : 0;
265
+ const limitingRatio = Math.min(
266
+ widthRatio > 0 ? widthRatio : Number.POSITIVE_INFINITY,
267
+ heightRatio > 0 ? heightRatio : Number.POSITIVE_INFINITY,
268
+ );
269
+ const shrinkPercent =
270
+ Number.isFinite(limitingRatio) && limitingRatio < 1
271
+ ? Math.ceil((1 - limitingRatio) * 100)
272
+ : 0;
273
+ const targetFont =
274
+ shrinkPercent > 0 && Number.isFinite(fontSize) && fontSize > 0
275
+ ? ` or shrink font-size from ${formatPx(fontSize)} to ~${formatPx(fontSize * limitingRatio)}`
276
+ : "";
277
+ const sizeTarget =
278
+ horizontalOverflow > 0 && verticalOverflow > 0
279
+ ? `resize ${targetName} to at least ~${formatPx(neededWidth)} x ${formatPx(neededHeight)}`
280
+ : horizontalOverflow > 0
281
+ ? `widen ${targetName} to at least ~${formatPx(neededWidth)}`
282
+ : `increase ${targetName} height to at least ~${formatPx(neededHeight)}`;
283
+
284
+ return `Text is ${formatPx(textRect.width)} x ${formatPx(textRect.height)} inside ${formatPx(containerRect.width)} x ${formatPx(containerRect.height)} and overflows by up to ${formatPx(maxOverflow(overflow))}; ${sizeTarget}${targetFont}, or allow wrapping with max-width/fitTextFontSize.`;
285
+ }
286
+
287
+ function clippedTextIssue(element, time, tolerance) {
288
+ const style = getComputedStyle(element);
289
+ if (!clipsOverflow(style)) return null;
290
+ const overflowX = element.scrollWidth - element.clientWidth;
291
+ const overflowY = element.scrollHeight - element.clientHeight;
292
+ if (overflowX <= tolerance && overflowY <= tolerance) return null;
293
+ const overflow = {};
294
+ if (overflowX > tolerance) overflow.right = round(overflowX);
295
+ if (overflowY > tolerance) overflow.bottom = round(overflowY);
296
+ const selector = selectorFor(element);
297
+ const text = textContentFor(element);
298
+ const rect = toRect(element.getBoundingClientRect());
299
+ const fontSize = parsePx(style.fontSize);
300
+ return {
301
+ code: "clipped_text",
302
+ severity: "error",
303
+ time,
304
+ selector,
305
+ text,
306
+ message: "Text content is clipped by its own box.",
307
+ rect,
308
+ overflow,
309
+ fixHint: textOverflowFixHint(rect, rect, overflow, fontSize, "the text box"),
310
+ };
311
+ }
312
+
313
+ function textOverflowIssues(element, root, rootRect, time, tolerance) {
314
+ const textRect = textRectFor(element);
315
+ if (!textRect) return [];
316
+ const text = textContentFor(element);
317
+ const selector = selectorFor(element);
318
+ const issues = [];
319
+
320
+ const container = nearestConstraint(element, root, rootRect);
321
+ const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect());
322
+ const containerOverflow = overflowFor(textRect, containerRect, tolerance);
323
+ if (containerOverflow && !hasAllowOverflowFlag(element)) {
324
+ const style = getComputedStyle(element);
325
+ issues.push({
326
+ code: "text_box_overflow",
327
+ severity: "error",
328
+ time,
329
+ selector,
330
+ containerSelector: selectorFor(container),
331
+ text,
332
+ message: "Text extends outside its nearest visual/container box.",
333
+ rect: textRect,
334
+ containerRect,
335
+ overflow: containerOverflow,
336
+ fixHint: textOverflowFixHint(
337
+ textRect,
338
+ containerRect,
339
+ containerOverflow,
340
+ parsePx(style.fontSize),
341
+ "the container",
342
+ ),
343
+ });
344
+ }
345
+
346
+ const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
347
+ if (canvasOverflow && !hasAllowOverflowFlag(element)) {
348
+ issues.push({
349
+ code: "canvas_overflow",
350
+ severity: "info",
351
+ time,
352
+ selector,
353
+ containerSelector: selectorFor(root),
354
+ text,
355
+ message: "Text extends outside the composition canvas.",
356
+ rect: textRect,
357
+ containerRect: rootRect,
358
+ overflow: canvasOverflow,
359
+ fixHint:
360
+ "Move the text inward, reduce its size, or mark intentional off-canvas animation with data-layout-allow-overflow.",
361
+ });
362
+ }
363
+
364
+ return issues;
365
+ }
366
+
367
+ function containerOverflowIssues(root, time, tolerance) {
368
+ const issues = [];
369
+ const containers = Array.from(root.querySelectorAll("*")).filter((element) => {
370
+ if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) return false;
371
+ const style = getComputedStyle(element);
372
+ return clipsOverflow(style) || element.hasAttribute("data-layout-boundary");
373
+ });
374
+
375
+ for (const container of containers) {
376
+ const containerRect = toRect(container.getBoundingClientRect());
377
+ for (const child of Array.from(container.children)) {
378
+ if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) continue;
379
+ const childRect = toRect(child.getBoundingClientRect());
380
+ const overflow = overflowFor(childRect, containerRect, tolerance);
381
+ if (!overflow) continue;
382
+ issues.push({
383
+ code: "container_overflow",
384
+ severity: "warning",
385
+ time,
386
+ selector: selectorFor(child),
387
+ containerSelector: selectorFor(container),
388
+ message: "Element extends outside a clipping layout container.",
389
+ rect: childRect,
390
+ containerRect,
391
+ overflow,
392
+ fixHint:
393
+ "Resize/reposition the child or container, or mark intentional overflow with data-layout-allow-overflow.",
394
+ });
395
+ }
396
+ }
397
+
398
+ return issues;
399
+ }
400
+
401
+ window.__hyperframesLayoutAudit = function auditLayout(options) {
402
+ const time = options && typeof options.time === "number" ? options.time : 0;
403
+ const tolerance =
404
+ options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2;
405
+ const root =
406
+ document.querySelector("[data-composition-id][data-width][data-height]") ||
407
+ document.querySelector("[data-composition-id]") ||
408
+ document.body;
409
+ const rootRect = rootRectFor(root);
410
+ const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement);
411
+ const issues = [];
412
+
413
+ for (const element of elements) {
414
+ if (!hasOwnTextCandidate(element)) continue;
415
+ const clipped = clippedTextIssue(element, time, tolerance);
416
+ if (clipped) issues.push(clipped);
417
+ issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
418
+ }
419
+
420
+ issues.push(...containerOverflowIssues(root, time, tolerance));
421
+ return issues;
422
+ };
423
+ })();
@@ -277,11 +277,28 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl
277
277
  ## Output Checklist
278
278
 
279
279
  - [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass
280
+ - [ ] `npx hyperframes inspect` passes, or every reported overflow is intentionally marked
280
281
  - [ ] Contrast warnings addressed (see Quality Checks below)
282
+ - [ ] Layout issues addressed (see Quality Checks below)
281
283
  - [ ] Animation choreography verified (see Quality Checks below)
282
284
 
283
285
  ## Quality Checks
284
286
 
287
+ ### Visual Inspect
288
+
289
+ `hyperframes inspect` runs the composition in headless Chrome, seeks through the timeline, and maps visual layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`:
290
+
291
+ ```bash
292
+ npx hyperframes inspect
293
+ npx hyperframes inspect --json
294
+ ```
295
+
296
+ Failures usually mean text is spilling out of a bubble/card, a fixed-size label is clipping dynamic copy, or text has moved off the canvas. Fix by increasing container size or padding, reducing font size or letter spacing, adding a real `max-width` so text wraps inside the container, or using `window.__hyperframes.fitTextFontSize(...)` for dynamic copy.
297
+
298
+ Use `--samples 15` for dense videos and `--at 1.5,4,7.25` for specific hero frames. Repeated static issues are collapsed by default to avoid flooding agent context. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`.
299
+
300
+ `hyperframes layout` is the compatibility alias for the same check.
301
+
285
302
  ### Contrast
286
303
 
287
304
  `hyperframes validate` runs a WCAG contrast audit by default. It seeks to 5 timestamps, screenshots the page, samples background pixels behind every text element, and computes contrast ratios. Failures appear as warnings:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hyperframes-cli
3
- description: HyperFrames CLI tool — hyperframes init, lint, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting or validating compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment.
3
+ description: HyperFrames CLI tool — hyperframes init, lint, inspect, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting, validating, inspecting visual layout in compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment.
4
4
  ---
5
5
 
6
6
  # HyperFrames CLI
@@ -12,10 +12,11 @@ Everything runs through `npx hyperframes`. Requires Node.js >= 22 and FFmpeg.
12
12
  1. **Scaffold** — `npx hyperframes init my-video`
13
13
  2. **Write** — author HTML composition (see the `hyperframes` skill)
14
14
  3. **Lint** — `npx hyperframes lint`
15
- 4. **Preview** — `npx hyperframes preview`
16
- 5. **Render** — `npx hyperframes render`
15
+ 4. **Visual inspect** — `npx hyperframes inspect`
16
+ 5. **Preview** — `npx hyperframes preview`
17
+ 6. **Render** — `npx hyperframes render`
17
18
 
18
- Lint before preview catches missing `data-composition-id`, overlapping tracks, unregistered timelines.
19
+ Lint and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `inspect` opens the rendered composition in headless Chrome, seeks through the timeline, and reports text spilling out of bubbles/containers or off the canvas.
19
20
 
20
21
  ## Scaffolding
21
22
 
@@ -42,6 +43,27 @@ npx hyperframes lint --json # machine-readable
42
43
 
43
44
  Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`).
44
45
 
46
+ ## Visual Inspect
47
+
48
+ ```bash
49
+ npx hyperframes inspect # inspect rendered layout over the timeline
50
+ npx hyperframes inspect ./my-project # specific project
51
+ npx hyperframes inspect --json # agent-readable findings
52
+ npx hyperframes inspect --samples 15 # denser timeline sweep
53
+ npx hyperframes inspect --at 1.5,4,7.25 # explicit hero-frame timestamps
54
+ ```
55
+
56
+ Use this after `lint` and `validate`, especially for compositions with speech bubbles, cards, captions, or tight typography. It reports:
57
+
58
+ - Text extending outside the nearest visual container or bubble
59
+ - Text clipped by its own fixed-width/fixed-height box
60
+ - Text extending outside the composition canvas
61
+ - Children escaping clipping containers
62
+
63
+ Errors should be fixed before rendering. Warnings are surfaced for agent review; add `--strict` to fail on warnings too. Repeated static issues are collapsed by default so JSON output stays compact for LLM context windows. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`.
64
+
65
+ `npx hyperframes layout` remains available as a compatibility alias for the same visual inspection pass.
66
+
45
67
  ## Previewing
46
68
 
47
69
  ```bash