Skip to content

Commit 3ddd532

Browse files
committed
fix: composition issues in runtime code + producer parity
1 parent 20be2ea commit 3ddd532

9 files changed

Lines changed: 309 additions & 22 deletions

File tree

packages/core/src/lint/hyperframeLinter.ts

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,11 @@ export function lintHyperframeHtml(
276276
continue;
277277
}
278278
pushFinding({
279-
code: "suspicious_global_gsap_selector",
279+
code: "unscoped_gsap_selector",
280280
severity: "warning",
281-
message: `Timeline "${localTimelineCompId}" uses a global selector "${window.targetSelector}" that may escape composition scope.`,
281+
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
282282
selector: window.targetSelector,
283-
fixHint: `Scope the selector like \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
283+
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
284284
snippet: truncateSnippet(window.raw),
285285
});
286286
}
@@ -346,6 +346,75 @@ export function lintHyperframeHtml(
346346
}
347347
}
348348

349+
// #3.5: Self-closing <audio .../> or <video .../> — CRITICAL
350+
// In HTML5, <audio> and <video> are NOT void elements. The browser silently
351+
// ignores the "/>", leaving the tag open. All subsequent sibling elements
352+
// become invisible fallback content inside the media tag, making entire
353+
// compositions disappear. This is the #1 cause of "black preview" bugs.
354+
{
355+
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
356+
let scMatch: RegExpExecArray | null;
357+
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
358+
const tagName = scMatch[1] || "audio";
359+
const elementId = readAttr(scMatch[0], "id") || undefined;
360+
pushFinding({
361+
code: "self_closing_media_tag",
362+
severity: "error",
363+
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
364+
elementId,
365+
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
366+
snippet: truncateSnippet(scMatch[0]),
367+
});
368+
}
369+
}
370+
371+
// #3.6: Placeholder/fake media URLs — CRITICAL
372+
// Placeholder URLs (placehold.co, placeholder.com, example.com) will 404 at render time.
373+
{
374+
const PLACEHOLDER_DOMAINS = /\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
375+
for (const tag of tags) {
376+
if (!isMediaTag(tag.name)) continue;
377+
const src = readAttr(tag.raw, "src");
378+
if (!src) continue;
379+
if (PLACEHOLDER_DOMAINS.test(src)) {
380+
const elementId = readAttr(tag.raw, "id") || undefined;
381+
pushFinding({
382+
code: "placeholder_media_url",
383+
severity: "error",
384+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
385+
elementId,
386+
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
387+
snippet: truncateSnippet(tag.raw),
388+
});
389+
}
390+
}
391+
}
392+
393+
// #3.7: Fabricated inline base64 media — CRITICAL
394+
// Inline base64 audio/video data is almost always fabricated garbage that
395+
// won't play. Real audio files are 100KB+ when base64-encoded.
396+
{
397+
const base64MediaRe = /src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{100,}))["']/gi;
398+
let b64Match: RegExpExecArray | null;
399+
while ((b64Match = base64MediaRe.exec(source)) !== null) {
400+
// Check if it's suspiciously repetitive (fake data has long runs of repeated chars)
401+
const sample = (b64Match[2] || "").slice(0, 200);
402+
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
403+
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
404+
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
405+
// Any embedded base64 audio is suspicious — real audio should be a file
406+
pushFinding({
407+
code: "fabricated_inline_media",
408+
severity: isSuspicious ? "error" : "warning",
409+
message: isSuspicious
410+
? `Fabricated base64 media detected (${(dataSize / 1024).toFixed(0)} KB). This is almost certainly fake data that won't play.`
411+
: `Embedded base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB). Consider using a file URL instead.`,
412+
fixHint: "Remove the data: URI and use a real media file URL instead.",
413+
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
414+
});
415+
}
416+
}
417+
349418
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
350419
for (const tag of tags) {
351420
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
@@ -718,3 +787,94 @@ function truncateSnippet(value: string, maxLength = 220): string | undefined {
718787
}
719788
return `${normalized.slice(0, maxLength - 3)}...`;
720789
}
790+
791+
// ── Async media URL accessibility checker ─────────────────────────────────
792+
793+
/**
794+
* Extract all remote media URLs from HTML source.
795+
*/
796+
function extractMediaUrls(html: string): Array<{ url: string; tagName: string; elementId?: string; snippet: string }> {
797+
const results: Array<{ url: string; tagName: string; elementId?: string; snippet: string }> = [];
798+
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
799+
let match: RegExpExecArray | null;
800+
while ((match = tagRe.exec(html)) !== null) {
801+
const tagName = (match[1] ?? "").toLowerCase();
802+
const raw = match[0];
803+
const src = readAttr(raw, "src");
804+
if (!src) continue;
805+
if (/^https?:\/\//i.test(src)) {
806+
results.push({
807+
url: src,
808+
tagName,
809+
elementId: readAttr(raw, "id") || undefined,
810+
snippet: raw.length > 120 ? raw.slice(0, 117) + "..." : raw,
811+
});
812+
}
813+
}
814+
return results;
815+
}
816+
817+
/**
818+
* Async lint pass: HEAD-checks every remote media URL in the HTML.
819+
* Returns findings for URLs that are unreachable (non-2xx status or network error).
820+
*
821+
* Call this after `lintHyperframeHtml()` and merge the findings.
822+
*
823+
* @param timeoutMs - per-request timeout (default 8000ms)
824+
*/
825+
export async function lintMediaUrls(
826+
html: string,
827+
options: { timeoutMs?: number } = {},
828+
): Promise<HyperframeLintFinding[]> {
829+
const urls = extractMediaUrls(html);
830+
if (urls.length === 0) return [];
831+
832+
const timeout = options.timeoutMs ?? 8000;
833+
const findings: HyperframeLintFinding[] = [];
834+
835+
// Dedupe by URL
836+
const seen = new Set<string>();
837+
const unique = urls.filter((u) => {
838+
if (seen.has(u.url)) return false;
839+
seen.add(u.url);
840+
return true;
841+
});
842+
843+
// Check all URLs in parallel
844+
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
845+
try {
846+
const controller = new AbortController();
847+
const timer = setTimeout(() => controller.abort(), timeout);
848+
const resp = await fetch(url, {
849+
method: "HEAD",
850+
signal: controller.signal,
851+
redirect: "follow",
852+
});
853+
clearTimeout(timer);
854+
855+
if (!resp.ok) {
856+
findings.push({
857+
code: "inaccessible_media_url",
858+
severity: "error",
859+
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
860+
elementId,
861+
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
862+
snippet,
863+
});
864+
}
865+
} catch (err) {
866+
const reason = err instanceof Error ? err.name : "unknown";
867+
findings.push({
868+
code: "inaccessible_media_url",
869+
severity: "error",
870+
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
871+
elementId,
872+
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
873+
snippet,
874+
});
875+
}
876+
});
877+
878+
await Promise.all(checks);
879+
return findings;
880+
}

