import { projectContentEndUs, type ProjectCommand, type ProjectDocument, } from "@web-video-editor/domain"; import { useEffect, useRef, useState, type CSSProperties, type DragEvent, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { MIN_CLIP_DURATION_US, clampClipSourceEnd, clampClipTrimStart, clampClipMove, clampTextMove, clampTextTrimEnd, clampTextTrimStart, clipDurationUs, type ClipTrimLimit, type ClipTrimResult, pixelsToTimeUs, projectDurationUs, type TextTrimLimit, type TextTrimResult, timeUsToPixels, } from "./timelineMath"; import "./Timeline.css"; type TimelineEdit = Extract< ProjectCommand, { type: "clip.move" | "clip.trim" | "text.update" } >; export type TimelineProps = { canRedo: boolean; canUndo: boolean; onAddAudioTrack(): void; onAddTextTrack?(): void; onAddTitle(): void; onAddVideoTrack(): void; onDelete(): void; onDeleteTrack(trackId: string, cascade: boolean): void; onEdit(edit: TimelineEdit, transactionId: string): void; onPlayheadChange(playheadUs: number): void; onRedo(): void; onReorderTracks(trackIds: string[]): void; onSelectClip(clipId: string): void; onSelectTargetAudioTrack(trackId: string): void; onSelectTargetTextTrack?(trackId: string): void; onSelectTargetVideoTrack(trackId: string): void; onSelectText(textId: string): void; onTimelineDurationChange(durationUs: number): void; onSplit(): void; onUndo(): void; playheadUs: number; project: ProjectDocument; selectedClipId: string | null; selectedTextId: string | null; targetAudioTrackId: string | null; targetTextTrackId?: string | null; targetVideoTrackId: string | null; }; type ClipDragState = { clipId: string; initial: ProjectDocument["clips"][number]; item: "clip"; mode: "move" | "trim-end" | "trim-start"; pixelsPerSecond: number; pointerId: number; startClientX: number; transactionId: string; }; type TextDragState = { initial: ProjectDocument["texts"][number]; item: "text"; mode: "move" | "trim-end" | "trim-start"; pixelsPerSecond: number; pointerId: number; startClientX: number; transactionId: string; }; type DragState = ClipDragState | TextDragState; type TrackDragState = { trackId: string; }; const TIMELINE_DURATION_PRESETS_US = [ { label: "1min", value: 60_000_000 }, { label: "3min", value: 180_000_000 }, { label: "5min", value: 300_000_000 }, { label: "10min", value: 600_000_000 }, ] as const; const MIN_TIMELINE_PIXELS_PER_SECOND = 20; const MAX_TIMELINE_PIXELS_PER_SECOND = 240; const RULER_MAJOR_TICK_TARGET_PX = 96; const RULER_MAX_MAJOR_TICKS = 120; const RULER_MINOR_TICKS_PER_MAJOR = 4; const RULER_MAJOR_STEP_SECONDS = [ 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, ] as const; type RulerTick = { kind: "major" | "minor"; label?: string; timeUs: number; }; function clampPixelsPerSecond(pixelsPerSecond: number): number { return Math.max( MIN_TIMELINE_PIXELS_PER_SECOND, Math.min(MAX_TIMELINE_PIXELS_PER_SECOND, pixelsPerSecond), ); } function formatTime(timeUs: number): string { const totalSeconds = Math.max(0, Math.round(timeUs / 100_000) / 10); const minutes = Math.floor(totalSeconds / 60); const seconds = (totalSeconds % 60).toFixed(1).padStart(4, "0"); return `${minutes}:${seconds}`; } function formatRulerTime(timeUs: number): string { return timeUs === 0 ? "00:00" : formatTime(timeUs); } function rulerMajorStepUs(durationUs: number, pixelsPerSecond: number): number { const durationSeconds = durationUs / 1_000_000; const stepSeconds = RULER_MAJOR_STEP_SECONDS.find( (step) => step * pixelsPerSecond >= RULER_MAJOR_TICK_TARGET_PX && durationSeconds / step <= RULER_MAX_MAJOR_TICKS, ) ?? 600; return Math.round(stepSeconds * 1_000_000); } function buildRulerTicks( durationUs: number, pixelsPerSecond: number, ): RulerTick[] { const majorStepUs = rulerMajorStepUs(durationUs, pixelsPerSecond); const minorStepUs = Math.max( 1, Math.round(majorStepUs / RULER_MINOR_TICKS_PER_MAJOR), ); const majorTimes = new Set(); for (let timeUs = 0; timeUs <= durationUs; timeUs += majorStepUs) { majorTimes.add(timeUs); } majorTimes.add(durationUs); const ticks: RulerTick[] = []; const tickCount = Math.ceil(durationUs / minorStepUs); for (let index = 0; index <= tickCount; index += 1) { const timeUs = Math.min(durationUs, index * minorStepUs); if (majorTimes.has(timeUs)) { ticks.push({ kind: "major", label: formatRulerTime(timeUs), timeUs, }); } else { ticks.push({ kind: "minor", timeUs }); } } if (!ticks.some((tick) => tick.timeUs === durationUs)) { ticks.push({ kind: "major", label: formatRulerTime(durationUs), timeUs: durationUs, }); } return ticks.sort((left, right) => left.timeUs - right.timeUs); } function clampPlayheadUs(playheadUs: number, durationUs: number): number { return Math.max(0, Math.min(durationUs, Math.round(playheadUs))); } function durationInputValue(durationUs: number): string { return String(Math.round(durationUs / 1_000) / 1_000); } function trimLimitNotice(limit: ClipTrimLimit): string | undefined { switch (limit) { case "minimum-duration": return `片段已达到最小时长 ${durationInputValue(MIN_CLIP_DURATION_US)} 秒。`; case "source-boundary": return "裁剪已受素材边界限制。"; case "timeline-boundary": return "裁剪已受时间线起点限制。"; case "track-conflict": return "裁剪已受同轨相邻片段限制,不能产生重叠。"; case "none": return undefined; } } function textTrimLimitNotice(limit: TextTrimLimit): string | undefined { switch (limit) { case "minimum-duration": return "文字片段已达到最小时长。"; case "timeline-boundary": return "文字片段已受时间线起点限制。"; case "track-conflict": return "文字片段已受同轨相邻文字限制,不能产生重叠。"; case "none": return undefined; } } function clipTrimChanged( clip: ProjectDocument["clips"][number], trim: ClipTrimResult, ): boolean { return ( clip.timelineStartUs !== trim.timelineStartUs || clip.sourceStartUs !== trim.sourceStartUs || clip.sourceEndUs !== trim.sourceEndUs ); } function textTrimChanged( text: ProjectDocument["texts"][number], trim: TextTrimResult, ): boolean { return text.startUs !== trim.startUs || text.endUs !== trim.endUs; } export function Timeline({ canRedo, canUndo, onAddAudioTrack, onAddTextTrack, onAddTitle, onAddVideoTrack, onDelete, onDeleteTrack, onEdit, onPlayheadChange, onRedo, onReorderTracks, onSelectClip, onSelectTargetAudioTrack, onSelectTargetTextTrack, onSelectTargetVideoTrack, onSelectText, onTimelineDurationChange, onSplit, onUndo, playheadUs, project, selectedClipId, selectedTextId, targetAudioTrackId, targetTextTrackId, targetVideoTrackId, }: TimelineProps) { const dragRef = useRef(undefined); const rulerDragPointerIdRef = useRef(undefined); const trackDragRef = useRef(undefined); const durationUs = projectDurationUs(project); const contentEndUs = projectContentEndUs(project); const [dragNotice, setDragNotice] = useState(); const [durationInputSeconds, setDurationInputSeconds] = useState(() => durationInputValue(durationUs), ); const [durationNotice, setDurationNotice] = useState(); const [pixelsPerSecond, setPixelsPerSecond] = useState(() => clampPixelsPerSecond(project.timeline.defaultScale.pixelsPerSecond), ); useEffect(() => { setDurationInputSeconds(durationInputValue(durationUs)); }, [durationUs]); const timelineWidthPx = Math.max( 1, timeUsToPixels(durationUs, pixelsPerSecond), ); const clampedPlayheadUs = clampPlayheadUs(playheadUs, durationUs); const playheadGuideX = timeUsToPixels(clampedPlayheadUs, pixelsPerSecond); const rulerTicks = buildRulerTicks(durationUs, pixelsPerSecond); const timelineStyle = { "--timeline-content-width": `${Math.ceil(timelineWidthPx)}px`, } as CSSProperties; const assetById = new Map(project.assets.map((asset) => [asset.id, asset])); const orderedTracks = [...project.tracks].sort( (left, right) => left.order - right.order, ); const trackById = new Map(project.tracks.map((track) => [track.id, track])); const videoTracks = orderedTracks.filter((track) => track.kind === "video"); const audioTracks = orderedTracks.filter((track) => track.kind === "audio"); const textTracks = orderedTracks.filter((track) => track.kind === "text"); const clipsByTrack = new Map(); for (const clip of project.clips) { const clips = clipsByTrack.get(clip.trackId) ?? []; clips.push(clip); clipsByTrack.set(clip.trackId, clips); } const textsByTrack = new Map(); for (const text of project.texts) { const texts = textsByTrack.get(text.trackId) ?? []; texts.push(text); textsByTrack.set(text.trackId, texts); } const selectMediaClip = (clip: ProjectDocument["clips"][number]) => { const track = trackById.get(clip.trackId); if (track?.kind === "audio") { onSelectTargetAudioTrack(clip.trackId); } else if (track?.kind === "video") { onSelectTargetVideoTrack(clip.trackId); } onSelectClip(clip.id); }; const trackIndex = ( track: ProjectDocument["tracks"][number], tracks: ProjectDocument["tracks"], ) => tracks.findIndex((candidate) => candidate.id === track.id) + 1; const testIdForTrack = (track: ProjectDocument["tracks"][number]) => track.kind === "video" ? `video-track-${track.id}` : track.id === `${track.kind}-track` ? track.id : `${track.kind}-track-${track.id}`; const orderedClipsForTrack = (trackId: string) => [...(clipsByTrack.get(trackId) ?? [])].sort( (left, right) => left.timelineStartUs - right.timelineStartUs, ); const orderedTextsForTrack = (trackId: string) => [...(textsByTrack.get(trackId) ?? [])].sort( (left, right) => left.startUs - right.startUs, ); const trackAtPointer = (clientY: number) => { if (!Number.isFinite(clientY)) { return undefined; } const lanes = document.querySelectorAll( ".timeline__lane[data-track-id]", ); for (const lane of lanes) { const rect = lane.getBoundingClientRect(); if (clientY >= rect.top && clientY <= rect.bottom) { return trackById.get(lane.dataset.trackId ?? ""); } } return undefined; }; const updateDragNotice = (notice: string | undefined) => { setDragNotice((current) => (current === notice ? current : notice)); }; const trackKindLabel = (kind: ProjectDocument["tracks"][number]["kind"]) => kind === "video" ? "视频" : kind === "audio" ? "音频" : "文字"; const trackLabel = ( track: ProjectDocument["tracks"][number], index: number, ) => { const prefix = track.kind === "video" ? "V" : track.kind === "audio" ? "A" : "T"; return `${prefix}${index} ${track.name}`; }; const reorderedTrackIds = (sourceTrackId: string, targetTrackId: string) => { const nextTrackIds = orderedTracks.map((track) => track.id); const sourceIndex = nextTrackIds.indexOf(sourceTrackId); const targetIndex = nextTrackIds.indexOf(targetTrackId); if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { return nextTrackIds; } const [movedTrackId] = nextTrackIds.splice(sourceIndex, 1); if (!movedTrackId) { return nextTrackIds; } nextTrackIds.splice(targetIndex, 0, movedTrackId); return nextTrackIds; }; const beginTrackDrag = ( event: DragEvent, track: ProjectDocument["tracks"][number], ) => { trackDragRef.current = { trackId: track.id }; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", track.id); }; const dragOverTrack = ( event: DragEvent, track: ProjectDocument["tracks"][number], ) => { const sourceTrackId = trackDragRef.current?.trackId; if (sourceTrackId && sourceTrackId !== track.id) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }; const dropTrack = ( event: DragEvent, track: ProjectDocument["tracks"][number], ) => { event.preventDefault(); const sourceTrackId = trackDragRef.current?.trackId || event.dataTransfer.getData("text/plain"); trackDragRef.current = undefined; if (!sourceTrackId || sourceTrackId === track.id) { return; } const nextTrackIds = reorderedTrackIds(sourceTrackId, track.id); if ( nextTrackIds.join("\0") !== orderedTracks.map((item) => item.id).join("\0") ) { onReorderTracks(nextTrackIds); } }; const deleteTrack = (track: ProjectDocument["tracks"][number]) => { const sameKindTrackCount = project.tracks.filter( (candidate) => candidate.kind === track.kind, ).length; if (sameKindTrackCount <= 1) { onDeleteTrack(track.id, false); return; } const contentCount = (clipsByTrack.get(track.id)?.length ?? 0) + (textsByTrack.get(track.id)?.length ?? 0); if ( contentCount > 0 && !window.confirm( `轨道 "${track.name}" 包含 ${contentCount} 个内容,删除后会一并移除。确认删除?`, ) ) { return; } onDeleteTrack(track.id, contentCount > 0); }; const renderTrackHeader = ( track: ProjectDocument["tracks"][number], label: string, content: ReactNode, ) => (
{ trackDragRef.current = undefined; }} onDragOver={(event) => dragOverTrack(event, track)} onDragStart={(event) => beginTrackDrag(event, track)} onDrop={(event) => dropTrack(event, track)} > {content}
); const requestTimelineDuration = (requestedDurationUs: number) => { const roundedDurationUs = Math.max(0, Math.round(requestedDurationUs)); onTimelineDurationChange(roundedDurationUs); if (roundedDurationUs < contentEndUs) { setDurationNotice( `内容末尾为 ${formatTime(contentEndUs)},实际总长已保留到内容末尾。`, ); return; } setDurationNotice(`时间线总长已设置为 ${formatTime(roundedDurationUs)}。`); }; const applyDurationInput = () => { const seconds = Number(durationInputSeconds); if (!Number.isFinite(seconds) || seconds < 0) { setDurationNotice("请输入非负秒数。"); return; } requestTimelineDuration(seconds * 1_000_000); }; const playheadUsFromRulerPointer = ( event: ReactPointerEvent, ) => { const rect = event.currentTarget.getBoundingClientRect(); const x = Math.max(0, event.clientX - rect.left); return clampPlayheadUs(pixelsToTimeUs(x, pixelsPerSecond), durationUs); }; const beginRulerDrag = (event: ReactPointerEvent) => { event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); rulerDragPointerIdRef.current = event.pointerId; onPlayheadChange(playheadUsFromRulerPointer(event)); }; const continueRulerDrag = (event: ReactPointerEvent) => { if (rulerDragPointerIdRef.current !== event.pointerId) { return; } onPlayheadChange(playheadUsFromRulerPointer(event)); }; const endRulerDrag = (event: ReactPointerEvent) => { if (rulerDragPointerIdRef.current === event.pointerId) { rulerDragPointerIdRef.current = undefined; } }; const beginDrag = ( event: ReactPointerEvent, clip: ProjectDocument["clips"][number], mode: DragState["mode"], ) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { clipId: clip.id, initial: { ...clip, effects: clip.effects.map((effect) => ({ ...effect })), }, item: "clip", mode, pixelsPerSecond, pointerId: event.pointerId, startClientX: event.clientX, transactionId: `drag-${clip.id}-${crypto.randomUUID()}`, }; selectMediaClip(clip); }; const beginTextDrag = ( event: ReactPointerEvent, text: ProjectDocument["texts"][number], mode: TextDragState["mode"], ) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { initial: { ...text }, item: "text", mode, pixelsPerSecond, pointerId: event.pointerId, startClientX: event.clientX, transactionId: `drag-${text.id}-${crypto.randomUUID()}`, }; onSelectTargetTextTrack?.(text.trackId); onSelectText(text.id); }; const continueDrag = (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) { return; } const deltaUs = pixelsToTimeUs( event.clientX - drag.startClientX, drag.pixelsPerSecond, ); if (drag.item === "text") { const text = drag.initial; if (drag.mode === "trim-start") { const trim = clampTextTrimStart(project, text, text.startUs + deltaUs); updateDragNotice(textTrimLimitNotice(trim.limit)); const currentText = project.texts.find((candidate) => candidate.id === text.id) ?? text; if (!textTrimChanged(currentText, trim)) { return; } onEdit( { patch: { endUs: trim.endUs, startUs: trim.startUs, }, textId: text.id, type: "text.update", }, drag.transactionId, ); return; } if (drag.mode === "trim-end") { const trim = clampTextTrimEnd(project, text, text.endUs + deltaUs); updateDragNotice(textTrimLimitNotice(trim.limit)); const currentText = project.texts.find((candidate) => candidate.id === text.id) ?? text; if (!textTrimChanged(currentText, trim)) { return; } onEdit( { patch: { endUs: trim.endUs, startUs: trim.startUs, }, textId: text.id, type: "text.update", }, drag.transactionId, ); return; } const sourceTrack = trackById.get(text.trackId); const targetTrack = trackAtPointer(event.clientY) ?? sourceTrack; if (!targetTrack || targetTrack.kind !== "text") { updateDragNotice("文字片段只能移动到文字轨。"); return; } const proposedStartUs = text.startUs + deltaUs; const timelineStartUs = clampTextMove( project, text.id, proposedStartUs, targetTrack.id, ); const textDurationUs = text.endUs - text.startUs; updateDragNotice( timelineStartUs === Math.max(0, Math.round(proposedStartUs)) ? undefined : `目标文字轨存在时间冲突,已夹紧到 ${formatTime(timelineStartUs)}。`, ); onEdit( { patch: { endUs: timelineStartUs + textDurationUs, startUs: timelineStartUs, trackId: targetTrack.id, }, textId: text.id, type: "text.update", }, drag.transactionId, ); return; } const clip = drag.initial; const asset = assetById.get(clip.assetId); if (!asset) { return; } if (drag.mode === "move") { const sourceTrack = trackById.get(clip.trackId); const targetTrack = trackAtPointer(event.clientY) ?? sourceTrack; if ( !sourceTrack || !targetTrack || targetTrack.kind !== sourceTrack.kind || targetTrack.kind === "text" ) { updateDragNotice( sourceTrack && targetTrack ? `不能将${trackKindLabel(sourceTrack.kind)}片段移动到${trackKindLabel(targetTrack.kind)}轨。` : "未命中可移动的目标轨道。", ); return; } const proposedStartUs = clip.timelineStartUs + deltaUs; const timelineStartUs = clampClipMove( project, clip.id, proposedStartUs, targetTrack.id, ); updateDragNotice( timelineStartUs === Math.max(0, Math.round(proposedStartUs)) ? undefined : `目标轨道存在时间冲突,已夹紧到 ${formatTime(timelineStartUs)}。`, ); onEdit( { clipId: clip.id, timelineStartUs, trackId: targetTrack.id, type: "clip.move", }, drag.transactionId, ); return; } if (drag.mode === "trim-start") { const trim = clampClipTrimStart(project, clip, deltaUs); updateDragNotice(trimLimitNotice(trim.limit)); const currentClip = project.clips.find((candidate) => candidate.id === clip.id) ?? clip; if (!clipTrimChanged(currentClip, trim)) { return; } onEdit( { clipId: clip.id, sourceEndUs: trim.sourceEndUs, sourceStartUs: trim.sourceStartUs, timelineStartUs: trim.timelineStartUs, type: "clip.trim", }, drag.transactionId, ); return; } const trim = clampClipSourceEnd( project, clip, asset.durationUs, clip.sourceEndUs + deltaUs, ); updateDragNotice(trimLimitNotice(trim.limit)); const currentClip = project.clips.find((candidate) => candidate.id === clip.id) ?? clip; if (!clipTrimChanged(currentClip, trim)) { return; } onEdit( { clipId: clip.id, sourceEndUs: trim.sourceEndUs, sourceStartUs: trim.sourceStartUs, timelineStartUs: trim.timelineStartUs, type: "clip.trim", }, drag.transactionId, ); }; const endDrag = (event: ReactPointerEvent) => { if (dragRef.current?.pointerId === event.pointerId) { dragRef.current = undefined; setDragNotice(undefined); } }; const clipStyle = (startUs: number, endUs: number): CSSProperties => ({ left: `${timeUsToPixels(startUs, pixelsPerSecond)}px`, width: `${Math.max(8, timeUsToPixels(endUs - startUs, pixelsPerSecond))}px`, }); const tickStyle = (timeUs: number): CSSProperties => ({ left: `${timeUsToPixels(timeUs, pixelsPerSecond)}px`, }); const renderTrackLane = (track: ProjectDocument["tracks"][number]) => { if (track.kind === "text") { return (
{orderedTextsForTrack(track.id).map((text) => (
{ onSelectTargetTextTrack?.(text.trackId); onSelectText(text.id); }} onPointerDown={(event) => beginTextDrag(event, text, "move")} onPointerMove={continueDrag} onPointerUp={endDrag} role="button" style={clipStyle(text.startUs, text.endUs)} tabIndex={0} >
))}
); } const isVideo = track.kind === "video"; const clipKindLabel = isVideo ? "视频" : "音频"; return (
{orderedClipsForTrack(track.id).map((clip) => { const asset = assetById.get(clip.assetId); return (
selectMediaClip(clip)} onPointerDown={(event) => beginDrag(event, clip, "move")} onPointerMove={continueDrag} onPointerUp={endDrag} role="button" style={clipStyle( clip.timelineStartUs, clip.timelineStartUs + clipDurationUs(clip), )} tabIndex={0} >
); })}
); }; return (
时间线 {formatTime(playheadUs)}
{TIMELINE_DURATION_PRESETS_US.map((preset) => ( ))}
{dragNotice || durationNotice ? (

{dragNotice ?? durationNotice}

) : null}
{rulerTicks.map((tick) => ( {tick.label ? ( {tick.label} ) : null} ))}
); }