import type { ProjectDocument } from "@web-video-editor/domain"; import type { BrowserMediaSource } from "@web-video-editor/media-runtime"; import { BoundedTaskQueue, WorkerClient, type QueueStats, type ScheduledTaskHandle, } from "@web-video-editor/media-runtime"; import { LogHub, StructuredLogger } from "@web-video-editor/observability"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ActionAvailability } from "../capabilities"; import { inspectExportFile } from "./export-inspection"; import { getExportFile } from "./export-opfs"; import { MEDIA_EXPORT_OPERATION, type MediaExportProgress, type MediaExportRequest, type MediaExportResult, } from "./export-types"; import "./ExportPanel.css"; type ExportStatus = "cancelled" | "completed" | "failed" | "idle" | "running"; export type ExportPanelProps = { actionAvailability?: ActionAvailability; logHub: LogHub; project: ProjectDocument; sources: Readonly>; }; function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; } return `${Math.ceil(bytes / 1024)} KiB`; } function formatDuration(milliseconds: number): string { if (!Number.isFinite(milliseconds) || milliseconds <= 0) { return "—"; } return `${(milliseconds / 1_000).toFixed(1)}s`; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function ExportPanel({ actionAvailability, logHub, project, sources, }: ExportPanelProps) { const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(); const [downloadUrl, setDownloadUrl] = useState(); const taskRef = useRef< ScheduledTaskHandle | undefined >(undefined); const resultRef = useRef(null); const progressRef = useRef(null); const downloadUrlRef = useRef(undefined); const client = useMemo( () => new WorkerClient({ defaultTimeoutMs: 60 * 60_000, logger: new StructuredLogger(logHub, "export-panel"), workerFactory: () => new Worker(new URL("./export.worker.ts", import.meta.url), { name: "export-worker", type: "module", }), workerLogSink: logHub, }), [logHub], ); const queue = useMemo( () => new BoundedTaskQueue(client, { concurrency: 1, highWatermark: 1, logger: new StructuredLogger(logHub, "export-task-queue"), marker: "[EXPORT]", operation: MEDIA_EXPORT_OPERATION, }), [client, logHub], ); const [taskQueue, setTaskQueue] = useState(() => queue.stats()); const missingSources = project.clips .map((clip) => clip.assetId) .filter((assetId, index, all) => all.indexOf(assetId) === index) .filter((assetId) => !sources[assetId]); const canStart = status !== "running" && project.clips.length > 0 && missingSources.length === 0 && actionAvailability?.enabled === true; const replaceDownloadUrl = useCallback((next?: string) => { if (downloadUrlRef.current) { URL.revokeObjectURL(downloadUrlRef.current); } downloadUrlRef.current = next; setDownloadUrl(next); }, []); const start = useCallback(() => { if ( taskRef.current || project.clips.length === 0 || missingSources.length > 0 || actionAvailability?.enabled !== true ) { return; } replaceDownloadUrl(); setError(undefined); setResult(null); resultRef.current = null; setProgress(null); progressRef.current = null; setStatus("running"); const task = queue.schedule< MediaExportRequest, MediaExportResult, MediaExportProgress >( project.revision, { project, sources: [...new Set(project.clips.map((clip) => clip.assetId))].map( (assetId) => ({ assetId, source: sources[assetId]! }), ), }, { requestId: `export_${crypto.randomUUID()}`, timeoutMs: 60 * 60_000, }, ); taskRef.current = task; task.progress$.subscribe(({ payload }) => { if (!payload) { return; } progressRef.current = payload; setProgress(payload); }); void task.result.then( async (nextResult) => { if (taskRef.current !== task) { return; } taskRef.current = undefined; resultRef.current = nextResult; setResult(nextResult); setStatus("completed"); const file = await getExportFile(nextResult); replaceDownloadUrl(URL.createObjectURL(file)); }, (reason: unknown) => { if (taskRef.current !== task) { return; } taskRef.current = undefined; const cancelled = reason instanceof DOMException && reason.name === "AbortError"; setStatus(cancelled ? "cancelled" : "failed"); setError( cancelled ? "导出已取消,临时文件正在清理" : errorMessage(reason), ); }, ); }, [ actionAvailability?.enabled, missingSources.length, project, queue, replaceDownloadUrl, sources, ]); const cancel = useCallback(() => { taskRef.current?.cancel("用户取消导出"); }, []); useEffect( () => () => { taskRef.current?.cancel("导出面板已卸载"); queue.dispose(); client.dispose(); if (downloadUrlRef.current) { URL.revokeObjectURL(downloadUrlRef.current); } }, [client, queue], ); useEffect(() => { const subscription = queue.events$.subscribe(() => { queueMicrotask(() => setTaskQueue(queue.stats())); }); return () => subscription.unsubscribe(); }, [queue]); useEffect(() => { window.__TASK_11_EXPORT__ = { cancel, getFile: async () => resultRef.current ? getExportFile(resultRef.current) : null, getProgress: () => progressRef.current, getResult: () => resultRef.current, getTaskQueue: () => queue.stats(), inspectFile: async () => resultRef.current ? inspectExportFile(await getExportFile(resultRef.current)) : null, start, }; return () => { delete window.__TASK_11_EXPORT__; }; }, [cancel, queue, start]); return (

ORIGINAL SOURCE · TASK 11

高质量 MP4 导出

Export Worker · ECS · OffscreenCanvas · WebCodecs · Mediabunny

source=original
输出
{project.exportSettings.width}×{project.exportSettings.height} ·{" "} {project.exportSettings.frameRate} FPS
编码
H.264 / AAC · MP4 fragmented stream
临时存储
OPFS · 4 MiB chunk · 不聚合整文件内存
{progress?.stage ?? status} {progress?.processedFrames ?? 0}/{progress?.totalFrames ?? 0} 帧 ETA {formatDuration(progress?.etaMs ?? 0)} VQ {progress?.videoQueue ?? 0} AQ {progress?.audioQueue ?? 0} Task {taskQueue.active}/{taskQueue.queued} · HWM{" "} {taskQueue.highWatermark} VF/AD{" "} {progress ? `${progress.resources.byType["video-frame"].active}/${progress.resources.byType["audio-data"].active}` : "0/0"} Heap{" "} {progress?.heap.available ? formatBytes(progress.heap.usedJSHeapSize ?? 0) : "N/A"} {formatBytes(result?.bytes ?? progress?.outputBytes ?? 0)}
{error ? (

{error}

) : null} {!actionAvailability ? (

正在检测 H.264/AAC 编码能力…

) : actionAvailability.enabled !== true ? (

{actionAvailability.reason}

) : missingSources.length > 0 ? (

缺少原素材 source:{missingSources.join(", ")}。代理禁止导出。

) : project.clips.length === 0 ? (

先将素材添加到时间线。

) : null} {result ? (

{result.frames} 帧 · {formatBytes(result.bytes)} ·{" "} {formatDuration(result.elapsedMs)} · {result.audioFrames} audio frames · peak VF/AD {result.resources.byType["video-frame"].peak}/ {result.resources.byType["audio-data"].peak}

) : null}
{downloadUrl && result ? ( 下载 MP4 ) : null}
); }