返回 html-video
HtmlFrameDriver.tsx
根目录 / packages / adapter-remotion / src / bridge / HtmlFrameDriver.tsx
1 // HtmlFrameDriver — RFC-08 §3.2 "time-driver injection".
2 //
3 // Renders an existing html-video HTML frame (passed inline as `html`) inside a
4 // Remotion composition via iframe `srcdoc`, and keeps its CSS / GSAP animation
5 // in sync with Remotion's deterministic frame clock. Without this, Remotion
6 // freezes its clock at frame N, screenshots, jumps to N+1 — but the iframe's CSS
7 // keyframes / GSAP run on the browser's own wall-clock, so each screenshot
8 // catches the animation at a random real-time point → flicker. We pause the
9 // iframe's clock and seek every animation to Remotion's current time per frame.
10 //
11 // Not compiled by the adapter's tsc; it's a static asset handed to Remotion's
12 // bundle() (webpack understands the JSX).
13 import React, { useCallback, useEffect, useRef, useState } from 'react';
14 import { useCurrentFrame, useVideoConfig, delayRender, continueRender } from 'remotion';
15
16 export type HtmlFrameDriverProps = {
17 /** The HTML frame's full source, inlined and rendered via iframe srcdoc. */
18 html: string;
19 width: number;
20 height: number;
21 };
22
23 type GsapTimeline = { pause: () => void; time: (s?: number) => unknown };
24 type GsapWindow = Window & { gsap?: { globalTimeline?: GsapTimeline } };
25
26 /** Read the iframe's content document, or undefined if not reachable yet. */
27 function frameDoc(iframe: HTMLIFrameElement | null): Document | undefined {
28 try {
29 return iframe?.contentWindow?.document ?? undefined;
30 } catch {
31 return undefined; // cross-origin (shouldn't happen for srcdoc)
32 }
33 }
34
35 /**
36 * The srcdoc document is "ready to seek + screenshot" only when BOTH hold:
37 *
38 * 1. Content is parsed — `readyState === 'complete'` is NOT enough (an empty
39 * doc reports complete too, and seeking it silently no-ops). We require a
40 * populated body or ≥1 registered animation.
41 *
42 * 2. Web fonts / external stylesheets have settled — `doc.fonts.status ===
43 * 'loaded'`. This is the one that actually caused the all-black render: a
44 * template with `<link href="fonts.googleapis.com/...">` keeps the iframe's
45 * render tree from painting until that external CSS resolves, so Remotion
46 * screenshots a fully black frame even though the DOM (opacity, etc.) is
47 * already correct. Waiting on fonts also kills FOUT (same idea as the
48 * hyperframes-side font-freeze fix). `fonts.status` starts 'loading' while
49 * any face/stylesheet is pending and flips to 'loaded' when all settle; a
50 * doc with no web fonts reports 'loaded' immediately.
51 */
52 function docReady(doc: Document | undefined): boolean {
53 if (!doc || !doc.body) return false;
54 const hasAnims = (doc.getAnimations?.()?.length ?? 0) > 0;
55 const hasBody = doc.body.innerHTML.trim().length > 0;
56 if (!hasAnims && !hasBody) return false;
57 // doc.fonts may be undefined in exotic engines — treat absence as ready.
58 const fontsLoaded = (doc as Document & { fonts?: FontFaceSet }).fonts?.status !== 'loading';
59 return fontsLoaded;
60 }
61
62 export const HtmlFrameDriver: React.FC<HtmlFrameDriverProps> = ({ html, width, height }) => {
63 const frame = useCurrentFrame();
64 const { fps } = useVideoConfig();
65 const iframeRef = useRef<HTMLIFrameElement | null>(null);
66 const tMs = (frame / fps) * 1000;
67
68 const seek = useCallback((timeMs: number) => {
69 const win = iframeRef.current?.contentWindow as GsapWindow | null | undefined;
70 const doc = frameDoc(iframeRef.current);
71 if (!win || !doc) return;
72
73 // (A) CSS Animations / Web Animations API — pure CSS @keyframes.
74 try {
75 const anims = (doc.getAnimations?.() ?? []) as Animation[];
76 for (const a of anims) {
77 try {
78 a.pause();
79 a.currentTime = timeMs;
80 } catch {
81 /* idle/finished animation rejects currentTime — ignore */
82 }
83 }
84 } catch {
85 /* getAnimations unsupported — fall through */
86 }
87
88 // (B) GSAP global timeline.
89 try {
90 const tl = win.gsap?.globalTimeline;
91 if (tl) {
92 tl.pause();
93 tl.time(timeMs / 1000);
94 }
95 } catch {
96 /* no gsap — fine */
97 }
98 }, []);
99
100 // Per-frame delayRender: Remotion captures each frame in its own pass, so the
101 // bridge must hold *each* frame open until (1) the srcdoc document has truly
102 // loaded its animations and (2) we've seeked them to this frame's time. The
103 // earlier single-handle-on-mount version only synced the first frame; every
104 // later frame screenshotted before the async re-seek landed → black output.
105 //
106 // We create a fresh handle for the current `tMs`, poll until the doc is ready,
107 // seek, then continueRender. A short rAF settle lets the seeked styles paint
108 // before Remotion screenshots. The handle is keyed to tMs so a new frame can't
109 // clear a stale one.
110 const [handle] = useState(() => delayRender(`HTML frame seek @0ms`));
111 const firstClearedRef = useRef(false);
112
113 useEffect(() => {
114 let cancelled = false;
115 // For the very first frame we reuse the mount-time handle; subsequent frames
116 // open their own so Remotion waits for each re-seek.
117 const h = firstClearedRef.current ? delayRender(`HTML frame seek @${Math.round(tMs)}ms`) : handle;
118 let tries = 0;
119 const finish = () => {
120 if (cancelled) return;
121 seek(tMs);
122 // One rAF so the seeked computed styles are committed before screenshot.
123 requestAnimationFrame(() => {
124 if (cancelled) return;
125 firstClearedRef.current = true;
126 continueRender(h);
127 });
128 };
129 const tick = () => {
130 if (cancelled) return;
131 if (docReady(frameDoc(iframeRef.current)) || tries++ >= 200) {
132 finish();
133 } else {
134 setTimeout(tick, 25);
135 }
136 };
137 tick();
138 return () => {
139 cancelled = true;
140 };
141 // eslint-disable-next-line react-hooks/exhaustive-deps
142 }, [tMs, seek]);
143
144 return (
145 <iframe
146 ref={iframeRef}
147 srcDoc={html}
148 width={width}
149 height={height}
150 style={{ width, height, border: 'none', display: 'block' }}
151 sandbox="allow-same-origin allow-scripts"
152 />
153 );
154 };
155
155 lines Plain Text