Files
hyperframes/packages/lint/src/rules/media.ts
T
Vance IngallsandClaude Sonnet 5 1abad17650 feat(studio,lint): carve targets voiceover groups — always, when plural
Plural voiceover carve now targets a group instead of naming each clip:
`resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its
current members at analysis time, so a clip added to the group later is
covered without touching `sources`. The picker (`useFxCarve.ts`) offers a
grouped voice as one option instead of one row per member, tests overlap
as a union of member spans (a group overlaps the bed if ANY member does),
and prefers a qualifying group over its individual members in
`autoSourceIds`.

Picking two or more ungrouped voice clips in the carve flow now mints a
group behind them (`mintGroupId`, de-duped against every id in the
document) and writes `data-audio-group` on each picked clip atomically,
one undo entry — `createAudioGroupAndAssignMembers` in
`timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target
write shape. The DSP is untouched: `mixCarveSources` already sums
multiple sources correctly (verified in the design doc's own
investigation) — this only fixes the picker.

New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/
media.ts`, alongside `audio_volume_double_automation`) warns when a
`data-fx-carve`'s `sources` names two or more plain clip ids instead of a
group — the shape that silently rots when a clip is added. `/hyperframes-
audio` states the same rule as an invariant, not a tip, with the grouped-
narration HTML example from the design doc.

The group-matching and auto-group logic (`withAutoGroupedSources`,
`collectCarveCandidates`) is split into `useFxCarveGrouping.ts` —
`useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings`
is deliberately NOT an `async function`: wrapping it in one would force a
microtask on every call, including the synchronous branch — the exact bug
`withAutoGroupedSources`'s own sync-when-possible contract exists to avoid,
and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures)
before fixing it back to a plain function the caller conditionally awaits.

Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and
`useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600
lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker
IIFE to clear fallow's complexity gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 02:11:37 -07:00

772 lines
33 KiB
TypeScript

