Skip to content

Commit 7c62880

Browse files
committed
fix: composition issues in runtime code + producer parity
1 parent 7c48f2a commit 7c62880

10 files changed

Lines changed: 401 additions & 198 deletions

File tree

packages/core/src/lint/hyperframeLinter.ts

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,11 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
265265
continue;
266266
}
267267
pushFinding({
268-
code: "suspicious_global_gsap_selector",
268+
code: "unscoped_gsap_selector",
269269
severity: "warning",
270-
message: `Timeline "${localTimelineCompId}" uses a global selector "${window.targetSelector}" that may escape composition scope.`,
270+
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
271271
selector: window.targetSelector,
272-
fixHint: `Scope the selector like \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
272+
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
273273
snippet: truncateSnippet(window.raw),
274274
});
275275
}
@@ -331,6 +331,75 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
331331
}
332332
}
333333

334+
// #3.5: Self-closing <audio .../> or <video .../> — CRITICAL
335+
// In HTML5, <audio> and <video> are NOT void elements. The browser silently
336+
// ignores the "/>", leaving the tag open. All subsequent sibling elements
337+
// become invisible fallback content inside the media tag, making entire
338+
// compositions disappear. This is the #1 cause of "black preview" bugs.
339+
{
340+
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
341+
let scMatch: RegExpExecArray | null;
342+
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
343+
const tagName = scMatch[1] || "audio";
344+
const elementId = readAttr(scMatch[0], "id") || undefined;
345+
pushFinding({
346+
code: "self_closing_media_tag",
347+
severity: "error",
348+
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.`,
349+
elementId,
350+
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
351+
snippet: truncateSnippet(scMatch[0]),
352+
});
353+
}
354+
}
355+
356+
// #3.6: Placeholder/fake media URLs — CRITICAL
357+
// Agents sometimes fabricate URLs (placehold.co, placeholder.com, example.com)
358+
// instead of using fetch_media or generate_image. These 404 at render time.
359+
{
360+
const PLACEHOLDER_DOMAINS = /\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
361+
for (const tag of tags) {
362+
if (!isMediaTag(tag.name)) continue;
363+
const src = readAttr(tag.raw, "src");
364+
if (!src) continue;
365+
if (PLACEHOLDER_DOMAINS.test(src)) {
366+
const elementId = readAttr(tag.raw, "id") || undefined;
367+
pushFinding({
368+
code: "placeholder_media_url",
369+
severity: "error",
370+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
371+
elementId,
372+
fixHint: "Use fetch_media to find real stock media, or generate_image/generate_video for AI-generated content.",
373+
snippet: truncateSnippet(tag.raw),
374+
});
375+
}
376+
}
377+
}
378+
379+
// #3.7: Fabricated inline base64 media — CRITICAL
380+
// Agents sometimes embed fake base64 audio/video data instead of using fetch_media.
381+
// Even small base64 data URIs for audio are almost always fabricated garbage that
382+
// won't play. Real audio files are 100KB+ when base64-encoded.
383+
{
384+
const base64MediaRe = /src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{100,}))["']/gi;
385+
let b64Match: RegExpExecArray | null;
386+
while ((b64Match = base64MediaRe.exec(source)) !== null) {
387+
// Check if it's suspiciously repetitive (fake data has long runs of repeated chars)
388+
const sample = (b64Match[2] || "").slice(0, 200);
389+
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
390+
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
391+
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
392+
// Any embedded base64 audio is suspicious — real audio should be a file
393+
pushFinding({
394+
code: "fabricated_inline_media",
395+
severity: "error",
396+
message: `Embedded base64 ${isSuspicious ? "FABRICATED" : ""} media detected (${(dataSize / 1024).toFixed(0)} KB). Inline base64 audio/video is almost always fake data that won't play. Use fetch_media or extract_audio to get real audio files.`,
397+
fixHint: "Remove the data: URI. Use fetch_media to search for stock music, or extract_audio to get audio from a video.",
398+
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
399+
});
400+
}
401+
}
402+
334403
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
335404
for (const tag of tags) {
336405
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
@@ -697,3 +766,94 @@ function truncateSnippet(value: string, maxLength = 220): string | undefined {
697766
}
698767
return `${normalized.slice(0, maxLength - 3)}...`;
699768
}
769+
770+
// ── Async media URL accessibility checker ─────────────────────────────────
771+
772+
/**
773+
* Extract all remote media URLs from HTML source.
774+
*/
775+
function extractMediaUrls(html: string): Array<{ url: string; tagName: string; elementId?: string; snippet: string }> {
776+
const results: Array<{ url: string; tagName: string; elementId?: string; snippet: string }> = [];
777+
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
778+
let match: RegExpExecArray | null;
779+
while ((match = tagRe.exec(html)) !== null) {
780+
const tagName = (match[1] ?? "").toLowerCase();
781+
const raw = match[0];
782+
const src = readAttr(raw, "src");
783+
if (!src) continue;
784+
if (/^https?:\/\//i.test(src)) {
785+
results.push({
786+
url: src,
787+
tagName,
788+
elementId: readAttr(raw, "id") || undefined,
789+
snippet: raw.length > 120 ? raw.slice(0, 117) + "..." : raw,
790+
});
791+
}
792+
}
793+
return results;
794+
}
795+
796+
/**
797+
* Async lint pass: HEAD-checks every remote media URL in the HTML.
798+
* Returns findings for URLs that are unreachable (non-2xx status or network error).
799+
*
800+
* Call this after `lintHyperframeHtml()` and merge the findings.
801+
*
802+
* @param timeoutMs - per-request timeout (default 8000ms)
803+
*/
804+
export async function lintMediaUrls(
805+
html: string,
806+
options: { timeoutMs?: number } = {},
807+
): Promise<HyperframeLintFinding[]> {
808+
const urls = extractMediaUrls(html);
809+
if (urls.length === 0) return [];
810+
811+
const timeout = options.timeoutMs ?? 8000;
812+
const findings: HyperframeLintFinding[] = [];
813+
814+
// Dedupe by URL
815+
const seen = new Set<string>();
816+
const unique = urls.filter((u) => {
817+
if (seen.has(u.url)) return false;
818+
seen.add(u.url);
819+
return true;
820+
});
821+
822+
// Check all URLs in parallel
823+
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
824+
try {
825+
const controller = new AbortController();
826+
const timer = setTimeout(() => controller.abort(), timeout);
827+
const resp = await fetch(url, {
828+
method: "HEAD",
829+
signal: controller.signal,
830+
redirect: "follow",
831+
});
832+
clearTimeout(timer);
833+
834+
if (!resp.ok) {
835+
findings.push({
836+
code: "inaccessible_media_url",
837+
severity: "error",
838+
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
839+
elementId,
840+
fixHint: "This URL is not accessible. Use fetch_media to find real stock media, or check the URL is correct.",
841+
snippet,
842+
});
843+
}
844+
} catch (err) {
845+
const reason = err instanceof Error ? err.name : "unknown";
846+
findings.push({
847+
code: "inaccessible_media_url",
848+
severity: "error",
849+
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
850+
elementId,
851+
fixHint: "This URL is not accessible. Use fetch_media to find real stock media, or check the URL is correct.",
852+
snippet,
853+
});
854+
}
855+
});
856+
857+
await Promise.all(checks);
858+
return findings;
859+
}

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: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -177,19 +177,22 @@ export function initSandboxRuntimeModular(): void {
177177
};
178178

