Skip to content

Commit 4f4701d

Browse files
committed
Worker based mediainfo.js
1 parent 0747370 commit 4f4701d

File tree

6 files changed

+190
-121
lines changed

6 files changed

+190
-121
lines changed

Caddyfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
header {
99
X-Frame-Options "DENY"
1010
X-Content-Type-Options "nosniff"
11+
Content-Security-Policy "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; frame-src 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'; connect-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' 'wasm-unsafe-eval'; media-src 'self' blob:; upgrade-insecure-requests"
1112
Referrer-Policy "no-referrer"
1213
Permissions-Policy "autoplay=(self), fullscreen=(self), picture-in-picture=(), geolocation=(), microphone=(), camera=(), display-capture=(), screen-wake-lock=(), usb=(), serial=(), hid=(), midi=(), payment=(), accelerometer=(), gyroscope=(), magnetometer=(), clipboard-read=(), clipboard-write=(), idle-detection=(), encrypted-media=(), storage-access=(), attribution-reporting=(), browsing-topics=(), run-ad-auction=(), join-ad-interest-group=(), publickey-credentials-get=(), xr-spatial-tracking=(), gamepad=(), sync-xhr=(), local-fonts=(), otp-credentials=(), window-management=()"
1314
Cross-Origin-Resource-Policy "same-origin"

pnpm-lock.yaml

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/store/video.ts

Lines changed: 51 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { atom } from "jotai";
2+
import type { Getter, Setter } from "jotai";
23
import { atomWithReset } from "jotai/utils";
34
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";
69

710
import { isVideoFile } from "../utils";
811
import {
@@ -48,140 +51,72 @@ export const hasVideoMetadataAtom = atom(
4851
(get) => get(videoMetadataAtom) !== null,
4952
);
5053

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;
5257
export const mediaInfoMetadataAtom = atom<MediaInfoMetadata | null>(null);
5358

5459
// 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" });
8066

8167
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
8972
}
90-
set(mediaInfoInstanceAtom, null);
73+
set(mediaInfoWorkerAtom, null);
9174
};
9275
});
9376

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-
12577
// 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);
12880
const file = get(videoFileAtom);
12981

130-
// If no instance or file, clear metadata and stop
131-
if (!mi || !file) {
82+
if (!worker || !file) {
13283
set(mediaInfoMetadataAtom, null);
13384
return;
13485
}
13586

13687
let canceled = false;
88+
const id = ++workerReqId;
13789

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);
180105
set(mediaInfoMetadataAtom, null);
181-
});
106+
worker.removeEventListener("message", handleMessage);
107+
}
108+
};
109+
110+
worker.addEventListener("message", handleMessage);
111+
worker.postMessage({ id, type: "analyze", file });
182112

183113
return () => {
184114
canceled = true;
115+
try {
116+
worker.removeEventListener("message", handleMessage);
117+
} catch {
118+
// ignore
119+
}
185120
};
186121
});
187122