packages/core/src/lint/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ export type {
44
HyperframeLintResult,
55
HyperframeLinterOptions,
66
} from "./types";
7-
export { lintHyperframeHtml } from "./hyperframeLinter";
7+
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter";

packages/core/src/runtime/init.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,21 +184,20 @@ export function initSandboxRuntimeModular(): void {
184184
};
185185

186186
const resolveRootCompositionElement = (): HTMLElement | null => {
187-
const mainComp = document.getElementById("main-comp");
188-
if (mainComp instanceof HTMLElement && mainComp.hasAttribute("data-composition-id")) {
189-
return mainComp;
190-
}
187+
// 1. Explicit root marker takes priority
191188
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
192189
if (explicitRoot instanceof HTMLElement) {
193190
return explicitRoot;
194191
}
192+
// 3. Topmost composition element (not nested inside another)
195193
const compositionNodes = Array.from(
196194
document.querySelectorAll("[data-composition-id]"),
197195
) as HTMLElement[];
198196
if (compositionNodes.length === 0) return null;
199197
return (
200198
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
201-
compositionNodes[0]
199+
compositionNodes[0] ??
200+
null
202201
);
203202
};
204203

@@ -216,14 +215,17 @@ export function initSandboxRuntimeModular(): void {
216215
const sanitizeCompositionDurationAttributes = () => {
217216
const rootEl = resolveRootCompositionElement();
218217
const compositionNodes = Array.from(
219-
document.querySelectorAll("[data-composition-id][data-duration]"),
220-
) as HTMLElement[];
218+
document.querySelectorAll("[data-composition-id]"),
219+
).filter((n) => n.hasAttribute("data-duration") || n.hasAttribute("data-end")) as HTMLElement[];
221220
for (const node of compositionNodes) {
222221
// Preserve explicit root duration so timeline payload can distinguish
223222
// authored finite duration from loop-inflated timeline duration.
224223
if (rootEl && node === rootEl) continue;
225224
// Non-root compositions derive duration from timeline.
225+
// Strip both data-duration AND data-end so the visibility system
226+
// falls back to the GSAP timeline duration (parity with preview).
226227
node.removeAttribute("data-duration");
228+
node.removeAttribute("data-end");
227229
}
228230
};
229231