179179
const resolveRootCompositionElement = (): HTMLElement | null => {
180-
const mainComp = document.getElementById("main-comp");
181-
if (mainComp instanceof HTMLElement && mainComp.hasAttribute("data-composition-id")) {
182-
return mainComp;
183-
}
180+
// 1. Explicit root marker takes priority
184181
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
185182
if (explicitRoot instanceof HTMLElement) {
186183
return explicitRoot;
187184
}
185+
// 2. Find the topmost composition element (one that is not nested inside another)
188186
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
189187
if (compositionNodes.length === 0) return null;
190-
return (
191-
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? compositionNodes[0]
192-
);
188+
const topLevel = compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]"));
189+
if (topLevel) return topLevel;
190+
// 3. Legacy fallback: #main-comp (only if no hierarchy is detectable)
191+
const mainComp = document.getElementById("main-comp");
192+
if (mainComp instanceof HTMLElement && mainComp.hasAttribute("data-composition-id")) {
193+
return mainComp;
194+
}
195+
return compositionNodes[0];
193196
};
194197

195198
const applyCompositionSizing = () => {
@@ -212,7 +215,29 @@ export function initSandboxRuntimeModular(): void {
212215
// Preserve explicit root duration so timeline payload can distinguish
213216
// authored finite duration from loop-inflated timeline duration.
214217
if (rootEl && node === rootEl) continue;
215-
// Non-root compositions derive duration from timeline.
218+
// Preserve data-duration on composition hosts whose GSAP timeline is
219+
// shorter than the declared duration. The host's data-duration is the
220+
// authored window length; stripping it causes the visibility system to
221+
// fall back to the (potentially tiny) GSAP timeline duration, hiding
222+
// the composition prematurely.
223+
const compId = node.getAttribute("data-composition-id");
224+
const declaredDur = Number(node.getAttribute("data-duration"));
225+
if (compId && Number.isFinite(declaredDur) && declaredDur > 0) {
226+
const tl = (window.__timelines ?? {} as Record<string, RuntimeTimelineLike | undefined>)[compId];
227+
if (!tl || typeof tl.duration !== "function") continue;
228+
try {
229+
const tlDur = Number(tl.duration());
230+
// Only strip if the timeline duration is at least as long as declared
231+
if (Number.isFinite(tlDur) && tlDur >= declaredDur) {
232+
node.removeAttribute("data-duration");
233+
}
234+
// Otherwise keep data-duration — it's the authored visibility window
235+
} catch {
236+
// keep data-duration if timeline access fails
237+
}
238+
continue;
239+
}
240+
// Non-root compositions without explicit duration derive from timeline.
216241
node.removeAttribute("data-duration");
217242
}
218243
};

