Write video output to a real OPFS file instead of assembling chunks
The hand-rolled chunk assembly was verified correct in Node but still produced a broken MP4 in the browser, so stop relying on it: the muxer now streams into an Origin Private File System file (random access is exactly what MP4 needs), the worker returns the OPFS File directly, and the entry is deleted after a successful apply (stale ones are purged hourly). The chunk collector remains only as a fallback for browsers without OPFS. The result is validated before it reaches the UI: MP4s must contain a moov box and WebM files must start with the EBML magic, so a broken muxer output surfaces as an error instead of a 0x0 preview.
This commit is contained in:
@@ -9,6 +9,7 @@ import { formatBytes } from "@/lib/format";
|
||||
import type { MediaItem } from "@/lib/types";
|
||||
|
||||
import { detectVideoCodecs } from "./detect";
|
||||
import { deleteStoredFile } from "./opfs";
|
||||
import {
|
||||
DEFAULT_OPTIONS,
|
||||
kindForExtension,
|
||||
@@ -220,6 +221,7 @@ export function OptimizeModal({
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (result?.storageKey) void deleteStoredFile(result.storageKey);
|
||||
setApplied(true);
|
||||
setApplyError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||
@@ -250,6 +252,7 @@ export function OptimizeModal({
|
||||
function process() {
|
||||
setApplied(false);
|
||||
setApplyError(null);
|
||||
if (result?.storageKey) void deleteStoredFile(result.storageKey);
|
||||
setResultInfo(null);
|
||||
run({
|
||||
url: item.raw_url,
|
||||
@@ -259,6 +262,11 @@ export function OptimizeModal({
|
||||
});
|
||||
}
|
||||
|
||||
function resetRun() {
|
||||
if (result?.storageKey) void deleteStoredFile(result.storageKey);
|
||||
reset();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-8 backdrop-blur-sm"
|
||||
@@ -645,7 +653,7 @@ export function OptimizeModal({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={reset}
|
||||
onClick={resetRun}
|
||||
disabled={status === "running" || (!result && !error)}
|
||||
>
|
||||
Reset
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Small helpers around the Origin Private File System, used to stream muxer
|
||||
* output into a real file with random access instead of hand-assembling
|
||||
* chunks in memory. Browser-only; every function is a no-op when OPFS is not
|
||||
* available.
|
||||
*/
|
||||
|
||||
const PREFIX = "j621-optimize-";
|
||||
const STALE_MS = 60 * 60 * 1000; // purge leftovers after an hour
|
||||
|
||||
function directory(): Promise<FileSystemDirectoryHandle> | null {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
const storage = navigator.storage as StorageManager | undefined;
|
||||
if (!storage?.getDirectory) return null;
|
||||
return storage.getDirectory();
|
||||
}
|
||||
|
||||
export function opfsSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
Boolean((navigator.storage as StorageManager | undefined)?.getDirectory)
|
||||
);
|
||||
}
|
||||
|
||||
export async function createStoredFile(
|
||||
extension: string,
|
||||
): Promise<{ name: string; handle: FileSystemFileHandle } | null> {
|
||||
const root = await directory();
|
||||
if (!root) return null;
|
||||
const name = `${PREFIX}${crypto.randomUUID()}.${extension}`;
|
||||
const handle = await root.getFileHandle(name, { create: true });
|
||||
return { name, handle };
|
||||
}
|
||||
|
||||
export async function deleteStoredFile(name: string): Promise<void> {
|
||||
try {
|
||||
const root = await directory();
|
||||
if (!root) return;
|
||||
await root.removeEntry(name);
|
||||
} catch {
|
||||
// Best effort: stale entries are purged on the next run anyway.
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgeStaleStoredFiles(): Promise<void> {
|
||||
try {
|
||||
const root = await directory();
|
||||
if (!root) return;
|
||||
const now = Date.now();
|
||||
const entries = (
|
||||
root as unknown as {
|
||||
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
|
||||
}
|
||||
).entries();
|
||||
for await (const [name, handle] of entries) {
|
||||
if (!name.startsWith(PREFIX) || handle.kind !== "file") continue;
|
||||
try {
|
||||
const file = await (handle as FileSystemFileHandle).getFile();
|
||||
if (now - file.lastModified > STALE_MS) {
|
||||
await root.removeEntry(name);
|
||||
}
|
||||
} catch {
|
||||
// Ignore entries that vanish underneath us.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OPFS iteration is best effort.
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ scope.onmessage = async (event: MessageEvent<OptimizeRequest>) => {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
note: result.note,
|
||||
storageKey: result.storageKey,
|
||||
});
|
||||
} catch (error) {
|
||||
scope.postMessage({
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
import { StreamTarget, type StreamTargetChunk } from "mediabunny";
|
||||
|
||||
import { createStoredFile, opfsSupported, purgeStaleStoredFiles } from "./opfs";
|
||||
|
||||
interface CollectedChunk {
|
||||
blob: Blob;
|
||||
position: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the muxed output into Blob parts instead of one giant ArrayBuffer —
|
||||
* a 4K file would otherwise blow the tab's memory. Writes may arrive out of
|
||||
* order (the muxer patches headers), so the newest write wins per byte range
|
||||
* when the final blob is assembled.
|
||||
*/
|
||||
export function createStreamOutput(): {
|
||||
export interface StreamOutput {
|
||||
target: StreamTarget;
|
||||
toBlob: (type: string) => Blob;
|
||||
} {
|
||||
finalize(type: string): Promise<{ blob: Blob; storageKey?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the muxed output somewhere that is not one giant ArrayBuffer — a 4K
|
||||
* file would otherwise blow the tab's memory.
|
||||
*
|
||||
* Preferred: a real Origin Private File System file with random access, which
|
||||
* is what the muxer expects (it rewrites earlier byte ranges for headers).
|
||||
* Fallback: collect chunks and assemble them, with the newest write winning
|
||||
* per byte range.
|
||||
*/
|
||||
export async function createStreamOutput(
|
||||
extension: string,
|
||||
): Promise<StreamOutput> {
|
||||
if (opfsSupported()) {
|
||||
const stored = await createStoredFile(extension);
|
||||
if (stored) {
|
||||
void purgeStaleStoredFiles();
|
||||
const writable = await stored.handle.createWritable();
|
||||
return {
|
||||
target: new StreamTarget(writable),
|
||||
finalize: async (type: string) => {
|
||||
await writable.close();
|
||||
const file = await stored.handle.getFile();
|
||||
return {
|
||||
blob: new File([file], stored.name, { type }),
|
||||
storageKey: stored.name,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return createBufferOutput();
|
||||
}
|
||||
|
||||
function createBufferOutput(): StreamOutput {
|
||||
const chunks: CollectedChunk[] = [];
|
||||
let sequence = 0;
|
||||
const writable = new WritableStream<StreamTargetChunk>({
|
||||
@@ -32,7 +63,9 @@ export function createStreamOutput(): {
|
||||
chunked: true,
|
||||
chunkSize: 8 * 1024 * 1024,
|
||||
}),
|
||||
toBlob: (type: string) => assembleBlob(chunks, type),
|
||||
finalize: async (type: string) => ({
|
||||
blob: assembleBlob(chunks, type),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface OptimizeResult {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
note?: string;
|
||||
/** OPFS entry to clean up once the file has been applied. */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export type OptimizerMessage =
|
||||
@@ -62,6 +64,7 @@ export type OptimizerMessage =
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
note?: string;
|
||||
storageKey?: string;
|
||||
}
|
||||
| { type: "error"; message: string };
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export function useOptimizer() {
|
||||
width: message.width,
|
||||
height: message.height,
|
||||
note: message.note,
|
||||
storageKey: message.storageKey,
|
||||
},
|
||||
});
|
||||
worker.terminate();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type VideoCodec,
|
||||
} from "mediabunny";
|
||||
|
||||
import { createStreamOutput } from "./stream";
|
||||
import { createStreamOutput, type StreamOutput } from "./stream";
|
||||
import {
|
||||
extensionOf,
|
||||
type OptimizeOptions,
|
||||
@@ -83,7 +83,7 @@ export async function optimizeVideo(
|
||||
| {
|
||||
conversion: Conversion;
|
||||
input: Input;
|
||||
stream: ReturnType<typeof createStreamOutput>;
|
||||
stream: StreamOutput;
|
||||
codec: VideoCodec;
|
||||
}
|
||||
| null = null;
|
||||
@@ -95,7 +95,7 @@ export async function optimizeVideo(
|
||||
source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const stream = createStreamOutput();
|
||||
const stream = await createStreamOutput(container);
|
||||
const output = new Output({ format, target: stream.target });
|
||||
const attempt = await Conversion.init({
|
||||
input,
|
||||
@@ -141,11 +141,61 @@ export async function optimizeVideo(
|
||||
winner.input.dispose?.();
|
||||
}
|
||||
|
||||
report(0.97, "Packaging…");
|
||||
report(0.97, "Checking the result…");
|
||||
const { blob, storageKey } = await winner.stream.finalize(
|
||||
container === "webm" ? "video/webm" : "video/mp4",
|
||||
);
|
||||
await validateMedia(blob, container);
|
||||
return {
|
||||
blob: winner.stream.toBlob(container === "webm" ? "video/webm" : "video/mp4"),
|
||||
blob,
|
||||
width: outputWidth,
|
||||
height: outputHeight,
|
||||
note: `${winner.codec} · ${container}`,
|
||||
storageKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject a truncated or structurally broken muxer output up front. */
|
||||
async function validateMedia(
|
||||
file: Blob,
|
||||
container: "mp4" | "webm",
|
||||
): Promise<void> {
|
||||
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
||||
if (container === "webm") {
|
||||
const magic = [0x1a, 0x45, 0xdf, 0xa3];
|
||||
if (!magic.every((byte, index) => head[index] === byte)) {
|
||||
throw new Error("The muxer produced an invalid WebM file.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Walk the top-level MP4 boxes until a moov box is found.
|
||||
let offset = 0;
|
||||
for (let guard = 0; guard < 64 && offset + 8 <= file.size; guard++) {
|
||||
const header = new Uint8Array(
|
||||
await file.slice(offset, offset + 16).arrayBuffer(),
|
||||
);
|
||||
const size =
|
||||
((header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3]) >>>
|
||||
0;
|
||||
const type = String.fromCharCode(
|
||||
header[4],
|
||||
header[5],
|
||||
header[6],
|
||||
header[7],
|
||||
);
|
||||
if (type === "moov") return;
|
||||
let boxSize = size;
|
||||
if (size === 1 && header.length >= 16) {
|
||||
boxSize = Number(
|
||||
new DataView(header.buffer, header.byteOffset, header.byteLength).getBigUint64(
|
||||
8,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!boxSize || boxSize < 8) break;
|
||||
offset += boxSize;
|
||||
}
|
||||
throw new Error(
|
||||
"The muxer produced an invalid MP4 file (no moov box found).",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user