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;