mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint,player): fast-capture lint rule + player media sync (#1921)
* feat(engine): drawElementImage capture service * feat(engine): 3D projection + compositor-effect risk gate * fix(engine): gate filter drop-shadow wherever blur gates (review) detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only detected blur( in its three scan paths — a drop-shadow comp stayed on the fast path despite the gate's own correctness contract. Detect drop-shadow( in computed styles, stylesheet rules, and GSAP tween vars, pinned by a focused test that runs the real page-side closure against a DOM shim (computed / stylesheet / tween coverage + blur regression + effect-free null). Addresses miguel-heygen's blocker on #1918. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension # Conflicts: # packages/engine/src/services/screenshotService.ts * fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review) Addresses miguel-heygen's blockers on #1919: - HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to a documented diagnostic flag: it exists for upstream-Chromium repro work (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect classes; renders under it may be damaged BY DESIGN since it bypasses gates whose thresholds encode measured damage. Never production; the safety-net blank guard also stands down under it so diagnostic frames arrive unmodified. - armStaticDedup is now idempotent: the drawElement init path arms dedup before canvas injection, then initializeSession called it again — the second run overwrote the armed state with skipReason="capture_mode" (captureMode is "drawelement" by then), producing contradictory telemetry (armed frames + a skip reason), and re-ran the verification seeks on the fallback path. It now no-ops once staticFrames or a skip decision exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(producer): fast-capture render stages + remote bg-image localizer * feat(lint,player): fast-capture lint rule + player media sync --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1d0dbcd3b2
commit
992a9b6607
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
@@ -607,6 +608,78 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing_data_no_timeline", () => {
|
||||
it("warns when root has no timeline registration and no data-no-timeline", async () => {
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_data_no_timeline");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not warn when data-no-timeline is present (boolean form)", async () => {
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="c1" data-no-timeline data-width="320" data-height="180" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when a script registers window.__timelines[id]", async () => {
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when there is no root composition-id", async () => {
|
||||
const html = `<!DOCTYPE html><html><body><p>hello</p></body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not false-positive when data-no-timeline appears only inside an attribute value", async () => {
|
||||
// Regression: /\bdata-no-timeline\b/ matched substrings inside values
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="c1" title="add data-no-timeline here" data-width="320" data-height="180" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not suppress when a hyphenated variant like data-no-timeline-start is present", async () => {
|
||||
// Regression: /\bdata-no-timeline\b/ matched data-no-timeline-start because
|
||||
// hyphen is a non-word char and \b fires between 'e' and '-'
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="c1" data-no-timeline-start="0" data-width="320" data-height="180" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not warn for sub-compositions", async () => {
|
||||
const html = `<template><div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div></template>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when composition has external scripts (cannot scan for timeline registration)", async () => {
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
|
||||
<script src="app.js"></script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("root_composition_missing_data_duration (removed)", () => {
|
||||
// The rule was a static proxy for the runtime's loop-inflation Infinity
|
||||
// emission, but lint cannot observe GSAP timeline duration statically and
|
||||
|
||||
@@ -495,6 +495,42 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
return findings;
|
||||
},
|
||||
|
||||
// missing_data_no_timeline
|
||||
// The producer polls window.__timelines[id] with a 45-second timeout waiting
|
||||
// for GSAP timeline registration. Compositions that never call
|
||||
// window.__timelines[id] = tl stall for 45 s every render. Adding
|
||||
// data-no-timeline to the root element tells the producer to skip the poll.
|
||||
({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
|
||||
if (options.isSubComposition) return [];
|
||||
if (!rootCompositionId || !rootTag) return [];
|
||||
// readAttr only matches valued attrs (attr="..."); data-no-timeline is
|
||||
// typically boolean (no value). Strip quoted attribute values first to
|
||||
// avoid matching attr names that appear inside other values
|
||||
// (e.g. title="add data-no-timeline here"), then check with a boundary
|
||||
// that rejects hyphenated variants (data-no-timeline-start has '-' next,
|
||||
// not a word-break char).
|
||||
const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
|
||||
if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
|
||||
// Can't scan external script files for timeline registration; skip to avoid
|
||||
// false positives on compositions that register via a bundled JS file.
|
||||
if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
|
||||
const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
|
||||
if (registersTimeline) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_data_no_timeline",
|
||||
severity: "warning",
|
||||
message:
|
||||
"This composition has no `window.__timelines` registration but is missing `data-no-timeline`. " +
|
||||
"The producer polls for timeline registration for up to 45 seconds before timing out, " +
|
||||
"adding 45 s to every render.",
|
||||
fixHint:
|
||||
'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
|
||||
snippet: truncateSnippet(rootTag.raw),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// requestanimationframe_in_composition
|
||||
({ scripts, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface ControlsCallbacks {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSeek: (fraction: number) => void;
|
||||
/** Scrub drag started (mousedown/touchstart on the scrubber). */
|
||||
onScrubStart?: () => void;
|
||||
/** Scrub drag ended (mouseup/touchend). */
|
||||
onScrubEnd?: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onMuteToggle: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
@@ -293,13 +297,17 @@ export function createControls(
|
||||
scrubber.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
scrubbing = true;
|
||||
callbacks.onScrubStart?.();
|
||||
handleScrubAt(e.clientX);
|
||||
});
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (scrubbing) handleScrubAt(e.clientX);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
scrubbing = false;
|
||||
if (scrubbing) {
|
||||
scrubbing = false;
|
||||
callbacks.onScrubEnd?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
@@ -308,6 +316,7 @@ export function createControls(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
scrubbing = true;
|
||||
callbacks.onScrubStart?.();
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleScrubAt(touch.clientX);
|
||||
},
|
||||
@@ -320,7 +329,10 @@ export function createControls(
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
scrubbing = false;
|
||||
if (scrubbing) {
|
||||
scrubbing = false;
|
||||
callbacks.onScrubEnd?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
document.addEventListener("touchend", onTouchEnd);
|
||||
|
||||
@@ -82,6 +82,9 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _currentTime = 0;
|
||||
private _duration = 0;
|
||||
private _paused = true;
|
||||
/** True while the user is dragging the scrubber — makes seek() play audio at the
|
||||
* playhead (audible scrub) instead of positioning it silently. */
|
||||
private _scrubbing = false;
|
||||
private _lastUpdateMs = 0;
|
||||
private _volume = 1;
|
||||
private _compositionWidth = 1920;
|
||||
@@ -325,13 +328,20 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this._stopParentTickClock();
|
||||
this._currentTime = timeInSeconds;
|
||||
if (this._media.audioOwner === "parent") {
|
||||
// Pause BEFORE seek: leaving the proxy playing turns the next
|
||||
// `mirrorTime` drift-correction tick into a perpetual seek→play→drift→seek
|
||||
// stutter loop, where ~80ms of audio plays past the (now frozen) timeline,
|
||||
// then mirrorTime yanks `currentTime` back to match it. Symmetric with
|
||||
// `pause()` below.
|
||||
this._media.pauseAll();
|
||||
this._media.seekAll(timeInSeconds);
|
||||
if (this._scrubbing) {
|
||||
// Audible scrub: play the proxy audio at the playhead so the viewer hears
|
||||
// the track as they drag. Each move re-seeks, restarting playback from the
|
||||
// new position. onScrubEnd settles back to silence via a normal seek.
|
||||
this._media.scrubAll(timeInSeconds);
|
||||
} else {
|
||||
// Pause BEFORE seek: leaving the proxy playing turns the next
|
||||
// `mirrorTime` drift-correction tick into a perpetual seek→play→drift→seek
|
||||
// stutter loop, where ~80ms of audio plays past the (now frozen) timeline,
|
||||
// then mirrorTime yanks `currentTime` back to match it. Symmetric with
|
||||
// `pause()` below.
|
||||
this._media.pauseAll();
|
||||
this._media.seekAll(timeInSeconds);
|
||||
}
|
||||
}
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
@@ -747,6 +757,15 @@ class HyperframesPlayer extends HTMLElement {
|
||||
onPlay: () => this.play(),
|
||||
onPause: () => this.pause(),
|
||||
onSeek: (f) => this.seek(f * this._duration),
|
||||
onScrubStart: () => {
|
||||
this._scrubbing = true;
|
||||
},
|
||||
onScrubEnd: () => {
|
||||
this._scrubbing = false;
|
||||
// Settle: a normal (silent) seek pauses the proxy audio at the final
|
||||
// scrub position, matching the paused playhead.
|
||||
this.seek(this._currentTime);
|
||||
},
|
||||
onSpeedChange: (s) => void (this.playbackRate = s),
|
||||
onMuteToggle: () => void (this.muted = !this.muted),
|
||||
onVolumeChange: (v) => void (this.volume = v),
|
||||
|
||||
@@ -122,6 +122,22 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
source.remove();
|
||||
});
|
||||
|
||||
it("scrubAll plays in-window proxies at the playhead and pauses out-of-window ones", () => {
|
||||
const mgr = makeManager({ owner: "parent" });
|
||||
const inWin = makeFakeAudio(true); // currently paused — scrub should start it
|
||||
const outWin = makeFakeAudio(false); // currently playing, but outside its window
|
||||
mgr.entries.push({ el: inWin, start: 0, duration: 5, driftSamples: 0 });
|
||||
mgr.entries.push({ el: outWin, start: 10, duration: 5, driftSamples: 0 });
|
||||
|
||||
mgr.scrubAll(2); // playhead at 2s
|
||||
|
||||
// in-window proxy: positioned at rel time and AUDIBLE (the point of scrub-audio)
|
||||
expect(inWin.currentTime).toBe(2);
|
||||
expect(inWin.paused).toBe(false);
|
||||
// out-of-window proxy: paused, not blipped
|
||||
expect(outWin.paused).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate or hijack a clip the composition already owns", () => {
|
||||
const mgr = makeManager();
|
||||
// The composition already adopted a clip with this URL.
|
||||
|
||||
@@ -189,6 +189,26 @@ export class ParentMediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Audible scrub: position every proxy at `timeInSeconds` AND play the ones whose
|
||||
// clip window covers it, so the viewer hears the track under the playhead while
|
||||
// dragging the scrubber (vs seekAll, which positions silently). Each drag move
|
||||
// re-seeks to the new position, so playback restarts from the playhead and you
|
||||
// hear the audio you're scrubbing over. The caller settles back to silence on
|
||||
// scrub end (a normal pause+seekAll). Muted proxies stay silent (play() is a no-op
|
||||
// for output). Out-of-window proxies are paused.
|
||||
scrubAll(timeInSeconds: number): void {
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryBounds(m);
|
||||
const relTime = timeInSeconds - m.start;
|
||||
if (relTime >= 0 && relTime < m.duration) {
|
||||
m.el.currentTime = relTime;
|
||||
this._playEntry(m);
|
||||
} else if (!m.el.paused) {
|
||||
m.el.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror parent-proxy `currentTime` to the iframe timeline, with optional
|
||||
* jitter-coalescing. Pass `{ force: true }` for alignment moments (ownership
|
||||
|
||||
Reference in New Issue
Block a user