import type { Asset } from "@web-video-editor/domain"; import type { BrowserMediaSource, MediaProxyResult, MediaProbeResult, QueueStats, } from "@web-video-editor/media-runtime"; import { LogHub } from "@web-video-editor/observability"; import { useEffect, useMemo, useRef, useState } from "react"; import { createInitialWebVAState, WebVASdk, type WebVAAssetEntry, type WebVARuntimeSource, } from "web-va-sdk"; import type { ActionAvailability } from "../capabilities"; import "./MediaPanel.css"; import { ProxyProgress, type ProxyProgressState } from "./ProxyProgress"; import { TEST_ASSETS } from "./testAssets"; const LONG_PROXY_DURATION_SEC = 10 * 60; type ProbeItem = WebVAAssetEntry; export type TimelineAddContext = { assetName: string; cacheStatus: "hit" | "miss" | "pending"; isLongVideo: boolean; previewSource: "proxy" | "source"; proxyStatus: ProxyProgressState["status"] | "not-started"; risk: string; }; export type TimelineAddKind = "audio" | "video"; export type TimelineAddPlacement = "append" | "playhead"; export type TimelineAddOptions = { kind: TimelineAddKind; placement: TimelineAddPlacement; }; export type MediaPanelProps = { actionAvailability?: ActionAvailability; logHub?: LogHub; onAddToTimeline?: ( assetId: string, context: TimelineAddContext, options: TimelineAddOptions, ) => void; onAssetImported?: ( asset: Asset, result: MediaProbeResult, source: BrowserMediaSource, runtimeSource?: WebVARuntimeSource, ) => void; onProxyReady?: ( asset: Asset, result: MediaProxyResult, runtimeSource?: WebVARuntimeSource, ) => void; projectAssets?: readonly Asset[]; timelineAssetIds?: readonly string[]; }; type MediaQueueDiagnostics = { getImportQueue(): QueueStats; getProxyQueue(): QueueStats; probe(source: BrowserMediaSource): Promise; }; declare global { interface Window { __TASK_14_MEDIA__?: MediaQueueDiagnostics; } } function formatDuration(seconds: number): string { const rounded = Math.round(seconds); const hours = Math.floor(rounded / 3600); const minutes = Math.floor((rounded % 3600) / 60); const remaining = rounded % 60; return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}` : `${minutes}:${String(remaining).padStart(2, "0")}`; } function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; } return `${Math.ceil(bytes / 1024)} KiB`; } function timelineAddContext(item: ProbeItem): TimelineAddContext | undefined { if (!item.result) { return undefined; } const proxyStatus = item.proxy.status === "idle" ? "not-started" : item.proxy.status; const cacheStatus = item.proxy.result?.cache.status ?? item.proxy.progress?.cacheStatus ?? "pending"; const proxyReady = proxyStatus === "ready"; const isLongVideo = item.result.durationSec >= LONG_PROXY_DURATION_SEC; const previewSource = proxyReady ? "proxy" : "source"; let risk: string; if (proxyReady && cacheStatus === "hit") { risk = "Proxy cache hit,可直接使用 OPFS proxy 预览。"; } else if (proxyReady) { risk = "Proxy 已就绪,添加后使用低分辨率 proxy 预览。"; } else if (proxyStatus === "queued" || proxyStatus === "running") { risk = isLongVideo ? "可立即添加,但当前使用 source fallback;长视频直解码 seek/播放可能较慢,proxy 将在后台继续生成。" : "可立即添加,当前临时使用 source fallback;proxy 后台完成后会切换到 proxy。"; } else if (proxyStatus === "failed") { risk = "Proxy 生成失败;仍可添加,但会持续使用 source fallback,预览性能取决于原素材解码。"; } else if (proxyStatus === "cancelled") { risk = "Proxy 已取消;仍可添加,但会使用 source fallback,可直接重试代理。"; } else { risk = "可立即添加,proxy 尚未开始回传状态时使用 source fallback。"; } return { assetName: item.name, cacheStatus, isLongVideo, previewSource, proxyStatus, risk, }; } function proxyStatusText(context: TimelineAddContext): string { const parts = [ `preview=${context.previewSource}`, `proxy=${context.proxyStatus}`, `cache=${context.cacheStatus}`, ]; if (context.isLongVideo) { parts.push("long-video"); } return parts.join(" · "); } export function MediaPanel({ actionAvailability, logHub: providedLogHub, onAddToTimeline, onAssetImported, onProxyReady, projectAssets = [], timelineAssetIds = [], }: MediaPanelProps) { const [sdkState, setSdkState] = useState(createInitialWebVAState); const sdkRef = useRef(undefined); const fallbackLogHub = useMemo(() => new LogHub(), []); const logHub = providedLogHub ?? fallbackLogHub; const importEnabled = actionAvailability?.enabled === true; const { cacheStats, entries: items, importQueue: importQueueStats, proxyQueue: queueStats, } = sdkState; useEffect(() => { if (!importEnabled) { return; } const sdk = new WebVASdk({ logHub }); sdkRef.current = sdk; const unsubscribe = sdk.subscribe(() => setSdkState(sdk.getSnapshot())); window.__TASK_14_MEDIA__ = { getImportQueue: () => sdk.getImportQueueStats(), getProxyQueue: () => sdk.getProxyQueueStats(), probe: (source) => sdk.probe(source), }; return () => { sdkRef.current = undefined; unsubscribe(); delete window.__TASK_14_MEDIA__; sdk.dispose(); }; }, [importEnabled, logHub]); const generateProxy = (id: string) => { const sdk = sdkRef.current; if (!sdk) { return; } void sdk .buildOpfs(id) .then((entry) => { if ( entry.asset && entry.proxy.result && entry.runtimeSource?.kind === "opfs-proxy" ) { onProxyReady?.(entry.asset, entry.proxy.result, entry.runtimeSource); } }) .catch(() => undefined); }; const probe = (source: BrowserMediaSource) => { const sdk = sdkRef.current; if (!sdk) { return; } void sdk .importAsset(source, { autoBuildOpfs: false }) .then((entry) => { if (entry.asset && entry.result && entry.runtimeSource) { onAssetImported?.( entry.asset, entry.result, entry.source, entry.runtimeSource, ); generateProxy(entry.id); } }) .catch(() => undefined); }; const clearProxyCacheAndProbeList = () => { void sdkRef.current?.clearOpfsCache().catch(() => undefined); }; const selectFiles = (files: FileList | null) => { for (const file of Array.from(files ?? [])) { probe({ blob: file, kind: "file", lastModified: file.lastModified, name: file.name, }); } }; return (

MEDIA IMPORT · TASK 06

素材探测

File/Blob/URL 由 Media Worker 按需读取;工程仅保存指纹和小型元数据。

{TEST_ASSETS.map((asset) => ( ))}
{!actionAvailability ? (

正在检测导入能力…

) : !actionAvailability.enabled ? (

导入已禁用:{actionAvailability.reason}

) : null}
Project Assets: {projectAssets.length} Runtime probes: {items.length} OPFS: {cacheStats ? formatBytes(cacheStats.committedBytes) : "—"} Proxy: {queueStats.active}/{queueStats.queued} · peak{" "} {queueStats.activePeak}/{queueStats.queuedPeak} · HWM{" "} {queueStats.highWatermark} Import: {importQueueStats.active}/{importQueueStats.queued} · peak{" "} {importQueueStats.activePeak}/{importQueueStats.queuedPeak} · HWM{" "} {importQueueStats.highWatermark} Storage:{" "} {cacheStats?.storageEstimate.available ? `${formatBytes(cacheStats.storageEstimate.usageBytes ?? 0)} / ${formatBytes(cacheStats.storageEstimate.quotaBytes ?? 0)}` : "N/A"}
{items.length === 0 ? (

选择本地文件或载入固定测试素材开始探测。

) : (
    {items.map((item) => { const asset = item.asset; const video = item.result?.videoTracks.find( (track) => track.trackId === item.result?.primaryVideoTrackId, ); const audio = item.result?.audioTracks.find( (track) => track.trackId === item.result?.primaryAudioTrackId, ); const addContext = timelineAddContext(item); return (
  • {item.name} {item.status.toUpperCase()}
    {item.status === "probing" ? (

    {item.progress?.stage ?? "starting"} · 已按需读取{" "} {formatBytes(item.progress?.bytesRead ?? 0)}

    ) : null} {item.error ? (

    {item.error}

    ) : null} {item.result && video && asset ? ( <>
    视频
    {video.codec?.toUpperCase()} {video.profile} ·{" "} {video.displayWidth}×{video.displayHeight} ·{" "} {video.frameRate.toFixed(2)}fps · rotation{" "} {video.rotation}°
    音频
    {audio ? `${audio.codec?.toUpperCase()} ${audio.profile} · ${audio.sampleRate}Hz · ${audio.channels}ch` : "无"}
    时长
    {formatDuration(item.result.durationSec)}
    读取
    {item.result.read.adapter} · {item.result.read.mode} ·{" "} {formatBytes(item.result.read.uniqueBytesRead)} /{" "} {formatBytes(item.result.source.size)} ·{" "} {(item.result.read.readRatio * 100).toFixed(3)}%
    JS Heap
    {item.progress?.heap.available ? formatBytes( item.progress.heap.usedJSHeapSize ?? 0, ) : "N/A"}
    指纹
    {item.result.fingerprint}
    排除
    {item.result.excludedVideoTracks.length > 0 ? item.result.excludedVideoTracks .map( (track) => `${track.codec.toUpperCase()} (${track.reason})`, ) .join(", ") : "无附加视频轨"}
    {addContext ? (
    {proxyStatusText(addContext)} {addContext.risk}
    ) : null}
    {asset.hasAudio ? ( <> ) : null}
    ) : null} {item.proxy.status !== "idle" ? ( sdkRef.current?.cancelOpfs(item.id)} onRetry={() => generateProxy(item.id)} state={item.proxy as ProxyProgressState} /> ) : null}
  • ); })}
)}
); }