From 184f57c63dc050d5895a622be05fcc2355852699 Mon Sep 17 00:00:00 2001 From: Martin Delophy <845046459@qq.com> Date: Thu, 16 Jul 2026 17:48:31 +0800 Subject: [PATCH] Add Remaster enhancement and timeline interaction fixes (#10) * Add comprehensive GEO search foundation * Enable free positioning for timeline clips * Add Remaster enhancement and timeline interaction fixes --------- Co-authored-by: haixin.yang --- src/App.jsx | 195 ++++++++++++++++++++++++++- src/components/EditorOverlays.jsx | 33 +++++ src/components/PreviewStage.jsx | 11 ++ src/components/VoicePanel.jsx | 12 ++ src/components/panels.jsx | 22 ++- src/config/models.js | 10 ++ src/hooks/useEditorLifecycle.js | 3 + src/hooks/useEditorRefs.js | 1 + src/hooks/useProjectFiles.js | 2 +- src/i18n.js | 126 +++++++++++++++-- src/lib/float16.js | 13 ++ src/lib/float16.test.js | 16 +++ src/lib/imageResizeControl.js | 5 + src/lib/media.js | 91 +++++++++++++ src/lib/playbackControls.js | 7 +- src/lib/playbackControls.test.js | 54 ++++++++ src/lib/remasterClipEnhancement.js | 134 ++++++++++++++++++ src/lib/remasterEnhancement.js | 72 ++++++++++ src/lib/remasterProgress.js | 7 + src/lib/remasterProgress.test.js | 22 +++ src/lib/timelineMoveControls.js | 3 + src/lib/timelinePlaybackDrag.test.js | 70 ++++++++++ src/lib/timelineReorderControls.js | 1 + src/styles.css | 90 ++++++++++++- src/workers/remaster.worker.js | 171 +++++++++++++++++++++++ vite.config.mjs | 7 + 26 files changed, 1158 insertions(+), 20 deletions(-) create mode 100644 src/lib/float16.js create mode 100644 src/lib/float16.test.js create mode 100644 src/lib/playbackControls.test.js create mode 100644 src/lib/remasterClipEnhancement.js create mode 100644 src/lib/remasterEnhancement.js create mode 100644 src/lib/remasterProgress.js create mode 100644 src/lib/remasterProgress.test.js create mode 100644 src/lib/timelinePlaybackDrag.test.js create mode 100644 src/workers/remaster.worker.js diff --git a/src/App.jsx b/src/App.jsx index 05eecc1..a45c2e4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,7 +5,7 @@ import { PreviewStage } from "./components/PreviewStage.jsx"; import { VoicePanel } from "./components/VoicePanel.jsx"; import { Timeline } from "./components/Timeline.jsx"; import { Topbar } from "./components/Topbar.jsx"; -import { AssetDragPreview, ExportProgressOverlay } from "./components/EditorOverlays.jsx"; +import { AssetDragPreview, ExportProgressOverlay, RemasterProgressOverlay } from "./components/EditorOverlays.jsx"; import { EditorSidebar } from "./components/EditorSidebar.jsx"; import { useExportElapsed } from "./hooks/useExportElapsed.js"; import { usePreviewFrameSize } from "./hooks/usePreviewFrameSize.js"; @@ -56,6 +56,8 @@ import { downloadBlob } from "./lib/media.js"; import { getImageThumbnailCount, getVisualSegmentsTotal } from "./lib/timeline.js"; import { removeVisualPropertyKeyframe, updateVisualSegmentPlaybackRate, upsertVisualKeyframe, upsertVisualPropertyKeyframe } from "./lib/visualEffects.js"; import { getLinkedSourceAudioEnd, getLinkedSourceAudioSegments } from "./lib/sourceAudioSync.js"; +import { captureRemasterSource, enhanceRemasterFrame } from "./lib/remasterEnhancement.js"; +import { enhanceRemasterClip } from "./lib/remasterClipEnhancement.js"; function getExportDimensions(ratio, longEdge) { const sourceLongEdge = Math.max(ratio.width, ratio.height); @@ -74,7 +76,9 @@ export function App() { const [uiLanguage, setUiLanguage] = useState(() => getStoredLanguage()); const [showExportMenu, setShowExportMenu] = useState(false); const [exportSettings, setExportSettings] = useState({ resolution: "1080", frameRate: 30, codec: "h264", quality: "high" }); + const [remasterQuality, setRemasterQuality] = useState("fast"); const [introClosing, setIntroClosing] = useState(false); + const [remasterJob, setRemasterJob] = useState({ running: false, mode: "", segmentId: "", progress: 0, phase: "", frameIndex: 0, totalFrames: 0, startedAt: 0, backend: "" }); const { captionPlacement, captionPosition, captionSegments, captionSize, captionStyle, captionsEnabled, script, selectedSegmentId, setCaptionPlacement, @@ -141,7 +145,7 @@ export function App() { avatarMotionCacheRef, avatarMotionWorkerRef, avatarRenderWorkerRef, avatarTestAudioImportedRef, avatarTestImportedRef, currentTimeRef, draggedAssetIdRef, exportStartRef, fileInputRef, imageUrlRefs, musicRef, musicUrlRef, pointerAssetDragRef, - previewCanvasRef, previewShellRef, previewVideoRef, projectFileInputRef, sourceAudioRef, + previewCanvasRef, previewShellRef, previewVideoRef, projectFileInputRef, remasterAbortControllerRef, sourceAudioRef, sourceAudioUrlRef, suppressAssetClickRef, suppressTimelineClipClickRef, timelineClipDragRef, timelineDurationRef, trackScrollRef, visionAbortControllerRef, visionJobGenerationRef, visionObjectUrlsRef, visualPlaybackFrameRef, @@ -230,6 +234,24 @@ export function App() { if (change.removePropertyKeyframe) return { ...item, keyframes: removeVisualPropertyKeyframe(item.keyframes, change.removePropertyKeyframe.time, change.removePropertyKeyframe.key) }; if (Number.isFinite(change.removeKeyframeAt)) return { ...item, keyframes: (item.keyframes ?? []).filter((frame) => Math.abs(frame.time - change.removeKeyframeAt) > 0.04) }; if (change.mask) return { ...item, mask: change.mask }; + if (typeof change.enhancementEnabled === "boolean" && item.enhancement) { + if (item.enhancement.mode === "remaster-drunet-full") { + const source = change.enhancementEnabled ? item.enhancement.processed : item.enhancement.original; + if (!source?.src) return item; + return { + ...item, + src: source.src, + blob: source.blob, + width: source.width, + height: source.height, + sourceStart: source.sourceStart, + sourceDuration: source.sourceDuration, + trackFrames: source.trackFrames ?? [], + enhancement: { ...item.enhancement, enabled: change.enhancementEnabled }, + }; + } + if (item.enhancement.previewUrl) return { ...item, enhancement: { ...item.enhancement, enabled: change.enhancementEnabled } }; + } return item; }); if (Number.isFinite(change.playbackRate)) { @@ -250,6 +272,155 @@ export function App() { return nextItems; }); }; + const clearSelectedVisualEnhancement = () => { + if (!selectedVisualSegment?.id) return; + remasterAbortControllerRef.current?.abort(); + setVisualSegments((items) => items.map((item) => { + if (item.id !== selectedVisualSegment.id) return item; + if (item.enhancement?.mode !== "remaster-drunet-full" || !item.enhancement.original?.src) return { ...item, enhancement: null }; + const original = item.enhancement.original; + return { + ...item, + src: original.src, + blob: original.blob, + width: original.width, + height: original.height, + sourceStart: original.sourceStart, + sourceDuration: original.sourceDuration, + trackFrames: original.trackFrames ?? [], + enhancement: null, + }; + })); + setRemasterJob({ running: false, mode: "", segmentId: "", progress: 0, phase: "", frameIndex: 0, totalFrames: 0, startedAt: 0 }); + }; + const enhanceSelectedVisualFrame = async () => { + if (!selectedVisualSegment?.id || !selectedVisualSegment.src) return notify(t("remasterSelectClip")); + if (remasterJob.running) return; + const controller = new AbortController(); + remasterAbortControllerRef.current = controller; + setIsPlaying(false); + previewVideoRef.current?.pause(); + setRemasterJob({ running: true, mode: "frame", segmentId: selectedVisualSegment.id, progress: 1, phaseKey: "remasterPreparing", frameIndex: 0, totalFrames: 1, startedAt: Date.now() }); + try { + const bitmap = await captureRemasterSource({ + type: selectedVisualSegment.type, + src: selectedVisualSegment.src, + video: previewVideoRef.current, + }); + const result = await enhanceRemasterFrame({ + bitmap, + maxLongEdge: remasterQuality === "quality" ? 960 : 640, + signal: controller.signal, + onProgress: ({ progress, phaseKey, phaseParams }) => setRemasterJob((job) => ({ ...job, running: true, progress, phaseKey, phaseParams })), + }); + if (controller.signal.aborted) return; + const previewUrl = URL.createObjectURL(result.blob); + imageUrlRefs.current.add(previewUrl); + setVisualSegments((items) => items.map((item) => { + if (item.id !== selectedVisualSegment.id) return item; + return { ...item, enhancement: { + enabled: true, + mode: "remaster-drunet-frame-preview", + previewUrl, + previewBlob: result.blob, + localTime: visualLocalTime, + sourceTime: previewVisualSourceTime, + width: result.width, + height: result.height, + inferenceMs: result.inferenceMs, + } }; + })); + setRemasterJob({ running: false, mode: "frame", segmentId: selectedVisualSegment.id, progress: 100, phase: t("remasterReady"), frameIndex: 1, totalFrames: 1, startedAt: 0 }); + notify(t("remasterReady")); + } catch (error) { + if (error?.name !== "AbortError") notify(error instanceof Error ? error.message : t("remasterFailed")); + setRemasterJob({ running: false, mode: "frame", segmentId: selectedVisualSegment.id, progress: 0, phase: "", frameIndex: 0, totalFrames: 1, startedAt: 0 }); + } finally { + if (remasterAbortControllerRef.current === controller) remasterAbortControllerRef.current = null; + } + }; + const cancelRemasterEnhancement = () => { + if (!remasterJob.running) return; + setRemasterJob((job) => ({ ...job, phase: "", phaseKey: "remasterCanceling", phaseParams: {} })); + remasterAbortControllerRef.current?.abort(); + }; + const enhanceSelectedVisualClip = async () => { + if (!selectedVisualSegment?.id || !selectedVisualSegment.src) return notify(t("remasterSelectClip")); + if (selectedVisualSegment.type !== "video") return notify(t("remasterClipOnlyVideo")); + if (remasterJob.running) return; + const existingOriginal = selectedVisualSegment.enhancement?.mode === "remaster-drunet-full" + ? selectedVisualSegment.enhancement.original + : null; + const original = existingOriginal ?? { + src: selectedVisualSegment.src, + blob: selectedVisualSegment.blob, + width: selectedVisualSegment.width, + height: selectedVisualSegment.height, + sourceStart: selectedVisualSegment.sourceStart ?? 0, + sourceDuration: selectedVisualSegment.sourceDuration, + trackFrames: selectedVisualSegment.trackFrames ?? [], + }; + const inputSegment = { ...selectedVisualSegment, ...original, type: "video" }; + const controller = new AbortController(); + remasterAbortControllerRef.current = controller; + setIsPlaying(false); + previewVideoRef.current?.pause(); + setRemasterJob({ running: true, mode: "clip", segmentId: selectedVisualSegment.id, progress: 1, phaseKey: "remasterClipPreparing", frameIndex: 0, totalFrames: 0, startedAt: Date.now() }); + try { + const result = await enhanceRemasterClip({ + segment: inputSegment, + videoElement: previewVideoRef.current, + frameRate: exportSettings.frameRate, + maxLongEdge: 960, + signal: controller.signal, + onProgress: ({ progress, phaseKey, phaseParams, frameIndex = 0, totalFrames = 0, backend = "" }) => setRemasterJob((job) => ({ + ...job, + running: true, + progress, + phase: "", + phaseKey, + phaseParams, + frameIndex, + totalFrames, + backend: backend || job.backend, + })), + }); + if (controller.signal.aborted) return; + const processedUrl = URL.createObjectURL(result.blob); + imageUrlRefs.current.add(processedUrl); + const processed = { + src: processedUrl, + blob: result.blob, + width: result.width, + height: result.height, + sourceStart: 0, + sourceDuration: result.sourceDuration, + trackFrames: [], + }; + setVisualSegments((items) => items.map((item) => item.id === selectedVisualSegment.id ? { + ...item, + ...processed, + enhancement: { + enabled: true, + mode: "remaster-drunet-full", + original, + processed, + frameRate: result.frameRate, + totalFrames: result.totalFrames, + backend: result.backend, + quality: remasterQuality, + }, + } : item)); + setRemasterJob({ running: false, mode: "clip", segmentId: selectedVisualSegment.id, progress: 100, phase: t("remasterClipReady"), frameIndex: result.totalFrames, totalFrames: result.totalFrames, startedAt: 0 }); + notify(t("remasterClipReady")); + } catch (error) { + if (error?.name === "AbortError") notify(t("remasterCanceled")); + else notify(error instanceof Error ? error.message : t("remasterFailed")); + setRemasterJob({ running: false, mode: "clip", segmentId: selectedVisualSegment.id, progress: 0, phase: "", frameIndex: 0, totalFrames: 0, startedAt: 0 }); + } finally { + if (remasterAbortControllerRef.current === controller) remasterAbortControllerRef.current = null; + } + }; const exportElapsedSeconds = useExportElapsed(exporting, exportStartRef); const { effectiveCaptionPlacement, previewSmartCropRect, previewVisionAnalysis, @@ -426,7 +597,7 @@ export function App() { selectedStickerSegmentId, selectedTrack, selectedVisualSegmentId, setCurrentVisualAsset, setFitMode, setRatioId, setSelectedSegmentId, setSelectedVisualSegmentId, setUserAssets, sourceAudioBlob, sourceAudioUrlRef, stickerSegments, - visionAbortControllerRef, visionObjectUrlsRef, visualSegments, + remasterAbortControllerRef, visionAbortControllerRef, visionObjectUrlsRef, visualSegments, voiceRecorderStreamRef, voiceRecorderTimerRef, }); @@ -446,6 +617,11 @@ export function App() { sourceAudioRef, sourceAudioStart, sourceAudioUrl, timelineDuration, timelineDurationRef, trackScrollRef, trackVisibility, visualSegments, visualTimeline, }); + const pauseForTimelineEdit = () => { + if (!isPlaying) return; + pauseTimelineMedia(); + setIsPlaying(false); + }; useMediaSync({ audioRef, audioSegmentRefs, audioSegments, currentTime, currentTimeRef, estimatedDuration, @@ -464,7 +640,7 @@ export function App() { setSelectedStickerSegmentId, setSelectedTrack, setStickerSegments, setTimelineHorizon, setMusicStart, setSourceAudioLinked, setSourceAudioStart, musicDuration, musicStart, sourceAudioDuration, sourceAudioStart, stickerSegments, suppressTimelineClipClickRef, t, timelineDurationRef, - trackLocks, trackScrollRef, + trackLocks, trackScrollRef, pauseForTimelineEdit, }); const startImageResize = createImageResizeControl({ @@ -473,7 +649,7 @@ export function App() { setCurrentTime, setImageClipCount, setImageDuration, setSelectedTrack, setSelectedVisualSegmentId, setSnapGuide, setVisualSegments, sourceAudioBlob, sourceAudioDuration, sourceAudioStart, timelineDuration, timelineDurationRef, - trackLocks, trackScrollRef, visualSegments, + trackLocks, trackScrollRef, visualSegments, pauseForTimelineEdit, }); const extractVideoSourceAudio = useSourceAudioExtraction({ @@ -559,7 +735,7 @@ export function App() { captionSegments, captionTargetDuration, commitCaptionSegments, commitVisualSegments, notify, renderedVisualSegments, seekTo, setSelectedSegmentId, setSelectedTrack, setSelectedVisualSegmentId, setTimelineClipDrag, suppressTimelineClipClickRef, - timelineClipDragRef, timelineDuration, trackLocks, visualSegments, + timelineClipDragRef, timelineDuration, trackLocks, visualSegments, pauseForTimelineEdit, }); return ( @@ -752,6 +928,12 @@ export function App() { visualLocalTime={visualLocalTime} visualTimelineStart={selectedVisualRange?.start ?? 0} updateSelectedVisualEffects={updateSelectedVisualEffects} + remasterJob={remasterJob} + remasterQuality={remasterQuality} + setRemasterQuality={setRemasterQuality} + enhanceSelectedVisualFrame={enhanceSelectedVisualFrame} + enhanceSelectedVisualClip={enhanceSelectedVisualClip} + clearSelectedVisualEnhancement={clearSelectedVisualEnhancement} selectedFilterId={selectedFilterId} setSelectedFilterId={setSelectedFilterId} trOption={trOption} @@ -848,6 +1030,7 @@ export function App() { + {shouldShowLanguageIntro ? ( ) : null} diff --git a/src/components/EditorOverlays.jsx b/src/components/EditorOverlays.jsx index a6e5e3b..cb4e5af 100644 --- a/src/components/EditorOverlays.jsx +++ b/src/components/EditorOverlays.jsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import { translateRemasterPhase } from "../lib/remasterProgress.js"; import { formatClock } from "../lib/timeline.js"; export function AssetDragPreview({ preview, t }) { @@ -22,3 +24,34 @@ export function ExportProgressOverlay({ exporting, percent, phase, elapsedSecond
{phase || t("preparingExport")}{formatClock(elapsedSeconds)}
; } + +export function RemasterProgressOverlay({ job, onCancel, t }) { + const active = Boolean(job?.running && job?.mode === "clip"); + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (!active) return undefined; + setNow(Date.now()); + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [active]); + if (!active) return null; + const percent = Math.max(0, Math.min(100, Math.round(job.progress || 0))); + const elapsedSeconds = Math.max(0, Math.floor((now - (job.startedAt || now)) / 1000)); + const frameText = job.totalFrames > 0 + ? t("remasterClipProgressFrames").replace("{current}", String(job.frameIndex || 0)).replace("{total}", String(job.totalFrames)) + : job.phase || t("remasterClipPreparing"); + const backendText = job.backend === "webgpu" ? t("remasterGpuActive") : job.backend === "wasm" ? t("remasterCpuFallback") : t("remasterGpuAuto"); + const phaseText = translateRemasterPhase(job, t); + return
+
+ +
+
{t("remasterClipProgressTitle")}{percent}%
+
+
{phaseText}{backendText} · {frameText} · {formatClock(elapsedSeconds)}
+