@@ -276,7 +211,7 @@ export const toggleMuteAtom = atom(null, (get, set) => {
276211

277212
// EFFECT ATOMS
278213
// 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) => {
280215
const url = get(videoUrlAtom);
281216
return () => {
282217
if (url) URL.revokeObjectURL(url);
@@ -311,7 +246,7 @@ export const updateVolumeStateAtom = atom(
311246
);
312247

313248
// Keep the effect for URL cleanup only
314-
export const videoElementSyncEffect = atomEffect((get, set) => {
249+
export const videoElementSyncEffect = atomEffect((get: Getter, set: Setter) => {
315250
const element = get(videoElementAtom);
316251
if (!element) return;
317252

src/workers/mediainfo.worker.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import mediaInfoFactory from "mediainfo.js";
2+
import type { MediaInfo } from "mediainfo.js";
3+
4+
// Keep types in sync with app
5+
export interface MediaInfoMetadata {
6+
title?: string;
7+
videoCodec?: string;
8+
videoHeight?: number;
9+
videoWidth?: number;
10+
videoFrameRate?: number;
11+
videoBitrate?: number;
12+
videoColorSpace?: string;
13+
videoProfile?: string;
14+
videoBitDepth?: number;
15+
audioCodec?: string;
16+
audioBitrate?: number;
17+
audioSampleRate?: number;
18+
audioChannels?: number;
19+
containerFormat?: string;
20+
fileSize?: number;
21+
fileName?: string;
22+
duration?: number;
23+
}
24+
25+
type WorkerRequest =
26+
| { id: number; type: "warmup" }
27+
| { id: number; type: "analyze"; file: File };
28+
29+
type WorkerResponse =
30+
| { id: number; type: "ready" }
31+
| { id: number; type: "metadata"; metadata: MediaInfoMetadata | null }
32+
| { id: number; type: "error"; message: string };
33+
34+
let miInstance: MediaInfo | null = null;
35+
36+
const parseNumber = (value: unknown): number | undefined => {
37+
if (typeof value === "number") return value;
38+
if (typeof value === "string") {
39+
const cleaned = value.replace(/[, ]/g, "");
40+
const match = /^[0-9]+(?:\.[0-9]+)?/.exec(cleaned);
41+
if (match) return Number(match[0]);
42+
}
43+
return undefined;
44+
};
45+
46+
async function ensureMediaInfo(): Promise<MediaInfo> {
47+
if (miInstance) return miInstance;
48+
miInstance = await mediaInfoFactory({ format: "object" as const });
49+
return miInstance;
50+
}
51+
52+
interface WorkerTrackShape {
53+
"@type"?: string;
54+
Title?: string;
55+
Format?: string;
56+
CodecID?: string;
57+
Height?: string | number;
58+
Width?: string | number;
59+
FrameRate?: string | number;
60+
BitRate?: string | number;
61+
ColorSpace?: string;
62+
SamplingRate?: string | number;
63+
}
64+
65+
self.onmessage = async (evt: MessageEvent<WorkerRequest>) => {
66+
const msg = evt.data;
67+
try {
68+
switch (msg.type) {
69+
case "warmup": {
70+
await ensureMediaInfo();
71+
const res: WorkerResponse = { id: msg.id, type: "ready" };
72+
self.postMessage(res);
73+
return;
74+
}
75+
case "analyze": {
76+
const mi = await ensureMediaInfo();
77+
const file = msg.file;
78+
79+
const result = await mi.analyzeData(
80+
file.size,
81+
async (chunkSize: number, offset: number) => {
82+
const blob = file.slice(offset, offset + chunkSize);
83+
const buf = await blob.arrayBuffer();
84+
return new Uint8Array(buf);
85+
},
86+
);
87+
88+
const mediaObj = result as unknown as {
89+
media?: { track?: WorkerTrackShape[] };
90+
};
91+
const tracks = mediaObj.media?.track ?? [];
92+
93+
const general = tracks.find((t) => t["@type"] === "General");
94+
const video = tracks.find((t) => t["@type"] === "Video");
95+
const audio = tracks.find((t) => t["@type"] === "Audio");
96+
97+
const metadata: MediaInfoMetadata = {
98+
title: general?.Title ?? undefined,
99+
videoCodec: video?.Format ?? video?.CodecID,
100+
videoHeight: parseNumber(video?.Height),
101+
videoWidth: parseNumber(video?.Width),
102+
videoFrameRate: parseNumber(video?.FrameRate),
103+
videoBitrate: parseNumber(video?.BitRate),
104+
videoColorSpace: video?.ColorSpace ?? undefined,
105+
audioCodec: audio?.Format ?? audio?.CodecID,
106+
audioBitrate: parseNumber(audio?.BitRate),
107+
audioSampleRate: parseNumber(audio?.SamplingRate),
108+
};
109+
110+
const res: WorkerResponse = { id: msg.id, type: "metadata", metadata };
111+
self.postMessage(res);
112+
return;
113+
}
114+
}
115+
} catch (e) {
116+
const res: WorkerResponse = {
117+
id: msg.id,
118+
type: "error",
119+
message: e instanceof Error ? e.message : String(e),
120+
};
121+
self.postMessage(res);
122+
}
123+
};
124+
125+
export {};

vercel.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
},
3333
{
3434
"key": "Content-Security-Policy",
35-
"value": "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; frame-src 'none'; form-action 'none'; manifest-src 'self'; worker-src 'self'; connect-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'; media-src 'self' blob:; upgrade-insecure-requests"
35+
"value": "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; frame-src 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'; connect-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' 'wasm-unsafe-eval'; media-src 'self' blob:; upgrade-insecure-requests"
3636
}
3737
]
3838
}

0 commit comments

Comments
 (0)