import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils";
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
/**
* Does the GSAP call that names `#id` also set `volume` in the same call?
*
* Depth-counted rather than regex-bounded: the selector opens somewhere inside a
* call, and the interesting region ends when THAT call closes — a nested
* `fadeTime(2)` opens and closes on the way and must not end the scan. A regex
* cannot count parens, and both fixed bounds were wrong in opposite directions:
* unbounded blamed a later element, first-paren missed a whole ordinary shape.
*/
function tweensVolumeInSameCall(script: string, id: string): boolean {
const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
let depth = 0;
// Cap the scan so a malformed script cannot walk the whole file.
const limit = Math.min(script.length, hit.index + 2000);
for (let i = hit.index; i < limit; i += 1) {
const ch = script[i];
if (ch === "(") depth += 1;
else if (ch === ")") {
// Past the end of the call the selector sits in.
if (depth === 0) break;
depth -= 1;
} else if (ch === ";" && depth === 0) break;
else if (ch === "v" && /^volume\s*:/.test(script.slice(i))) return true;
}
}
return false;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hasAttrName(tagSource: string, attr: string): boolean {
const escaped = escapeRegExp(attr);
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
}
function classNamesFromAttr(classAttr: string | null): string[] {
if (!classAttr) return [];
return classAttr.split(/\s+/).filter(Boolean);
}
type MediaSelectorIndex = {
ids: Set<string>;
classes: Set<string>;
hasVideo: boolean;
hasAudio: boolean;
};
function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean {
const normalized = selector.trim();
if (!normalized) return false;
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
for (const mediaId of mediaIndex.ids) {
const escapedId = escapeRegExp(mediaId);
if (
new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) ||
normalized.includes(`[id="${mediaId}"]`) ||
normalized.includes(`[id='${mediaId}']`)
) {
return true;
}
}
for (const className of mediaIndex.classes) {
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
return true;
}
}
return false;
}
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
const mediaIndex: MediaSelectorIndex = {
ids: new Set(
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)),
),
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
hasVideo: mediaTags.some((tag) => tag.name === "video"),
hasAudio: mediaTags.some((tag) => tag.name === "audio"),
};
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
for (const script of ctx.scripts) {
const mediaVars = new Map<string, string | undefined>();
const assignmentPatterns = [
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
variableIndex: 1,
targetIndex: 2,
},
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
variableIndex: 1,
targetIndex: 3,
},
];
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const variableName = match[variableIndex];
const target = match[targetIndex];
if (!variableName || !target) continue;
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined);
}
}
}
const directIdPatterns = [
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 2,
},
];
for (const { pattern, kind, targetIndex } of directIdPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const target = match[targetIndex];
if (!target) continue;
const elementId = mediaIndex.ids.has(target)
? target
: selectorTargetsManagedMedia(target, mediaIndex)
? undefined
: null;
if (elementId === null) continue;
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId: elementId || undefined,
fixHint:
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
for (const [variableName, elementId] of mediaVars) {
const escapedVar = escapeRegExp(variableName);
const variablePatterns = [
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
{
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
kind: "muted assignment",
},
];
for (const { pattern, kind } of variablePatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId,
fixHint:
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
}
}
return findings;
}
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// duplicate_media_id + duplicate_media_discovery_risk
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const mediaById = new Map<string, typeof tags>();
const mediaFingerprintCounts = new Map<string, number>();
for (const tag of tags) {
if (!isMediaTag(tag.name)) continue;
const elementId = readAttr(tag.raw, "id");
if (elementId) {
const existing = mediaById.get(elementId) || [];
existing.push(tag);
mediaById.set(elementId, existing);
}
const fingerprint = [
tag.name,
readAttr(tag.raw, "src") || "",
readAttr(tag.raw, "data-start") || "",
readAttr(tag.raw, "data-duration") || "",
].join("|");
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
}
for (const [elementId, mediaTags] of mediaById) {
if (mediaTags.length < 2) continue;
findings.push({
code: "duplicate_media_id",
severity: "error",
message: `Media id "${elementId}" is defined multiple times.`,
elementId,
fixHint:
"Give each media element a unique id so preview and producer discover the same media graph.",
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
});
}
for (const [fingerprint, count] of mediaFingerprintCounts) {
if (count < 2) continue;
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
findings.push({
code: "duplicate_media_discovery_risk",
severity: "warning",
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
snippet: truncateSnippet(
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`,
),
});
}
return findings;
},
// color_grading_* — grading is a structured media-only contract. Unknown
// keys are ignored by the runtime, so catch them before an agent can report
// controls that never actually rendered.
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
const raw = readDecodedAttr(tag.raw, "data-color-grading");
if (raw === null) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
const report = (code: string, message: string, fixHint: string) => {
findings.push({
code,
severity: "error",
message,
elementId,
fixHint,
snippet: truncateSnippet(tag.raw),
});
};
if (tag.name !== "video" && tag.name !== "img") {
report(
"color_grading_non_media",
`data-color-grading on <${tag.name}> has no effect. The shader runtime only grades real <video> and <img> elements.`,
"Move the grading attribute to the real <video> or <img> media element. Do not attach it to a wrapper or CSS background.",
);
continue;
}
const trimmed = raw.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
report(
"color_grading_invalid_json",
"data-color-grading contains malformed JSON and will not render.",
'Use valid JSON, for example {"preset":"skin-soft","intensity":0.6,"adjust":{"highlights":-0.08}}.',
);
continue;
}
for (const issue of validateColorGradingContract(parsed)) {
report(
"color_grading_invalid_structure",
`data-color-grading ${issue.path} ${issue.message}.`,
issue.hint ??
"Use the documented media-treatment contract and correct or remove the invalid value.",
);
}
}
return findings;
},
// video_missing_muted
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video") continue;
const hasMuted = hasAttrName(tag.raw, "muted");
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_missing_muted",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
elementId,
fixHint:
'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
snippet: truncateSnippet(tag.raw),
});
}
if (hasMuted && hasDeclaredAudio) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_muted_with_declared_audio",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
elementId,
fixHint:
'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// video_nested_in_timed_element
({ source, tags }) => {
const findings: HyperframeLintFinding[] = [];
// HTML5 void elements cannot contain children, so they can never be a
// parent of a nested <video>. Skipping them avoids false positives where
// the linter looks for `</img>` and never finds it.
const voidElements = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr",
]);
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
if (voidElements.has(tag.name)) continue;
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-start")) {
timedTagPositions.push({
name: tag.name,
start: tag.index,
id: readAttr(tag.raw, "id") || undefined,
});
}
}
for (const tag of tags) {
if (tag.name !== "video") continue;
if (!readAttr(tag.raw, "data-start")) continue;
for (const parent of timedTagPositions) {
if (parent.start < tag.index) {
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
const between = source.substring(parent.start, tag.index);
if (!parentClosePattern.test(between)) {
findings.push({
code: "video_nested_in_timed_element",
severity: "error",
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
snippet: truncateSnippet(tag.raw),
});
break;
}
}
}
}
return findings;
},
// self_closing_media_tag
({ source }) => {
const findings: HyperframeLintFinding[] = [];
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
let scMatch: RegExpExecArray | null;
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
const tagName = scMatch[1] || "audio";
const elementId = readAttr(scMatch[0], "id") || undefined;
findings.push({
code: "self_closing_media_tag",
severity: "error",
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.`,
elementId,
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
snippet: truncateSnippet(scMatch[0]),
});
}
return findings;
},
// placeholder_media_url
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const PLACEHOLDER_DOMAINS =
/\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
for (const tag of tags) {
if (!isMediaTag(tag.name)) continue;
const src = readAttr(tag.raw, "src");
if (!src) continue;
if (PLACEHOLDER_DOMAINS.test(src)) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "placeholder_media_url",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
elementId,
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// base64_media_prohibited
({ source }) => {
const findings: HyperframeLintFinding[] = [];
const base64MediaRe =
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
let b64Match: RegExpExecArray | null;
while ((b64Match = base64MediaRe.exec(source)) !== null) {
const sample = (b64Match[2] || "").slice(0, 200);
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
findings.push({
code: "base64_media_prohibited",
severity: "error",
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
fixHint:
"Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
});
}
return findings;
},
// media_missing_data_start + media_missing_id + media_missing_src + media_preload_none
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const hasDataStart = readAttr(tag.raw, "data-start");
const hasId = readAttr(tag.raw, "id");
const hasSrc = readAttr(tag.raw, "src");
if (hasSrc && !hasDataStart) {
findings.push({
code: "media_missing_data_start",
severity: "error",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
elementId: hasId || undefined,
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasDataStart && !hasId) {
findings.push({
code: "media_missing_id",
severity: "error",
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasDataStart && hasId && !hasSrc) {
const varSrc = readAttr(tag.raw, "data-var-src");
if (varSrc) {
// Variable-bound media without a fallback still renders when the
// variable resolves, but a render without a value can't load the
// media, and the audio pipeline discovers tracks from the AUTHORED
// src — warn instead of hard-failing the binding pattern.
findings.push({
code: "media_variable_src_no_fallback",
severity: "warning",
message: `<${tag.name} id="${hasId}"> relies on data-var-src="${varSrc}" with no fallback src. Renders without a "${varSrc}" value cannot load this media, and audio extraction reads the authored src.`,
elementId: hasId,
fixHint: `Add a fallback src the composition can render with when the variable is not provided.`,
snippet: truncateSnippet(tag.raw),
});
} else {
findings.push({
code: "media_missing_src",
severity: "error",
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
elementId: hasId,
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
snippet: truncateSnippet(tag.raw),
});
}
}
if (readAttr(tag.raw, "preload") === "none") {
findings.push({
code: "media_preload_none",
severity: "warning",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
elementId: hasId || undefined,
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// media_crossorigin_breaks_preview — `crossorigin` on <video>/<audio> forces a
// CORS-checked fetch. The server-side renderer downloads media directly (no CORS),
// so it always works there; but Studio preview runs in the browser, where a media
// host that omits Access-Control-Allow-Origin silently fails the load — the media
// shows BLANK/black in preview while renders look fine, hiding the bug. Plain
// displayed media never needs crossorigin; it's only required to read pixels/samples
// back (canvas/WebGL texture, WebAudio createMediaElementSource) AND only when the
// host is known CORS-enabled.
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
if (!hasAttrName(tag.raw, "crossorigin")) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_crossorigin_breaks_preview",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has crossorigin, which forces a CORS-checked fetch. If the media host omits Access-Control-Allow-Origin, the load silently fails in Studio preview (media shows BLANK/black) while server-side renders still work — hiding the bug.`,
elementId,
fixHint:
"Remove the crossorigin attribute unless you read the media back via canvas/WebGL/WebAudio AND the host is known to send CORS headers. Plain displayed media never needs it.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// video_audio_double_source — catches audible <video> paired with a separate
// <audio> pointing to the same file, which causes double playback at runtime
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const videoSources = new Map<string, { id?: string; raw: string }>();
const audioSources = new Map<string, { id?: string; raw: string }>();
for (const tag of tags) {
if (!readAttr(tag.raw, "data-start")) continue;
const src = readAttr(tag.raw, "src");
if (!src) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
if (tag.name === "video") {
const isMuted = hasAttrName(tag.raw, "muted");
if (!isMuted) {
videoSources.set(src, { id: elementId, raw: tag.raw });
}
} else if (tag.name === "audio") {
audioSources.set(src, { id: elementId, raw: tag.raw });
}
}
for (const [src, audioInfo] of audioSources) {
const videoInfo = videoSources.get(src);
if (!videoInfo) continue;
findings.push({
code: "video_audio_double_source",
severity: "error",
message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source. The unmuted video already provides audio — the duplicate <audio> will cause double playback and echo.`,
elementId: audioInfo.id,
fixHint:
"Either mute the video (add `muted` attribute) and keep the separate <audio>, or remove the <audio> element and let the video provide its own audio track.",
snippet: truncateSnippet(audioInfo.raw),
});
}
return findings;
},
// imperative_media_control
findImperativeMediaControlFindings,
// audio_volume_double_automation
findVolumeDoubleAutomationFindings,
// audio_volume_tween_overrides_gain
findVolumeTweenOverridesGainFindings,
// audio_carve_ungrouped_sources
findCarveUngroupedSourcesFindings,
];
/**
* Tween values on `volume` are ABSOLUTE gains, not multipliers of the authored
* `data-volume`: the probed keyframes replace that baseline outright, in
* preview and in the render alike. So a clip carrying both plays at whatever
* the tween names — `{ volume: 1 }` is 0 dB even on a clip the fader says is
* at +5.8 dB, and Studio's fader gives no sign of it.
*
* Silent before this rule, and easier to hit since the fader gained +12 dB of
* boost and `normalize-audio` writes into the very same attribute.
*/
function findVolumeTweenOverridesGainFindings(ctx: LintContext): HyperframeLintFinding[] {
const boosted = ctx.tags
.filter((tag) => isMediaTag(tag.name))
// Absent means unity, as it does everywhere else. Reading it raw gave
// `Number(null)` — 0, finite and not 1, so a clip with NO `data-volume`
// cleared both filters and was reported as authored at silence. That is the
// shape the docs recommend for a tweened clip, so the rule fired on exactly
// the case it exists to bless.
.map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume") ?? "1") }))
.filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1)
// A lane already has its own rule, and it wins over both of these.
.filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation"))
.map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") }))
.filter((entry): entry is typeof entry & { id: string } => Boolean(entry.id));
if (boosted.length === 0) return [];
const script = ctx.scripts.map((block) => stripJsComments(block.content)).join("\n");
const findings: HyperframeLintFinding[] = [];
for (const { tag, id, volume } of boosted) {
if (!tweensVolumeInSameCall(script, id)) continue;
const db = volume > 0 ? `${(20 * Math.log10(volume)).toFixed(1)} dB` : "silence";
findings.push({
code: "audio_volume_tween_overrides_gain",
severity: "warning",
message: `#${id} has data-volume="${volume}" (${db}) and a GSAP tween on \`volume\`. Tween values are absolute — they REPLACE this gain rather than scale it — so wherever the tween names a value the clip plays at that value, not at ${db}.`,
elementId: id,
fixHint:
"Write the tween's targets in the same absolute gain (e.g. `volume: 1.95`, not `volume: 1`), or reset data-volume to 1 and let the tween carry the level on its own.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}
/**
* A track can have its volume shaped by an automation lane or by a GSAP tween,
* and only the lane is heard: the runtime reads `data-automation` first and
* never falls through to the probed tween. Both present means one of them is
* silently doing nothing, which is invisible in the file and in preview.
*/
function findVolumeDoubleAutomationFindings(ctx: LintContext): HyperframeLintFinding[] {
const automated = ctx.tags
.filter((tag) => isMediaTag(tag.name))
.map((tag) => ({ tag, automation: readDecodedAttr(tag.raw, "data-automation") }))
.filter((entry) => entry.automation && /"target"\s*:\s*"volume"/.test(entry.automation))
.map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") }))
.filter((entry): entry is typeof entry & { id: string } => Boolean(entry.id));
if (automated.length === 0) return [];
const script = ctx.scripts.map((block) => stripJsComments(block.content)).join("\n");
const findings: HyperframeLintFinding[] = [];
for (const { tag, id } of automated) {
// ponytail: a tween is recognised by a `volume` key appearing shortly after
// the element's own selector, rather than by parsing the timeline. It reads
// the same call the runtime's own probe would pick up, and the rule only
// warns, so a miss costs nothing.
// Scan to the end of the call the selector opened, rather than to the first
// `)`. A chained timeline has no semicolon until the end of the whole chain,
// so an unbounded run matched `volume` in a LATER `.to()` and named the
// wrong element — but stopping at the first `)` instead silenced the rule
// for any object holding a call, e.g.
// `gsap.to("#bgm", { duration: fadeTime(2), volume: 0.2 })`, which is the
// ordinary case rather than an exotic one. Counting depth keeps the match
// inside the selector's own call AND lets it cross a nested one.
const tweened = tweensVolumeInSameCall(script, id);
if (!tweened) continue;
findings.push({
code: "audio_volume_double_automation",
severity: "warning",
message: `#${id} has both a volume automation lane and a GSAP tween on \`volume\`. The lane wins — the tween is ignored in preview and in the render.`,
elementId: id,
fixHint:
"Keep one of them: delete the volume lane to go back to tweening, or drop the tween and shape the level in the automation lane.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}
/**
* A carve's `sources` naming two or more plain clip ids is the normative
* mistake groups exist to prevent (groups doc §1.6): the list silently rots
* when a voice clip is added or removed, since nothing re-derives it. Naming
* a group instead means membership resolves at analysis time. Silent when
* `sources` already names a group, or names at most one clip.
*/
function findCarveUngroupedSourcesFindings(ctx: LintContext): HyperframeLintFinding[] {
const groupIds = new Set(
ctx.tags.filter((tag) => tag.name === "hf-audio-group").map((tag) => readAttr(tag.raw, "id")),
);
const findings: HyperframeLintFinding[] = [];
for (const tag of ctx.tags) {
const raw = readDecodedAttr(tag.raw, "data-fx-carve");
if (raw === null) continue;
const trimmed = raw.trim();
if (!trimmed.startsWith("{")) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
const sources = (parsed as { sources?: unknown }).sources;
if (!Array.isArray(sources)) continue;
const clipIds = sources.filter(
(id): id is string => typeof id === "string" && !groupIds.has(id),
);
if (clipIds.length < 2) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "audio_carve_ungrouped_sources",
severity: "warning",
message: `${elementId ? `#${elementId}'s` : "This"} carve names ${clipIds.length} voice clips directly (${clipIds.join(", ")}) instead of a group.`,
elementId,
fixHint:
"Group the voice clips and carve against the group — a hand-rolled clip list silently rots when a clip is added.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}