From bacf0804b1964483b14dc63107a91830fb50c115 Mon Sep 17 00:00:00 2001 From: Martin Delophy <845046459@qq.com> Date: Thu, 16 Jul 2026 12:39:18 +0800 Subject: [PATCH] Enable free positioning for timeline clips (#9) * Add comprehensive GEO search foundation * Enable free positioning for timeline clips --------- Co-authored-by: haixin.yang --- src/App.jsx | 36 +++++---- src/components/Timeline.jsx | 113 +++++++++++++++++++++++------ src/hooks/useAudioTrackState.js | 5 +- src/hooks/useEditorHistory.js | 2 + src/hooks/useMediaSync.js | 12 ++- src/hooks/useProjectFiles.js | 3 +- src/hooks/useTimelineModel.js | 4 +- src/hooks/useVideoExport.js | 4 +- src/lib/audioTrackActions.js | 2 + src/lib/media.js | 3 +- src/lib/playbackControls.js | 4 +- src/lib/timeline.js | 30 ++++++++ src/lib/timeline.test.js | 37 ++++++++++ src/lib/timelineMoveControls.js | 29 +++++++- src/lib/timelineReorderControls.js | 39 ++++++++-- src/lib/timelineViewModel.js | 17 +++-- src/styles.css | 21 ++++++ 17 files changed, 297 insertions(+), 64 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 774bb1b..05eecc1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -82,10 +82,10 @@ export function App() { setCaptionsEnabled, setScript, setSelectedSegmentId, } = useCaptionState(); const { - audioSegments, favoriteVoiceIds, historyItems, musicBlob, musicDuration, musicName, + audioSegments, favoriteVoiceIds, historyItems, musicBlob, musicDuration, musicName, musicStart, musicPeaks, musicUrl, musicVolume, recordedVoices, recordingElapsed, recordingState, selectedAudioSegmentId, selectedVoiceId, setAudioSegments, setFavoriteVoiceIds, - setHistoryItems, setMusicBlob, setMusicDuration, setMusicName, setMusicPeaks, + setHistoryItems, setMusicBlob, setMusicDuration, setMusicName, setMusicPeaks, setMusicStart, setMusicUrl, setMusicVolume, setRecordedVoices, setRecordingElapsed, setRecordingState, setSelectedAudioSegmentId, setSelectedVoiceId, setSourceAudioBlob, setSourceAudioAssetId, setSourceAudioDuration, setSourceAudioLinked, setSourceAudioName, setSourceAudioPeaks, setSourceAudioStart, @@ -131,7 +131,7 @@ export function App() { const [avatarJob, setAvatarJob] = useState({ running: false, progress: 0, phase: "" }); const lastSaved = useAutosaveTimestamp([ script, imageSrc, visualType, imageDuration, captionPlacement, selectedVoiceId, speed, - volume, musicName, musicDuration, musicVolume, sourceAudioName, sourceAudioDuration, + volume, musicName, musicDuration, musicStart, musicVolume, sourceAudioName, sourceAudioDuration, sourceAudioStart, sourceAudioVolume, ratioId, fitMode, selectedFilterId, selectedStickerId, captionSegments, visualSegments, visionRecords, timelineZoom, ]); @@ -152,14 +152,14 @@ export function App() { const { redo, undo } = useEditorHistory({ audioSegments, captionPlacement, captionPosition, captionSegments, captionSize, captionStyle, captionsEnabled, currentTime, fitMode, imageClipCount, imageDuration, - imageMeta, imageName, imageSrc, imageUrlRefs, musicBlob, musicDuration, musicName, + imageMeta, imageName, imageSrc, imageUrlRefs, musicBlob, musicDuration, musicName, musicStart, musicPeaks, musicUrl, musicUrlRef, musicVolume, notify, selectedAudioSegmentId, selectedFilterId, selectedSegmentId, selectedStickerId, selectedStickerSegmentId, selectedTrack, selectedTransitionId, selectedVisualSegmentId, script, setAudioSegments, setCaptionPlacement, setCaptionPosition, setCaptionSegments, setCaptionSize, setCaptionStyle, setCaptionsEnabled, setCurrentTime, setFitMode, setImageClipCount, setImageDuration, setImageMeta, setImageName, setImageSrc, setIsPlaying, setMusicBlob, - setMusicDuration, setMusicName, setMusicPeaks, setMusicUrl, setMusicVolume, setScript, + setMusicDuration, setMusicName, setMusicPeaks, setMusicStart, setMusicUrl, setMusicVolume, setScript, setSelectedAudioSegmentId, setSelectedFilterId, setSelectedSegmentId, setSelectedStickerId, setSelectedStickerSegmentId, setSelectedTrack, setSelectedTransitionId, setSelectedVisualSegmentId, setSourceAudioBlob, @@ -315,7 +315,7 @@ export function App() { musicUrlRef, notify, script, selectedVoice, selectedVoiceId, setActiveTool, setAudioSegments, setCaptionSegments, setCurrentTime, setHistoryItems, setIsPlaying, setMusicBlob, setMusicDuration, setMusicName, setMusicPeaks, - setMusicUrl, setProgress, setSelectedAudioSegmentId, setSelectedSegmentId, + setMusicStart, setMusicUrl, setProgress, setSelectedAudioSegmentId, setSelectedSegmentId, setSelectedTrack, setSourceAudioAssetId, setSourceAudioBlob, setSourceAudioDuration, setSourceAudioLinked, setSourceAudioName, setSourceAudioPeaks, setSourceAudioStart, setSourceAudioUrl, setSourceAudioVolume, setStatus, setStatusText, setTimelineHorizon, sourceAudioBlob, sourceAudioDuration, @@ -440,7 +440,7 @@ export function App() { const { getTimelineTimeFromClientX, handlePlayToggle, pauseTimelineMedia, seekTo, startTimelineSeek } = createPlaybackControls({ audioSegmentRefs, audioSegments, canPreview, currentTimeRef, currentVisualRange, - estimatedDuration, isPlaying, musicDuration, musicRef, musicUrl, notify, + estimatedDuration, isPlaying, musicDuration, musicRef, musicStart, musicUrl, notify, linkedSourceAudioSegments, previewVideoRef, previewVisualType, setCurrentTime, setIsPlaying, sourceAudioDuration, sourceAudioLinked, sourceAudioRef, sourceAudioStart, sourceAudioUrl, timelineDuration, @@ -449,7 +449,7 @@ export function App() { useMediaSync({ audioRef, audioSegmentRefs, audioSegments, currentTime, currentTimeRef, estimatedDuration, - isPlaying, musicRef, musicUrl, musicVolume, pauseTimelineMedia, previewVideoRef, + isPlaying, musicDuration, musicRef, musicStart, musicUrl, musicVolume, pauseTimelineMedia, previewVideoRef, previewVisualSegment, previewVisualSourceTime, previewVisualSrc, previewVisualType, linkedSourceAudioSegments, setCurrentTime, setIsPlaying, setPreviewVideoMediaTime, sourceAudioDuration, sourceAudioLinked, @@ -458,11 +458,12 @@ export function App() { visualPlaybackStartedAtRef, visualPlaybackStartTimeRef, }); - const { startAudioSegmentMove, startStickerSegmentMove } = createTimelineMoveControls({ + const { startAudioSegmentMove, startMusicMove, startSourceAudioMove, startStickerSegmentMove } = createTimelineMoveControls({ audioSegments, captionSegments, estimatedDuration, notify, seekTo, setActiveTool, setAudioSegments, setCaptionSegments, setSelectedAudioSegmentId, setSelectedStickerId, setSelectedStickerSegmentId, setSelectedTrack, setStickerSegments, setTimelineHorizon, - stickerSegments, suppressTimelineClipClickRef, t, timelineDurationRef, + setMusicStart, setSourceAudioLinked, setSourceAudioStart, musicDuration, musicStart, + sourceAudioDuration, sourceAudioStart, stickerSegments, suppressTimelineClipClickRef, t, timelineDurationRef, trackLocks, trackScrollRef, }); @@ -513,11 +514,11 @@ export function App() { audioBlob, audioDuration, captionPlacement, captionPosition, captionSegments, captionSize, captionStyle, captionsEnabled, captionStyleFallback: captionStyle, clearAllVisionState, clearAudioTrack, clearImageTrack, clearMusicTrack, clearSourceAudioTrack, fitMode, - imageUrlRefs, musicBlob, musicDuration, musicName, musicVolume, notify, projectFileInputRef, + imageUrlRefs, musicBlob, musicDuration, musicName, musicStart, musicVolume, notify, projectFileInputRef, ratioId, replaceAudio, replaceMusic, replaceSourceAudio, script, selectedFilterId, selectedStickerId, selectedTransitionId, selectedVoiceId, setCaptionPlacement, setCaptionPosition, setCaptionSegments, setCaptionSize, setCaptionStyle, setCaptionsEnabled, - setCurrentTime, setFitMode, setImageClipCount, setImageDuration, setMusicVolume, + setCurrentTime, setFitMode, setImageClipCount, setImageDuration, setMusicStart, setMusicVolume, setRatioId, setScript, setSelectedFilterId, setSelectedSegmentId, setSelectedStickerId, setSelectedStickerSegmentId, setSelectedTransitionId, setSelectedVoiceId, setShowFileMenu, setSourceAudioAssetId, setSourceAudioLinked, setSourceAudioVolume, setSpeed, setStickerSegments, setTimelineZoom, setTrackVisibility, @@ -528,7 +529,7 @@ export function App() { const { activeTimelineClipDrag, audioClipPercent, displayedCaptionSegments, - displayedCaptionTimeline, displayedVisualSegments, exportPercent, musicClipPercent, + displayedCaptionTimeline, displayedVisualSegments, exportPercent, musicClipPercent, musicStartPercent, playheadPercent, previewFrameStyle, previewRatio, progressPercent, renderedVisualSegments, renderedVisualTimeline, showStickerTrack, sourceAudioClipPercent, sourceAudioStartPercent, @@ -536,14 +537,14 @@ export function App() { assetDragPreview, assetDropTargetTrack, audioBlob, audioDuration, captionSegments, captionTargetDuration, captionTimeline, currentTime, draggedAssetId, exportProgress, findAssetById, getCurrentVisualAssetSnapshot, imageDuration, imageSrc, musicBlob, - musicDuration, previewFrameSize, progress, ratio, selectedTrack, sourceAudioBlob, + musicDuration, musicStart, previewFrameSize, progress, ratio, selectedTrack, sourceAudioBlob, linkedSourceAudioSegments, sourceAudioDuration, sourceAudioLinked, sourceAudioStart, stickerSegments, timelineClipDrag, timelineDuration, visualSegments, }); const handleExportVideo = useVideoExport({ audioSegments, captionDuration, captionPlacement, captionPosition, captionSegments, captionSize, captionStyle, captionsEnabled, exporting, exportStartRef, fitMode, - imageDuration, imageSrc, musicBlob, musicDuration, musicVolume, notify, + imageDuration, imageSrc, musicBlob, musicDuration, musicStart, musicVolume, notify, previewFrameSize, ratio, renderedVisualSegments, script, selectedFilter, selectedSticker, selectedTransitionId, setExporting, setExportPhase, setExportProgress, setStatus, setStatusText, sourceAudioBlob, sourceAudioDuration, @@ -558,7 +559,7 @@ export function App() { captionSegments, captionTargetDuration, commitCaptionSegments, commitVisualSegments, notify, renderedVisualSegments, seekTo, setSelectedSegmentId, setSelectedTrack, setSelectedVisualSegmentId, setTimelineClipDrag, suppressTimelineClipClickRef, - timelineClipDragRef, trackLocks, visualSegments, + timelineClipDragRef, timelineDuration, trackLocks, visualSegments, }); return ( @@ -836,10 +837,13 @@ export function App() { selectedAudioSegmentId={selectedAudioSegmentId} setSelectedAudioSegmentId={setSelectedAudioSegmentId} startAudioSegmentMove={startAudioSegmentMove} + startSourceAudioMove={startSourceAudioMove} musicBlob={musicBlob} musicPeaks={musicPeaks} musicClipPercent={musicClipPercent} + musicStartPercent={musicStartPercent} musicDuration={musicDuration} + startMusicMove={startMusicMove} /> diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 5b8c4b1..8de71f4 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -20,11 +20,12 @@ import { Play, PlusCircle, Scissors, + SlidersHorizontal, Trash, } from "@phosphor-icons/react"; import { IMAGE_SEGMENT_SECONDS } from "../config/editor.js"; -import { formatClock, formatTime, getImageThumbnailCount, getSegmentStartTime } from "../lib/timeline.js"; +import { formatClock, formatTime, getImageThumbnailCount, getSegmentStartTime, packTimedSegmentsIntoLanes } from "../lib/timeline.js"; import { sliceSourceAudioPeaks } from "../lib/sourceAudioSync.js"; import { TIMELINE_MIN_ZOOM, @@ -46,19 +47,6 @@ const TIMELINE_WHEEL_ZOOM_CONTENT_SELECTOR = [ ".audio-clip", ].join(", "); -function packAudioSegmentsIntoLanes(segments) { - const lanes = []; - [...segments].sort((a, b) => a.start - b.start || a.id.localeCompare(b.id)).forEach((segment) => { - const laneIndex = lanes.findIndex((lane) => { - const last = lane.at(-1); - return !last || last.start + last.duration <= segment.start + 0.001; - }); - if (laneIndex >= 0) lanes[laneIndex].push(segment); - else lanes.push([segment]); - }); - return lanes.length ? lanes : [[]]; -} - function packCaptionSegmentsIntoLanes(segments, timeline) { const lanes = []; segments @@ -194,10 +182,13 @@ export function Timeline({ selectedAudioSegmentId, setSelectedAudioSegmentId, startAudioSegmentMove, + startSourceAudioMove, musicBlob, musicPeaks, musicClipPercent, + musicStartPercent, musicDuration, + startMusicMove, }) { const draggingVisualSegment = activeTimelineClipDrag?.track === "image" @@ -207,17 +198,18 @@ export function Timeline({ activeTimelineClipDrag?.track === "caption" ? displayedCaptionSegments.find((segment) => segment.id === activeTimelineClipDrag.segmentId) : null; - const audioLanes = useMemo(() => packAudioSegmentsIntoLanes(audioSegments), [audioSegments]); + const audioLanes = useMemo(() => packTimedSegmentsIntoLanes(audioSegments), [audioSegments]); + const stickerLanes = useMemo(() => packTimedSegmentsIntoLanes(stickerSegments), [stickerSegments]); const captionLanes = useMemo( () => packCaptionSegmentsIntoLanes(displayedCaptionSegments, displayedCaptionTimeline), [displayedCaptionSegments, displayedCaptionTimeline], ); - const contentRows = ["46px", ...(showStickerTrack ? ["46px"] : []), ...captionLanes.map(() => "46px"), "58px", ...audioLanes.map(() => "58px"), "58px"]; + const contentRows = ["46px", ...(showStickerTrack ? stickerLanes.map(() => "46px") : []), ...captionLanes.map(() => "46px"), "58px", ...audioLanes.map(() => "58px"), "58px"]; const timelineTrackRows = ["28px", ...contentRows].join(" "); const timelineLabelRows = contentRows.join(" "); const timelineTrackLabels = [ ["image", t("imageTrack")], - ...(showStickerTrack ? [["sticker", t("stickerTrack")]] : []), + ...(showStickerTrack ? stickerLanes.map((_, index) => ["sticker", `${t("stickerTrack")} ${index + 1}`, `sticker-${index}`]) : []), ...captionLanes.map((_, index) => ["caption", `${t("caption")} ${index + 1}`, `caption-${index}`]), ["source", t("sourceTrack")], ...audioLanes.map((_, index) => ["audio", `${t("voiceTrack")} ${index + 1}`, `audio-${index}`]), @@ -230,6 +222,45 @@ export function Timeline({ viewportWidth: 0, contentWidth: 0, }); + const [contextMenu, setContextMenu] = useState(null); + const trackTool = (track) => ({ image: "media", sticker: "stickers", caption: "caption", source: "audio", audio: "audio", music: "audio" })[track] || "media"; + const openTrackPanel = (track) => { + setSelectedTrack(track); + setActiveTool(trackTool(track)); + }; + const selectContextTarget = (track, segmentId = "") => { + openTrackPanel(track); + if (track === "image" && segmentId) setSelectedVisualSegmentId(segmentId); + if (track === "sticker" && segmentId) setSelectedStickerSegmentId(segmentId); + if (track === "caption" && segmentId) setSelectedSegmentId(segmentId); + if (track === "audio" && segmentId) setSelectedAudioSegmentId(segmentId); + }; + const showTrackContextMenu = (event, track, segmentId = "") => { + event.preventDefault(); event.stopPropagation(); + selectContextTarget(track, segmentId); + setContextMenu({ + x: Math.max(10, Math.min(window.innerWidth - 234, event.clientX)), + y: Math.max(10, Math.min(window.innerHeight - 258, event.clientY)), + track, segmentId, kind: segmentId ? "clip" : "track", + }); + }; + const runContextAction = (action) => { + setContextMenu(null); + window.requestAnimationFrame(action); + }; + useEffect(() => { + if (!contextMenu) return undefined; + const close = () => setContextMenu(null); + const closeOnKey = (event) => event.key === "Escape" && close(); + window.addEventListener("pointerdown", close); + window.addEventListener("keydown", closeOnKey); + window.addEventListener("blur", close); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("keydown", closeOnKey); + window.removeEventListener("blur", close); + }; + }, [contextMenu]); const [localTimelineZoom, setLocalTimelineZoom] = useState(() => clampTimelineZoom(timelineZoom)); const timelineZoomRef = useRef(timelineZoom); const wheelZoomFrameRef = useRef(0); @@ -500,11 +531,12 @@ export function Timeline({ ) : null; - const renderStickerTrack = () => + const renderStickerTrack = (lane, laneIndex) => showStickerTrack ? (
handleTrackAssetDragLeave(event, "sticker")} onDrop={(event) => handleTrackAssetDrop(event, "sticker")} data-asset-drop-track="sticker" + onContextMenu={(event) => showTrackContextMenu(event, "sticker")} > {assetDropTargetTrack === "sticker" ? (
{t("dropStickerHere")}
) : null} - {stickerSegments.map((segment) => { + {lane.map((segment) => { const segmentLeft = timelineDuration > 0 ? Math.max(0, Math.min(100, ((segment.start || 0) / timelineDuration) * 100)) @@ -541,6 +574,7 @@ export function Timeline({ "--sticker-width": `${segmentWidth}%`, }} onPointerDown={(event) => startStickerSegmentMove(event, segment.id)} + onContextMenu={(event) => showTrackContextMenu(event, "sticker", segment.id)} onClick={(event) => { event.stopPropagation(); if (suppressTimelineClipClickRef.current === segment.id) { @@ -645,6 +679,7 @@ export function Timeline({ !isRowVisible(track, rowId) ? "is-track-disabled" : "" }`} key={rowId} + onContextMenu={(event) => showTrackContextMenu(event, track)} >
- {renderStickerTrack()} + {showStickerTrack ? stickerLanes.map((lane, laneIndex) => renderStickerTrack(lane, laneIndex)) : null} {captionLanes.map((lane, laneIndex) => (
showTrackContextMenu(event, "caption")} > {lane.map(({ segment, index, range: segmentRange }) => { const segmentDuration = segmentRange?.duration ?? 0; @@ -910,6 +948,7 @@ export function Timeline({ "--caption-width": `${segmentWidth}%`, }} onPointerDown={(event) => startTimelineClipDrag(event, "caption", segment.id, index)} + onContextMenu={(event) => showTrackContextMenu(event, "caption", segment.id)} onClick={(event) => { event.stopPropagation(); if (suppressTimelineClipClickRef.current === segment.id) { @@ -939,6 +978,7 @@ export function Timeline({ onDragLeave={(event) => handleTrackAssetDragLeave(event, "source")} onDrop={(event) => handleTrackAssetDrop(event, "source")} data-asset-drop-track="source" + onContextMenu={(event) => showTrackContextMenu(event, "source")} > {assetDropTargetTrack === "source" ? (
{t("dropSourceHere")}
@@ -952,6 +992,8 @@ export function Timeline({ width: `${timelineDuration > 0 ? Math.max(0.01, Math.min(100, (segment.duration / timelineDuration) * 100)) : 0}%`, left: `${timelineDuration > 0 ? Math.max(0, Math.min(100, (segment.start / timelineDuration) * 100)) : 0}%`, }} + onContextMenu={(event) => showTrackContextMenu(event, "source", segment.id)} + onPointerDown={startSourceAudioMove} > {formatTime(segment.duration)} @@ -963,6 +1005,8 @@ export function Timeline({ width: `${sourceAudioClipPercent}%`, marginLeft: `${sourceAudioStartPercent}%`, }} + onContextMenu={(event) => showTrackContextMenu(event, "source", "source-audio")} + onPointerDown={startSourceAudioMove} > {formatTime(sourceAudioDuration)} @@ -983,6 +1027,7 @@ export function Timeline({ onDragLeave={(event) => laneIndex === 0 && handleTrackAssetDragLeave(event, "audio")} onDrop={(event) => laneIndex === 0 && handleTrackAssetDrop(event, "audio")} data-asset-drop-track={laneIndex === 0 ? "audio" : undefined} + onContextMenu={(event) => showTrackContextMenu(event, "audio")} > {laneIndex === 0 && assetDropTargetTrack === "audio" ? (
{t("dropVoiceHere")}
@@ -997,6 +1042,7 @@ export function Timeline({ key={segment.id} style={{ left: `${left}%`, width: `${width}%` }} onPointerDown={(event) => startAudioSegmentMove(event, segment.id)} + onContextMenu={(event) => showTrackContextMenu(event, "audio", segment.id)} onClick={(event) => { event.stopPropagation(); setSelectedTrack("audio"); @@ -1023,13 +1069,14 @@ export function Timeline({ onDragLeave={(event) => handleTrackAssetDragLeave(event, "music")} onDrop={(event) => handleTrackAssetDrop(event, "music")} data-asset-drop-track="music" + onContextMenu={(event) => showTrackContextMenu(event, "music")} > {assetDropTargetTrack === "music" ? (
{t("dropMusicHere")}
) : null} {renderAssetDropSlot("music")} {musicBlob ? ( -
+
showTrackContextMenu(event, "music", "music-audio")}> {formatTime(musicDuration)}
@@ -1054,7 +1101,7 @@ export function Timeline({ {formatClock(draggingVisualSegment.duration)}
) : null} - {draggingCaptionSegment ? ( + {draggingCaptionSegment && activeTimelineClipDrag.mode !== "move" ? (
{draggingCaptionSegment.text}
) : null} + {contextMenu ? ( +
event.stopPropagation()}> +
{contextMenu.kind === "clip" ? "片段操作" : "轨道操作"}{contextMenu.track}
+ + {contextMenu.kind === "clip" ? ( + <> + + +
+ + + ) : ( + <> + {["image", "caption"].includes(contextMenu.track) ? : null} + + + + )} +
+ ) : null} ); } diff --git a/src/hooks/useAudioTrackState.js b/src/hooks/useAudioTrackState.js index 0fbd3d7..7181f73 100644 --- a/src/hooks/useAudioTrackState.js +++ b/src/hooks/useAudioTrackState.js @@ -13,6 +13,7 @@ export function useAudioTrackState() { const [musicUrl, setMusicUrl] = useState(""); const [musicName, setMusicName] = useState(""); const [musicDuration, setMusicDuration] = useState(0); + const [musicStart, setMusicStart] = useState(0); const [musicPeaks, setMusicPeaks] = useState([]); const [musicVolume, setMusicVolume] = useState(0.35); const [sourceAudioBlob, setSourceAudioBlob] = useState(null); @@ -32,10 +33,10 @@ export function useAudioTrackState() { return { audioSegments, favoriteVoiceIds, historyItems, musicBlob, musicDuration, musicName, - musicPeaks, musicUrl, musicVolume, recordedVoices, recordingElapsed, recordingState, + musicPeaks, musicStart, musicUrl, musicVolume, recordedVoices, recordingElapsed, recordingState, selectedAudioSegmentId, selectedVoiceId, setAudioSegments, setFavoriteVoiceIds, setHistoryItems, setMusicBlob, setMusicDuration, setMusicName, setMusicPeaks, - setMusicUrl, setMusicVolume, setRecordedVoices, setRecordingElapsed, + setMusicStart, setMusicUrl, setMusicVolume, setRecordedVoices, setRecordingElapsed, setRecordingState, setSelectedAudioSegmentId, setSelectedVoiceId, setSourceAudioBlob, setSourceAudioAssetId, setSourceAudioDuration, setSourceAudioLinked, setSourceAudioName, setSourceAudioPeaks, setSourceAudioStart, setSourceAudioUrl, setSourceAudioVolume, setSpeed, setTimelineHorizon, setVolume, diff --git a/src/hooks/useEditorHistory.js b/src/hooks/useEditorHistory.js index 271f91e..9a37234 100644 --- a/src/hooks/useEditorHistory.js +++ b/src/hooks/useEditorHistory.js @@ -38,6 +38,7 @@ function createSnapshot(d) { audioSegments: cloneItems(d.audioSegments), timelineHorizon: d.timelineHorizon, musicBlob: d.musicBlob, + musicStart: d.musicStart, musicUrl: d.musicUrl, musicName: d.musicName, musicDuration: d.musicDuration, @@ -170,6 +171,7 @@ function restoreSnapshot(snapshot, d) { const musicUrl = getBlobUrl(snapshot.musicBlob) || snapshot.musicUrl; d.musicUrlRef.current = musicUrl; d.setMusicBlob(snapshot.musicBlob); + d.setMusicStart(Math.max(0, Number(snapshot.musicStart) || 0)); d.setMusicUrl(musicUrl); d.setMusicName(snapshot.musicName); d.setMusicDuration(snapshot.musicDuration); diff --git a/src/hooks/useMediaSync.js b/src/hooks/useMediaSync.js index 49c483b..db22c1d 100644 --- a/src/hooks/useMediaSync.js +++ b/src/hooks/useMediaSync.js @@ -25,6 +25,14 @@ export function useMediaSync(d) { if (play && a.paused) a.play().catch(() => {}); else if (!play && !a.paused) a.pause(); }, [d.currentTime, d.isPlaying, d.linkedSourceAudioSegments, d.sourceAudioDuration, d.sourceAudioLinked, d.sourceAudioStart, d.sourceAudioUrl, d.trackVisibility.source]); useEffect(() => { if (d.musicRef.current) d.musicRef.current.volume = d.musicVolume; }, [d.musicVolume, d.musicUrl]); + useEffect(() => { + const music = d.musicRef.current; if (!music || !d.musicUrl) return; + const active = isTimelineTimeInsideTrack(d.currentTime, d.musicStart, d.musicDuration); + const expected = getTimelineTrackLocalTime(d.currentTime, d.musicStart, d.musicDuration); + if (Math.abs(music.currentTime - expected) > 0.22) music.currentTime = expected; + const play = d.isPlaying && d.trackVisibility.music && active; + if (play && music.paused) music.play().catch(() => {}); else if (!play && !music.paused) music.pause(); + }, [d.currentTime, d.isPlaying, d.musicDuration, d.musicStart, d.musicUrl, d.trackVisibility.music]); useEffect(() => { d.currentTimeRef.current = d.currentTime; }, [d.currentTime]); useEffect(() => { d.setPreviewVideoMediaTime(d.previewVisualType === "video" ? Math.max(0, Number(d.previewVisualSegment?.sourceStart) || 0) : 0); }, [d.previewVisualSegment?.id, d.previewVisualSrc, d.previewVisualType]); useEffect(() => { @@ -60,7 +68,7 @@ export function useMediaSync(d) { if (d.sourceAudioRef.current && clamped !== time) d.sourceAudioRef.current.currentTime = d.sourceAudioLinked && d.linkedSourceAudioSegments?.length ? getLinkedSourceAudioState(d.linkedSourceAudioSegments, clamped).sourceTime : getTimelineTrackLocalTime(clamped, d.sourceAudioStart, d.sourceAudioDuration); - if (d.musicRef.current && clamped !== time) d.musicRef.current.currentTime = clamped; + if (d.musicRef.current && clamped !== time) d.musicRef.current.currentTime = getTimelineTrackLocalTime(clamped, d.musicStart, d.musicDuration); return clamped; - }); }, [d.linkedSourceAudioSegments, d.sourceAudioDuration, d.sourceAudioLinked, d.sourceAudioStart, d.timelineDuration]); + }); }, [d.linkedSourceAudioSegments, d.musicDuration, d.musicStart, d.sourceAudioDuration, d.sourceAudioLinked, d.sourceAudioStart, d.timelineDuration]); } diff --git a/src/hooks/useProjectFiles.js b/src/hooks/useProjectFiles.js index 5ad9a54..403c9b5 100644 --- a/src/hooks/useProjectFiles.js +++ b/src/hooks/useProjectFiles.js @@ -18,6 +18,7 @@ export function useProjectFiles(deps) { musicName: deps.musicName, musicDuration: deps.musicDuration, musicVolume: deps.musicVolume, sourceAudioName: deps.sourceAudioName, sourceAudioDuration: deps.sourceAudioDuration, sourceAudioStart: deps.sourceAudioStart, sourceAudioVolume: deps.sourceAudioVolume, + musicStart: deps.musicStart, sourceAudioAssetId: deps.sourceAudioAssetId, sourceAudioLinked: deps.sourceAudioLinked, }; }, [deps]); @@ -78,7 +79,7 @@ export function useProjectFiles(deps) { deps.setImageClipCount(getImageThumbnailCount(getVisualSegmentsTotal(visuals))); deps.setCurrentVisualAsset(visuals[0] || null); if (audio) { const decoded = await decodeWaveform(audio); deps.replaceAudio(audio, Number(data.audioDuration) || decoded.duration, decoded.peaks, "已恢复工程配音"); } else deps.clearAudioTrack(""); if (sourceAudio) { const decoded = await decodeWaveform(sourceAudio); deps.replaceSourceAudio(sourceAudio, Number(data.sourceAudioDuration) || decoded.duration, decoded.peaks, data.sourceAudioName || "source-audio", "", Number(data.sourceAudioStart) || 0, data.sourceAudioAssetId || ""); } else deps.clearSourceAudioTrack(""); - if (music) { const decoded = await decodeWaveform(music); deps.replaceMusic(music, Number(data.musicDuration) || decoded.duration, decoded.peaks, data.musicName || "background-music", ""); } else deps.clearMusicTrack(""); + if (music) { const decoded = await decodeWaveform(music); deps.replaceMusic(music, Number(data.musicDuration) || decoded.duration, decoded.peaks, data.musicName || "background-music", ""); deps.setMusicStart(Math.max(0, Number(data.musicStart) || 0)); } else deps.clearMusicTrack(""); deps.setMusicVolume(Number(data.musicVolume) || 0.35); deps.setSourceAudioVolume(Number(data.sourceAudioVolume) || 1); deps.setSourceAudioAssetId(data.sourceAudioAssetId || ""); deps.setSourceAudioLinked(data.sourceAudioLinked !== false); deps.setCurrentTime(0); deps.clearAllVisionState(); deps.setShowFileMenu(false); diff --git a/src/hooks/useTimelineModel.js b/src/hooks/useTimelineModel.js index 4c859d6..a8d9a01 100644 --- a/src/hooks/useTimelineModel.js +++ b/src/hooks/useTimelineModel.js @@ -81,13 +81,13 @@ export function useTimelineModel(d) { voiceTrackDuration, captionDuration, d.sourceAudioBlob ? (d.sourceAudioTimelineEnd ?? d.sourceAudioStart + d.sourceAudioDuration) : 0, - d.musicBlob ? d.musicDuration : 0, + d.musicBlob ? d.musicStart + d.musicDuration : 0, stickerDuration, estimateDuration(d.script), d.imageSrc ? d.imageDuration : 0, ), [ voiceTrackDuration, captionDuration, d.imageDuration, d.imageSrc, d.musicBlob, - d.musicDuration, d.script, d.sourceAudioBlob, d.sourceAudioDuration, + d.musicDuration, d.musicStart, d.script, d.sourceAudioBlob, d.sourceAudioDuration, d.sourceAudioStart, d.sourceAudioTimelineEnd, stickerDuration, ]); const timelineDuration = useMemo(() => Math.min( diff --git a/src/hooks/useVideoExport.js b/src/hooks/useVideoExport.js index be130c3..a38f52a 100644 --- a/src/hooks/useVideoExport.js +++ b/src/hooks/useVideoExport.js @@ -26,10 +26,10 @@ export function useVideoExport(d) { sourceAudioBlob: d.trackVisibility.source ? d.sourceAudioBlob : null, sourceAudioVolume: d.sourceAudioVolume, sourceAudioSegments: d.sourceAudioLinked ? d.linkedSourceAudioSegments : [], sourceAudioStart: d.sourceAudioStart, musicBlob: d.trackVisibility.music ? d.musicBlob : null, - musicVolume: d.musicVolume, text: d.script, captionSegments: d.captionSegments, + musicVolume: d.musicVolume, musicStart: d.musicStart, text: d.script, captionSegments: d.captionSegments, duration: Math.max(d.trackVisibility.audio ? d.voiceTrackDuration : 0, d.captionDuration, d.trackVisibility.source && d.sourceAudioBlob ? d.sourceAudioTimelineEnd : 0, - d.trackVisibility.music && d.musicBlob ? d.musicDuration : 0, + d.trackVisibility.music && d.musicBlob ? d.musicStart + d.musicDuration : 0, d.trackVisibility.sticker ? d.stickerDuration : 0, d.imageDuration, estimateDuration(d.script)), ratio: d.ratio, fitMode: d.fitMode, filter: d.selectedFilter.css, captionsEnabled: d.captionsEnabled && d.trackVisibility.caption, diff --git a/src/lib/audioTrackActions.js b/src/lib/audioTrackActions.js index 568fda5..6f6dbc8 100644 --- a/src/lib/audioTrackActions.js +++ b/src/lib/audioTrackActions.js @@ -57,6 +57,7 @@ export function createAudioTrackActions(d) { d.setMusicUrl(nextUrl); d.setMusicName(nextName); d.setMusicDuration(duration || 0); + d.setMusicStart(0); d.setMusicPeaks(nextPeaks); d.setSelectedTrack("music"); d.setActiveTool("audio"); @@ -129,6 +130,7 @@ export function createAudioTrackActions(d) { d.setMusicUrl(""); d.setMusicName(""); d.setMusicDuration(0); + d.setMusicStart(0); d.setMusicPeaks([]); d.setCurrentTime((time) => Math.min(time, Math.max( d.audioBlob ? d.audioDuration : 0, diff --git a/src/lib/media.js b/src/lib/media.js index a62656b..70ea61a 100644 --- a/src/lib/media.js +++ b/src/lib/media.js @@ -714,6 +714,7 @@ export async function exportBrowserVideo({ sourceAudioSegments = [], musicBlob, musicVolume = 0.35, + musicStart = 0, text, captionSegments, duration, @@ -839,7 +840,7 @@ export async function exportBrowserVideo({ : sourceAudioBlob ? [{ blob: sourceAudioBlob, volume: sourceAudioVolume, role: "source", start: Math.max(0, sourceAudioStart || 0) }] : []), - musicBlob ? { blob: musicBlob, volume: musicVolume, role: "music", start: 0 } : null, + musicBlob ? { blob: musicBlob, volume: musicVolume, role: "music", start: Math.max(0, musicStart || 0) } : null, ].filter(Boolean); if (audioInputs.length) { diff --git a/src/lib/playbackControls.js b/src/lib/playbackControls.js index 389541f..5965ceb 100644 --- a/src/lib/playbackControls.js +++ b/src/lib/playbackControls.js @@ -24,7 +24,7 @@ export function createPlaybackControls(deps) { if (audio) audio.currentTime = getTimelineTrackLocalTime(clamped, segment.start, segment.duration); }); if (deps.sourceAudioRef.current) deps.sourceAudioRef.current.currentTime = getSourceState(clamped).sourceTime; - if (deps.musicRef.current) deps.musicRef.current.currentTime = clamped; + if (deps.musicRef.current) deps.musicRef.current.currentTime = getTimelineTrackLocalTime(clamped, deps.musicStart, deps.musicDuration); }; const getTimelineTimeFromClientX = (clientX) => { const rect = deps.trackScrollRef.current?.getBoundingClientRect(); const duration = deps.timelineDurationRef.current; @@ -59,7 +59,7 @@ export function createPlaybackControls(deps) { source.playbackRate = sourceState.playbackRate; playIf(source, sourceState.active); } - if (music && deps.musicUrl) { music.currentTime = Math.max(0, Math.min(deps.musicDuration || timelineTime, timelineTime)); playIf(music, timelineTime <= (deps.musicDuration || timelineTime)); } + if (music && deps.musicUrl) { const active = isTimelineTimeInsideTrack(timelineTime, deps.musicStart, deps.musicDuration); music.currentTime = getTimelineTrackLocalTime(timelineTime, deps.musicStart, deps.musicDuration); playIf(music, active); } if (video && deps.previewVisualType === "video") { const index = getVisualSegmentIndexAtTime(deps.visualSegments, timelineTime); const range = deps.visualTimeline[Math.max(0, index)] ?? deps.currentVisualRange; diff --git a/src/lib/timeline.js b/src/lib/timeline.js index a7c8cf1..828f1ac 100644 --- a/src/lib/timeline.js +++ b/src/lib/timeline.js @@ -75,6 +75,19 @@ export function getTimedSegmentsEnd(segments) { ); } +export function packTimedSegmentsIntoLanes(segments) { + const lanes = []; + [...segments].sort((a, b) => (a.start || 0) - (b.start || 0) || String(a.id).localeCompare(String(b.id))).forEach((segment) => { + const laneIndex = lanes.findIndex((lane) => { + const last = lane.at(-1); + return !last || (last.start || 0) + (last.duration || 0) <= (segment.start || 0) + 0.001; + }); + if (laneIndex >= 0) lanes[laneIndex].push(segment); + else lanes.push([segment]); + }); + return lanes.length ? lanes : [[]]; +} + export function getTimedSegmentIndexAtTime(segments, time) { const safeTime = Math.max(0, Number.isFinite(time) ? time : 0); for (let index = segments.length - 1; index >= 0; index -= 1) { @@ -110,6 +123,23 @@ export function reorderTimelineItems(items, fromIndex, toIndex) { return nextItems; } +export function moveTimedCaptionSegment(segments, segmentId, start, end) { + const safeStart = Math.max(0, Number(start) || 0); + const safeEnd = Math.max(safeStart + MIN_TIMED_CAPTION_SECONDS, Number(end) || safeStart); + return segments.map((segment) => + segment.id === segmentId ? { ...segment, start: safeStart, end: safeEnd } : segment, + ); +} + +export function materializeCaptionTimings(segments, targetDuration) { + const timeline = getCaptionTimeline(segments, targetDuration); + return segments.map((segment, index) => ({ + ...segment, + start: timeline[index]?.start ?? 0, + end: timeline[index]?.end ?? Math.max(MIN_TIMED_CAPTION_SECONDS, timeline[index]?.start ?? 0), + })); +} + export function getVisualSegmentTimeline(segments) { let cursor = 0; return segments.map((segment) => { diff --git a/src/lib/timeline.test.js b/src/lib/timeline.test.js index 6e90771..1c3ddfe 100644 --- a/src/lib/timeline.test.js +++ b/src/lib/timeline.test.js @@ -9,6 +9,9 @@ import { getTimedSegmentsEnd, getVisualSegmentIndexAtTime, getVisualSegmentTimeline, + moveTimedCaptionSegment, + materializeCaptionTimings, + packTimedSegmentsIntoLanes, reorderTimelineItems, } from "./timeline.js"; import { @@ -81,6 +84,40 @@ describe("timeline primitives", () => { ]); }); + it("moves one timed caption without changing its duration or neighboring captions", () => { + const source = [ + { id: "a", text: "first", start: 1, end: 2.5 }, + { id: "b", text: "second", start: 4, end: 5 }, + ]; + const moved = moveTimedCaptionSegment(source, "a", 6, 7.5); + expect(moved[0]).toMatchObject({ id: "a", start: 6, end: 7.5 }); + expect(moved[0].end - moved[0].start).toBe(1.5); + expect(moved[1]).toEqual(source[1]); + expect(source[0]).toMatchObject({ start: 1, end: 2.5 }); + }); + + it("materializes contiguous untimed captions before free-position dragging", () => { + const source = createCaptionSegments("第一条。第二条。"); + const materialized = materializeCaptionTimings(source, 8); + expect(materialized).toHaveLength(2); + expect(materialized[0].start).toBe(0); + expect(materialized[0].end).toBeCloseTo(materialized[1].start, 8); + const moved = moveTimedCaptionSegment(materialized, materialized[1].id, 6, 8); + expect(moved[1]).toMatchObject({ start: 6, end: 8 }); + expect(moved[1].start - moved[0].end).toBeGreaterThan(0); + }); + + it("packs overlapping free-position clips into additional lanes", () => { + const lanes = packTimedSegmentsIntoLanes([ + { id: "a", start: 0, duration: 4 }, + { id: "b", start: 2, duration: 3 }, + { id: "c", start: 5, duration: 1 }, + ]); + expect(lanes).toHaveLength(2); + expect(lanes[0].map((item) => item.id)).toEqual(["a", "c"]); + expect(lanes[1].map((item) => item.id)).toEqual(["b"]); + }); + it("clamps sticker start and duration to timeline limits", () => { const sticker = createStickerSegment({ id: "spark", src: "/spark.png" }, -5, 0.1); expect(sticker.start).toBe(0); diff --git a/src/lib/timelineMoveControls.js b/src/lib/timelineMoveControls.js index b41c91d..9e56ab7 100644 --- a/src/lib/timelineMoveControls.js +++ b/src/lib/timelineMoveControls.js @@ -1,6 +1,28 @@ import { DEFAULT_STICKER_SEGMENT_SECONDS, MAX_TIMELINE_DURATION_SECONDS, MIN_VISUAL_SEGMENT_SECONDS } from "../config/editor.js"; export function createTimelineMoveControls(d) { + const startSingleTrackMove = (event, track) => { + if (event.button !== 0) return; + const isSource = track === "source"; + const clipDuration = isSource ? d.sourceAudioDuration : d.musicDuration; + const start = isSource ? d.sourceAudioStart : d.musicStart; + if (!(clipDuration > 0) || d.trackLocks[track]) return void d.notify(`${isSource ? "视频原声" : "音乐"}轨已锁定,无法移动`); + const rect = d.trackScrollRef.current?.getBoundingClientRect(); const duration = d.timelineDurationRef.current || 10; + if (!rect) return; + event.preventDefault(); event.stopPropagation(); d.setSelectedTrack(track); d.setActiveTool("audio"); + const startX = event.clientX; let moved = false; let latest = start; + const move = (e) => { + if (!moved && Math.abs(e.clientX - startX) < 4) return; + moved = true; e.preventDefault(); + latest = Math.max(0, Math.min(MAX_TIMELINE_DURATION_SECONDS - clipDuration, start + ((e.clientX - startX) / Math.max(rect.width, 1)) * duration)); + if (isSource) { d.setSourceAudioLinked(false); d.setSourceAudioStart(latest); } + else d.setMusicStart(latest); + d.setTimelineHorizon((value) => Math.max(value, Math.ceil((latest + clipDuration + 5) / 10) * 10)); + }; + const cleanup = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); removeEventListener("pointercancel", cleanup); }; + const up = () => { cleanup(); if (moved) { d.seekTo(latest); d.notify(`${isSource ? "视频原声" : "音乐"}片段位置已调整`); } }; + addEventListener("pointermove", move, { passive: false }); addEventListener("pointerup", up); addEventListener("pointercancel", cleanup); + }; const startAudioSegmentMove = (event, id = "") => { if (event.button !== 0) return; const segment = d.audioSegments.find((item) => item.id === id); if (!segment) return; @@ -54,5 +76,10 @@ export function createTimelineMoveControls(d) { }; addEventListener("pointermove", move, { passive: false }); addEventListener("pointerup", up); addEventListener("pointercancel", cleanup); }; - return { startAudioSegmentMove, startStickerSegmentMove }; + return { + startAudioSegmentMove, + startMusicMove: (event) => startSingleTrackMove(event, "music"), + startSourceAudioMove: (event) => startSingleTrackMove(event, "source"), + startStickerSegmentMove, + }; } diff --git a/src/lib/timelineReorderControls.js b/src/lib/timelineReorderControls.js index 79af24d..1999c70 100644 --- a/src/lib/timelineReorderControls.js +++ b/src/lib/timelineReorderControls.js @@ -1,4 +1,4 @@ -import { getSegmentStartTime, getVisualSegmentTimeline, reorderTimelineItems } from "./timeline.js"; +import { getVisualSegmentTimeline, materializeCaptionTimings, moveTimedCaptionSegment, reorderTimelineItems } from "./timeline.js"; export function createTimelineReorderControls(d) { const getTimelineReorderIndex = (track, x, y) => { @@ -21,17 +21,44 @@ export function createTimelineReorderControls(d) { const next = reorderTimelineItems(source, fromIndex, toIndex); d.commitVisualSegments(next, "已调整视觉片段顺序", toIndex); d.seekTo(getVisualSegmentTimeline(next)[toIndex]?.start ?? 0); return; } - if (track === "caption" && d.captionSegments.length >= 2) { - const next = reorderTimelineItems(d.captionSegments, fromIndex, toIndex); - d.commitCaptionSegments(next, "已调整字幕片段顺序", toIndex); - d.seekTo(getSegmentStartTime(next, toIndex, d.captionTargetDuration)); - } }; const startTimelineClipDrag = (event, track, segmentId, index) => { if (event.button !== 0 || event.target.closest(".image-resize-handle")) return; if (d.trackLocks[track]) return void d.notify(track === "image" ? "图片轨已锁定,无法拖动片段" : "字幕轨已锁定,无法拖动片段"); if (track === "image") { d.setSelectedTrack("image"); d.setSelectedVisualSegmentId(segmentId); } else { d.setSelectedTrack("caption"); d.setSelectedSegmentId(segmentId); } + const materializedCaptions = track === "caption" ? materializeCaptionTimings(d.captionSegments, d.captionTargetDuration) : []; + const caption = materializedCaptions[index]; + if (caption) { + event.preventDefault(); event.stopPropagation(); + const duration = Math.max(0.2, caption.end - caption.start); + const initial = { track, mode: "move", segmentId, fromIndex: index, startX: event.clientX, startY: event.clientY, + originalStart: caption.start, originalEnd: caption.end, previewStart: caption.start, previewEnd: caption.end, + previewSegments: materializedCaptions, dragging: false }; + d.timelineClipDragRef.current = initial; d.setTimelineClipDrag(initial); + const move = (e) => { + const state = d.timelineClipDragRef.current; if (!state || state.segmentId !== segmentId) return; + if (!state.dragging && Math.hypot(e.clientX - state.startX, e.clientY - state.startY) < 4) return; + const trackElement = document.querySelector('[data-timeline-reorder-track="caption"]'); + const width = Math.max(1, trackElement?.getBoundingClientRect().width || 1); + const delta = ((e.clientX - state.startX) / width) * d.timelineDuration; + const previewStart = Math.max(0, Math.min(d.timelineDuration - duration, state.originalStart + delta)); + const previewEnd = previewStart + duration; + const previewSegments = moveTimedCaptionSegment(materializedCaptions, segmentId, previewStart, previewEnd); + const next = { ...state, previewStart, previewEnd, previewSegments, dragging: true }; + d.timelineClipDragRef.current = next; d.setTimelineClipDrag(next); + }; + const up = () => { + removeEventListener("pointermove", move); removeEventListener("pointerup", up); + const state = d.timelineClipDragRef.current; d.timelineClipDragRef.current = null; d.setTimelineClipDrag(null); + if (!state?.dragging) return; + d.suppressTimelineClipClickRef.current = segmentId; + setTimeout(() => { if (d.suppressTimelineClipClickRef.current === segmentId) d.suppressTimelineClipClickRef.current = ""; }, 120); + d.commitCaptionSegments(state.previewSegments, "已移动字幕片段", index); d.seekTo(state.previewStart); + }; + addEventListener("pointermove", move); addEventListener("pointerup", up, { once: true }); + return; + } const count = track === "image" ? d.renderedVisualSegments.length : d.captionSegments.length; if (count < 2) return; event.preventDefault(); event.stopPropagation(); diff --git a/src/lib/timelineViewModel.js b/src/lib/timelineViewModel.js index 18ebaef..db292b7 100644 --- a/src/lib/timelineViewModel.js +++ b/src/lib/timelineViewModel.js @@ -38,11 +38,13 @@ export function createTimelineViewModel(d) { : renderedVisualSegments; const renderedVisualTimeline = getVisualSegmentTimeline(displayedVisualSegments); const displayedCaptionSegments = activeTimelineClipDrag?.track === "caption" - ? reorderTimelineItems( - d.captionSegments, - activeTimelineClipDrag.fromIndex, - activeTimelineClipDrag.overIndex, - ) + ? activeTimelineClipDrag.mode === "move" + ? activeTimelineClipDrag.previewSegments + : reorderTimelineItems( + d.captionSegments, + activeTimelineClipDrag.fromIndex, + activeTimelineClipDrag.overIndex, + ) : d.captionSegments; const displayedCaptionTimeline = activeTimelineClipDrag?.track === "caption" ? getCaptionTimeline(displayedCaptionSegments, d.captionTargetDuration) @@ -65,6 +67,9 @@ export function createTimelineViewModel(d) { const musicClipPercent = d.musicBlob && d.timelineDuration > 0 ? Math.max(0.01, Math.min(100, (d.musicDuration / d.timelineDuration) * 100)) : 0; + const musicStartPercent = d.musicBlob && d.timelineDuration > 0 + ? Math.max(0, Math.min(100, (d.musicStart / d.timelineDuration) * 100)) + : 0; const exportPercent = Math.max(0, Math.min(100, Math.round(d.exportProgress))); const previewFrameStyle = d.previewFrameSize.width > 0 && d.previewFrameSize.height > 0 ? { @@ -77,7 +82,7 @@ export function createTimelineViewModel(d) { return { activeTimelineClipDrag, audioClipPercent, displayedCaptionSegments, displayedCaptionTimeline, displayedVisualSegments, exportPercent, musicClipPercent, - playheadPercent, previewFrameStyle, previewRatio, progressPercent, + musicStartPercent, playheadPercent, previewFrameStyle, previewRatio, progressPercent, renderedVisualSegments, renderedVisualTimeline, showStickerTrack, sourceAudioClipPercent, sourceAudioStartPercent, }; diff --git a/src/styles.css b/src/styles.css index f4839b1..3593a1b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3818,6 +3818,27 @@ button:disabled { 0 0 0 1px rgba(53, 234, 217, 0.24); } +.timeline-context-menu { + position: fixed; + z-index: 180; + display: grid; + width: 224px; + max-width: calc(100vw - 20px); + padding: 7px; + border: 1px solid rgba(104, 132, 139, .4); + border-radius: 11px; + background: rgba(15, 20, 26, .98); + box-shadow: 0 18px 50px rgba(0, 0, 0, .48); + transform: translateY(-4px); +} +.timeline-context-heading { display: flex; justify-content: space-between; padding: 7px 9px 8px; color: #e9f2f4; font-size: 12px; font-weight: 750; } +.timeline-context-heading span { color: #71818a; font-size: 10px; font-weight: 650; text-transform: uppercase; } +.timeline-context-menu button { display: flex; align-items: center; gap: 9px; min-height: 34px; padding: 7px 9px; border: 0; border-radius: 7px; color: #cbd7da; text-align: left; background: transparent; cursor: pointer; } +.timeline-context-menu button:hover { color: #f2ffff; background: rgba(53, 234, 217, .1); } +.timeline-context-menu button.is-danger { color: #ff9a9a; } +.timeline-context-menu button.is-danger:hover { background: rgba(255, 90, 90, .1); } +.timeline-context-divider { height: 1px; margin: 5px 4px; background: rgba(255,255,255,.08); } + .timeline-drag-ghost { position: fixed; z-index: 220;