{t("remasterClipProgressSafe")}

+ +
+
+
; +} diff --git a/src/components/PreviewStage.jsx b/src/components/PreviewStage.jsx index bf8c55b..7860de5 100644 --- a/src/components/PreviewStage.jsx +++ b/src/components/PreviewStage.jsx @@ -66,6 +66,11 @@ export function PreviewStage({ const activeObjectPosition = visualObjectPosition || "50% 50%"; const visualTransform = resolveVisualTransform(visualEffects?.keyframes, visualLocalTime); const visualMask = visualEffects?.mask ?? {}; + const enhancement = visualEffects?.enhancement ?? null; + const showRemasterPreview = Boolean( + enhancement?.enabled !== false && enhancement?.previewUrl && + (previewVisualType === "image" || (!isPlaying && Math.abs((enhancement.localTime ?? 0) - visualLocalTime) <= 0.08)), + ); const maskCenterX = Number.isFinite(visualMask.centerX) ? visualMask.centerX : 50; const maskCenterY = Number.isFinite(visualMask.centerY) ? visualMask.centerY : 50; const frameWidth = Math.max(1, previewFrameSize.width || 1); @@ -195,6 +200,12 @@ export function PreviewStage({ maskRepeat: previewVisionMaskUrl ? "no-repeat" : undefined, }} /> : null} + {showRemasterPreview ? {t("remasterPreviewAlt")} : null} ) : null} {showVisionOverlays diff --git a/src/components/VoicePanel.jsx b/src/components/VoicePanel.jsx index f44a079..4fdcbe0 100644 --- a/src/components/VoicePanel.jsx +++ b/src/components/VoicePanel.jsx @@ -320,6 +320,12 @@ export function VoicePanel({ visualLocalTime, visualTimelineStart = 0, updateSelectedVisualEffects, + remasterJob, + remasterQuality, + setRemasterQuality, + enhanceSelectedVisualFrame, + enhanceSelectedVisualClip, + clearSelectedVisualEnhancement, selectedFilterId, setSelectedFilterId, trOption, @@ -384,6 +390,12 @@ export function VoicePanel({ trOption={trOption} onSelectFilter={(id) => { setSelectedFilterId(id); notify(t("effectApplied")); }} sourceAudioLinked={sourceAudioLinked} + enhancementJob={remasterJob} + enhancementQuality={remasterQuality} + onEnhancementQualityChange={setRemasterQuality} + onEnhance={enhanceSelectedVisualFrame} + onEnhanceClip={enhanceSelectedVisualClip} + onClearEnhancement={clearSelectedVisualEnhancement} /> ) : null} {isCaptionContext ? ( diff --git a/src/components/panels.jsx b/src/components/panels.jsx index dc1d2b2..186e1f9 100644 --- a/src/components/panels.jsx +++ b/src/components/panels.jsx @@ -31,6 +31,7 @@ import { VOICES, } from "../config/editor.js"; import { APP_LANGUAGES } from "../i18n.js"; +import { translateRemasterPhase } from "../lib/remasterProgress.js"; import { formatClock, formatTime, getSegmentStartTime } from "../lib/timeline.js"; import { hasVisualPropertyKeyframe, normalizeVisualKeyframes, resolveVisualTransform } from "../lib/visualEffects.js"; import { Popover } from "./ui.jsx"; @@ -589,7 +590,7 @@ export function ToolPanel(props) { ); } -export function VisualEffectsPanel({ t, segment, localTime, onChange, onSeek, selectedFilterId, trOption, onSelectFilter, contextMode = false, sourceAudioLinked = false }) { +export function VisualEffectsPanel({ t, segment, localTime, onChange, onSeek, selectedFilterId, trOption, onSelectFilter, contextMode = false, sourceAudioLinked = false, enhancementJob = null, enhancementQuality = "fast", onEnhancementQualityChange, onEnhance, onEnhanceClip, onClearEnhancement }) { const [activeTab, setActiveTab] = useState("transform"); const keyframes = normalizeVisualKeyframes(segment?.keyframes ?? []); const transform = resolveVisualTransform(keyframes, localTime); @@ -599,12 +600,18 @@ export function VisualEffectsPanel({ t, segment, localTime, onChange, onSeek, se const isVideo = segment?.type === "video"; const playbackRate = Math.max(0.25, Math.min(4, Number(segment?.playbackRate) || 1)); const sourceDuration = Math.max(0, Number(segment?.sourceDuration) || (Number(segment?.duration) || 0) * playbackRate); + const enhancement = segment?.enhancement ?? null; + const fullClipEnhanced = enhancement?.mode === "remaster-drunet-full"; + const hasEnhancement = Boolean(fullClipEnhanced || enhancement?.previewUrl); + const enhancementRunning = Boolean(enhancementJob?.running && enhancementJob.segmentId === segment?.id); + const enhancementBackend = enhancementRunning ? enhancementJob?.backend : enhancement?.backend; const updateTransform = (key, value) => onChange?.({ propertyKeyframe: { time: localTime, key, value } }); const tabs = [ ["transform", t("visualTabTransform")], ["mask", t("visualTabMask")], ["speed", t("visualTabSpeed")], ["effects", t("visualTabEffects")], + ["ai", t("visualTabAi")], ]; return (
@@ -645,6 +652,19 @@ export function VisualEffectsPanel({ t, segment, localTime, onChange, onSeek, se :
{t("visualSpeedImageHint")}
} : null} {activeTab === "effects" ? : null} + {activeTab === "ai" ? +
+
{t("remasterTitle")}{t("remasterExperimental")}
+
Remaster DRUNet{t("remasterModelMeta")}{fullClipEnhanced ? t("remasterFullReadyBadge") : enhancement?.previewUrl ? t("remasterReadyBadge") : t("remasterNotRun")}
+ {isVideo ?
{enhancementBackend === "webgpu" ? t("remasterGpuActive") : enhancementBackend === "wasm" ? t("remasterCpuFallback") : t("remasterGpuAuto")}
: null} + {hasEnhancement ? : null} + {enhancementRunning ?
{translateRemasterPhase(enhancementJob, t)} · {Math.round(enhancementJob.progress || 0)}%
: null} + {isVideo ? : null} + + {hasEnhancement ?
{fullClipEnhanced ? `${enhancement.totalFrames} ${t("remasterFramesShort")}` : `${enhancement.width}×${enhancement.height}`}{fullClipEnhanced ? `${enhancement.frameRate} fps` : `${Math.max(0, enhancement.inferenceMs || 0)} ms`}
: null} +

{segment?.type === "video" ? t("remasterVideoHint") : t("remasterImageHint")}

+
+ : null} }
); diff --git a/src/config/models.js b/src/config/models.js index 929d76f..edf2d39 100644 --- a/src/config/models.js +++ b/src/config/models.js @@ -1,5 +1,15 @@ export const MODEL_ID = "onnx-community/Kokoro-82M-v1.0-ONNX"; export const AUTOMATIC_CAPTION_MODEL_ID = "onnx-community/whisper-small"; + +export const REMASTER_DRUNET_MODEL = { + id: "seantempesta/remaster-drunet", + label: "Remaster DRUNet Student", + revision: "018e7815aa8ef6e3eb6433d2572433d4f36e180e", + file: "drunet_student.onnx", +}; + +export const REMASTER_DRUNET_MODEL_URL = + `https://huggingface.co/${REMASTER_DRUNET_MODEL.id}/resolve/${REMASTER_DRUNET_MODEL.revision}/${REMASTER_DRUNET_MODEL.file}`; export const AUTOMATIC_CAPTION_MODEL_LABEL = "Whisper small"; export const YOLOS_TINY_MODEL_ID = "Xenova/yolos-tiny"; export const YOLOS_TINY_MODEL_LABEL = "YOLOS tiny"; diff --git a/src/hooks/useEditorLifecycle.js b/src/hooks/useEditorLifecycle.js index 7adca46..31906b4 100644 --- a/src/hooks/useEditorLifecycle.js +++ b/src/hooks/useEditorLifecycle.js @@ -4,6 +4,7 @@ import { RATIO_OPTIONS } from "../config/editor.js"; import { decodeWaveform } from "../lib/media.js"; import { disposeVisionWorker } from "../lib/vision.js"; import { disposeVocalSeparationWorker } from "../lib/vocalSeparation.js"; +import { disposeRemasterWorker } from "../lib/remasterEnhancement.js"; import { getNearestRatioIdForSize, revokeVisionObjectUrls, @@ -156,9 +157,11 @@ export function useEditorLifecycle(d) { d.imageUrlRefs.current.forEach((url) => URL.revokeObjectURL(url)); d.imageUrlRefs.current.clear(); d.visionAbortControllerRef.current?.abort(); + d.remasterAbortControllerRef.current?.abort(); d.visionObjectUrlsRef.current.forEach((urls) => revokeVisionObjectUrls(urls)); d.visionObjectUrlsRef.current.clear(); disposeVisionWorker(); + disposeRemasterWorker(); disposeVocalSeparationWorker(); d.voiceRecorderStreamRef.current?.getTracks().forEach((track) => track.stop()); window.clearInterval(d.voiceRecorderTimerRef.current); diff --git a/src/hooks/useEditorRefs.js b/src/hooks/useEditorRefs.js index d24796f..c764421 100644 --- a/src/hooks/useEditorRefs.js +++ b/src/hooks/useEditorRefs.js @@ -23,6 +23,7 @@ export function useEditorRefs() { previewCanvasRef: useRef(null), previewShellRef: useRef(null), previewVideoRef: useRef(null), + remasterAbortControllerRef: useRef(null), projectFileInputRef: useRef(null), sourceAudioRef: useRef(null), sourceAudioUrlRef: useRef(""), diff --git a/src/hooks/useProjectFiles.js b/src/hooks/useProjectFiles.js index 403c9b5..46251ed 100644 --- a/src/hooks/useProjectFiles.js +++ b/src/hooks/useProjectFiles.js @@ -6,7 +6,7 @@ import { createCaptionSegments, getImageThumbnailCount, getVisualSegmentsTotal } export function useProjectFiles(deps) { const getProjectSnapshot = useCallback(() => { - const visualSegments = deps.visualSegments.map(({ blob, trackFrames, src, cutoutVisual, ...segment }) => segment); + const visualSegments = deps.visualSegments.map(({ blob, trackFrames, src, cutoutVisual, enhancement: _enhancement, ...segment }) => segment); return { script: deps.script, selectedVoiceId: deps.selectedVoiceId, speed: deps.speed, volume: deps.volume, ratioId: deps.ratioId, fitMode: deps.fitMode, captionPosition: deps.captionPosition, diff --git a/src/i18n.js b/src/i18n.js index 9b84a14..60a7188 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -53,16 +53,16 @@ const VISUAL_KEYFRAME_ACTION_COPY = { }; const VISUAL_TAB_COPY = { - zh: { visualTabTransform: "基础", visualTabMask: "蒙版", visualTabSpeed: "变速", visualTabEffects: "效果", visualSpeed: "播放速度", visualSourceDuration: "素材时长", visualTimelineDuration: "时间轴时长", visualSpeedVisualOnlyHint: "仅调整当前画面片段;视频原声轨保持独立。", visualSpeedImageHint: "静态图片没有播放速度,可直接调整片段时长。" }, - en: { visualTabTransform: "Basic", visualTabMask: "Mask", visualTabSpeed: "Speed", visualTabEffects: "Effects", visualSpeed: "Playback speed", visualSourceDuration: "Source duration", visualTimelineDuration: "Timeline duration", visualSpeedVisualOnlyHint: "Changes this visual clip only; the source-audio track stays independent.", visualSpeedImageHint: "Still images have no playback speed. Adjust the clip duration instead." }, - ja: { visualTabTransform: "基本", visualTabMask: "マスク", visualTabSpeed: "速度", visualTabEffects: "効果", visualSpeed: "再生速度", visualSourceDuration: "素材の長さ", visualTimelineDuration: "タイムラインの長さ", visualSpeedVisualOnlyHint: "現在の映像クリップだけを変更し、元音声トラックは独立したままです。", visualSpeedImageHint: "静止画には再生速度がありません。クリップの長さを調整してください。" }, - ko: { visualTabTransform: "기본", visualTabMask: "마스크", visualTabSpeed: "속도", visualTabEffects: "효과", visualSpeed: "재생 속도", visualSourceDuration: "원본 길이", visualTimelineDuration: "타임라인 길이", visualSpeedVisualOnlyHint: "현재 영상 클립만 변경되며 원본 오디오 트랙은 독립적으로 유지됩니다.", visualSpeedImageHint: "정지 이미지에는 재생 속도가 없습니다. 클립 길이를 조정하세요." }, - es: { visualTabTransform: "Básico", visualTabMask: "Máscara", visualTabSpeed: "Velocidad", visualTabEffects: "Efectos", visualSpeed: "Velocidad de reproducción", visualSourceDuration: "Duración de origen", visualTimelineDuration: "Duración en la línea", visualSpeedVisualOnlyHint: "Solo cambia este clip visual; el audio de origen permanece independiente.", visualSpeedImageHint: "Las imágenes fijas no tienen velocidad. Ajusta la duración del clip." }, - fr: { visualTabTransform: "Base", visualTabMask: "Masque", visualTabSpeed: "Vitesse", visualTabEffects: "Effets", visualSpeed: "Vitesse de lecture", visualSourceDuration: "Durée source", visualTimelineDuration: "Durée sur la timeline", visualSpeedVisualOnlyHint: "Modifie uniquement ce clip visuel ; la piste audio source reste indépendante.", visualSpeedImageHint: "Une image fixe n’a pas de vitesse. Réglez plutôt la durée du clip." }, - de: { visualTabTransform: "Basis", visualTabMask: "Maske", visualTabSpeed: "Tempo", visualTabEffects: "Effekte", visualSpeed: "Wiedergabegeschwindigkeit", visualSourceDuration: "Quelldauer", visualTimelineDuration: "Timeline-Dauer", visualSpeedVisualOnlyHint: "Ändert nur diesen Bildclip; die Originaltonspur bleibt unabhängig.", visualSpeedImageHint: "Standbilder haben kein Wiedergabetempo. Passe stattdessen die Cliplänge an." }, - pt: { visualTabTransform: "Básico", visualTabMask: "Máscara", visualTabSpeed: "Velocidade", visualTabEffects: "Efeitos", visualSpeed: "Velocidade de reprodução", visualSourceDuration: "Duração da fonte", visualTimelineDuration: "Duração na linha", visualSpeedVisualOnlyHint: "Altera apenas este clipe visual; a faixa de áudio original permanece independente.", visualSpeedImageHint: "Imagens estáticas não têm velocidade. Ajuste a duração do clipe." }, - th: { visualTabTransform: "พื้นฐาน", visualTabMask: "มาสก์", visualTabSpeed: "ความเร็ว", visualTabEffects: "เอฟเฟกต์", visualSpeed: "ความเร็วการเล่น", visualSourceDuration: "ความยาวต้นฉบับ", visualTimelineDuration: "ความยาวบนไทม์ไลน์", visualSpeedVisualOnlyHint: "ปรับเฉพาะคลิปภาพนี้ ส่วนแทร็กเสียงต้นฉบับยังแยกอิสระ", visualSpeedImageHint: "ภาพนิ่งไม่มีความเร็วในการเล่น ให้ปรับความยาวคลิปแทน" }, - vi: { visualTabTransform: "Cơ bản", visualTabMask: "Mặt nạ", visualTabSpeed: "Tốc độ", visualTabEffects: "Hiệu ứng", visualSpeed: "Tốc độ phát", visualSourceDuration: "Thời lượng nguồn", visualTimelineDuration: "Thời lượng dòng thời gian", visualSpeedVisualOnlyHint: "Chỉ thay đổi clip hình ảnh này; rãnh âm thanh gốc vẫn độc lập.", visualSpeedImageHint: "Ảnh tĩnh không có tốc độ phát. Hãy điều chỉnh thời lượng clip." }, + zh: { visualTabTransform: "基础", visualTabMask: "蒙版", visualTabSpeed: "变速", visualTabEffects: "效果", visualTabAi: "AI 增强", visualSpeed: "播放速度", visualSourceDuration: "素材时长", visualTimelineDuration: "时间轴时长", visualSpeedVisualOnlyHint: "仅调整当前画面片段;视频原声轨保持独立。", visualSpeedImageHint: "静态图片没有播放速度,可直接调整片段时长。" }, + en: { visualTabTransform: "Basic", visualTabMask: "Mask", visualTabSpeed: "Speed", visualTabEffects: "Effects", visualTabAi: "AI Enhance", visualSpeed: "Playback speed", visualSourceDuration: "Source duration", visualTimelineDuration: "Timeline duration", visualSpeedVisualOnlyHint: "Changes this visual clip only; the source-audio track stays independent.", visualSpeedImageHint: "Still images have no playback speed. Adjust the clip duration instead." }, + ja: { visualTabTransform: "基本", visualTabMask: "マスク", visualTabSpeed: "速度", visualTabEffects: "効果", visualTabAi: "AI補正", visualSpeed: "再生速度", visualSourceDuration: "素材の長さ", visualTimelineDuration: "タイムラインの長さ", visualSpeedVisualOnlyHint: "現在の映像クリップだけを変更し、元音声トラックは独立したままです。", visualSpeedImageHint: "静止画には再生速度がありません。クリップの長さを調整してください。" }, + ko: { visualTabTransform: "기본", visualTabMask: "마스크", visualTabSpeed: "속도", visualTabEffects: "효과", visualTabAi: "AI 향상", visualSpeed: "재생 속도", visualSourceDuration: "원본 길이", visualTimelineDuration: "타임라인 길이", visualSpeedVisualOnlyHint: "현재 영상 클립만 변경되며 원본 오디오 트랙은 독립적으로 유지됩니다.", visualSpeedImageHint: "정지 이미지에는 재생 속도가 없습니다. 클립 길이를 조정하세요." }, + es: { visualTabTransform: "Básico", visualTabMask: "Máscara", visualTabSpeed: "Velocidad", visualTabEffects: "Efectos", visualTabAi: "Mejora IA", visualSpeed: "Velocidad de reproducción", visualSourceDuration: "Duración de origen", visualTimelineDuration: "Duración en la línea", visualSpeedVisualOnlyHint: "Solo cambia este clip visual; el audio de origen permanece independiente.", visualSpeedImageHint: "Las imágenes fijas no tienen velocidad. Ajusta la duración del clip." }, + fr: { visualTabTransform: "Base", visualTabMask: "Masque", visualTabSpeed: "Vitesse", visualTabEffects: "Effets", visualTabAi: "Amélioration IA", visualSpeed: "Vitesse de lecture", visualSourceDuration: "Durée source", visualTimelineDuration: "Durée sur la timeline", visualSpeedVisualOnlyHint: "Modifie uniquement ce clip visuel ; la piste audio source reste indépendante.", visualSpeedImageHint: "Une image fixe n’a pas de vitesse. Réglez plutôt la durée du clip." }, + de: { visualTabTransform: "Basis", visualTabMask: "Maske", visualTabSpeed: "Tempo", visualTabEffects: "Effekte", visualTabAi: "KI-Optimierung", visualSpeed: "Wiedergabegeschwindigkeit", visualSourceDuration: "Quelldauer", visualTimelineDuration: "Timeline-Dauer", visualSpeedVisualOnlyHint: "Ändert nur diesen Bildclip; die Originaltonspur bleibt unabhängig.", visualSpeedImageHint: "Standbilder haben kein Wiedergabetempo. Passe stattdessen die Cliplänge an." }, + pt: { visualTabTransform: "Básico", visualTabMask: "Máscara", visualTabSpeed: "Velocidade", visualTabEffects: "Efeitos", visualTabAi: "Aprimorar IA", visualSpeed: "Velocidade de reprodução", visualSourceDuration: "Duração da fonte", visualTimelineDuration: "Duração na linha", visualSpeedVisualOnlyHint: "Altera apenas este clipe visual; a faixa de áudio original permanece independente.", visualSpeedImageHint: "Imagens estáticas não têm velocidade. Ajuste a duração do clipe." }, + th: { visualTabTransform: "พื้นฐาน", visualTabMask: "มาสก์", visualTabSpeed: "ความเร็ว", visualTabEffects: "เอฟเฟกต์", visualTabAi: "ปรับด้วย AI", visualSpeed: "ความเร็วการเล่น", visualSourceDuration: "ความยาวต้นฉบับ", visualTimelineDuration: "ความยาวบนไทม์ไลน์", visualSpeedVisualOnlyHint: "ปรับเฉพาะคลิปภาพนี้ ส่วนแทร็กเสียงต้นฉบับยังแยกอิสระ", visualSpeedImageHint: "ภาพนิ่งไม่มีความเร็วในการเล่น ให้ปรับความยาวคลิปแทน" }, + vi: { visualTabTransform: "Cơ bản", visualTabMask: "Mặt nạ", visualTabSpeed: "Tốc độ", visualTabEffects: "Hiệu ứng", visualTabAi: "Tăng cường AI", visualSpeed: "Tốc độ phát", visualSourceDuration: "Thời lượng nguồn", visualTimelineDuration: "Thời lượng dòng thời gian", visualSpeedVisualOnlyHint: "Chỉ thay đổi clip hình ảnh này; rãnh âm thanh gốc vẫn độc lập.", visualSpeedImageHint: "Ảnh tĩnh không có tốc độ phát. Hãy điều chỉnh thời lượng clip." }, }; const SOURCE_AUDIO_SYNC_COPY = { @@ -78,12 +78,73 @@ const SOURCE_AUDIO_SYNC_COPY = { vi: { linkSourceAudio: "Đồng bộ hình ảnh và âm thanh gốc", unlinkSourceAudio: "Bỏ liên kết hình ảnh và âm thanh gốc", sourceAudioSynced: "Hình và âm thanh đã đồng bộ", sourceAudioIndependent: "Hình và âm thanh độc lập" }, }; +const VISUAL_AI_TAB_COPY = Object.fromEntries( + APP_LANGUAGES.map(({ id }) => [id, { visualTabAi: "AI" }]), +); + +const REMASTER_COPY = { + zh: { remasterTitle: "AI 视频增强", remasterExperimental: "当前帧试用", remasterModelMeta: "MIT · 本地 ONNX · 去噪与压缩修复", remasterReadyBadge: "已生成", remasterNotRun: "未运行", remasterShowResult: "显示增强结果", remasterProcessing: "正在增强", remasterRerun: "重新增强当前帧", remasterEnhanceFrame: "增强当前帧", remasterClear: "清除", remasterVideoHint: "当前为质量验证版:仅增强播放头处的视频帧,不会冒充整段处理或写入导出。确认效果后再启用逐帧预计算。", remasterImageHint: "图片会按当前素材生成完整增强预览;当前试用结果暂不写入导出。", remasterPreviewAlt: "Remaster DRUNet 增强预览", remasterSelectClip: "请先选择一个画面片段", remasterPreparing: "准备当前画面", remasterReady: "Remaster 当前帧增强完成", remasterFailed: "视频增强失败" }, + en: { remasterTitle: "AI video enhancement", remasterExperimental: "Current-frame trial", remasterModelMeta: "MIT · local ONNX · denoise and artifact repair", remasterReadyBadge: "Ready", remasterNotRun: "Not run", remasterShowResult: "Show enhanced result", remasterProcessing: "Enhancing", remasterRerun: "Enhance current frame again", remasterEnhanceFrame: "Enhance current frame", remasterClear: "Clear", remasterVideoHint: "Quality trial: enhances only the video frame at the playhead. It does not pretend the full clip is processed or include it in export yet.", remasterImageHint: "Creates a complete enhanced preview for this image. Trial results are not exported yet.", remasterPreviewAlt: "Remaster DRUNet enhanced preview", remasterSelectClip: "Select a visual clip first", remasterPreparing: "Preparing current frame", remasterReady: "Remaster frame enhancement complete", remasterFailed: "Video enhancement failed" }, + ja: { remasterTitle: "AI動画補正", remasterExperimental: "現在フレーム試用", remasterModelMeta: "MIT · ローカルONNX · ノイズ・圧縮劣化補正", remasterReadyBadge: "生成済み", remasterNotRun: "未実行", remasterShowResult: "補正結果を表示", remasterProcessing: "補正中", remasterRerun: "現在フレームを再補正", remasterEnhanceFrame: "現在フレームを補正", remasterClear: "クリア", remasterVideoHint: "品質確認版では再生ヘッド位置の1フレームのみ補正し、まだ書き出しには含めません。", remasterImageHint: "画像全体の補正プレビューを生成します。試用結果はまだ書き出しません。", remasterPreviewAlt: "Remaster DRUNet補正プレビュー", remasterSelectClip: "映像クリップを選択してください", remasterPreparing: "フレームを準備中", remasterReady: "フレーム補正が完了しました", remasterFailed: "動画補正に失敗しました" }, + ko: { remasterTitle: "AI 영상 향상", remasterExperimental: "현재 프레임 시험", remasterModelMeta: "MIT · 로컬 ONNX · 노이즈 및 압축 복원", remasterReadyBadge: "완료", remasterNotRun: "미실행", remasterShowResult: "향상 결과 표시", remasterProcessing: "향상 중", remasterRerun: "현재 프레임 다시 향상", remasterEnhanceFrame: "현재 프레임 향상", remasterClear: "지우기", remasterVideoHint: "품질 시험판은 재생 헤드의 한 프레임만 향상하며 아직 내보내기에 포함하지 않습니다.", remasterImageHint: "이미지 전체 향상 미리보기를 생성하며 시험 결과는 아직 내보내지 않습니다.", remasterPreviewAlt: "Remaster DRUNet 향상 미리보기", remasterSelectClip: "영상 클립을 먼저 선택하세요", remasterPreparing: "현재 프레임 준비 중", remasterReady: "프레임 향상 완료", remasterFailed: "영상 향상 실패" }, + es: { remasterTitle: "Mejora de vídeo con IA", remasterExperimental: "Prueba del fotograma actual", remasterModelMeta: "MIT · ONNX local · reducción de ruido y artefactos", remasterReadyBadge: "Listo", remasterNotRun: "Sin ejecutar", remasterShowResult: "Mostrar resultado mejorado", remasterProcessing: "Mejorando", remasterRerun: "Mejorar de nuevo", remasterEnhanceFrame: "Mejorar fotograma actual", remasterClear: "Borrar", remasterVideoHint: "Prueba de calidad: solo mejora el fotograma bajo el cabezal y todavía no se incluye en la exportación.", remasterImageHint: "Genera una vista previa mejorada de la imagen; aún no se exporta.", remasterPreviewAlt: "Vista previa mejorada de Remaster DRUNet", remasterSelectClip: "Selecciona primero un clip visual", remasterPreparing: "Preparando fotograma", remasterReady: "Mejora del fotograma completada", remasterFailed: "Falló la mejora de vídeo" }, + fr: { remasterTitle: "Amélioration vidéo IA", remasterExperimental: "Essai de l’image actuelle", remasterModelMeta: "MIT · ONNX local · débruitage et artefacts", remasterReadyBadge: "Prêt", remasterNotRun: "Non exécuté", remasterShowResult: "Afficher le résultat amélioré", remasterProcessing: "Amélioration", remasterRerun: "Améliorer à nouveau", remasterEnhanceFrame: "Améliorer l’image actuelle", remasterClear: "Effacer", remasterVideoHint: "Essai qualité : seule l’image sous la tête de lecture est améliorée et n’est pas encore exportée.", remasterImageHint: "Crée un aperçu amélioré complet de l’image, pas encore exporté.", remasterPreviewAlt: "Aperçu amélioré Remaster DRUNet", remasterSelectClip: "Sélectionnez d’abord un clip visuel", remasterPreparing: "Préparation de l’image", remasterReady: "Amélioration terminée", remasterFailed: "Échec de l’amélioration vidéo" }, + de: { remasterTitle: "KI-Videoverbesserung", remasterExperimental: "Test des aktuellen Frames", remasterModelMeta: "MIT · lokales ONNX · Rauschen und Artefakte", remasterReadyBadge: "Fertig", remasterNotRun: "Nicht ausgeführt", remasterShowResult: "Verbessertes Ergebnis anzeigen", remasterProcessing: "Verbesserung läuft", remasterRerun: "Frame erneut verbessern", remasterEnhanceFrame: "Aktuellen Frame verbessern", remasterClear: "Löschen", remasterVideoHint: "Qualitätstest: Nur der Frame am Abspielkopf wird verbessert und noch nicht exportiert.", remasterImageHint: "Erstellt eine vollständige verbesserte Bildvorschau, die noch nicht exportiert wird.", remasterPreviewAlt: "Remaster DRUNet Vorschau", remasterSelectClip: "Zuerst einen Bildclip auswählen", remasterPreparing: "Frame wird vorbereitet", remasterReady: "Frame-Verbesserung abgeschlossen", remasterFailed: "Videoverbesserung fehlgeschlagen" }, + pt: { remasterTitle: "Aprimoramento de vídeo por IA", remasterExperimental: "Teste do quadro atual", remasterModelMeta: "MIT · ONNX local · ruído e artefatos", remasterReadyBadge: "Pronto", remasterNotRun: "Não executado", remasterShowResult: "Mostrar resultado aprimorado", remasterProcessing: "Aprimorando", remasterRerun: "Aprimorar novamente", remasterEnhanceFrame: "Aprimorar quadro atual", remasterClear: "Limpar", remasterVideoHint: "Teste de qualidade: aprimora apenas o quadro no cursor e ainda não entra na exportação.", remasterImageHint: "Gera uma prévia aprimorada completa da imagem, ainda sem exportação.", remasterPreviewAlt: "Prévia aprimorada Remaster DRUNet", remasterSelectClip: "Selecione primeiro um clipe visual", remasterPreparing: "Preparando quadro", remasterReady: "Aprimoramento concluído", remasterFailed: "Falha no aprimoramento" }, + th: { remasterTitle: "ปรับปรุงวิดีโอด้วย AI", remasterExperimental: "ทดลองเฟรมปัจจุบัน", remasterModelMeta: "MIT · ONNX ในเครื่อง · ลดนอยส์และรอยบีบอัด", remasterReadyBadge: "พร้อม", remasterNotRun: "ยังไม่รัน", remasterShowResult: "แสดงผลที่ปรับปรุง", remasterProcessing: "กำลังปรับปรุง", remasterRerun: "ปรับปรุงเฟรมอีกครั้ง", remasterEnhanceFrame: "ปรับปรุงเฟรมปัจจุบัน", remasterClear: "ล้าง", remasterVideoHint: "รุ่นทดสอบคุณภาพจะปรับปรุงเฉพาะเฟรมที่หัวเล่นและยังไม่รวมในการส่งออก", remasterImageHint: "สร้างตัวอย่างภาพที่ปรับปรุงทั้งภาพ แต่ยังไม่ส่งออก", remasterPreviewAlt: "ตัวอย่าง Remaster DRUNet", remasterSelectClip: "เลือกคลิปภาพก่อน", remasterPreparing: "กำลังเตรียมเฟรม", remasterReady: "ปรับปรุงเฟรมเสร็จแล้ว", remasterFailed: "ปรับปรุงวิดีโอล้มเหลว" }, + vi: { remasterTitle: "Tăng cường video AI", remasterExperimental: "Thử khung hình hiện tại", remasterModelMeta: "MIT · ONNX cục bộ · khử nhiễu và lỗi nén", remasterReadyBadge: "Sẵn sàng", remasterNotRun: "Chưa chạy", remasterShowResult: "Hiện kết quả tăng cường", remasterProcessing: "Đang tăng cường", remasterRerun: "Tăng cường lại", remasterEnhanceFrame: "Tăng cường khung hiện tại", remasterClear: "Xóa", remasterVideoHint: "Bản thử chất lượng chỉ xử lý khung tại đầu phát và chưa đưa vào xuất video.", remasterImageHint: "Tạo bản xem trước tăng cường toàn ảnh, chưa đưa vào xuất.", remasterPreviewAlt: "Xem trước Remaster DRUNet", remasterSelectClip: "Hãy chọn clip hình ảnh trước", remasterPreparing: "Đang chuẩn bị khung", remasterReady: "Tăng cường khung hoàn tất", remasterFailed: "Tăng cường video thất bại" }, +}; + +const REMASTER_CLIP_COPY = { + zh: { remasterEnhanceClip: "增强整个片段", remasterRerunClip: "重新增强整个片段", remasterFramePreview: "预览当前帧", remasterFullReadyBadge: "整段已增强", remasterUseEnhanced: "使用增强视频", remasterFramesShort: "帧", remasterClipProgressTitle: "正在增强整个视频片段", remasterClipProgressFrames: "已处理 {current} / {total} 帧", remasterClipProgressSafe: "增强在本地逐帧完成。取消后会保留原片,不会替换时间线素材。", remasterCancel: "取消增强", remasterCanceling: "正在取消…", remasterCanceled: "已取消整段增强,原片保持不变", remasterClipReady: "整个片段增强完成,预览和导出已使用增强素材", remasterClipOnlyVideo: "整段增强仅适用于视频片段", remasterClipPreparing: "正在读取视频片段", remasterVideoHint: "可先预览当前帧确认画质;整段增强会逐帧处理并生成新视频,完成后预览和导出使用同一结果。" }, + en: { remasterEnhanceClip: "Enhance entire clip", remasterRerunClip: "Enhance entire clip again", remasterFramePreview: "Preview current frame", remasterFullReadyBadge: "Full clip ready", remasterUseEnhanced: "Use enhanced video", remasterFramesShort: "frames", remasterClipProgressTitle: "Enhancing the entire video clip", remasterClipProgressFrames: "Processed {current} / {total} frames", remasterClipProgressSafe: "Frames are enhanced locally. Canceling keeps the original clip and does not replace timeline media.", remasterCancel: "Cancel enhancement", remasterCanceling: "Canceling…", remasterCanceled: "Full-clip enhancement canceled; the original is unchanged", remasterClipReady: "Full clip enhanced; preview and export now use the enhanced media", remasterClipOnlyVideo: "Full-clip enhancement is available for video clips only", remasterClipPreparing: "Reading video clip", remasterVideoHint: "Preview the current frame first, then enhance every frame into a new video used consistently by preview and export." }, + ja: { remasterEnhanceClip: "クリップ全体を補正", remasterRerunClip: "クリップ全体を再補正", remasterFramePreview: "現在フレームをプレビュー", remasterFullReadyBadge: "全体補正済み", remasterUseEnhanced: "補正動画を使用", remasterFramesShort: "フレーム", remasterClipProgressTitle: "動画クリップ全体を補正中", remasterClipProgressFrames: "{current} / {total} フレーム処理済み", remasterClipProgressSafe: "処理はローカルで行われ、キャンセルしても元のクリップは保持されます。", remasterCancel: "補正をキャンセル", remasterCanceling: "キャンセル中…", remasterCanceled: "補正をキャンセルしました", remasterClipReady: "クリップ全体の補正が完了しました", remasterClipOnlyVideo: "動画クリップのみ利用できます", remasterClipPreparing: "動画を読み込み中", remasterVideoHint: "現在フレームで確認後、全フレームを処理した動画をプレビューと書き出しに使用できます。" }, + ko: { remasterEnhanceClip: "전체 클립 향상", remasterRerunClip: "전체 클립 다시 향상", remasterFramePreview: "현재 프레임 미리보기", remasterFullReadyBadge: "전체 완료", remasterUseEnhanced: "향상 영상 사용", remasterFramesShort: "프레임", remasterClipProgressTitle: "전체 영상 클립 향상 중", remasterClipProgressFrames: "{current} / {total} 프레임 처리", remasterClipProgressSafe: "로컬에서 처리되며 취소해도 원본 클립은 유지됩니다.", remasterCancel: "향상 취소", remasterCanceling: "취소 중…", remasterCanceled: "향상이 취소되었습니다", remasterClipReady: "전체 클립 향상 완료", remasterClipOnlyVideo: "비디오 클립에서만 사용할 수 있습니다", remasterClipPreparing: "비디오 읽는 중", remasterVideoHint: "현재 프레임을 확인한 뒤 전체 프레임을 처리해 미리보기와 내보내기에 동일하게 사용합니다." }, + es: { remasterEnhanceClip: "Mejorar clip completo", remasterRerunClip: "Volver a mejorar el clip", remasterFramePreview: "Previsualizar fotograma", remasterFullReadyBadge: "Clip listo", remasterUseEnhanced: "Usar vídeo mejorado", remasterFramesShort: "fotogramas", remasterClipProgressTitle: "Mejorando todo el clip", remasterClipProgressFrames: "{current} / {total} fotogramas", remasterClipProgressSafe: "El proceso es local; cancelar conserva el clip original.", remasterCancel: "Cancelar mejora", remasterCanceling: "Cancelando…", remasterCanceled: "Mejora cancelada", remasterClipReady: "Clip completo mejorado", remasterClipOnlyVideo: "Solo disponible para clips de vídeo", remasterClipPreparing: "Leyendo vídeo", remasterVideoHint: "Previsualiza un fotograma y luego procesa el clip completo para vista previa y exportación." }, + fr: { remasterEnhanceClip: "Améliorer tout le clip", remasterRerunClip: "Réaméliorer tout le clip", remasterFramePreview: "Aperçu de l’image", remasterFullReadyBadge: "Clip prêt", remasterUseEnhanced: "Utiliser la vidéo améliorée", remasterFramesShort: "images", remasterClipProgressTitle: "Amélioration du clip complet", remasterClipProgressFrames: "{current} / {total} images", remasterClipProgressSafe: "Le traitement est local ; l’annulation conserve le clip original.", remasterCancel: "Annuler", remasterCanceling: "Annulation…", remasterCanceled: "Amélioration annulée", remasterClipReady: "Clip complet amélioré", remasterClipOnlyVideo: "Disponible uniquement pour les clips vidéo", remasterClipPreparing: "Lecture de la vidéo", remasterVideoHint: "Prévisualisez une image puis traitez tout le clip pour l’aperçu et l’export." }, + de: { remasterEnhanceClip: "Ganzen Clip verbessern", remasterRerunClip: "Ganzen Clip erneut verbessern", remasterFramePreview: "Frame-Vorschau", remasterFullReadyBadge: "Clip fertig", remasterUseEnhanced: "Verbessertes Video verwenden", remasterFramesShort: "Frames", remasterClipProgressTitle: "Gesamten Videoclip verbessern", remasterClipProgressFrames: "{current} / {total} Frames", remasterClipProgressSafe: "Die Verarbeitung ist lokal; beim Abbruch bleibt das Original erhalten.", remasterCancel: "Verbesserung abbrechen", remasterCanceling: "Abbruch…", remasterCanceled: "Verbesserung abgebrochen", remasterClipReady: "Gesamter Clip verbessert", remasterClipOnlyVideo: "Nur für Videoclips verfügbar", remasterClipPreparing: "Video wird gelesen", remasterVideoHint: "Frame prüfen und dann den gesamten Clip für Vorschau und Export verarbeiten." }, + pt: { remasterEnhanceClip: "Aprimorar clipe inteiro", remasterRerunClip: "Aprimorar clipe novamente", remasterFramePreview: "Prévia do quadro", remasterFullReadyBadge: "Clipe pronto", remasterUseEnhanced: "Usar vídeo aprimorado", remasterFramesShort: "quadros", remasterClipProgressTitle: "Aprimorando o clipe inteiro", remasterClipProgressFrames: "{current} / {total} quadros", remasterClipProgressSafe: "O processamento é local; cancelar preserva o clipe original.", remasterCancel: "Cancelar", remasterCanceling: "Cancelando…", remasterCanceled: "Aprimoramento cancelado", remasterClipReady: "Clipe inteiro aprimorado", remasterClipOnlyVideo: "Disponível apenas para vídeos", remasterClipPreparing: "Lendo vídeo", remasterVideoHint: "Confira um quadro e processe o clipe inteiro para prévia e exportação." }, + th: { remasterEnhanceClip: "ปรับปรุงทั้งคลิป", remasterRerunClip: "ปรับปรุงทั้งคลิปอีกครั้ง", remasterFramePreview: "ดูตัวอย่างเฟรม", remasterFullReadyBadge: "คลิปพร้อม", remasterUseEnhanced: "ใช้วิดีโอที่ปรับปรุง", remasterFramesShort: "เฟรม", remasterClipProgressTitle: "กำลังปรับปรุงวิดีโอทั้งคลิป", remasterClipProgressFrames: "{current} / {total} เฟรม", remasterClipProgressSafe: "ประมวลผลในเครื่องและการยกเลิกจะเก็บคลิปต้นฉบับไว้", remasterCancel: "ยกเลิก", remasterCanceling: "กำลังยกเลิก…", remasterCanceled: "ยกเลิกการปรับปรุงแล้ว", remasterClipReady: "ปรับปรุงทั้งคลิปเสร็จแล้ว", remasterClipOnlyVideo: "ใช้ได้กับคลิปวิดีโอเท่านั้น", remasterClipPreparing: "กำลังอ่านวิดีโอ", remasterVideoHint: "ตรวจสอบหนึ่งเฟรมก่อน แล้วประมวลผลทั้งคลิปสำหรับตัวอย่างและส่งออก" }, + vi: { remasterEnhanceClip: "Tăng cường toàn bộ clip", remasterRerunClip: "Tăng cường lại toàn clip", remasterFramePreview: "Xem trước khung hiện tại", remasterFullReadyBadge: "Clip đã sẵn sàng", remasterUseEnhanced: "Dùng video tăng cường", remasterFramesShort: "khung", remasterClipProgressTitle: "Đang tăng cường toàn bộ clip", remasterClipProgressFrames: "{current} / {total} khung", remasterClipProgressSafe: "Xử lý cục bộ; hủy sẽ giữ nguyên clip gốc.", remasterCancel: "Hủy tăng cường", remasterCanceling: "Đang hủy…", remasterCanceled: "Đã hủy tăng cường", remasterClipReady: "Đã tăng cường toàn bộ clip", remasterClipOnlyVideo: "Chỉ dùng cho clip video", remasterClipPreparing: "Đang đọc video", remasterVideoHint: "Xem trước một khung rồi xử lý toàn clip cho cả xem trước và xuất." }, +}; + +const REMASTER_GPU_COPY = { + zh: { remasterPerformanceMode: "增强性能", remasterFastMode: "极速 · 640p", remasterQualityMode: "画质 · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "自动选择 GPU" }, + en: { remasterPerformanceMode: "Enhancement performance", remasterFastMode: "Fast · 640p", remasterQualityMode: "Quality · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "Auto-select GPU" }, + ja: { remasterPerformanceMode: "補正性能", remasterFastMode: "高速 · 640p", remasterQualityMode: "高画質 · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "GPUを自動選択" }, + ko: { remasterPerformanceMode: "향상 성능", remasterFastMode: "고속 · 640p", remasterQualityMode: "화질 · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "GPU 자동 선택" }, + es: { remasterPerformanceMode: "Rendimiento", remasterFastMode: "Rápido · 640p", remasterQualityMode: "Calidad · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "GPU automática" }, + fr: { remasterPerformanceMode: "Performances", remasterFastMode: "Rapide · 640p", remasterQualityMode: "Qualité · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "GPU automatique" }, + de: { remasterPerformanceMode: "Leistung", remasterFastMode: "Schnell · 640p", remasterQualityMode: "Qualität · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "GPU automatisch" }, + pt: { remasterPerformanceMode: "Desempenho", remasterFastMode: "Rápido · 640p", remasterQualityMode: "Qualidade · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "GPU automática" }, + th: { remasterPerformanceMode: "ประสิทธิภาพ", remasterFastMode: "เร็ว · 640p", remasterQualityMode: "คุณภาพ · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "เลือก GPU อัตโนมัติ" }, + vi: { remasterPerformanceMode: "Hiệu năng", remasterFastMode: "Nhanh · 640p", remasterQualityMode: "Chất lượng · 960p", remasterGpuActive: "GPU · WebGPU", remasterCpuFallback: "CPU · WASM", remasterGpuAuto: "Tự chọn GPU" }, +}; + +const REMASTER_PHASE_COPY = { + zh: { remasterPhasePrepareFrame: "正在准备当前画面", remasterPhaseReadClip: "正在读取视频片段", remasterPhaseReuseVideo: "正在读取播放器中的视频", remasterPhaseDownloadModel: "正在下载 {model}", remasterPhaseInitModel: "正在初始化视频增强模型", remasterPhaseGpuReady: "WebGPU 已启用", remasterPhaseCpuFallback: "WebGPU 不可用,正在使用 CPU", remasterPhaseGpuFrame: "GPU 正在增强当前帧", remasterPhaseCpuFrame: "CPU 正在增强当前帧", remasterPhaseGeneratePreview: "正在生成增强画面", remasterPhaseEnhancingFrame: "正在增强第 {current} / {total} 帧", remasterPhaseFrameEnhanced: "已增强 {current} / {total} 帧", remasterPhaseLoadEncoder: "正在加载视频编码器", remasterPhaseEncodeVideo: "正在编码增强视频", remasterPhaseCreateAsset: "正在生成增强素材" }, + en: { remasterPhasePrepareFrame: "Preparing the current frame", remasterPhaseReadClip: "Reading the video clip", remasterPhaseReuseVideo: "Reading video from the player", remasterPhaseDownloadModel: "Downloading {model}", remasterPhaseInitModel: "Initializing the enhancement model", remasterPhaseGpuReady: "WebGPU enabled", remasterPhaseCpuFallback: "WebGPU unavailable; using CPU", remasterPhaseGpuFrame: "Enhancing the current frame on GPU", remasterPhaseCpuFrame: "Enhancing the current frame on CPU", remasterPhaseGeneratePreview: "Generating the enhanced frame", remasterPhaseEnhancingFrame: "Enhancing frame {current} / {total}", remasterPhaseFrameEnhanced: "Enhanced {current} / {total} frames", remasterPhaseLoadEncoder: "Loading the video encoder", remasterPhaseEncodeVideo: "Encoding the enhanced video", remasterPhaseCreateAsset: "Creating the enhanced media" }, + ja: { remasterPhasePrepareFrame: "現在のフレームを準備中", remasterPhaseReadClip: "動画クリップを読み込み中", remasterPhaseReuseVideo: "プレーヤーの動画を読み込み中", remasterPhaseDownloadModel: "{model} をダウンロード中", remasterPhaseInitModel: "補正モデルを初期化中", remasterPhaseGpuReady: "WebGPU が有効です", remasterPhaseCpuFallback: "WebGPUを利用できないためCPUを使用中", remasterPhaseGpuFrame: "GPUで現在フレームを補正中", remasterPhaseCpuFrame: "CPUで現在フレームを補正中", remasterPhaseGeneratePreview: "補正フレームを生成中", remasterPhaseEnhancingFrame: "{current} / {total} フレームを補正中", remasterPhaseFrameEnhanced: "{current} / {total} フレーム補正済み", remasterPhaseLoadEncoder: "動画エンコーダーを読み込み中", remasterPhaseEncodeVideo: "補正動画をエンコード中", remasterPhaseCreateAsset: "補正メディアを生成中" }, + ko: { remasterPhasePrepareFrame: "현재 프레임 준비 중", remasterPhaseReadClip: "영상 클립 읽는 중", remasterPhaseReuseVideo: "플레이어 영상을 읽는 중", remasterPhaseDownloadModel: "{model} 다운로드 중", remasterPhaseInitModel: "향상 모델 초기화 중", remasterPhaseGpuReady: "WebGPU 활성화됨", remasterPhaseCpuFallback: "WebGPU를 사용할 수 없어 CPU 사용 중", remasterPhaseGpuFrame: "GPU로 현재 프레임 향상 중", remasterPhaseCpuFrame: "CPU로 현재 프레임 향상 중", remasterPhaseGeneratePreview: "향상 프레임 생성 중", remasterPhaseEnhancingFrame: "{current} / {total} 프레임 향상 중", remasterPhaseFrameEnhanced: "{current} / {total} 프레임 완료", remasterPhaseLoadEncoder: "비디오 인코더 로드 중", remasterPhaseEncodeVideo: "향상 영상 인코딩 중", remasterPhaseCreateAsset: "향상 미디어 생성 중" }, + es: { remasterPhasePrepareFrame: "Preparando el fotograma actual", remasterPhaseReadClip: "Leyendo el clip de vídeo", remasterPhaseReuseVideo: "Leyendo el vídeo del reproductor", remasterPhaseDownloadModel: "Descargando {model}", remasterPhaseInitModel: "Inicializando el modelo de mejora", remasterPhaseGpuReady: "WebGPU activada", remasterPhaseCpuFallback: "WebGPU no disponible; usando CPU", remasterPhaseGpuFrame: "Mejorando el fotograma en la GPU", remasterPhaseCpuFrame: "Mejorando el fotograma en la CPU", remasterPhaseGeneratePreview: "Generando el fotograma mejorado", remasterPhaseEnhancingFrame: "Mejorando fotograma {current} / {total}", remasterPhaseFrameEnhanced: "{current} / {total} fotogramas mejorados", remasterPhaseLoadEncoder: "Cargando el codificador de vídeo", remasterPhaseEncodeVideo: "Codificando el vídeo mejorado", remasterPhaseCreateAsset: "Creando el contenido mejorado" }, + fr: { remasterPhasePrepareFrame: "Préparation de l’image actuelle", remasterPhaseReadClip: "Lecture du clip vidéo", remasterPhaseReuseVideo: "Lecture de la vidéo du lecteur", remasterPhaseDownloadModel: "Téléchargement de {model}", remasterPhaseInitModel: "Initialisation du modèle d’amélioration", remasterPhaseGpuReady: "WebGPU activé", remasterPhaseCpuFallback: "WebGPU indisponible ; utilisation du CPU", remasterPhaseGpuFrame: "Amélioration de l’image sur le GPU", remasterPhaseCpuFrame: "Amélioration de l’image sur le CPU", remasterPhaseGeneratePreview: "Génération de l’image améliorée", remasterPhaseEnhancingFrame: "Amélioration de l’image {current} / {total}", remasterPhaseFrameEnhanced: "{current} / {total} images améliorées", remasterPhaseLoadEncoder: "Chargement de l’encodeur vidéo", remasterPhaseEncodeVideo: "Encodage de la vidéo améliorée", remasterPhaseCreateAsset: "Création du média amélioré" }, + de: { remasterPhasePrepareFrame: "Aktuellen Frame vorbereiten", remasterPhaseReadClip: "Videoclip wird gelesen", remasterPhaseReuseVideo: "Video aus dem Player wird gelesen", remasterPhaseDownloadModel: "{model} wird heruntergeladen", remasterPhaseInitModel: "Verbesserungsmodell wird initialisiert", remasterPhaseGpuReady: "WebGPU aktiviert", remasterPhaseCpuFallback: "WebGPU nicht verfügbar; CPU wird verwendet", remasterPhaseGpuFrame: "Aktueller Frame wird auf der GPU verbessert", remasterPhaseCpuFrame: "Aktueller Frame wird auf der CPU verbessert", remasterPhaseGeneratePreview: "Verbesserten Frame erzeugen", remasterPhaseEnhancingFrame: "Frame {current} / {total} wird verbessert", remasterPhaseFrameEnhanced: "{current} / {total} Frames verbessert", remasterPhaseLoadEncoder: "Video-Encoder wird geladen", remasterPhaseEncodeVideo: "Verbessertes Video wird codiert", remasterPhaseCreateAsset: "Verbessertes Medium wird erstellt" }, + pt: { remasterPhasePrepareFrame: "Preparando o quadro atual", remasterPhaseReadClip: "Lendo o clipe de vídeo", remasterPhaseReuseVideo: "Lendo o vídeo do player", remasterPhaseDownloadModel: "Baixando {model}", remasterPhaseInitModel: "Inicializando o modelo de aprimoramento", remasterPhaseGpuReady: "WebGPU ativada", remasterPhaseCpuFallback: "WebGPU indisponível; usando CPU", remasterPhaseGpuFrame: "Aprimorando o quadro na GPU", remasterPhaseCpuFrame: "Aprimorando o quadro na CPU", remasterPhaseGeneratePreview: "Gerando o quadro aprimorado", remasterPhaseEnhancingFrame: "Aprimorando quadro {current} / {total}", remasterPhaseFrameEnhanced: "{current} / {total} quadros aprimorados", remasterPhaseLoadEncoder: "Carregando o codificador de vídeo", remasterPhaseEncodeVideo: "Codificando o vídeo aprimorado", remasterPhaseCreateAsset: "Criando a mídia aprimorada" }, + th: { remasterPhasePrepareFrame: "กำลังเตรียมเฟรมปัจจุบัน", remasterPhaseReadClip: "กำลังอ่านคลิปวิดีโอ", remasterPhaseReuseVideo: "กำลังอ่านวิดีโอจากตัวเล่น", remasterPhaseDownloadModel: "กำลังดาวน์โหลด {model}", remasterPhaseInitModel: "กำลังเริ่มต้นโมเดลปรับปรุง", remasterPhaseGpuReady: "เปิดใช้ WebGPU แล้ว", remasterPhaseCpuFallback: "ใช้ WebGPU ไม่ได้ กำลังใช้ CPU", remasterPhaseGpuFrame: "กำลังปรับปรุงเฟรมด้วย GPU", remasterPhaseCpuFrame: "กำลังปรับปรุงเฟรมด้วย CPU", remasterPhaseGeneratePreview: "กำลังสร้างเฟรมที่ปรับปรุง", remasterPhaseEnhancingFrame: "กำลังปรับปรุงเฟรม {current} / {total}", remasterPhaseFrameEnhanced: "ปรับปรุงแล้ว {current} / {total} เฟรม", remasterPhaseLoadEncoder: "กำลังโหลดตัวเข้ารหัสวิดีโอ", remasterPhaseEncodeVideo: "กำลังเข้ารหัสวิดีโอที่ปรับปรุง", remasterPhaseCreateAsset: "กำลังสร้างสื่อที่ปรับปรุง" }, + vi: { remasterPhasePrepareFrame: "Đang chuẩn bị khung hiện tại", remasterPhaseReadClip: "Đang đọc clip video", remasterPhaseReuseVideo: "Đang đọc video từ trình phát", remasterPhaseDownloadModel: "Đang tải {model}", remasterPhaseInitModel: "Đang khởi tạo mô hình tăng cường", remasterPhaseGpuReady: "Đã bật WebGPU", remasterPhaseCpuFallback: "Không dùng được WebGPU; đang dùng CPU", remasterPhaseGpuFrame: "Đang tăng cường khung bằng GPU", remasterPhaseCpuFrame: "Đang tăng cường khung bằng CPU", remasterPhaseGeneratePreview: "Đang tạo khung đã tăng cường", remasterPhaseEnhancingFrame: "Đang tăng cường khung {current} / {total}", remasterPhaseFrameEnhanced: "Đã tăng cường {current} / {total} khung", remasterPhaseLoadEncoder: "Đang tải bộ mã hóa video", remasterPhaseEncodeVideo: "Đang mã hóa video tăng cường", remasterPhaseCreateAsset: "Đang tạo nội dung tăng cường" }, +}; + export const UI_COPY = { zh: { ...VISUAL_EDITOR_COPY.zh, ...VISUAL_MASK_SHAPE_COPY.zh, ...VISUAL_KEYFRAME_ACTION_COPY.zh, ...VISUAL_TAB_COPY.zh, + ...VISUAL_AI_TAB_COPY.zh, + ...REMASTER_COPY.zh, + ...REMASTER_CLIP_COPY.zh, + ...REMASTER_GPU_COPY.zh, + ...REMASTER_PHASE_COPY.zh, ...SOURCE_AUDIO_SYNC_COPY.zh, languageKicker: "AI Voice Studio", languageTitle: "选择界面语言", @@ -442,6 +503,11 @@ export const UI_COPY = { ...VISUAL_MASK_SHAPE_COPY.en, ...VISUAL_KEYFRAME_ACTION_COPY.en, ...VISUAL_TAB_COPY.en, + ...VISUAL_AI_TAB_COPY.en, + ...REMASTER_COPY.en, + ...REMASTER_CLIP_COPY.en, + ...REMASTER_GPU_COPY.en, + ...REMASTER_PHASE_COPY.en, ...SOURCE_AUDIO_SYNC_COPY.en, languageKicker: "AI Voice Studio", languageTitle: "Choose Interface Language", @@ -800,6 +866,11 @@ export const UI_COPY = { ...VISUAL_MASK_SHAPE_COPY.ja, ...VISUAL_KEYFRAME_ACTION_COPY.ja, ...VISUAL_TAB_COPY.ja, + ...VISUAL_AI_TAB_COPY.ja, + ...REMASTER_COPY.ja, + ...REMASTER_CLIP_COPY.ja, + ...REMASTER_GPU_COPY.ja, + ...REMASTER_PHASE_COPY.ja, ...SOURCE_AUDIO_SYNC_COPY.ja, languageTitle: "表示言語を選択", languageSubtitle: "一度保存すると、次回からこの言語で開きます。", @@ -867,6 +938,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.ko, ...VISUAL_KEYFRAME_ACTION_COPY.ko, ...VISUAL_TAB_COPY.ko, + ...VISUAL_AI_TAB_COPY.ko, + ...REMASTER_COPY.ko, + ...REMASTER_CLIP_COPY.ko, + ...REMASTER_GPU_COPY.ko, + ...REMASTER_PHASE_COPY.ko, ...SOURCE_AUDIO_SYNC_COPY.ko, ...UI_COPY.en, languageTitle: "인터페이스 언어 선택", @@ -914,6 +990,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.es, ...VISUAL_KEYFRAME_ACTION_COPY.es, ...VISUAL_TAB_COPY.es, + ...VISUAL_AI_TAB_COPY.es, + ...REMASTER_COPY.es, + ...REMASTER_CLIP_COPY.es, + ...REMASTER_GPU_COPY.es, + ...REMASTER_PHASE_COPY.es, ...SOURCE_AUDIO_SYNC_COPY.es, languageTitle: "Elige el idioma", languageSubtitle: "Se guarda una vez y se abrirá así la próxima vez.", @@ -960,6 +1041,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.fr, ...VISUAL_KEYFRAME_ACTION_COPY.fr, ...VISUAL_TAB_COPY.fr, + ...VISUAL_AI_TAB_COPY.fr, + ...REMASTER_COPY.fr, + ...REMASTER_CLIP_COPY.fr, + ...REMASTER_GPU_COPY.fr, + ...REMASTER_PHASE_COPY.fr, ...SOURCE_AUDIO_SYNC_COPY.fr, languageTitle: "Choisir la langue", languageSubtitle: "Enregistrée une fois, elle sera utilisée à la prochaine ouverture.", @@ -1006,6 +1092,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.de, ...VISUAL_KEYFRAME_ACTION_COPY.de, ...VISUAL_TAB_COPY.de, + ...VISUAL_AI_TAB_COPY.de, + ...REMASTER_COPY.de, + ...REMASTER_CLIP_COPY.de, + ...REMASTER_GPU_COPY.de, + ...REMASTER_PHASE_COPY.de, ...SOURCE_AUDIO_SYNC_COPY.de, languageTitle: "Sprache wählen", languageSubtitle: "Einmal gespeichert, startet der Editor künftig in dieser Sprache.", @@ -1052,6 +1143,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.pt, ...VISUAL_KEYFRAME_ACTION_COPY.pt, ...VISUAL_TAB_COPY.pt, + ...VISUAL_AI_TAB_COPY.pt, + ...REMASTER_COPY.pt, + ...REMASTER_CLIP_COPY.pt, + ...REMASTER_GPU_COPY.pt, + ...REMASTER_PHASE_COPY.pt, ...SOURCE_AUDIO_SYNC_COPY.pt, languageTitle: "Escolha o idioma", languageSubtitle: "Salvo uma vez, o editor abrirá nesse idioma depois.", @@ -1098,6 +1194,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.th, ...VISUAL_KEYFRAME_ACTION_COPY.th, ...VISUAL_TAB_COPY.th, + ...VISUAL_AI_TAB_COPY.th, + ...REMASTER_COPY.th, + ...REMASTER_CLIP_COPY.th, + ...REMASTER_GPU_COPY.th, + ...REMASTER_PHASE_COPY.th, ...SOURCE_AUDIO_SYNC_COPY.th, languageTitle: "เลือกภาษาอินเทอร์เฟซ", languageSubtitle: "บันทึกครั้งเดียว ครั้งถัดไปจะเปิดด้วยภาษานี้", @@ -1144,6 +1245,11 @@ Object.assign(UI_COPY, { ...VISUAL_MASK_SHAPE_COPY.vi, ...VISUAL_KEYFRAME_ACTION_COPY.vi, ...VISUAL_TAB_COPY.vi, + ...VISUAL_AI_TAB_COPY.vi, + ...REMASTER_COPY.vi, + ...REMASTER_CLIP_COPY.vi, + ...REMASTER_GPU_COPY.vi, + ...REMASTER_PHASE_COPY.vi, ...SOURCE_AUDIO_SYNC_COPY.vi, languageTitle: "Chọn ngôn ngữ giao diện", languageSubtitle: "Lưu một lần, lần sau sẽ mở bằng ngôn ngữ này.", diff --git a/src/lib/float16.js b/src/lib/float16.js new file mode 100644 index 0000000..7184571 --- /dev/null +++ b/src/lib/float16.js @@ -0,0 +1,13 @@ +export function float16BitsToFloat32(value) { + const sign = value & 0x8000 ? -1 : 1; + const exponent = (value >>> 10) & 0x1f; + const mantissa = value & 0x3ff; + if (exponent === 0) return sign * 2 ** -14 * (mantissa / 1024); + if (exponent === 0x1f) return mantissa ? Number.NaN : sign * Infinity; + return sign * 2 ** (exponent - 15) * (1 + mantissa / 1024); +} + +export function readFloat16TensorValue(data, index) { + if (data?.constructor?.name === "Float16Array") return Number(data[index]); + return float16BitsToFloat32(data[index]); +} diff --git a/src/lib/float16.test.js b/src/lib/float16.test.js new file mode 100644 index 0000000..994c0cc --- /dev/null +++ b/src/lib/float16.test.js @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { float16BitsToFloat32, readFloat16TensorValue } from "./float16.js"; + +describe("float16 tensor decoding", () => { + it("decodes legacy Uint16 bit patterns", () => { + expect(float16BitsToFloat32(0x3c00)).toBe(1); + expect(readFloat16TensorValue(new Uint16Array([0x3800]), 0)).toBe(0.5); + }); + + it("does not decode native Float16Array values twice", () => { + const NativeFloat16Like = class Float16Array extends Array {}; + const values = new NativeFloat16Like(); + values.push(0.625); + expect(readFloat16TensorValue(values, 0)).toBe(0.625); + }); +}); diff --git a/src/lib/imageResizeControl.js b/src/lib/imageResizeControl.js index 1a5362e..8f8c019 100644 --- a/src/lib/imageResizeControl.js +++ b/src/lib/imageResizeControl.js @@ -29,8 +29,13 @@ export function createImageResizeControl(d) { d.musicBlob && d.musicDuration > 0 ? { time: Math.min(MAX_TIMELINE_DURATION_SECONDS, d.musicDuration), label: "音乐结尾" } : null, ].filter(Boolean); let activeLabel = ""; + let editingStarted = false; const apply = (clientX) => { if (!rect) return; + if (!editingStarted) { + editingStarted = true; + d.pauseForTimelineEdit?.(); + } const pointerX = clientX - rect.left; const inTrackX = Math.max(0, Math.min(rect.width, pointerX)); const raw = (inTrackX / Math.max(rect.width, 1)) * timelineDuration + Math.max(0, pointerX - rect.width) * overflowRate; diff --git a/src/lib/media.js b/src/lib/media.js index 70ea61a..59622fb 100644 --- a/src/lib/media.js +++ b/src/lib/media.js @@ -1147,6 +1147,97 @@ function runFfmpegTask(task) { return nextTask; } +function createAbortError(message = "任务已取消") { + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +function getAbortableFfmpeg(signal) { + const loading = getFfmpeg(); + if (!signal) return loading; + if (signal.aborted) return Promise.reject(createAbortError("整段增强已取消")); + return new Promise((resolve, reject) => { + let settled = false; + const abort = () => { + if (settled) return; + settled = true; + reject(createAbortError("整段增强已取消")); + }; + signal.addEventListener("abort", abort, { once: true }); + loading.then((ffmpeg) => { + signal.removeEventListener("abort", abort); + if (settled || signal.aborted) { + try { ffmpeg.terminate(); } catch { /* The loader may already be closed. */ } + ffmpegLoadPromise = null; + return; + } + settled = true; + resolve(ffmpeg); + }, (error) => { + signal.removeEventListener("abort", abort); + if (settled) return; + settled = true; + reject(error); + }); + }); +} + +export async function encodePngFrameSequence({ totalFrames, frameRate, produceFrame, signal, onProgress }) { + return runFfmpegTask(async () => { + if (signal?.aborted) throw createAbortError("整段增强已取消"); + let ffmpeg = null; + const id = makeId("remaster"); + const prefix = `${id}-frame`; + const outputName = `${id}.mp4`; + const frameNames = []; + const frameBlobs = []; + let terminated = false; + const abort = () => { + terminated = true; + try { ffmpeg.terminate(); } catch { /* FFmpeg may already be stopped. */ } + ffmpegLoadPromise = null; + }; + signal?.addEventListener("abort", abort, { once: true }); + try { + for (let index = 0; index < totalFrames; index += 1) { + if (signal?.aborted) throw createAbortError("整段增强已取消"); + const blob = await produceFrame(index); + if (signal?.aborted) throw createAbortError("整段增强已取消"); + frameBlobs.push(blob); + } + if (signal?.aborted) throw createAbortError("整段增强已取消"); + onProgress?.({ progress: 91, phaseKey: "remasterPhaseLoadEncoder" }); + ffmpeg = await getAbortableFfmpeg(signal); + if (signal?.aborted) throw createAbortError("整段增强已取消"); + for (let index = 0; index < frameBlobs.length; index += 1) { + const name = `${prefix}-${String(index).padStart(6, "0")}.png`; + frameNames.push(name); + await ffmpeg.writeFile(name, new Uint8Array(await frameBlobs[index].arrayBuffer())); + } + if (signal?.aborted) throw createAbortError("整段增强已取消"); + onProgress?.({ progress: 92, phaseKey: "remasterPhaseEncodeVideo" }); + await ffmpeg.exec([ + "-framerate", String(frameRate), + "-i", `${prefix}-%06d.png`, + "-an", "-c:v", "libx264", "-preset", "veryfast", + "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "faststart", + outputName, + ]); + if (signal?.aborted) throw createAbortError("整段增强已取消"); + const data = await ffmpeg.readFile(outputName); + onProgress?.({ progress: 99, phaseKey: "remasterPhaseCreateAsset" }); + return new Blob([data], { type: "video/mp4" }); + } finally { + signal?.removeEventListener("abort", abort); + if (!terminated && ffmpeg) { + await Promise.all(frameNames.map((name) => ffmpeg.deleteFile(name).catch(() => {}))); + await ffmpeg.deleteFile(outputName).catch(() => {}); + } + } + }); +} + export async function transcodeWebmToMp4(webmBlob) { return runFfmpegTask(async () => { const [{ fetchFile }, ffmpeg] = await Promise.all([import("@ffmpeg/util"), getFfmpeg()]); diff --git a/src/lib/playbackControls.js b/src/lib/playbackControls.js index 5965ceb..74129bc 100644 --- a/src/lib/playbackControls.js +++ b/src/lib/playbackControls.js @@ -33,7 +33,12 @@ export function createPlaybackControls(deps) { }; const startTimelineSeek = (event) => { if (event.button !== 0 || deps.timelineDuration <= 0) return; - event.preventDefault(); event.stopPropagation(); seekTo(getTimelineTimeFromClientX(event.clientX)); + event.preventDefault(); event.stopPropagation(); + if (deps.isPlaying) { + pauseTimelineMedia(); + deps.setIsPlaying(false); + } + seekTo(getTimelineTimeFromClientX(event.clientX)); const move = (e) => seekTo(getTimelineTimeFromClientX(e.clientX)); const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); }; addEventListener("pointermove", move); addEventListener("pointerup", up, { once: true }); diff --git a/src/lib/playbackControls.test.js b/src/lib/playbackControls.test.js new file mode 100644 index 0000000..4d9f43f --- /dev/null +++ b/src/lib/playbackControls.test.js @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createPlaybackControls } from "./playbackControls.js"; + +afterEach(() => vi.unstubAllGlobals()); + +function createDeps(overrides = {}) { + return { + isPlaying: true, + timelineDuration: 10, + timelineDurationRef: { current: 10 }, + trackScrollRef: { current: { getBoundingClientRect: () => ({ left: 100, width: 500 }) } }, + currentTimeRef: { current: 0 }, + setCurrentTime: vi.fn(), setIsPlaying: vi.fn(), + audioSegments: [], audioSegmentRefs: { current: new Map() }, + sourceAudioRef: { current: { pause: vi.fn(), currentTime: 0 } }, + musicRef: { current: { pause: vi.fn(), currentTime: 0 } }, + previewVideoRef: { current: { pause: vi.fn() } }, + sourceAudioLinked: false, sourceAudioStart: 0, sourceAudioDuration: 0, + musicStart: 0, musicDuration: 0, + ...overrides, + }; +} + +describe("timeline playhead seeking", () => { + it("pauses playback immediately on pointer-down before any move", () => { + const pointerListeners = new Map(); + vi.stubGlobal("addEventListener", vi.fn((type, listener) => pointerListeners.set(type, listener))); + vi.stubGlobal("removeEventListener", vi.fn()); + const deps = createDeps(); + const controls = createPlaybackControls(deps); + controls.startTimelineSeek({ + button: 0, clientX: 250, preventDefault: vi.fn(), stopPropagation: vi.fn(), + }); + expect(deps.sourceAudioRef.current.pause).toHaveBeenCalledTimes(1); + expect(deps.musicRef.current.pause).toHaveBeenCalledTimes(1); + expect(deps.previewVideoRef.current.pause).toHaveBeenCalledTimes(1); + expect(deps.setIsPlaying).toHaveBeenCalledWith(false); + expect(deps.setCurrentTime).toHaveBeenCalledWith(3); + expect(pointerListeners.has("pointermove")).toBe(true); + }); + + it("does not issue a redundant pause when playback is already stopped", () => { + vi.stubGlobal("addEventListener", vi.fn()); + vi.stubGlobal("removeEventListener", vi.fn()); + const deps = createDeps({ isPlaying: false }); + createPlaybackControls(deps).startTimelineSeek({ + button: 0, clientX: 350, preventDefault: vi.fn(), stopPropagation: vi.fn(), + }); + expect(deps.previewVideoRef.current.pause).not.toHaveBeenCalled(); + expect(deps.setIsPlaying).not.toHaveBeenCalled(); + expect(deps.setCurrentTime).toHaveBeenCalledWith(5); + }); +}); diff --git a/src/lib/remasterClipEnhancement.js b/src/lib/remasterClipEnhancement.js new file mode 100644 index 0000000..7c63bfa --- /dev/null +++ b/src/lib/remasterClipEnhancement.js @@ -0,0 +1,134 @@ +import { encodePngFrameSequence } from "./media.js"; +import { enhanceRemasterFrame } from "./remasterEnhancement.js"; + +function createAbortError() { + const error = new Error("整段增强已取消"); + error.name = "AbortError"; + return error; +} + +function throwIfAborted(signal) { + if (signal?.aborted) throw createAbortError(); +} + +function waitForVideo(video, eventName, signal, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + let timer = 0; + const cleanup = () => { + video.removeEventListener(eventName, ready); + video.removeEventListener("error", failed); + signal?.removeEventListener("abort", aborted); + window.clearTimeout(timer); + }; + const ready = () => { cleanup(); resolve(); }; + const failed = () => { cleanup(); reject(new Error("无法读取待增强视频")); }; + const aborted = () => { cleanup(); reject(createAbortError()); }; + video.addEventListener(eventName, ready, { once: true }); + video.addEventListener("error", failed, { once: true }); + signal?.addEventListener("abort", aborted, { once: true }); + timer = window.setTimeout(() => { + if (video.readyState >= 2) ready(); + else { cleanup(); reject(new Error("读取视频帧超时,请重新选择片段后重试")); } + }, timeoutMs); + }); +} + +async function loadVideo(src, signal) { + const video = document.createElement("video"); + video.muted = true; video.playsInline = true; video.preload = "auto"; + video.src = src; + video.load(); + if (video.readyState < 1) await waitForVideo(video, "loadedmetadata", signal); + if (video.readyState < 2) await waitForVideo(video, "loadeddata", signal); + return video; +} + +async function seekVideo(video, time, signal) { + throwIfAborted(signal); + const maximum = Math.max(0, (Number(video.duration) || 0) - 0.001); + const target = Math.max(0, Math.min(maximum, time)); + if (Math.abs(video.currentTime - target) <= 0.0005 && video.readyState >= 2) return; + const waiting = waitForVideo(video, "seeked", signal); + video.currentTime = target; + await waiting; +} + +export async function enhanceRemasterClip({ segment, videoElement = null, frameRate = 30, maxLongEdge = 960, signal, onProgress }) { + if (!segment?.src || segment.type !== "video") throw new Error("请选择一个视频片段"); + throwIfAborted(signal); + onProgress?.({ progress: 1, phaseKey: "remasterPhaseReadClip", frameIndex: 0, totalFrames: 0 }); + const expectedSrc = new URL(segment.src, window.location.href).href; + const reusableVideo = videoElement + && videoElement.readyState >= 2 + && videoElement.videoWidth > 0 + && (videoElement.currentSrc === expectedSrc || videoElement.src === expectedSrc); + const video = reusableVideo ? videoElement : await loadVideo(segment.src, signal); + if (reusableVideo) onProgress?.({ progress: 2, phaseKey: "remasterPhaseReuseVideo", frameIndex: 0, totalFrames: 0 }); + const restoreTime = reusableVideo ? video.currentTime : 0; + const sourceStart = Math.max(0, Math.min(Number(video.duration) || 0, Number(segment.sourceStart) || 0)); + const availableDuration = Math.max(0.001, (Number(video.duration) || 0) - sourceStart); + const sourceDuration = Math.max(0.001, Math.min( + availableDuration, + Number(segment.sourceDuration) || Number(segment.duration) || availableDuration, + )); + const safeFrameRate = Math.max(1, Math.min(60, Math.round(Number(frameRate) || 30))); + const totalFrames = Math.max(1, Math.ceil(sourceDuration * safeFrameRate)); + let outputSize = null; + let backend = ""; + try { + const blob = await encodePngFrameSequence({ + totalFrames, + frameRate: safeFrameRate, + signal, + onProgress, + produceFrame: async (index) => { + throwIfAborted(signal); + await seekVideo(video, sourceStart + index / safeFrameRate, signal); + const bitmap = await createImageBitmap(video); + const result = await enhanceRemasterFrame({ + bitmap, + maxLongEdge, + signal, + onProgress: ({ progress: frameProgress, backend: frameBackend }) => { + if (frameBackend) backend = frameBackend; + onProgress?.({ + progress: Math.min(90, 4 + ((index + Math.max(0, Math.min(100, frameProgress || 0)) / 100) / totalFrames) * 86), + phaseKey: "remasterPhaseEnhancingFrame", + phaseParams: { current: index + 1, total: totalFrames }, + frameIndex: index + 1, + totalFrames, + backend, + }); + }, + }); + if (result.backend) backend = result.backend; + outputSize ??= { width: result.width, height: result.height }; + onProgress?.({ + progress: Math.min(90, 4 + ((index + 1) / totalFrames) * 86), + phaseKey: "remasterPhaseFrameEnhanced", + phaseParams: { current: index + 1, total: totalFrames }, + frameIndex: index + 1, + totalFrames, + backend, + }); + return result.blob; + }, + }); + return { + blob, + width: outputSize?.width || video.videoWidth, + height: outputSize?.height || video.videoHeight, + sourceDuration, + frameRate: safeFrameRate, + totalFrames, + backend, + }; + } finally { + video.pause(); + if (reusableVideo) { + if (Number.isFinite(restoreTime)) video.currentTime = Math.min(Math.max(0, restoreTime), Math.max(0, (Number(video.duration) || 0) - 0.001)); + } else { + video.removeAttribute("src"); video.load(); + } + } +} diff --git a/src/lib/remasterEnhancement.js b/src/lib/remasterEnhancement.js new file mode 100644 index 0000000..a79e038 --- /dev/null +++ b/src/lib/remasterEnhancement.js @@ -0,0 +1,72 @@ +import { REMASTER_DRUNET_MODEL, REMASTER_DRUNET_MODEL_URL } from "../config/models.js"; + +export const REMASTER_DRUNET_MODEL_LABEL = REMASTER_DRUNET_MODEL.label; +export { REMASTER_DRUNET_MODEL_URL }; + +let worker = null; +const pending = new Map(); + +function getWorker() { + if (worker) return worker; + worker = new Worker(new URL("../workers/remaster.worker.js", import.meta.url), { type: "module" }); + worker.addEventListener("message", (event) => { + const message = event.data ?? {}; + const request = pending.get(message.requestId); + if (!request) return; + if (message.type === "progress") { request.onProgress?.(message); return; } + pending.delete(message.requestId); + request.signal?.removeEventListener("abort", request.abort); + if (message.type === "result") request.resolve(message.result); + else request.reject(new Error(message.error || "视频增强失败")); + }); + worker.addEventListener("error", (event) => { + const error = new Error(event.message || "视频增强 Worker 运行失败"); + pending.forEach((request) => request.reject(error)); pending.clear(); + worker?.terminate(); worker = null; + }); + return worker; +} + +export function enhanceRemasterFrame({ bitmap, maxLongEdge = 960, onProgress, signal }) { + if (!bitmap) return Promise.reject(new Error("没有可增强的画面")); + const requestId = `remaster-${crypto.randomUUID?.() ?? Date.now()}`; + return new Promise((resolve, reject) => { + const activeWorker = getWorker(); + const abort = () => { + pending.delete(requestId); + bitmap.close?.(); + if (worker === activeWorker) { + activeWorker.terminate(); + worker = null; + pending.clear(); + } + const error = new Error("视频增强已取消"); error.name = "AbortError"; reject(error); + }; + if (signal?.aborted) { abort(); return; } + signal?.addEventListener("abort", abort, { once: true }); + pending.set(requestId, { resolve, reject, onProgress, signal, abort }); + activeWorker.postMessage({ type: "enhance", requestId, bitmap, maxLongEdge }, [bitmap]); + }); +} + +export async function captureRemasterSource({ type, src, video }) { + if (type === "video") { + if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) { + throw new Error("当前视频帧尚未准备好,请稍后重试"); + } + return createImageBitmap(video); + } + const response = await fetch(src); + if (!response.ok) throw new Error(`读取画面失败(HTTP ${response.status})`); + return createImageBitmap(await response.blob()); +} + +export function disposeRemasterWorker() { + worker?.terminate(); worker = null; + pending.forEach((request) => request.reject(new Error("视频增强 Worker 已关闭"))); + pending.clear(); +} + +if (import.meta.hot) { + import.meta.hot.dispose(disposeRemasterWorker); +} diff --git a/src/lib/remasterProgress.js b/src/lib/remasterProgress.js new file mode 100644 index 0000000..a627ee8 --- /dev/null +++ b/src/lib/remasterProgress.js @@ -0,0 +1,7 @@ +export function translateRemasterPhase(job, t, fallbackKey = "remasterProcessing") { + const template = job?.phaseKey ? t(job.phaseKey) : job?.phase || t(fallbackKey); + return Object.entries(job?.phaseParams || {}).reduce( + (text, [key, value]) => text.replaceAll(`{${key}}`, String(value)), + template, + ); +} diff --git a/src/lib/remasterProgress.test.js b/src/lib/remasterProgress.test.js new file mode 100644 index 0000000..ad1024e --- /dev/null +++ b/src/lib/remasterProgress.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { translateRemasterPhase } from "./remasterProgress.js"; + +describe("translateRemasterPhase", () => { + const t = (key) => ({ + remasterProcessing: "Enhancing", + remasterPhaseEnhancingFrame: "Enhancing frame {current} / {total}", + })[key] || key; + + it("translates phase keys and replaces variables", () => { + expect(translateRemasterPhase({ + phaseKey: "remasterPhaseEnhancingFrame", + phaseParams: { current: 9, total: 301 }, + }, t)).toBe("Enhancing frame 9 / 301"); + }); + + it("keeps legacy phase strings and supplies a translated fallback", () => { + expect(translateRemasterPhase({ phase: "Legacy phase" }, t)).toBe("Legacy phase"); + expect(translateRemasterPhase({}, t)).toBe("Enhancing"); + }); +}); diff --git a/src/lib/timelineMoveControls.js b/src/lib/timelineMoveControls.js index 9e56ab7..9ec45dc 100644 --- a/src/lib/timelineMoveControls.js +++ b/src/lib/timelineMoveControls.js @@ -3,6 +3,7 @@ import { DEFAULT_STICKER_SEGMENT_SECONDS, MAX_TIMELINE_DURATION_SECONDS, MIN_VIS export function createTimelineMoveControls(d) { const startSingleTrackMove = (event, track) => { if (event.button !== 0) return; + d.pauseForTimelineEdit?.(); const isSource = track === "source"; const clipDuration = isSource ? d.sourceAudioDuration : d.musicDuration; const start = isSource ? d.sourceAudioStart : d.musicStart; @@ -25,6 +26,7 @@ export function createTimelineMoveControls(d) { }; const startAudioSegmentMove = (event, id = "") => { if (event.button !== 0) return; + d.pauseForTimelineEdit?.(); const segment = d.audioSegments.find((item) => item.id === id); if (!segment) return; if (d.trackLocks.audio) return void d.notify(d.t("audioTrackLockedMove")); const rect = d.trackScrollRef.current?.getBoundingClientRect(); const duration = d.timelineDurationRef.current || 10; @@ -51,6 +53,7 @@ export function createTimelineMoveControls(d) { }; const startStickerSegmentMove = (event, id = "") => { if (event.button !== 0) return; + d.pauseForTimelineEdit?.(); const segment = d.stickerSegments.find((item) => item.id === id); if (!segment) return; if (d.trackLocks.sticker) return void d.notify("贴纸轨已锁定,无法移动贴纸"); const rect = d.trackScrollRef.current?.getBoundingClientRect(); diff --git a/src/lib/timelinePlaybackDrag.test.js b/src/lib/timelinePlaybackDrag.test.js new file mode 100644 index 0000000..d2d7c4d --- /dev/null +++ b/src/lib/timelinePlaybackDrag.test.js @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createTimelineMoveControls } from "./timelineMoveControls.js"; +import { createTimelineReorderControls } from "./timelineReorderControls.js"; + +function installPointerListeners() { + const listeners = new Map(); + vi.stubGlobal("addEventListener", vi.fn((type, listener) => listeners.set(type, listener))); + vi.stubGlobal("removeEventListener", vi.fn((type, listener) => { + if (listeners.get(type) === listener) listeners.delete(type); + })); + return listeners; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("timeline drag playback behavior", () => { + it("pauses immediately when a sticker clip is pressed", () => { + const listeners = installPointerListeners(); + const pauseForTimelineEdit = vi.fn(); + const setStickerSegments = vi.fn(); + const controls = createTimelineMoveControls({ + stickerSegments: [{ id: "sticker-1", start: 1, duration: 2, stickerId: "spark" }], + trackLocks: { sticker: false }, + trackScrollRef: { current: { getBoundingClientRect: () => ({ width: 100 }) } }, + timelineDurationRef: { current: 10 }, + estimatedDuration: 10, + setSelectedTrack: vi.fn(), setActiveTool: vi.fn(), setSelectedStickerSegmentId: vi.fn(), + setSelectedStickerId: vi.fn(), setStickerSegments, suppressTimelineClipClickRef: { current: "" }, + seekTo: vi.fn(), notify: vi.fn(), pauseForTimelineEdit, + }); + controls.startStickerSegmentMove({ button: 0, clientX: 20, clientY: 10, preventDefault: vi.fn(), stopPropagation: vi.fn() }, "sticker-1"); + expect(pauseForTimelineEdit).toHaveBeenCalledTimes(1); + listeners.get("pointermove")({ clientX: 22, clientY: 11, preventDefault: vi.fn() }); + expect(pauseForTimelineEdit).toHaveBeenCalledTimes(1); + listeners.get("pointermove")({ clientX: 30, clientY: 10, preventDefault: vi.fn() }); + listeners.get("pointermove")({ clientX: 35, clientY: 10, preventDefault: vi.fn() }); + expect(pauseForTimelineEdit).toHaveBeenCalledTimes(1); + expect(setStickerSegments).toHaveBeenCalled(); + }); + + it("pauses immediately when a caption clip is pressed", () => { + const listeners = installPointerListeners(); + vi.stubGlobal("document", { + querySelector: vi.fn(() => ({ + getBoundingClientRect: () => ({ width: 200, top: 0, bottom: 46 }), + querySelectorAll: () => [], + })), + }); + const pauseForTimelineEdit = vi.fn(); + const timelineClipDragRef = { current: null }; + const controls = createTimelineReorderControls({ + captionSegments: [{ id: "caption-1", text: "test", start: 1, end: 2 }], + captionTargetDuration: 5, + trackLocks: { caption: false }, + timelineDuration: 10, + timelineClipDragRef, + setTimelineClipDrag: vi.fn(), setSelectedTrack: vi.fn(), setSelectedSegmentId: vi.fn(), + commitCaptionSegments: vi.fn(), seekTo: vi.fn(), suppressTimelineClipClickRef: { current: "" }, + pauseForTimelineEdit, notify: vi.fn(), + }); + controls.startTimelineClipDrag({ button: 0, clientX: 40, clientY: 10, target: { closest: () => null }, preventDefault: vi.fn(), stopPropagation: vi.fn() }, "caption", "caption-1", 0); + listeners.get("pointermove")({ clientX: 42, clientY: 10 }); + expect(pauseForTimelineEdit).toHaveBeenCalledTimes(1); + listeners.get("pointermove")({ clientX: 50, clientY: 10 }); + listeners.get("pointermove")({ clientX: 60, clientY: 10 }); + expect(pauseForTimelineEdit).toHaveBeenCalledTimes(1); + expect(timelineClipDragRef.current.dragging).toBe(true); + }); +}); diff --git a/src/lib/timelineReorderControls.js b/src/lib/timelineReorderControls.js index 1999c70..12da8ff 100644 --- a/src/lib/timelineReorderControls.js +++ b/src/lib/timelineReorderControls.js @@ -24,6 +24,7 @@ export function createTimelineReorderControls(d) { }; const startTimelineClipDrag = (event, track, segmentId, index) => { if (event.button !== 0 || event.target.closest(".image-resize-handle")) return; + d.pauseForTimelineEdit?.(); if (d.trackLocks[track]) return void d.notify(track === "image" ? "图片轨已锁定,无法拖动片段" : "字幕轨已锁定,无法拖动片段"); if (track === "image") { d.setSelectedTrack("image"); d.setSelectedVisualSegmentId(segmentId); } else { d.setSelectedTrack("caption"); d.setSelectedSegmentId(segmentId); } diff --git a/src/styles.css b/src/styles.css index 3593a1b..63e6310 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2086,7 +2086,7 @@ button:disabled { .visual-effects-panel { gap: 12px; } .visual-effects-panel > .tool-panel { padding: 0; } .visual-effects-panel > .tool-panel > h2 { font-size: 13px; color: #9ba9b2; } -.visual-context-tabs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; padding: 4px; border: 1px solid rgba(255,255,255,.075); border-radius: 9px; background: rgba(4,10,14,.42); } +.visual-context-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid rgba(255,255,255,.075); border-radius: 9px; background: rgba(4,10,14,.42); } .visual-context-tabs button { min-width: 0; border: 0; border-radius: 6px; padding: 8px 3px; color: #82919b; background: transparent; font-size: 11px; cursor: pointer; } .visual-context-tabs button:hover { color: #c8d8de; background: rgba(255,255,255,.04); } .visual-context-tabs button.is-active { color: #dffffb; background: rgba(53,234,217,.13); box-shadow: inset 0 0 0 1px rgba(53,234,217,.38); } @@ -2108,6 +2108,26 @@ button:disabled { .visual-speed-summary strong { color: #d9e6eb; font-size: 12px; font-variant-numeric: tabular-nums; } .visual-speed-hint { margin: 0; color: #7f8d97; font-size: 10px; line-height: 1.55; } .visual-speed-empty { min-height: 92px; } +.remaster-card { gap: 11px; } +.remaster-model-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px; border-radius: 7px; background: rgba(5,10,14,.38); } +.remaster-model-row > span { display: grid; gap: 3px; min-width: 0; } +.remaster-model-row strong { color: #dce8ed; font-size: 12px; } +.remaster-model-row em { color: #77858f; font-size: 10px; font-style: normal; } +.remaster-model-row > i { flex: 0 0 auto; border-radius: 999px; padding: 4px 7px; color: #8997a0; background: rgba(255,255,255,.05); font-size: 9px; font-style: normal; } +.remaster-model-row > i.is-ready { color: #8ff2e8; background: rgba(53,234,217,.12); } +.remaster-preview-toggle { margin: 0; } +.remaster-progress { display: grid; gap: 6px; } +.remaster-progress > span { height: 5px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.08); } +.remaster-progress > span > i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#67a9ff,#35ead9); transition: width 160ms ease; } +.remaster-progress small { color: #81909a; font-size: 10px; } +.remaster-result-meta { display: flex; align-items: center; gap: 7px; color: #84929b; font-size: 10px; } +.remaster-result-meta span { border-radius: 5px; padding: 4px 6px; background: rgba(255,255,255,.045); font-variant-numeric: tabular-nums; } +.remaster-result-meta button { margin-left: auto; border: 0; padding: 4px; color: #9aa8b0; background: transparent; font-size: 10px; cursor: pointer; } +.remaster-performance-row { display: grid; gap: 7px; } +.remaster-performance-row .visual-speed-presets { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.remaster-backend { justify-self: start; border-radius: 999px; padding: 4px 7px; color: #7f8d96; background: rgba(255,255,255,.045); font-size: 9px; } +.remaster-backend.is-gpu { color: #8ff2e8; background: rgba(53,234,217,.12); } +.remaster-backend.is-cpu { color: #f1c17b; background: rgba(241,170,70,.11); } .visual-add-all-keyframes { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 34px; color: #8debe2; } .visual-keyframe-times { display: flex; gap: 5px; overflow-x: auto; padding: 1px 0 3px; scrollbar-width: thin; } .visual-keyframe-times button { flex: 0 0 auto; border: 1px solid rgba(255,255,255,.08); border-radius: 999px; padding: 3px 7px; color: #829099; background: rgba(255,255,255,.03); font-size: 10px; font-variant-numeric: tabular-nums; cursor: pointer; } @@ -2368,6 +2388,7 @@ button:disabled { .visual-media-layer > video { grid-area: 1 / 1; } +.visual-media-layer > .remaster-preview-frame { z-index: 2; } .preview-frame img, .preview-frame video { @@ -4258,6 +4279,73 @@ button:disabled { white-space: nowrap; } +.remaster-progress-overlay { + position: fixed; + inset: 0; + z-index: 96; + display: grid; + place-items: center; + padding: 28px; + background: + radial-gradient(circle at 50% 42%, rgba(53, 234, 217, 0.13), transparent 36%), + rgba(2, 5, 9, 0.78); + backdrop-filter: blur(14px) saturate(0.78); +} + +.remaster-progress-card { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 22px; + width: min(560px, 100%); + border: 1px solid rgba(53, 234, 217, 0.34); + border-radius: 14px; + padding: 25px; + background: rgba(13, 19, 26, 0.97); + box-shadow: 0 32px 100px rgba(0, 0, 0, 0.64), 0 0 0 1px rgba(255,255,255,.025) inset; +} + +.remaster-progress-orbit { + position: relative; + width: 68px; + height: 68px; + border: 1px solid rgba(53, 234, 217, 0.22); + border-radius: 50%; + background: radial-gradient(circle, rgba(53,234,217,.2) 0 18%, transparent 20%); +} + +.remaster-progress-orbit::before, +.remaster-progress-orbit::after { + content: ""; + position: absolute; + inset: 7px; + border: 2px solid transparent; + border-top-color: #35ead9; + border-right-color: rgba(103,169,255,.72); + border-radius: 50%; + animation: remaster-orbit 1.1s linear infinite; +} + +.remaster-progress-orbit::after { inset: 17px; animation-direction: reverse; animation-duration: .75s; } + +@keyframes remaster-orbit { to { transform: rotate(360deg); } } + +.remaster-progress-copy { min-width: 0; } +.remaster-progress-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.remaster-progress-header span { color: #f3fbfc; font-size: 17px; font-weight: 760; } +.remaster-progress-header strong { color: #35ead9; font-size: 26px; font-variant-numeric: tabular-nums; } +.remaster-progress-bar { height: 9px; margin: 17px 0 13px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.08); } +.remaster-progress-bar > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#68a9ff,#35ead9); box-shadow: 0 0 15px rgba(53,234,217,.34); transition: width 180ms ease; } +.remaster-progress-detail { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #8999a5; font-size: 11px; font-variant-numeric: tabular-nums; } +.remaster-progress-detail strong { min-width: 0; overflow: hidden; color: #b9c7cd; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.remaster-progress-copy p { margin: 16px 0; color: #75838d; font-size: 11px; line-height: 1.55; } +.remaster-progress-copy button { width: 100%; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 10px 14px; color: #cdd7db; background: rgba(255,255,255,.055); font: inherit; font-size: 12px; cursor: pointer; } +.remaster-progress-copy button:hover { border-color: rgba(255,112,112,.45); color: #ffd7d7; background: rgba(255,83,83,.09); } + +@media (max-width: 560px) { + .remaster-progress-card { grid-template-columns: 1fr; } + .remaster-progress-orbit { margin: 0 auto; } +} + .toast { position: fixed; right: 22px; diff --git a/src/workers/remaster.worker.js b/src/workers/remaster.worker.js new file mode 100644 index 0000000..24055d9 --- /dev/null +++ b/src/workers/remaster.worker.js @@ -0,0 +1,171 @@ +import * as ort from "onnxruntime-web/webgpu"; +import ortWasmMjsUrl from "onnxruntime-web/ort-wasm-simd-threaded.asyncify.mjs?url"; +import ortWasmUrl from "onnxruntime-web/ort-wasm-simd-threaded.asyncify.wasm?url"; + +import { REMASTER_DRUNET_MODEL, REMASTER_DRUNET_MODEL_URL } from "../config/models.js"; +import { readFloat16TensorValue } from "../lib/float16.js"; + +const REMASTER_DRUNET_MODEL_LABEL = REMASTER_DRUNET_MODEL.label; + +ort.env.wasm.numThreads = self.crossOriginIsolated + ? Math.max(1, Math.min(4, Number(self.navigator?.hardwareConcurrency) || 1)) + : 1; +ort.env.wasm.wasmPaths = { mjs: ortWasmMjsUrl, wasm: ortWasmUrl }; +ort.env.webgpu.powerPreference = "high-performance"; +ort.env.webgpu.forceFallbackAdapter = false; + +let sessionPromise = null; +let activeRequestId = ""; +const canceledRequests = new Set(); + +function postProgress(requestId, progress, phaseKey, extra = {}) { + if (!canceledRequests.has(requestId)) { + self.postMessage({ type: "progress", requestId, progress, phaseKey, ...extra }); + } +} + +function describeError(error) { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return String(error || "未知错误"); +} + +async function fetchModel(requestId) { + const response = await fetch(REMASTER_DRUNET_MODEL_URL); + if (!response.ok) throw new Error(`Remaster DRUNet 下载失败(HTTP ${response.status})`); + const total = Number(response.headers.get("content-length")) || 0; + if (!response.body || !total) return response.arrayBuffer(); + const reader = response.body.getReader(); + const chunks = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); received += value.byteLength; + postProgress(requestId, 8 + Math.round(received / total * 38), "remasterPhaseDownloadModel", { phaseParams: { model: REMASTER_DRUNET_MODEL_LABEL } }); + } + const result = new Uint8Array(received); + let offset = 0; + chunks.forEach((chunk) => { result.set(chunk, offset); offset += chunk.byteLength; }); + return result.buffer; +} + +async function getSession(requestId) { + if (!sessionPromise) { + sessionPromise = (async () => { + const model = await fetchModel(requestId); + postProgress(requestId, 50, "remasterPhaseInitModel"); + const useWebGpu = Boolean(self.navigator?.gpu); + try { + if (!useWebGpu) throw new Error("当前环境不支持 WebGPU"); + const session = await ort.InferenceSession.create(model, { + executionProviders: [{ + name: "webgpu", + preferredLayout: "NCHW", + storageBufferCacheMode: "simple", + uniformBufferCacheMode: "simple", + validationMode: "wgpuOnly", + }], + graphOptimizationLevel: "all", + }); + postProgress(requestId, 54, "remasterPhaseGpuReady", { backend: "webgpu" }); + return { session, backend: "webgpu" }; + } catch (error) { + const fallbackReason = describeError(error); + console.warn(`Remaster WebGPU initialization failed; falling back to WASM. ${fallbackReason}`, error); + const session = await ort.InferenceSession.create(model, { + executionProviders: ["wasm"], + graphOptimizationLevel: "all", + }); + postProgress(requestId, 54, "remasterPhaseCpuFallback", { backend: "wasm", fallbackReason }); + return { session, backend: "wasm", fallbackReason }; + } + })().catch((error) => { sessionPromise = null; throw error; }); + } + return sessionPromise; +} + +function float32ToFloat16(value) { + const floatView = new Float32Array(1); + const intView = new Uint32Array(floatView.buffer); + floatView[0] = value; + const bits = intView[0]; + const sign = (bits >>> 16) & 0x8000; + const mantissa = bits & 0x7fffff; + const exponent = (bits >>> 23) & 0xff; + if (exponent === 0xff) return sign | (mantissa ? 0x7e00 : 0x7c00); + const halfExponent = exponent - 127 + 15; + if (halfExponent >= 0x1f) return sign | 0x7c00; + if (halfExponent <= 0) { + if (halfExponent < -10) return sign; + const shifted = (mantissa | 0x800000) >>> (1 - halfExponent); + return sign | ((shifted + 0x1000) >>> 13); + } + return sign | (halfExponent << 10) | ((mantissa + 0x1000) >>> 13); +} + +function getInferenceSize(width, height, maxLongEdge) { + const scale = Math.min(1, maxLongEdge / Math.max(width, height)); + return { + width: Math.max(8, Math.round(width * scale / 8) * 8), + height: Math.max(8, Math.round(height * scale / 8) * 8), + }; +} + +async function enhanceFrame(requestId, bitmap, maxLongEdge) { + const size = getInferenceSize(bitmap.width, bitmap.height, maxLongEdge); + const inputCanvas = new OffscreenCanvas(size.width, size.height); + const inputContext = inputCanvas.getContext("2d", { willReadFrequently: true }); + inputContext.drawImage(bitmap, 0, 0, size.width, size.height); + bitmap.close(); + const pixels = inputContext.getImageData(0, 0, size.width, size.height).data; + const planeSize = size.width * size.height; + const tensorData = new Uint16Array(planeSize * 3); + for (let index = 0; index < planeSize; index += 1) { + const pixelIndex = index * 4; + tensorData[index] = float32ToFloat16(pixels[pixelIndex] / 255); + tensorData[planeSize + index] = float32ToFloat16(pixels[pixelIndex + 1] / 255); + tensorData[planeSize * 2 + index] = float32ToFloat16(pixels[pixelIndex + 2] / 255); + } + if (canceledRequests.has(requestId)) return null; + const { session, backend, fallbackReason } = await getSession(requestId); + postProgress(requestId, 58, backend === "webgpu" ? "remasterPhaseGpuFrame" : "remasterPhaseCpuFrame", { backend, fallbackReason }); + const startedAt = performance.now(); + const outputMap = await session.run({ input: new ort.Tensor("float16", tensorData, [1, 3, size.height, size.width]) }); + const output = outputMap.output.data; + const readOutput = (index) => readFloat16TensorValue(output, index); + if (canceledRequests.has(requestId)) return null; + const enhanced = new Uint8ClampedArray(planeSize * 4); + for (let index = 0; index < planeSize; index += 1) { + const pixelIndex = index * 4; + enhanced[pixelIndex] = Math.round(Math.max(0, Math.min(1, readOutput(index))) * 255); + enhanced[pixelIndex + 1] = Math.round(Math.max(0, Math.min(1, readOutput(planeSize + index))) * 255); + enhanced[pixelIndex + 2] = Math.round(Math.max(0, Math.min(1, readOutput(planeSize * 2 + index))) * 255); + enhanced[pixelIndex + 3] = 255; + } + postProgress(requestId, 94, "remasterPhaseGeneratePreview"); + const outputCanvas = new OffscreenCanvas(size.width, size.height); + outputCanvas.getContext("2d").putImageData(new ImageData(enhanced, size.width, size.height), 0, 0); + const blob = await outputCanvas.convertToBlob({ type: "image/png" }); + return { blob, width: size.width, height: size.height, inferenceMs: Math.round(performance.now() - startedAt), backend, fallbackReason }; +} + +self.addEventListener("message", async (event) => { + const message = event.data ?? {}; + if (message.type === "cancel") { canceledRequests.add(message.requestId); return; } + if (message.type !== "enhance") return; + const { requestId, bitmap, maxLongEdge = 960 } = message; + if (activeRequestId) { + self.postMessage({ type: "error", requestId, error: "已有增强任务正在运行" }); + bitmap?.close?.(); return; + } + activeRequestId = requestId; + try { + postProgress(requestId, 2, "remasterPhasePrepareFrame"); + const result = await enhanceFrame(requestId, bitmap, maxLongEdge); + if (result && !canceledRequests.has(requestId)) self.postMessage({ type: "result", requestId, result }); + } catch (error) { + if (!canceledRequests.has(requestId)) self.postMessage({ type: "error", requestId, error: error instanceof Error ? error.message : "视频增强失败" }); + } finally { + canceledRequests.delete(requestId); activeRequestId = ""; + } +}); diff --git a/vite.config.mjs b/vite.config.mjs index 4938bd6..9137243 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -20,5 +20,12 @@ export default defineConfig({ clientFiles: ["./src/main.jsx"], }, }, + preview: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Resource-Policy": "same-origin", + }, + }, plugins: [react()], });