packages/core/src/runtime/startResolver.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ export function createRuntimeStartTimeResolver(params: {
118118
try {
119119
const expression = parseStartExpression(element.getAttribute("data-start"));
120120
if (!expression) {
121+
// If this element is a loaded composition inner root (has data-composition-id
122+
// but no data-start), walk up to the host parent which carries the actual
123+
// timing. This happens when the host uses a different data-composition-id
124+
// than the loaded file — e.g. host="montage" but file has "scene-10".
125+
// Check both data-composition-src (runtime) and data-composition-id (bundled,
126+
// where data-composition-src is stripped after inlining).
127+
if (element.hasAttribute("data-composition-id")) {
128+
const parent = element.parentElement;
129+
if (parent && (parent.hasAttribute("data-composition-src") || parent.hasAttribute("data-composition-id"))) {
130+
const parentStart = resolveStartForElementInternal(parent, fallback);
131+
startCache.set(element, parentStart);
132+
return parentStart;
133+
}
134+
}
121135
startCache.set(element, fallback);
122136
return fallback;
123137
}

packages/core/src/runtime/timeline.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,60 @@ function parseNum(value: string | null | undefined): number | null {
1111
return Number.isFinite(parsed) ? parsed : null;
1212
}
1313

14+
/**
15+
* When multiple content kinds share the same track number, split them
16+
* onto separate tracks so the timeline UI shows distinct rows.
17+
*
18+
* Preferred kind order (top → bottom): composition, video, image, element, audio.
19+
* Tracks that contain only one kind are left untouched.
20+
*/
21+
const KIND_ORDER: Record<string, number> = {
22+
composition: 0,
23+
video: 1,
24+
image: 2,
25+
element: 3,
26+
audio: 4,
27+
};
28+
29+
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
30+
if (clips.length === 0) return;
31+
32+
// Group clips by their raw track number and detect which tracks have mixed kinds
33+
const trackKinds = new Map<number, Set<string>>();
34+
for (const clip of clips) {
35+
const kinds = trackKinds.get(clip.track) ?? new Set();
36+
kinds.add(clip.kind);
37+
trackKinds.set(clip.track, kinds);
38+
}
39+
40+
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
41+
if (!hasMixedTracks) return;
42+
43+
// Build new contiguous track numbers, splitting mixed tracks by kind
44+
let nextTrack = 0;
45+
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
46+
47+
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
48+
for (const track of sortedTracks) {
49+
const kinds = trackKinds.get(track)!;
50+
if (kinds.size === 1) {
51+
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
52+
} else {
53+
// Split by kind in preferred order
54+
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
55+
for (const kind of sorted) {
56+
newTrackMap.set(`${track}:${kind}`, nextTrack++);
57+
}
58+
}
59+
}
60+
61+
for (const clip of clips) {
62+
const key = `${clip.track}:${clip.kind}`;
63+
const newTrack = newTrackMap.get(key);
64+
if (newTrack != null) clip.track = newTrack;
65+
}
66+
}
67+
1468
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
1569
const raw = String(rawValue ?? "").trim();
1670
if (!raw) return null;
@@ -118,7 +172,9 @@ export function collectRuntimeTimelinePayload(params: {
118172
inheritedStart = startResolver.resolveStartForElement(cursor, 0);
119173
}
120174
if (inheritedDuration == null) {
121-
inheritedDuration = parseNum(cursor.getAttribute("data-duration")) ?? null;
175+
inheritedDuration = parseNum(cursor.getAttribute("data-duration"))
176+
?? resolveTimelineDurationSeconds(compositionId)
177+
?? null;
122178
}
123179
}
124180
cursor = cursor.parentElement;
@@ -243,11 +299,12 @@ export function collectRuntimeTimelinePayload(params: {
243299
? "image"
244300
: "element";
245301
clips.push({
246-
id: (node as HTMLElement).id || `__node__index_${i}`,
302+
id: (node as HTMLElement).id || nodeCompositionId || `__node__index_${i}`,
247303
label:
248304
node.getAttribute("data-timeline-label") ??
249305
node.getAttribute("data-label") ??
250306
node.getAttribute("aria-label") ??
307+
nodeCompositionId ??
251308
(node as HTMLElement).id ??
252309
(node as HTMLElement).className?.split(" ")[0] ??
253310
kind,
@@ -272,6 +329,12 @@ export function collectRuntimeTimelinePayload(params: {
272329
timelinePriority: parseNum(node.getAttribute("data-timeline-priority")),
273330
});
274331
}
332+
// ── Track normalization ────────────────────────────────────────────────
333+
// When multiple content kinds (composition, audio, video, …) share the same
334+
// data-track-index value, split them onto separate tracks so the timeline UI
335+
// shows distinct rows for each kind.
336+
normalizeTrackAssignments(clips);
337+
275338
for (const compositionNode of compositionNodes) {
276339
if (compositionNode === root) continue;
277340
const compositionId = compositionNode.getAttribute("data-composition-id");

packages/producer/build.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ await Promise.all([
5757
}),
5858
]);
5959

60+
// Copy core runtime artifacts so the producer can find them at dist/
61+
import { copyFileSync, existsSync, readFileSync } from "fs";
62+
const coreDistDir = resolve(scriptDir, "../core/dist");
63+
try {
64+
const manifestSrc = resolve(coreDistDir, "hyperframe.manifest.json");
65+
if (existsSync(manifestSrc)) {
66+
copyFileSync(manifestSrc, "dist/hyperframe.manifest.json");
67+
const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
68+
const runtimeIife = manifest?.artifacts?.iife || "hyperframe.runtime.iife.js";
69+
copyFileSync(resolve(coreDistDir, runtimeIife), `dist/${runtimeIife}`);
70+
console.log(`[Build] Copied runtime: hyperframe.manifest.json, ${runtimeIife}`);
71+
}
72+
} catch (e) {
73+
console.warn("[Build] Warning: Could not copy runtime artifacts:", e.message);
74+
}
75+
6076
// Generate .d.ts declarations (esbuild doesn't emit them)
6177
import { execSync } from "child_process";
6278
execSync("tsc --emitDeclarationOnly --declaration --declarationMap", {

0 commit comments

Comments
 (0)