Skip to content

Commit 5c255c6

Browse files
committed
add Img
1 parent 59902d5 commit 5c255c6

4 files changed

Lines changed: 200 additions & 0 deletions

File tree

docs/docs/lib/media.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ For longer clips, set `showWaveform` to enable it explicitly:
2727
<Video video="assets/demo.mp4" showWaveform />
2828
```
2929

30+
### `<Img>`
31+
32+
Image component that waits for decode before the headless renderer captures frames.
33+
34+
```tsx
35+
import { Img } from "../src/lib/image"
36+
37+
<Img src="assets/intro.png" />
38+
```
39+
3040
### `video_length`
3141

3242
Returns the length of a video in frames.

docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/media.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ import { Video } from "../src/lib/video/video"
2727
<Video video="assets/demo.mp4" showWaveform />
2828
```
2929

30+
### `<Img>`
31+
32+
レンダラーがフレームを取得する前にデコード完了を待つ画像コンポーネントです。
33+
34+
```tsx
35+
import { Img } from "../src/lib/image"
36+
37+
<Img src="assets/intro.png" />
38+
```
39+
3040
### `video_length`
3141
動画の長さを取得します。
3242
```tsx

render/src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,18 @@ async fn wait_for_draw_text_ready(page: &Page) {
142142
page.evaluate(script).await.unwrap();
143143
}
144144

145+
async fn wait_for_images_ready(page: &Page) {
146+
let script = r#"
147+
(async () => {
148+
const api = window.__frameScript;
149+
if (api && typeof api.waitImagesReady === "function") {
150+
await api.waitImagesReady();
151+
}
152+
})()
153+
"#;
154+
page.evaluate(script).await.unwrap();
155+
}
156+
145157
async fn wait_for_webgl_ready(page: &Page) {
146158
let script = r#"
147159
(async () => {
@@ -367,6 +379,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
367379
);
368380
page.evaluate(script).await.unwrap();
369381

382+
wait_for_images_ready(&page).await;
370383
wait_for_webgl_frame(&page, frame).await;
371384

372385
let bytes = page

src/lib/image.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { useCallback, useEffect, useRef, type ImgHTMLAttributes, type SyntheticEvent } from "react"
2+
3+
type ImageTracker = {
4+
pending: number
5+
start: () => () => void
6+
wait: () => Promise<void>
7+
}
8+
9+
const IMAGE_TRACKER_KEY = "__frameScript_ImageTracker"
10+
11+
const getImageTracker = (): ImageTracker => {
12+
const g = globalThis as unknown as Record<string, unknown>
13+
const existing = g[IMAGE_TRACKER_KEY] as ImageTracker | undefined
14+
if (existing) return existing
15+
16+
let pending = 0
17+
const waiters = new Set<() => void>()
18+
19+
const notifyIfReady = () => {
20+
if (pending !== 0) return
21+
for (const resolve of Array.from(waiters)) {
22+
resolve()
23+
}
24+
waiters.clear()
25+
}
26+
27+
const tracker: ImageTracker = {
28+
get pending() {
29+
return pending
30+
},
31+
start: () => {
32+
pending += 1
33+
let done = false
34+
return () => {
35+
if (done) return
36+
done = true
37+
pending = Math.max(0, pending - 1)
38+
notifyIfReady()
39+
}
40+
},
41+
wait: () => {
42+
if (pending === 0) return Promise.resolve()
43+
return new Promise<void>((resolve) => {
44+
waiters.add(resolve)
45+
})
46+
},
47+
}
48+
49+
g[IMAGE_TRACKER_KEY] = tracker
50+
return tracker
51+
}
52+
53+
const waitForAnimationTick = () =>
54+
new Promise<void>((resolve) => {
55+
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
56+
setTimeout(resolve, 0)
57+
return
58+
}
59+
window.requestAnimationFrame(() => resolve())
60+
})
61+
62+
const installImageApi = () => {
63+
if (typeof window === "undefined") return
64+
const tracker = getImageTracker()
65+
const waitImagesReady = async () => {
66+
while (true) {
67+
if (tracker.pending === 0) {
68+
await waitForAnimationTick()
69+
if (tracker.pending === 0) return
70+
}
71+
await tracker.wait()
72+
}
73+
}
74+
75+
;(window as any).__frameScript = {
76+
...(window as any).__frameScript,
77+
waitImagesReady,
78+
getImagesPending: () => tracker.pending,
79+
}
80+
}
81+
82+
if (typeof window !== "undefined") {
83+
installImageApi()
84+
}
85+
86+
const useImagePending = () => {
87+
const loadIdRef = useRef(0)
88+
const pendingFinishRef = useRef<(() => void) | null>(null)
89+
90+
const beginPending = useCallback(() => {
91+
loadIdRef.current += 1
92+
if (!pendingFinishRef.current) {
93+
pendingFinishRef.current = getImageTracker().start()
94+
}
95+
return loadIdRef.current
96+
}, [])
97+
98+
const endPending = useCallback(() => {
99+
if (pendingFinishRef.current) {
100+
pendingFinishRef.current()
101+
pendingFinishRef.current = null
102+
}
103+
}, [])
104+
105+
useEffect(() => () => endPending(), [endPending])
106+
107+
return { beginPending, endPending, loadIdRef }
108+
}
109+
110+
export type ImgProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
111+
src: string
112+
}
113+
114+
/**
115+
* Image component that waits for decode before rendering in headless mode.
116+
*
117+
* デコード完了まで待機する <Img> です。
118+
*/
119+
export const Img = ({ src, onLoad, onError, ...props }: ImgProps) => {
120+
const imgRef = useRef<HTMLImageElement | null>(null)
121+
const { beginPending, endPending, loadIdRef } = useImagePending()
122+
123+
useEffect(() => {
124+
const img = imgRef.current
125+
if (!img || !src) return
126+
127+
const loadId = beginPending()
128+
let done = false
129+
130+
const finalize = () => {
131+
if (done) return
132+
done = true
133+
if (loadId === loadIdRef.current) {
134+
endPending()
135+
}
136+
}
137+
138+
const handleLoad = (event: Event) => {
139+
onLoad?.(event as unknown as SyntheticEvent<HTMLImageElement>)
140+
const decode = typeof img.decode === "function" ? img.decode() : Promise.resolve()
141+
decode.catch(() => {}).finally(finalize)
142+
}
143+
144+
const handleError = (event: Event) => {
145+
onError?.(event as unknown as SyntheticEvent<HTMLImageElement>)
146+
finalize()
147+
}
148+
149+
if (img.complete && img.naturalWidth > 0) {
150+
const decode = typeof img.decode === "function" ? img.decode() : Promise.resolve()
151+
decode.catch(() => {}).finally(finalize)
152+
} else {
153+
img.addEventListener("load", handleLoad)
154+
img.addEventListener("error", handleError)
155+
}
156+
157+
return () => {
158+
img.removeEventListener("load", handleLoad)
159+
img.removeEventListener("error", handleError)
160+
if (loadId === loadIdRef.current) {
161+
endPending()
162+
}
163+
}
164+
}, [src, onLoad, onError, beginPending, endPending])
165+
166+
return <img ref={imgRef} src={src} {...props} />
167+
}

0 commit comments

Comments
 (0)