|
1 | 1 | import { atom } from "jotai"; |
| 2 | +import type { Getter, Setter } from "jotai"; |
2 | 3 | import { atomWithReset } from "jotai/utils"; |
3 | 4 | import { atomEffect } from "jotai-effect"; |
4 | | -import mediaInfoFactory from "mediainfo.js"; |
5 | | -import type { MediaInfo } from "mediainfo.js"; |
| 5 | + |
| 6 | +// MediaInfo is offloaded to a Web Worker to avoid 'unsafe-eval' on the main page |
| 7 | +// and to scope CSP relaxation to worker only. |
| 8 | +import MediainfoWorker from "../workers/mediainfo.worker?worker"; |
6 | 9 |
|
7 | 10 | import { isVideoFile } from "../utils"; |
8 | 11 | import { |
@@ -48,140 +51,72 @@ export const hasVideoMetadataAtom = atom( |
48 | 51 | (get) => get(videoMetadataAtom) !== null, |
49 | 52 | ); |
50 | 53 |
|
51 | | -export const mediaInfoInstanceAtom = atom<MediaInfo | null>(null); |
| 54 | +// Worker instance and a request counter for correlating responses |
| 55 | +const mediaInfoWorkerAtom = atom<Worker | null>(null); |
| 56 | +let workerReqId = 0; |
52 | 57 | export const mediaInfoMetadataAtom = atom<MediaInfoMetadata | null>(null); |
53 | 58 |
|
54 | 59 | // Initialize a single MediaInfo instance for the app lifecycle and clean it up on unmount |
55 | | -export const mediaInfoInitEffect = atomEffect((_get, set) => { |
56 | | - let instance: MediaInfo | null = null; |
57 | | - let closed = false; |
58 | | - |
59 | | - // Create the MediaInfo instance (object format is easier to consume programmatically) |
60 | | - void mediaInfoFactory({ |
61 | | - format: "object" as const, |
62 | | - }) |
63 | | - .then((mi) => { |
64 | | - if (closed) { |
65 | | - // If effect already cleaned up, immediately close the created instance |
66 | | - try { |
67 | | - mi.close(); |
68 | | - } catch { |
69 | | - // ignore |
70 | | - } |
71 | | - return; |
72 | | - } |
73 | | - instance = mi; |
74 | | - set(mediaInfoInstanceAtom, mi); |
75 | | - }) |
76 | | - .catch((error: unknown) => { |
77 | | - console.error("Failed to initialize MediaInfo:", error); |
78 | | - set(mediaInfoInstanceAtom, null); |
79 | | - }); |
| 60 | +export const mediaInfoInitEffect = atomEffect((_get: Getter, set: Setter) => { |
| 61 | + // Lazily create one worker |
| 62 | + const worker = new MediainfoWorker(); |
| 63 | + set(mediaInfoWorkerAtom, worker); |
| 64 | + |
| 65 | + worker.postMessage({ id: ++workerReqId, type: "warmup" }); |
80 | 66 |
|
81 | 67 | return () => { |
82 | | - closed = true; |
83 | | - if (instance) { |
84 | | - try { |
85 | | - instance.close(); |
86 | | - } catch { |
87 | | - // ignore |
88 | | - } |
| 68 | + try { |
| 69 | + worker.terminate(); |
| 70 | + } catch { |
| 71 | + // ignore |
89 | 72 | } |
90 | | - set(mediaInfoInstanceAtom, null); |
| 73 | + set(mediaInfoWorkerAtom, null); |
91 | 74 | }; |
92 | 75 | }); |
93 | 76 |
|
94 | | -// Helper to parse numeric strings that may contain units (e.g., "1 920 pixels", "48 000 KHz") |
95 | | -const parseNumber = (value: unknown): number | undefined => { |
96 | | - if (typeof value === "number") return value; |
97 | | - if (typeof value === "string") { |
98 | | - const cleaned = value.replace(/[, ]/g, ""); |
99 | | - const match = /^[0-9]+(?:\.[0-9]+)?/.exec(cleaned); |
100 | | - if (match) return Number(match[0]); |
101 | | - } |
102 | | - return undefined; |
103 | | -}; |
104 | | - |
105 | | -// Minimal shape of mediainfo.js object output when using { format: 'object' } |
106 | | -interface MediaInfoTrack { |
107 | | - "@type"?: string; |
108 | | - Title?: string; |
109 | | - Format?: string; |
110 | | - CodecID?: string; |
111 | | - Height?: string | number; |
112 | | - Width?: string | number; |
113 | | - FrameRate?: string | number; |
114 | | - BitRate?: string | number; |
115 | | - ColorSpace?: string; |
116 | | - SamplingRate?: string | number; |
117 | | -} |
118 | | - |
119 | | -interface MediaInfoObjectResult { |
120 | | - media?: { |
121 | | - track?: MediaInfoTrack[]; |
122 | | - }; |
123 | | -} |
124 | | - |
125 | 77 | // EFFECT: Extract detailed metadata with MediaInfo when a file is set |
126 | | -export const mediaInfoExtractEffect = atomEffect((get, set) => { |
127 | | - const mi = get(mediaInfoInstanceAtom); |
| 78 | +export const mediaInfoExtractEffect = atomEffect((get: Getter, set: Setter) => { |
| 79 | + const worker = get(mediaInfoWorkerAtom); |
128 | 80 | const file = get(videoFileAtom); |
129 | 81 |
|
130 | | - // If no instance or file, clear metadata and stop |
131 | | - if (!mi || !file) { |
| 82 | + if (!worker || !file) { |
132 | 83 | set(mediaInfoMetadataAtom, null); |
133 | 84 | return; |
134 | 85 | } |
135 | 86 |
|
136 | 87 | let canceled = false; |
| 88 | + const id = ++workerReqId; |
137 | 89 |
|
138 | | - // analyzeData reads the file in chunks via the provided reader |
139 | | - void mi |
140 | | - .analyzeData(file.size, async (chunkSize: number, offset: number) => { |
141 | | - const blob = file.slice(offset, offset + chunkSize); |
142 | | - const buf = await blob.arrayBuffer(); |
143 | | - return new Uint8Array(buf); |
144 | | - }) |
145 | | - .then((result: unknown) => { |
146 | | - if (canceled) return; |
147 | | - |
148 | | - // result is the object output by mediainfo.js |
149 | | - const mediaObj = result as MediaInfoObjectResult; |
150 | | - const tracks = mediaObj.media?.track ?? []; |
151 | | - |
152 | | - const general: MediaInfoTrack | undefined = tracks.find( |
153 | | - (t) => t["@type"] === "General", |
154 | | - ); |
155 | | - const video: MediaInfoTrack | undefined = tracks.find( |
156 | | - (t) => t["@type"] === "Video", |
157 | | - ); |
158 | | - const audio: MediaInfoTrack | undefined = tracks.find( |
159 | | - (t) => t["@type"] === "Audio", |
160 | | - ); |
161 | | - |
162 | | - const mapped: MediaInfoMetadata = { |
163 | | - title: general?.Title, |
164 | | - videoCodec: video?.Format ?? video?.CodecID, |
165 | | - videoHeight: parseNumber(video?.Height), |
166 | | - videoWidth: parseNumber(video?.Width), |
167 | | - videoFrameRate: parseNumber(video?.FrameRate), |
168 | | - videoBitrate: parseNumber(video?.BitRate), |
169 | | - videoColorSpace: video?.ColorSpace, |
170 | | - audioCodec: audio?.Format ?? audio?.CodecID, |
171 | | - audioBitrate: parseNumber(audio?.BitRate), |
172 | | - audioSampleRate: parseNumber(audio?.SamplingRate), |
173 | | - }; |
174 | | - |
175 | | - set(mediaInfoMetadataAtom, mapped); |
176 | | - }) |
177 | | - .catch((err: unknown) => { |
178 | | - if (canceled) return; |
179 | | - console.warn("MediaInfo analyzeData failed:", err); |
| 90 | + interface WorkerMsg { |
| 91 | + id?: number; |
| 92 | + type?: string; |
| 93 | + metadata?: MediaInfoMetadata | null; |
| 94 | + message?: string; |
| 95 | + } |
| 96 | + const handleMessage = (evt: MessageEvent<WorkerMsg>) => { |
| 97 | + const data = evt.data; |
| 98 | + if (data.id !== id) return; |
| 99 | + if (canceled) return; |
| 100 | + if (data.type === "metadata") { |
| 101 | + set(mediaInfoMetadataAtom, data.metadata ?? null); |
| 102 | + worker.removeEventListener("message", handleMessage); |
| 103 | + } else if (data.type === "error") { |
| 104 | + console.warn("MediaInfo worker error:", data.message); |
180 | 105 | set(mediaInfoMetadataAtom, null); |
181 | | - }); |
| 106 | + worker.removeEventListener("message", handleMessage); |
| 107 | + } |
| 108 | + }; |
| 109 | + |
| 110 | + worker.addEventListener("message", handleMessage); |
| 111 | + worker.postMessage({ id, type: "analyze", file }); |
182 | 112 |
|
183 | 113 | return () => { |
184 | 114 | canceled = true; |
| 115 | + try { |
| 116 | + worker.removeEventListener("message", handleMessage); |
| 117 | + } catch { |
| 118 | + // ignore |
| 119 | + } |
185 | 120 | }; |
186 | 121 | }); |
187 | 122 |
|
@@ -276,7 +211,7 @@ export const toggleMuteAtom = atom(null, (get, set) => { |
276 | 211 |
|
277 | 212 | // EFFECT ATOMS |
278 | 213 | // eslint-disable-next-line @typescript-eslint/no-unused-vars |
279 | | -export const videoUrlCleanupEffect = atomEffect((get, _set) => { |
| 214 | +export const videoUrlCleanupEffect = atomEffect((get: Getter, _set: Setter) => { |
280 | 215 | const url = get(videoUrlAtom); |
281 | 216 | return () => { |
282 | 217 | if (url) URL.revokeObjectURL(url); |
@@ -311,7 +246,7 @@ export const updateVolumeStateAtom = atom( |
311 | 246 | ); |
312 | 247 |
|
313 | 248 | // Keep the effect for URL cleanup only |
314 | | -export const videoElementSyncEffect = atomEffect((get, set) => { |
| 249 | +export const videoElementSyncEffect = atomEffect((get: Getter, set: Setter) => { |
315 | 250 | const element = get(videoElementAtom); |
316 | 251 | if (!element) return; |
317 | 252 |
|
|
0 commit comments