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 <haixin.yang@weimob.com>
This commit is contained in:
committed by
GitHub
co-authored by
haixin.yang
parent
bacf0804b1
commit
184f57c63d
+189
-6
@@ -5,7 +5,7 @@ import { PreviewStage } from "./components/PreviewStage.jsx";
|
|||||||
import { VoicePanel } from "./components/VoicePanel.jsx";
|
import { VoicePanel } from "./components/VoicePanel.jsx";
|
||||||
import { Timeline } from "./components/Timeline.jsx";
|
import { Timeline } from "./components/Timeline.jsx";
|
||||||
import { Topbar } from "./components/Topbar.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 { EditorSidebar } from "./components/EditorSidebar.jsx";
|
||||||
import { useExportElapsed } from "./hooks/useExportElapsed.js";
|
import { useExportElapsed } from "./hooks/useExportElapsed.js";
|
||||||
import { usePreviewFrameSize } from "./hooks/usePreviewFrameSize.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 { getImageThumbnailCount, getVisualSegmentsTotal } from "./lib/timeline.js";
|
||||||
import { removeVisualPropertyKeyframe, updateVisualSegmentPlaybackRate, upsertVisualKeyframe, upsertVisualPropertyKeyframe } from "./lib/visualEffects.js";
|
import { removeVisualPropertyKeyframe, updateVisualSegmentPlaybackRate, upsertVisualKeyframe, upsertVisualPropertyKeyframe } from "./lib/visualEffects.js";
|
||||||
import { getLinkedSourceAudioEnd, getLinkedSourceAudioSegments } from "./lib/sourceAudioSync.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) {
|
function getExportDimensions(ratio, longEdge) {
|
||||||
const sourceLongEdge = Math.max(ratio.width, ratio.height);
|
const sourceLongEdge = Math.max(ratio.width, ratio.height);
|
||||||
@@ -74,7 +76,9 @@ export function App() {
|
|||||||
const [uiLanguage, setUiLanguage] = useState(() => getStoredLanguage());
|
const [uiLanguage, setUiLanguage] = useState(() => getStoredLanguage());
|
||||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
const [exportSettings, setExportSettings] = useState({ resolution: "1080", frameRate: 30, codec: "h264", quality: "high" });
|
const [exportSettings, setExportSettings] = useState({ resolution: "1080", frameRate: 30, codec: "h264", quality: "high" });
|
||||||
|
const [remasterQuality, setRemasterQuality] = useState("fast");
|
||||||
const [introClosing, setIntroClosing] = useState(false);
|
const [introClosing, setIntroClosing] = useState(false);
|
||||||
|
const [remasterJob, setRemasterJob] = useState({ running: false, mode: "", segmentId: "", progress: 0, phase: "", frameIndex: 0, totalFrames: 0, startedAt: 0, backend: "" });
|
||||||
const {
|
const {
|
||||||
captionPlacement, captionPosition, captionSegments, captionSize, captionStyle,
|
captionPlacement, captionPosition, captionSegments, captionSize, captionStyle,
|
||||||
captionsEnabled, script, selectedSegmentId, setCaptionPlacement,
|
captionsEnabled, script, selectedSegmentId, setCaptionPlacement,
|
||||||
@@ -141,7 +145,7 @@ export function App() {
|
|||||||
avatarMotionCacheRef, avatarMotionWorkerRef, avatarRenderWorkerRef,
|
avatarMotionCacheRef, avatarMotionWorkerRef, avatarRenderWorkerRef,
|
||||||
avatarTestAudioImportedRef, avatarTestImportedRef, currentTimeRef, draggedAssetIdRef,
|
avatarTestAudioImportedRef, avatarTestImportedRef, currentTimeRef, draggedAssetIdRef,
|
||||||
exportStartRef, fileInputRef, imageUrlRefs, musicRef, musicUrlRef, pointerAssetDragRef,
|
exportStartRef, fileInputRef, imageUrlRefs, musicRef, musicUrlRef, pointerAssetDragRef,
|
||||||
previewCanvasRef, previewShellRef, previewVideoRef, projectFileInputRef, sourceAudioRef,
|
previewCanvasRef, previewShellRef, previewVideoRef, projectFileInputRef, remasterAbortControllerRef, sourceAudioRef,
|
||||||
sourceAudioUrlRef, suppressAssetClickRef, suppressTimelineClipClickRef,
|
sourceAudioUrlRef, suppressAssetClickRef, suppressTimelineClipClickRef,
|
||||||
timelineClipDragRef, timelineDurationRef, trackScrollRef, visionAbortControllerRef,
|
timelineClipDragRef, timelineDurationRef, trackScrollRef, visionAbortControllerRef,
|
||||||
visionJobGenerationRef, visionObjectUrlsRef, visualPlaybackFrameRef,
|
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 (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 (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 (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;
|
return item;
|
||||||
});
|
});
|
||||||
if (Number.isFinite(change.playbackRate)) {
|
if (Number.isFinite(change.playbackRate)) {
|
||||||
@@ -250,6 +272,155 @@ export function App() {
|
|||||||
return nextItems;
|
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 exportElapsedSeconds = useExportElapsed(exporting, exportStartRef);
|
||||||
const {
|
const {
|
||||||
effectiveCaptionPlacement, previewSmartCropRect, previewVisionAnalysis,
|
effectiveCaptionPlacement, previewSmartCropRect, previewVisionAnalysis,
|
||||||
@@ -426,7 +597,7 @@ export function App() {
|
|||||||
selectedStickerSegmentId, selectedTrack, selectedVisualSegmentId, setCurrentVisualAsset,
|
selectedStickerSegmentId, selectedTrack, selectedVisualSegmentId, setCurrentVisualAsset,
|
||||||
setFitMode, setRatioId, setSelectedSegmentId, setSelectedVisualSegmentId,
|
setFitMode, setRatioId, setSelectedSegmentId, setSelectedVisualSegmentId,
|
||||||
setUserAssets, sourceAudioBlob, sourceAudioUrlRef, stickerSegments,
|
setUserAssets, sourceAudioBlob, sourceAudioUrlRef, stickerSegments,
|
||||||
visionAbortControllerRef, visionObjectUrlsRef, visualSegments,
|
remasterAbortControllerRef, visionAbortControllerRef, visionObjectUrlsRef, visualSegments,
|
||||||
voiceRecorderStreamRef, voiceRecorderTimerRef,
|
voiceRecorderStreamRef, voiceRecorderTimerRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,6 +617,11 @@ export function App() {
|
|||||||
sourceAudioRef, sourceAudioStart, sourceAudioUrl, timelineDuration,
|
sourceAudioRef, sourceAudioStart, sourceAudioUrl, timelineDuration,
|
||||||
timelineDurationRef, trackScrollRef, trackVisibility, visualSegments, visualTimeline,
|
timelineDurationRef, trackScrollRef, trackVisibility, visualSegments, visualTimeline,
|
||||||
});
|
});
|
||||||
|
const pauseForTimelineEdit = () => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
pauseTimelineMedia();
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
useMediaSync({
|
useMediaSync({
|
||||||
audioRef, audioSegmentRefs, audioSegments, currentTime, currentTimeRef, estimatedDuration,
|
audioRef, audioSegmentRefs, audioSegments, currentTime, currentTimeRef, estimatedDuration,
|
||||||
@@ -464,7 +640,7 @@ export function App() {
|
|||||||
setSelectedStickerSegmentId, setSelectedTrack, setStickerSegments, setTimelineHorizon,
|
setSelectedStickerSegmentId, setSelectedTrack, setStickerSegments, setTimelineHorizon,
|
||||||
setMusicStart, setSourceAudioLinked, setSourceAudioStart, musicDuration, musicStart,
|
setMusicStart, setSourceAudioLinked, setSourceAudioStart, musicDuration, musicStart,
|
||||||
sourceAudioDuration, sourceAudioStart, stickerSegments, suppressTimelineClipClickRef, t, timelineDurationRef,
|
sourceAudioDuration, sourceAudioStart, stickerSegments, suppressTimelineClipClickRef, t, timelineDurationRef,
|
||||||
trackLocks, trackScrollRef,
|
trackLocks, trackScrollRef, pauseForTimelineEdit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startImageResize = createImageResizeControl({
|
const startImageResize = createImageResizeControl({
|
||||||
@@ -473,7 +649,7 @@ export function App() {
|
|||||||
setCurrentTime, setImageClipCount, setImageDuration, setSelectedTrack,
|
setCurrentTime, setImageClipCount, setImageDuration, setSelectedTrack,
|
||||||
setSelectedVisualSegmentId, setSnapGuide, setVisualSegments, sourceAudioBlob,
|
setSelectedVisualSegmentId, setSnapGuide, setVisualSegments, sourceAudioBlob,
|
||||||
sourceAudioDuration, sourceAudioStart, timelineDuration, timelineDurationRef,
|
sourceAudioDuration, sourceAudioStart, timelineDuration, timelineDurationRef,
|
||||||
trackLocks, trackScrollRef, visualSegments,
|
trackLocks, trackScrollRef, visualSegments, pauseForTimelineEdit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const extractVideoSourceAudio = useSourceAudioExtraction({
|
const extractVideoSourceAudio = useSourceAudioExtraction({
|
||||||
@@ -559,7 +735,7 @@ export function App() {
|
|||||||
captionSegments, captionTargetDuration, commitCaptionSegments, commitVisualSegments,
|
captionSegments, captionTargetDuration, commitCaptionSegments, commitVisualSegments,
|
||||||
notify, renderedVisualSegments, seekTo, setSelectedSegmentId, setSelectedTrack,
|
notify, renderedVisualSegments, seekTo, setSelectedSegmentId, setSelectedTrack,
|
||||||
setSelectedVisualSegmentId, setTimelineClipDrag, suppressTimelineClipClickRef,
|
setSelectedVisualSegmentId, setTimelineClipDrag, suppressTimelineClipClickRef,
|
||||||
timelineClipDragRef, timelineDuration, trackLocks, visualSegments,
|
timelineClipDragRef, timelineDuration, trackLocks, visualSegments, pauseForTimelineEdit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -752,6 +928,12 @@ export function App() {
|
|||||||
visualLocalTime={visualLocalTime}
|
visualLocalTime={visualLocalTime}
|
||||||
visualTimelineStart={selectedVisualRange?.start ?? 0}
|
visualTimelineStart={selectedVisualRange?.start ?? 0}
|
||||||
updateSelectedVisualEffects={updateSelectedVisualEffects}
|
updateSelectedVisualEffects={updateSelectedVisualEffects}
|
||||||
|
remasterJob={remasterJob}
|
||||||
|
remasterQuality={remasterQuality}
|
||||||
|
setRemasterQuality={setRemasterQuality}
|
||||||
|
enhanceSelectedVisualFrame={enhanceSelectedVisualFrame}
|
||||||
|
enhanceSelectedVisualClip={enhanceSelectedVisualClip}
|
||||||
|
clearSelectedVisualEnhancement={clearSelectedVisualEnhancement}
|
||||||
selectedFilterId={selectedFilterId}
|
selectedFilterId={selectedFilterId}
|
||||||
setSelectedFilterId={setSelectedFilterId}
|
setSelectedFilterId={setSelectedFilterId}
|
||||||
trOption={trOption}
|
trOption={trOption}
|
||||||
@@ -848,6 +1030,7 @@ export function App() {
|
|||||||
|
|
||||||
<AssetDragPreview preview={assetDragPreview} t={t} />
|
<AssetDragPreview preview={assetDragPreview} t={t} />
|
||||||
<ExportProgressOverlay exporting={exporting} percent={exportPercent} phase={exportPhase} elapsedSeconds={exportElapsedSeconds} t={t} />
|
<ExportProgressOverlay exporting={exporting} percent={exportPercent} phase={exportPhase} elapsedSeconds={exportElapsedSeconds} t={t} />
|
||||||
|
<RemasterProgressOverlay job={remasterJob} onCancel={cancelRemasterEnhancement} t={t} />
|
||||||
{shouldShowLanguageIntro ? (
|
{shouldShowLanguageIntro ? (
|
||||||
<LanguageIntro t={t} closing={introClosing} onChoose={chooseInterfaceLanguage} />
|
<LanguageIntro t={t} closing={introClosing} onChoose={chooseInterfaceLanguage} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { translateRemasterPhase } from "../lib/remasterProgress.js";
|
||||||
import { formatClock } from "../lib/timeline.js";
|
import { formatClock } from "../lib/timeline.js";
|
||||||
|
|
||||||
export function AssetDragPreview({ preview, t }) {
|
export function AssetDragPreview({ preview, t }) {
|
||||||
@@ -22,3 +24,34 @@ export function ExportProgressOverlay({ exporting, percent, phase, elapsedSecond
|
|||||||
<div className="export-progress-meta"><span>{phase || t("preparingExport")}</span><span>{formatClock(elapsedSeconds)}</span></div>
|
<div className="export-progress-meta"><span>{phase || t("preparingExport")}</span><span>{formatClock(elapsedSeconds)}</span></div>
|
||||||
</div></div>;
|
</div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <div className="remaster-progress-overlay" role="dialog" aria-modal="true" aria-labelledby="remaster-progress-title">
|
||||||
|
<div className="remaster-progress-card">
|
||||||
|
<div className="remaster-progress-orbit" aria-hidden="true"><span /></div>
|
||||||
|
<div className="remaster-progress-copy">
|
||||||
|
<div className="remaster-progress-header"><span id="remaster-progress-title">{t("remasterClipProgressTitle")}</span><strong>{percent}%</strong></div>
|
||||||
|
<div className="remaster-progress-bar" role="progressbar" aria-label={t("remasterClipProgressTitle")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}><span style={{ width: `${percent}%` }} /></div>
|
||||||
|
<div className="remaster-progress-detail"><strong>{phaseText}</strong><span>{backendText} · {frameText} · {formatClock(elapsedSeconds)}</span></div>
|
||||||
|
<p>{t("remasterClipProgressSafe")}</p>
|
||||||
|
<button type="button" onClick={onCancel}>{job.phaseKey === "remasterCanceling" || job.phase === t("remasterCanceling") ? t("remasterCanceling") : t("remasterCancel")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export function PreviewStage({
|
|||||||
const activeObjectPosition = visualObjectPosition || "50% 50%";
|
const activeObjectPosition = visualObjectPosition || "50% 50%";
|
||||||
const visualTransform = resolveVisualTransform(visualEffects?.keyframes, visualLocalTime);
|
const visualTransform = resolveVisualTransform(visualEffects?.keyframes, visualLocalTime);
|
||||||
const visualMask = visualEffects?.mask ?? {};
|
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 maskCenterX = Number.isFinite(visualMask.centerX) ? visualMask.centerX : 50;
|
||||||
const maskCenterY = Number.isFinite(visualMask.centerY) ? visualMask.centerY : 50;
|
const maskCenterY = Number.isFinite(visualMask.centerY) ? visualMask.centerY : 50;
|
||||||
const frameWidth = Math.max(1, previewFrameSize.width || 1);
|
const frameWidth = Math.max(1, previewFrameSize.width || 1);
|
||||||
@@ -195,6 +200,12 @@ export function PreviewStage({
|
|||||||
maskRepeat: previewVisionMaskUrl ? "no-repeat" : undefined,
|
maskRepeat: previewVisionMaskUrl ? "no-repeat" : undefined,
|
||||||
}}
|
}}
|
||||||
/> : null}
|
/> : null}
|
||||||
|
{showRemasterPreview ? <img
|
||||||
|
className="remaster-preview-frame"
|
||||||
|
src={enhancement.previewUrl}
|
||||||
|
alt={t("remasterPreviewAlt")}
|
||||||
|
style={{ ...visualTransformStyle, filter: selectedFilter.css, objectFit: activeObjectFit, objectPosition: activeObjectPosition }}
|
||||||
|
/> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showVisionOverlays
|
{showVisionOverlays
|
||||||
|
|||||||
@@ -320,6 +320,12 @@ export function VoicePanel({
|
|||||||
visualLocalTime,
|
visualLocalTime,
|
||||||
visualTimelineStart = 0,
|
visualTimelineStart = 0,
|
||||||
updateSelectedVisualEffects,
|
updateSelectedVisualEffects,
|
||||||
|
remasterJob,
|
||||||
|
remasterQuality,
|
||||||
|
setRemasterQuality,
|
||||||
|
enhanceSelectedVisualFrame,
|
||||||
|
enhanceSelectedVisualClip,
|
||||||
|
clearSelectedVisualEnhancement,
|
||||||
selectedFilterId,
|
selectedFilterId,
|
||||||
setSelectedFilterId,
|
setSelectedFilterId,
|
||||||
trOption,
|
trOption,
|
||||||
@@ -384,6 +390,12 @@ export function VoicePanel({
|
|||||||
trOption={trOption}
|
trOption={trOption}
|
||||||
onSelectFilter={(id) => { setSelectedFilterId(id); notify(t("effectApplied")); }}
|
onSelectFilter={(id) => { setSelectedFilterId(id); notify(t("effectApplied")); }}
|
||||||
sourceAudioLinked={sourceAudioLinked}
|
sourceAudioLinked={sourceAudioLinked}
|
||||||
|
enhancementJob={remasterJob}
|
||||||
|
enhancementQuality={remasterQuality}
|
||||||
|
onEnhancementQualityChange={setRemasterQuality}
|
||||||
|
onEnhance={enhanceSelectedVisualFrame}
|
||||||
|
onEnhanceClip={enhanceSelectedVisualClip}
|
||||||
|
onClearEnhancement={clearSelectedVisualEnhancement}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isCaptionContext ? (
|
{isCaptionContext ? (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
VOICES,
|
VOICES,
|
||||||
} from "../config/editor.js";
|
} from "../config/editor.js";
|
||||||
import { APP_LANGUAGES } from "../i18n.js";
|
import { APP_LANGUAGES } from "../i18n.js";
|
||||||
|
import { translateRemasterPhase } from "../lib/remasterProgress.js";
|
||||||
import { formatClock, formatTime, getSegmentStartTime } from "../lib/timeline.js";
|
import { formatClock, formatTime, getSegmentStartTime } from "../lib/timeline.js";
|
||||||
import { hasVisualPropertyKeyframe, normalizeVisualKeyframes, resolveVisualTransform } from "../lib/visualEffects.js";
|
import { hasVisualPropertyKeyframe, normalizeVisualKeyframes, resolveVisualTransform } from "../lib/visualEffects.js";
|
||||||
import { Popover } from "./ui.jsx";
|
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 [activeTab, setActiveTab] = useState("transform");
|
||||||
const keyframes = normalizeVisualKeyframes(segment?.keyframes ?? []);
|
const keyframes = normalizeVisualKeyframes(segment?.keyframes ?? []);
|
||||||
const transform = resolveVisualTransform(keyframes, localTime);
|
const transform = resolveVisualTransform(keyframes, localTime);
|
||||||
@@ -599,12 +600,18 @@ export function VisualEffectsPanel({ t, segment, localTime, onChange, onSeek, se
|
|||||||
const isVideo = segment?.type === "video";
|
const isVideo = segment?.type === "video";
|
||||||
const playbackRate = Math.max(0.25, Math.min(4, Number(segment?.playbackRate) || 1));
|
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 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 updateTransform = (key, value) => onChange?.({ propertyKeyframe: { time: localTime, key, value } });
|
||||||
const tabs = [
|
const tabs = [
|
||||||
["transform", t("visualTabTransform")],
|
["transform", t("visualTabTransform")],
|
||||||
["mask", t("visualTabMask")],
|
["mask", t("visualTabMask")],
|
||||||
["speed", t("visualTabSpeed")],
|
["speed", t("visualTabSpeed")],
|
||||||
["effects", t("visualTabEffects")],
|
["effects", t("visualTabEffects")],
|
||||||
|
["ai", t("visualTabAi")],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className={`tool-panel visual-effects-panel ${contextMode ? "is-context-mode" : ""}`}>
|
<div className={`tool-panel visual-effects-panel ${contextMode ? "is-context-mode" : ""}`}>
|
||||||
@@ -645,6 +652,19 @@ export function VisualEffectsPanel({ t, segment, localTime, onChange, onSeek, se
|
|||||||
</> : <div className="empty-state visual-speed-empty">{t("visualSpeedImageHint")}</div>}
|
</> : <div className="empty-state visual-speed-empty">{t("visualSpeedImageHint")}</div>}
|
||||||
</section> : null}
|
</section> : null}
|
||||||
{activeTab === "effects" ? <VisualChoicePanel title={t("visualEffects")} kind="effect" options={EFFECT_OPTIONS} selectedId={selectedFilterId} trOption={trOption} onSelect={onSelectFilter} /> : null}
|
{activeTab === "effects" ? <VisualChoicePanel title={t("visualEffects")} kind="effect" options={EFFECT_OPTIONS} selectedId={selectedFilterId} trOption={trOption} onSelect={onSelectFilter} /> : null}
|
||||||
|
{activeTab === "ai" ?
|
||||||
|
<section className="visual-editor-card remaster-card">
|
||||||
|
<div className="visual-editor-heading"><strong>{t("remasterTitle")}</strong><em>{t("remasterExperimental")}</em></div>
|
||||||
|
<div className="remaster-model-row"><span><strong>Remaster DRUNet</strong><em>{t("remasterModelMeta")}</em></span><i className={hasEnhancement ? "is-ready" : ""}>{fullClipEnhanced ? t("remasterFullReadyBadge") : enhancement?.previewUrl ? t("remasterReadyBadge") : t("remasterNotRun")}</i></div>
|
||||||
|
{isVideo ? <div className="remaster-performance-row"><div className="visual-speed-presets" aria-label={t("remasterPerformanceMode")}><button type="button" className={enhancementQuality === "fast" ? "is-active" : ""} disabled={enhancementRunning} onClick={() => onEnhancementQualityChange?.("fast")}>{t("remasterFastMode")}</button><button type="button" className={enhancementQuality === "quality" ? "is-active" : ""} disabled={enhancementRunning} onClick={() => onEnhancementQualityChange?.("quality")}>{t("remasterQualityMode")}</button></div><span className={`remaster-backend ${enhancementBackend === "webgpu" ? "is-gpu" : enhancementBackend ? "is-cpu" : ""}`}>{enhancementBackend === "webgpu" ? t("remasterGpuActive") : enhancementBackend === "wasm" ? t("remasterCpuFallback") : t("remasterGpuAuto")}</span></div> : null}
|
||||||
|
{hasEnhancement ? <label className="switch-row remaster-preview-toggle"><input type="checkbox" checked={enhancement.enabled !== false} onChange={(event) => onChange?.({ enhancementEnabled: event.target.checked })} />{fullClipEnhanced ? t("remasterUseEnhanced") : t("remasterShowResult")}</label> : null}
|
||||||
|
{enhancementRunning ? <div className="remaster-progress" role="status" aria-live="polite"><span><i style={{ width: `${Math.max(2, enhancementJob.progress || 0)}%` }} /></span><small>{translateRemasterPhase(enhancementJob, t)} · {Math.round(enhancementJob.progress || 0)}%</small></div> : null}
|
||||||
|
{isVideo ? <button className="panel-primary" type="button" disabled={enhancementRunning || !onEnhanceClip} onClick={onEnhanceClip}>{enhancementRunning && enhancementJob?.mode === "clip" ? t("remasterProcessing") : fullClipEnhanced ? t("remasterRerunClip") : t("remasterEnhanceClip")}</button> : null}
|
||||||
|
<button className={isVideo ? "panel-secondary" : "panel-primary"} type="button" disabled={enhancementRunning || !onEnhance || fullClipEnhanced} onClick={onEnhance}>{enhancementRunning && enhancementJob?.mode === "frame" ? t("remasterProcessing") : t("remasterFramePreview")}</button>
|
||||||
|
{hasEnhancement ? <div className="remaster-result-meta"><span>{fullClipEnhanced ? `${enhancement.totalFrames} ${t("remasterFramesShort")}` : `${enhancement.width}×${enhancement.height}`}</span><span>{fullClipEnhanced ? `${enhancement.frameRate} fps` : `${Math.max(0, enhancement.inferenceMs || 0)} ms`}</span><button type="button" onClick={onClearEnhancement}>{t("remasterClear")}</button></div> : null}
|
||||||
|
<p className="visual-speed-hint">{segment?.type === "video" ? t("remasterVideoHint") : t("remasterImageHint")}</p>
|
||||||
|
</section>
|
||||||
|
: null}
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
export const MODEL_ID = "onnx-community/Kokoro-82M-v1.0-ONNX";
|
export const MODEL_ID = "onnx-community/Kokoro-82M-v1.0-ONNX";
|
||||||
export const AUTOMATIC_CAPTION_MODEL_ID = "onnx-community/whisper-small";
|
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 AUTOMATIC_CAPTION_MODEL_LABEL = "Whisper small";
|
||||||
export const YOLOS_TINY_MODEL_ID = "Xenova/yolos-tiny";
|
export const YOLOS_TINY_MODEL_ID = "Xenova/yolos-tiny";
|
||||||
export const YOLOS_TINY_MODEL_LABEL = "YOLOS tiny";
|
export const YOLOS_TINY_MODEL_LABEL = "YOLOS tiny";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RATIO_OPTIONS } from "../config/editor.js";
|
|||||||
import { decodeWaveform } from "../lib/media.js";
|
import { decodeWaveform } from "../lib/media.js";
|
||||||
import { disposeVisionWorker } from "../lib/vision.js";
|
import { disposeVisionWorker } from "../lib/vision.js";
|
||||||
import { disposeVocalSeparationWorker } from "../lib/vocalSeparation.js";
|
import { disposeVocalSeparationWorker } from "../lib/vocalSeparation.js";
|
||||||
|
import { disposeRemasterWorker } from "../lib/remasterEnhancement.js";
|
||||||
import {
|
import {
|
||||||
getNearestRatioIdForSize,
|
getNearestRatioIdForSize,
|
||||||
revokeVisionObjectUrls,
|
revokeVisionObjectUrls,
|
||||||
@@ -156,9 +157,11 @@ export function useEditorLifecycle(d) {
|
|||||||
d.imageUrlRefs.current.forEach((url) => URL.revokeObjectURL(url));
|
d.imageUrlRefs.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
d.imageUrlRefs.current.clear();
|
d.imageUrlRefs.current.clear();
|
||||||
d.visionAbortControllerRef.current?.abort();
|
d.visionAbortControllerRef.current?.abort();
|
||||||
|
d.remasterAbortControllerRef.current?.abort();
|
||||||
d.visionObjectUrlsRef.current.forEach((urls) => revokeVisionObjectUrls(urls));
|
d.visionObjectUrlsRef.current.forEach((urls) => revokeVisionObjectUrls(urls));
|
||||||
d.visionObjectUrlsRef.current.clear();
|
d.visionObjectUrlsRef.current.clear();
|
||||||
disposeVisionWorker();
|
disposeVisionWorker();
|
||||||
|
disposeRemasterWorker();
|
||||||
disposeVocalSeparationWorker();
|
disposeVocalSeparationWorker();
|
||||||
d.voiceRecorderStreamRef.current?.getTracks().forEach((track) => track.stop());
|
d.voiceRecorderStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
window.clearInterval(d.voiceRecorderTimerRef.current);
|
window.clearInterval(d.voiceRecorderTimerRef.current);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function useEditorRefs() {
|
|||||||
previewCanvasRef: useRef(null),
|
previewCanvasRef: useRef(null),
|
||||||
previewShellRef: useRef(null),
|
previewShellRef: useRef(null),
|
||||||
previewVideoRef: useRef(null),
|
previewVideoRef: useRef(null),
|
||||||
|
remasterAbortControllerRef: useRef(null),
|
||||||
projectFileInputRef: useRef(null),
|
projectFileInputRef: useRef(null),
|
||||||
sourceAudioRef: useRef(null),
|
sourceAudioRef: useRef(null),
|
||||||
sourceAudioUrlRef: useRef(""),
|
sourceAudioUrlRef: useRef(""),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createCaptionSegments, getImageThumbnailCount, getVisualSegmentsTotal }
|
|||||||
|
|
||||||
export function useProjectFiles(deps) {
|
export function useProjectFiles(deps) {
|
||||||
const getProjectSnapshot = useCallback(() => {
|
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 {
|
return {
|
||||||
script: deps.script, selectedVoiceId: deps.selectedVoiceId, speed: deps.speed, volume: deps.volume,
|
script: deps.script, selectedVoiceId: deps.selectedVoiceId, speed: deps.speed, volume: deps.volume,
|
||||||
ratioId: deps.ratioId, fitMode: deps.fitMode, captionPosition: deps.captionPosition,
|
ratioId: deps.ratioId, fitMode: deps.fitMode, captionPosition: deps.captionPosition,
|
||||||
|
|||||||
+116
-10
@@ -53,16 +53,16 @@ const VISUAL_KEYFRAME_ACTION_COPY = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VISUAL_TAB_COPY = {
|
const VISUAL_TAB_COPY = {
|
||||||
zh: { visualTabTransform: "基础", visualTabMask: "蒙版", visualTabSpeed: "变速", visualTabEffects: "效果", visualSpeed: "播放速度", visualSourceDuration: "素材时长", visualTimelineDuration: "时间轴时长", visualSpeedVisualOnlyHint: "仅调整当前画面片段;视频原声轨保持独立。", visualSpeedImageHint: "静态图片没有播放速度,可直接调整片段时长。" },
|
zh: { visualTabTransform: "基础", visualTabMask: "蒙版", visualTabSpeed: "变速", visualTabEffects: "效果", visualTabAi: "AI 增强", 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." },
|
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: "効果", visualSpeed: "再生速度", visualSourceDuration: "素材の長さ", visualTimelineDuration: "タイムラインの長さ", visualSpeedVisualOnlyHint: "現在の映像クリップだけを変更し、元音声トラックは独立したままです。", visualSpeedImageHint: "静止画には再生速度がありません。クリップの長さを調整してください。" },
|
ja: { visualTabTransform: "基本", visualTabMask: "マスク", visualTabSpeed: "速度", visualTabEffects: "効果", visualTabAi: "AI補正", visualSpeed: "再生速度", visualSourceDuration: "素材の長さ", visualTimelineDuration: "タイムラインの長さ", visualSpeedVisualOnlyHint: "現在の映像クリップだけを変更し、元音声トラックは独立したままです。", visualSpeedImageHint: "静止画には再生速度がありません。クリップの長さを調整してください。" },
|
||||||
ko: { visualTabTransform: "기본", visualTabMask: "마스크", visualTabSpeed: "속도", visualTabEffects: "효과", 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", 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." },
|
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", 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." },
|
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", 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." },
|
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", 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." },
|
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: "เอฟเฟกต์", visualSpeed: "ความเร็วการเล่น", visualSourceDuration: "ความยาวต้นฉบับ", visualTimelineDuration: "ความยาวบนไทม์ไลน์", visualSpeedVisualOnlyHint: "ปรับเฉพาะคลิปภาพนี้ ส่วนแทร็กเสียงต้นฉบับยังแยกอิสระ", visualSpeedImageHint: "ภาพนิ่งไม่มีความเร็วในการเล่น ให้ปรับความยาวคลิปแทน" },
|
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", 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." },
|
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 = {
|
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" },
|
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 = {
|
export const UI_COPY = {
|
||||||
zh: {
|
zh: {
|
||||||
...VISUAL_EDITOR_COPY.zh,
|
...VISUAL_EDITOR_COPY.zh,
|
||||||
...VISUAL_MASK_SHAPE_COPY.zh,
|
...VISUAL_MASK_SHAPE_COPY.zh,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.zh,
|
...VISUAL_KEYFRAME_ACTION_COPY.zh,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.zh,
|
||||||
languageKicker: "AI Voice Studio",
|
languageKicker: "AI Voice Studio",
|
||||||
languageTitle: "选择界面语言",
|
languageTitle: "选择界面语言",
|
||||||
@@ -442,6 +503,11 @@ export const UI_COPY = {
|
|||||||
...VISUAL_MASK_SHAPE_COPY.en,
|
...VISUAL_MASK_SHAPE_COPY.en,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.en,
|
...VISUAL_KEYFRAME_ACTION_COPY.en,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.en,
|
||||||
languageKicker: "AI Voice Studio",
|
languageKicker: "AI Voice Studio",
|
||||||
languageTitle: "Choose Interface Language",
|
languageTitle: "Choose Interface Language",
|
||||||
@@ -800,6 +866,11 @@ export const UI_COPY = {
|
|||||||
...VISUAL_MASK_SHAPE_COPY.ja,
|
...VISUAL_MASK_SHAPE_COPY.ja,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.ja,
|
...VISUAL_KEYFRAME_ACTION_COPY.ja,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.ja,
|
||||||
languageTitle: "表示言語を選択",
|
languageTitle: "表示言語を選択",
|
||||||
languageSubtitle: "一度保存すると、次回からこの言語で開きます。",
|
languageSubtitle: "一度保存すると、次回からこの言語で開きます。",
|
||||||
@@ -867,6 +938,11 @@ Object.assign(UI_COPY, {
|
|||||||
...VISUAL_MASK_SHAPE_COPY.ko,
|
...VISUAL_MASK_SHAPE_COPY.ko,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.ko,
|
...VISUAL_KEYFRAME_ACTION_COPY.ko,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.ko,
|
||||||
...UI_COPY.en,
|
...UI_COPY.en,
|
||||||
languageTitle: "인터페이스 언어 선택",
|
languageTitle: "인터페이스 언어 선택",
|
||||||
@@ -914,6 +990,11 @@ Object.assign(UI_COPY, {
|
|||||||
...VISUAL_MASK_SHAPE_COPY.es,
|
...VISUAL_MASK_SHAPE_COPY.es,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.es,
|
...VISUAL_KEYFRAME_ACTION_COPY.es,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.es,
|
||||||
languageTitle: "Elige el idioma",
|
languageTitle: "Elige el idioma",
|
||||||
languageSubtitle: "Se guarda una vez y se abrirá así la próxima vez.",
|
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_MASK_SHAPE_COPY.fr,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.fr,
|
...VISUAL_KEYFRAME_ACTION_COPY.fr,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.fr,
|
||||||
languageTitle: "Choisir la langue",
|
languageTitle: "Choisir la langue",
|
||||||
languageSubtitle: "Enregistrée une fois, elle sera utilisée à la prochaine ouverture.",
|
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_MASK_SHAPE_COPY.de,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.de,
|
...VISUAL_KEYFRAME_ACTION_COPY.de,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.de,
|
||||||
languageTitle: "Sprache wählen",
|
languageTitle: "Sprache wählen",
|
||||||
languageSubtitle: "Einmal gespeichert, startet der Editor künftig in dieser Sprache.",
|
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_MASK_SHAPE_COPY.pt,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.pt,
|
...VISUAL_KEYFRAME_ACTION_COPY.pt,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.pt,
|
||||||
languageTitle: "Escolha o idioma",
|
languageTitle: "Escolha o idioma",
|
||||||
languageSubtitle: "Salvo uma vez, o editor abrirá nesse idioma depois.",
|
languageSubtitle: "Salvo uma vez, o editor abrirá nesse idioma depois.",
|
||||||
@@ -1098,6 +1194,11 @@ Object.assign(UI_COPY, {
|
|||||||
...VISUAL_MASK_SHAPE_COPY.th,
|
...VISUAL_MASK_SHAPE_COPY.th,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.th,
|
...VISUAL_KEYFRAME_ACTION_COPY.th,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.th,
|
||||||
languageTitle: "เลือกภาษาอินเทอร์เฟซ",
|
languageTitle: "เลือกภาษาอินเทอร์เฟซ",
|
||||||
languageSubtitle: "บันทึกครั้งเดียว ครั้งถัดไปจะเปิดด้วยภาษานี้",
|
languageSubtitle: "บันทึกครั้งเดียว ครั้งถัดไปจะเปิดด้วยภาษานี้",
|
||||||
@@ -1144,6 +1245,11 @@ Object.assign(UI_COPY, {
|
|||||||
...VISUAL_MASK_SHAPE_COPY.vi,
|
...VISUAL_MASK_SHAPE_COPY.vi,
|
||||||
...VISUAL_KEYFRAME_ACTION_COPY.vi,
|
...VISUAL_KEYFRAME_ACTION_COPY.vi,
|
||||||
...VISUAL_TAB_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,
|
...SOURCE_AUDIO_SYNC_COPY.vi,
|
||||||
languageTitle: "Chọn ngôn ngữ giao diện",
|
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.",
|
languageSubtitle: "Lưu một lần, lần sau sẽ mở bằng ngôn ngữ này.",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,8 +29,13 @@ export function createImageResizeControl(d) {
|
|||||||
d.musicBlob && d.musicDuration > 0 ? { time: Math.min(MAX_TIMELINE_DURATION_SECONDS, d.musicDuration), label: "音乐结尾" } : null,
|
d.musicBlob && d.musicDuration > 0 ? { time: Math.min(MAX_TIMELINE_DURATION_SECONDS, d.musicDuration), label: "音乐结尾" } : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
let activeLabel = "";
|
let activeLabel = "";
|
||||||
|
let editingStarted = false;
|
||||||
const apply = (clientX) => {
|
const apply = (clientX) => {
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
if (!editingStarted) {
|
||||||
|
editingStarted = true;
|
||||||
|
d.pauseForTimelineEdit?.();
|
||||||
|
}
|
||||||
const pointerX = clientX - rect.left;
|
const pointerX = clientX - rect.left;
|
||||||
const inTrackX = Math.max(0, Math.min(rect.width, pointerX));
|
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;
|
const raw = (inTrackX / Math.max(rect.width, 1)) * timelineDuration + Math.max(0, pointerX - rect.width) * overflowRate;
|
||||||
|
|||||||
@@ -1147,6 +1147,97 @@ function runFfmpegTask(task) {
|
|||||||
return nextTask;
|
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) {
|
export async function transcodeWebmToMp4(webmBlob) {
|
||||||
return runFfmpegTask(async () => {
|
return runFfmpegTask(async () => {
|
||||||
const [{ fetchFile }, ffmpeg] = await Promise.all([import("@ffmpeg/util"), getFfmpeg()]);
|
const [{ fetchFile }, ffmpeg] = await Promise.all([import("@ffmpeg/util"), getFfmpeg()]);
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export function createPlaybackControls(deps) {
|
|||||||
};
|
};
|
||||||
const startTimelineSeek = (event) => {
|
const startTimelineSeek = (event) => {
|
||||||
if (event.button !== 0 || deps.timelineDuration <= 0) return;
|
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 move = (e) => seekTo(getTimelineTimeFromClientX(e.clientX));
|
||||||
const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); };
|
const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); };
|
||||||
addEventListener("pointermove", move); addEventListener("pointerup", up, { once: true });
|
addEventListener("pointermove", move); addEventListener("pointerup", up, { once: true });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { DEFAULT_STICKER_SEGMENT_SECONDS, MAX_TIMELINE_DURATION_SECONDS, MIN_VIS
|
|||||||
export function createTimelineMoveControls(d) {
|
export function createTimelineMoveControls(d) {
|
||||||
const startSingleTrackMove = (event, track) => {
|
const startSingleTrackMove = (event, track) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
|
d.pauseForTimelineEdit?.();
|
||||||
const isSource = track === "source";
|
const isSource = track === "source";
|
||||||
const clipDuration = isSource ? d.sourceAudioDuration : d.musicDuration;
|
const clipDuration = isSource ? d.sourceAudioDuration : d.musicDuration;
|
||||||
const start = isSource ? d.sourceAudioStart : d.musicStart;
|
const start = isSource ? d.sourceAudioStart : d.musicStart;
|
||||||
@@ -25,6 +26,7 @@ export function createTimelineMoveControls(d) {
|
|||||||
};
|
};
|
||||||
const startAudioSegmentMove = (event, id = "") => {
|
const startAudioSegmentMove = (event, id = "") => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
|
d.pauseForTimelineEdit?.();
|
||||||
const segment = d.audioSegments.find((item) => item.id === id); if (!segment) return;
|
const segment = d.audioSegments.find((item) => item.id === id); if (!segment) return;
|
||||||
if (d.trackLocks.audio) return void d.notify(d.t("audioTrackLockedMove"));
|
if (d.trackLocks.audio) return void d.notify(d.t("audioTrackLockedMove"));
|
||||||
const rect = d.trackScrollRef.current?.getBoundingClientRect(); const duration = d.timelineDurationRef.current || 10;
|
const rect = d.trackScrollRef.current?.getBoundingClientRect(); const duration = d.timelineDurationRef.current || 10;
|
||||||
@@ -51,6 +53,7 @@ export function createTimelineMoveControls(d) {
|
|||||||
};
|
};
|
||||||
const startStickerSegmentMove = (event, id = "") => {
|
const startStickerSegmentMove = (event, id = "") => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
|
d.pauseForTimelineEdit?.();
|
||||||
const segment = d.stickerSegments.find((item) => item.id === id); if (!segment) return;
|
const segment = d.stickerSegments.find((item) => item.id === id); if (!segment) return;
|
||||||
if (d.trackLocks.sticker) return void d.notify("贴纸轨已锁定,无法移动贴纸");
|
if (d.trackLocks.sticker) return void d.notify("贴纸轨已锁定,无法移动贴纸");
|
||||||
const rect = d.trackScrollRef.current?.getBoundingClientRect();
|
const rect = d.trackScrollRef.current?.getBoundingClientRect();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ export function createTimelineReorderControls(d) {
|
|||||||
};
|
};
|
||||||
const startTimelineClipDrag = (event, track, segmentId, index) => {
|
const startTimelineClipDrag = (event, track, segmentId, index) => {
|
||||||
if (event.button !== 0 || event.target.closest(".image-resize-handle")) return;
|
if (event.button !== 0 || event.target.closest(".image-resize-handle")) return;
|
||||||
|
d.pauseForTimelineEdit?.();
|
||||||
if (d.trackLocks[track]) return void d.notify(track === "image" ? "图片轨已锁定,无法拖动片段" : "字幕轨已锁定,无法拖动片段");
|
if (d.trackLocks[track]) return void d.notify(track === "image" ? "图片轨已锁定,无法拖动片段" : "字幕轨已锁定,无法拖动片段");
|
||||||
if (track === "image") { d.setSelectedTrack("image"); d.setSelectedVisualSegmentId(segmentId); }
|
if (track === "image") { d.setSelectedTrack("image"); d.setSelectedVisualSegmentId(segmentId); }
|
||||||
else { d.setSelectedTrack("caption"); d.setSelectedSegmentId(segmentId); }
|
else { d.setSelectedTrack("caption"); d.setSelectedSegmentId(segmentId); }
|
||||||
|
|||||||
+89
-1
@@ -2086,7 +2086,7 @@ button:disabled {
|
|||||||
.visual-effects-panel { gap: 12px; }
|
.visual-effects-panel { gap: 12px; }
|
||||||
.visual-effects-panel > .tool-panel { padding: 0; }
|
.visual-effects-panel > .tool-panel { padding: 0; }
|
||||||
.visual-effects-panel > .tool-panel > h2 { font-size: 13px; color: #9ba9b2; }
|
.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 { 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: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); }
|
.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-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-hint { margin: 0; color: #7f8d97; font-size: 10px; line-height: 1.55; }
|
||||||
.visual-speed-empty { min-height: 92px; }
|
.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-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 { 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; }
|
.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 {
|
.visual-media-layer > video {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
}
|
}
|
||||||
|
.visual-media-layer > .remaster-preview-frame { z-index: 2; }
|
||||||
|
|
||||||
.preview-frame img,
|
.preview-frame img,
|
||||||
.preview-frame video {
|
.preview-frame video {
|
||||||
@@ -4258,6 +4279,73 @@ button:disabled {
|
|||||||
white-space: nowrap;
|
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 {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 22px;
|
right: 22px;
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -20,5 +20,12 @@ export default defineConfig({
|
|||||||
clientFiles: ["./src/main.jsx"],
|
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()],
|
plugins: [react()],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user