packages/core/src/runtime/startResolver.ts

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

packages/core/src/runtime/timeline.ts

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

9+
/**
10+
* When multiple content kinds share the same track number, split them
11+
* onto separate tracks so the timeline UI shows distinct rows.
12+
*
13+
* Preferred kind order (top → bottom): composition, video, image, element, audio.
14+
* Tracks that contain only one kind are left untouched.
15+
*/
16+
const KIND_ORDER: Record<string, number> = {
17+
composition: 0,
18+
video: 1,
19+
image: 2,
20+
element: 3,
21+
audio: 4,
22+
};
23+
24+
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
25+
if (clips.length === 0) return;
26+
27+
// Group clips by their raw track number and detect which tracks have mixed kinds
28+
const trackKinds = new Map<number, Set<string>>();
29+
for (const clip of clips) {
30+
const kinds = trackKinds.get(clip.track) ?? new Set();
31+
kinds.add(clip.kind);
32+
trackKinds.set(clip.track, kinds);
33+
}
34+
35+
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
36+
if (!hasMixedTracks) return;
37+
38+
// Build new contiguous track numbers, splitting mixed tracks by kind
39+
let nextTrack = 0;
40+
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
41+
42+
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
43+
for (const track of sortedTracks) {
44+
const kinds = trackKinds.get(track)!;
45+
if (kinds.size === 1) {
46+
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
47+
} else {
48+
// Split by kind in preferred order
49+
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
50+
for (const kind of sorted) {
51+
newTrackMap.set(`${track}:${kind}`, nextTrack++);
52+
}
53+
}
54+
}
55+
56+
for (const clip of clips) {
57+
const key = `${clip.track}:${clip.kind}`;
58+
const newTrack = newTrackMap.get(key);
59+
if (newTrack != null) clip.track = newTrack;
60+
}
61+
}
62+
963
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
1064
const raw = String(rawValue ?? "").trim();
1165
if (!raw) return null;
@@ -109,7 +163,9 @@ export function collectRuntimeTimelinePayload(params: {
109163
inheritedStart = startResolver.resolveStartForElement(cursor, 0);
110164
}
111165
if (inheritedDuration == null) {
112-
inheritedDuration = parseNum(cursor.getAttribute("data-duration")) ?? null;
166+
inheritedDuration = parseNum(cursor.getAttribute("data-duration"))
167+
?? resolveTimelineDurationSeconds(compositionId)
168+
?? null;
113169
}
114170
}
115171
cursor = cursor.parentElement;
@@ -217,11 +273,12 @@ export function collectRuntimeTimelinePayload(params: {
217273
? "image"
218274
: "element";
219275
clips.push({
220-
id: (node as HTMLElement).id || `__node__index_${i}`,
276+
id: (node as HTMLElement).id || nodeCompositionId || `__node__index_${i}`,
221277
label:
222278
node.getAttribute("data-timeline-label") ??
223279
node.getAttribute("data-label") ??
224280
node.getAttribute("aria-label") ??
281+
nodeCompositionId ??
225282
(node as HTMLElement).id ??
226283
(node as HTMLElement).className?.split(" ")[0] ??
227284
kind,
@@ -243,6 +300,12 @@ export function collectRuntimeTimelinePayload(params: {
243300
timelinePriority: parseNum(node.getAttribute("data-timeline-priority")),
244301
});
245302
}
303+
// ── Track normalization ────────────────────────────────────────────────
304+
// When multiple content kinds (composition, audio, video, …) share the same
305+
// data-track-index value, split them onto separate tracks so the timeline UI
306+
// shows distinct rows for each kind.
307+
normalizeTrackAssignments(clips);
308+
246309
for (const compositionNode of compositionNodes) {
247310
if (compositionNode === root) continue;
248311
const compositionId = compositionNode.getAttribute("data-composition-id");

0 commit comments

Comments
 (0)