From 1202b77b02605b4c3be377e77351f031aade6942 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Thu, 25 Jun 2026 22:36:48 +0800 Subject: [PATCH 1/7] feat(#94): clipboard copy/cut/paste (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(timeline): copy / cut / paste clips (⌘C / ⌘X / ⌘V) (#94) Adds the standard clipboard shortcuts that were completely missing. Only ⌘C/⌘X/⌘V were absent — the unmodified C/V already switch tools (razor / pointer), and the mod-prefixed branch had no handlers. Frontend only: - clipboardStore: new Zustand store holding deep snapshots of the selected clips plus the source first-frame, so a paste can re-place the group relative to the current playhead. UI-only, never persisted. - editActions: copyClips / cutClips / pasteClipsAtPlayhead. - copy: snapshot selected clips + their track index + min startFrame. - cut: copy then deleteSelectedClips. - paste: offset each clip's startFrame by `activeFrame - sourceFirstFrame`, clear addLinkedAudio so the paste stands alone (mirrors upstream `pasteClipsAtPlayhead` link re-reflection), and select the new clips. Clips whose source track no longer exists are skipped. - useKeyboardShortcuts: wire ⌘C / ⌘X / ⌘V inside the existing `if (mod)` block — no conflict with the unmodified C/V tool switches. - i18n: 4 new keys (zh-CN + en) for copy / cut / paste / clipboardEmpty. Closes #94. * fix(#94): rebase onto main + linkGroup re-mapping + empty-clipboard toast Address review feedback on PR #105: 1. Rebased onto latest main (resolved import conflict: kept both trimToPlayheadEdits and useClipboardStore). 2. copyClips now expands link groups: if a selected clip has a linkGroupId, all linked companions are included in the clipboard (mirrors upstream copyClips), so a paste reproduces the video+audio pair. 3. pasteClipsAtPlayhead now re-establishes link groups after addClips: clips that shared a linkGroupId in the clipboard are re-linked via linkClips, preserving video+audio linkage. 4. Empty-clipboard paste now shows a toast (edit.clipboardEmpty) instead of silently doing nothing. Added toast mechanism to uiStore + Toast component in App.tsx. --------- Co-authored-by: baiqing --- web/src/App.tsx | 33 ++++++ .../preview/TimelinePlaybackLayer.tsx | 2 +- web/src/hooks/useKeyboardShortcuts.ts | 18 +++ web/src/i18n/dict.ts | 12 ++ web/src/store/clipboardStore.ts | 40 +++++++ web/src/store/editActions.ts | 110 +++++++++++++++++- web/src/store/uiStore.ts | 9 ++ 7 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 web/src/store/clipboardStore.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index cbc8f69..a6990cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,38 @@ import { initI18n } from "./i18n"; import { initTheme } from "./store/settingsStore"; import { onGoHome } from "./lib/api"; +function Toast() { + const toast = useEditorUiStore((s) => s.toast); + const clearToast = useEditorUiStore((s) => s.clearToast); + useEffect(() => { + if (!toast) return; + const timer = setTimeout(clearToast, 2000); + return () => clearTimeout(timer); + }, [toast, clearToast]); + if (!toast) return null; + return ( +
+ {toast.message} +
+ ); +} + export default function App() { // Editor-only hooks are safe to keep mounted across views: they only act on // editor state/events and the keyboard handler is a no-op until the editor is @@ -65,6 +97,7 @@ export default function App() {
+ ); } diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index bcf9d86..2bd01e6 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -96,7 +96,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin if (!playing) { // Paused: keep elements in DOM but silence the clock and media. mediaClock.release(); - + for (const el of els.current.values()) el.pause(); return; } diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 6a5b3b8..f15a765 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -8,6 +8,8 @@ import { useEffect } from "react"; import { useEditorUiStore } from "../store/uiStore"; import { useProjectStore } from "../store/projectStore"; +import { useClipboardStore } from "../store/clipboardStore"; +import { t } from "../i18n"; import * as edit from "../store/editActions"; import { saveCurrentProject } from "../store/projectActions"; import { ZOOM } from "../lib/theme"; @@ -102,6 +104,22 @@ export function useKeyboardShortcuts() { return; } return; + case "KeyC": + e.preventDefault(); + edit.copyClips(); + return; + case "KeyX": + e.preventDefault(); + void edit.cutClips(); + return; + case "KeyV": + e.preventDefault(); + if (!useClipboardStore.getState().hasContent) { + useEditorUiStore.getState().pushToast(t("edit.clipboardEmpty")); + return; + } + void edit.pasteClipsAtPlayhead(); + return; } return; } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7aac2fa..b26da6a 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -197,6 +197,12 @@ const zh: Dict = { "common.cancel": "取消", "common.open": "打开", + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "复制 (⌘C)", + "edit.cut": "剪切 (⌘X)", + "edit.paste": "粘贴 (⌘V)", + "edit.clipboardEmpty": "剪贴板为空", + // Global asset library (#56) "library.title": "素材库", "library.entry": "素材库", @@ -389,6 +395,12 @@ const en: Dict = { "common.cancel": "Cancel", "common.open": "Open", + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "Copy (⌘C)", + "edit.cut": "Cut (⌘X)", + "edit.paste": "Paste (⌘V)", + "edit.clipboardEmpty": "Clipboard is empty", + // Global asset library (#56) "library.title": "Library", "library.entry": "Library", diff --git a/web/src/store/clipboardStore.ts b/web/src/store/clipboardStore.ts new file mode 100644 index 0000000..427f705 --- /dev/null +++ b/web/src/store/clipboardStore.ts @@ -0,0 +1,40 @@ +/** + * Front-end clipboard store for copy/cut/paste (Issue #94). Holds snapshots of + * the selected clips at copy time plus the source first-frame, so a paste can + * re-place them relative to the current playhead without touching the original + * clips. `linkGroupId` is cleared on paste so the backend re-assigns new + * groups (mirrors upstream `pasteClipsAtPlayhead` link re-reflection). + * + * The store is UI-only: the authoritative timeline lives in Rust; this is just + * a transient paste buffer, never persisted. + */ + +import { create } from "zustand"; +import type { Clip } from "../lib/types"; + +export interface ClipboardEntry { + /** Deep snapshot of the clip at copy time. */ + clip: Clip; + /** Track index the clip lived on when copied. Used to preserve track + * placement on paste (upstream behavior). */ + sourceTrackIndex: number; +} + +interface ClipboardState { + entries: ClipboardEntry[]; + /** The smallest `startFrame` among copied clips. Paste offsets every clip + * by `activeFrame - sourceFirstFrame` so the group lands at the playhead. */ + sourceFirstFrame: number; + hasContent: boolean; + set: (entries: ClipboardEntry[], sourceFirstFrame: number) => void; + clear: () => void; +} + +export const useClipboardStore = create((set) => ({ + entries: [], + sourceFirstFrame: 0, + hasContent: false, + set: (entries, sourceFirstFrame) => + set({ entries, sourceFirstFrame, hasContent: entries.length > 0 }), + clear: () => set({ entries: [], sourceFirstFrame: 0, hasContent: false }), +})); diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7885792..a27f690 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -10,6 +10,7 @@ import { forceRefresh } from "./sync"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { trimToPlayheadEdits } from "../lib/clip"; +import { useClipboardStore } from "./clipboardStore"; import type { Clip, ClipEntryReq, @@ -37,7 +38,7 @@ async function applyAndRefresh(cmd: Parameters[0]) { export async function addClips(entries: ClipEntryReq[]) { if (entries.length === 0) return; - await applyAndRefresh({ type: "addClips", entries }); + return applyAndRefresh({ type: "addClips", entries }); } export async function moveClips(moves: ClipMoveReq[]) { @@ -389,3 +390,110 @@ export async function addTextClip() { ui.selectClips(new Set(res.affectedClipIds)); } } + +// MARK: - Clipboard (copy / cut / paste, Issue #94) +// +// Front-end paste buffer: copy snapshots the selected clips; paste re-places +// them at the playhead with a fresh `linkGroupId` (cleared so the backend +// re-assigns, mirroring upstream `pasteClipsAtPlayhead` link re-reflection). +// Track placement is preserved (clip stays on its original track index); if +// the target track no longer exists the clip is skipped. + +/** Collect selected clips with their track index into the clipboard store. + * If any selected clip belongs to a link group, the entire group is copied + * (mirrors upstream `copyClips` which expands the selection to include + * linked companions, so a paste reproduces the video+audio pair). */ +export function copyClips() { + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const ids = ui.selectedClipIds; + if (ids.size === 0) return; + // Expand selection to include linked companions. + const expanded = new Set(ids); + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (ids.has(clip.id) && clip.linkGroupId) { + for (let tj = 0; tj < tl.tracks.length; tj++) { + for (const c2 of tl.tracks[tj].clips) { + if (c2.linkGroupId === clip.linkGroupId) expanded.add(c2.id); + } + } + } + } + } + const entries: { clip: Clip; sourceTrackIndex: number }[] = []; + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (expanded.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); + } + } + if (entries.length === 0) return; + const sourceFirstFrame = entries.reduce( + (min, e) => Math.min(min, e.clip.startFrame), + Number.POSITIVE_INFINITY, + ); + useClipboardStore.getState().set(entries, sourceFirstFrame); +} + +/** Copy then delete — the standard cut semantics. */ +export async function cutClips() { + copyClips(); + await deleteSelectedClips(); +} + +/** Paste clipboard entries at the current playhead. Each clip's `startFrame` + * is offset by `activeFrame - sourceFirstFrame`. After the clips are created, + * link groups are re-established: clips that shared a `linkGroupId` in the + * clipboard are re-linked via `linkClips` so the paste preserves video+audio + * linkage. Clips whose source track no longer exists are silently skipped + * (upstream drops them too). */ +export async function pasteClipsAtPlayhead() { + const cb = useClipboardStore.getState(); + if (!cb.hasContent || cb.entries.length === 0) return; + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const offset = ui.activeFrame - cb.sourceFirstFrame; + const entries: ClipEntryReq[] = []; + const sourceLinkGroups: (string | undefined)[] = []; + for (const e of cb.entries) { + if (e.sourceTrackIndex >= tl.tracks.length) continue; + const startFrame = Math.max(0, e.clip.startFrame + offset); + entries.push({ + mediaRef: e.clip.mediaRef, + mediaType: e.clip.mediaType, + sourceClipType: e.clip.sourceClipType, + trackIndex: e.sourceTrackIndex, + startFrame, + durationFrames: e.clip.durationFrames, + trimStartFrame: e.clip.trimStartFrame, + trimEndFrame: e.clip.trimEndFrame, + hasAudio: e.clip.mediaType === "audio" || e.clip.mediaType === "video", + // Don't auto-create a linked audio: the linked audio clip is already in + // the clipboard (copyClips expands link groups) and will be pasted as + // its own entry; addLinkedAudio=true would create a duplicate. + addLinkedAudio: false, + }); + sourceLinkGroups.push(e.clip.linkGroupId); + } + if (entries.length === 0) return; + const res = await addClips(entries); + if (!res || res.affectedClipIds.length === 0) return; + + // Re-establish link groups: map each old linkGroupId to the set of newly + // created clip ids, then call linkClips for each group. + const newGroupMap = new Map(); + for (let i = 0; i < res.affectedClipIds.length && i < sourceLinkGroups.length; i++) { + const oldGroup = sourceLinkGroups[i]; + if (!oldGroup) continue; + const newId = res.affectedClipIds[i]; + const arr = newGroupMap.get(oldGroup); + if (arr) arr.push(newId); + else newGroupMap.set(oldGroup, [newId]); + } + for (const ids of newGroupMap.values()) { + if (ids.length >= 2) await linkClips(ids); + } + + // Select the freshly pasted clips so the user can immediately move/trim them. + ui.selectClips(new Set(res.affectedClipIds)); +} diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 768bfb5..99e5715 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -143,6 +143,11 @@ interface UiState { setMediaTab: (tab: MediaTabId) => void; setMediaSubTab: (tab: MediaSubTabId) => void; setInspectorTab: (tab: InspectorTabId) => void; + + // Toast (transient message) + toast: { message: string; id: number } | null; + pushToast: (message: string) => void; + clearToast: () => void; } export const useEditorUiStore = create((set, get) => ({ @@ -272,4 +277,8 @@ export const useEditorUiStore = create((set, get) => ({ setMediaTab: (mediaTab) => set({ mediaTab }), setMediaSubTab: (mediaSubTab) => set({ mediaSubTab }), setInspectorTab: (inspectorTab) => set({ inspectorTab }), + + toast: null, + pushToast: (message) => set({ toast: { message, id: Date.now() } }), + clearToast: () => set({ toast: null }), })); From dd726dad31dc1f5fc67d8f7902665d1bcbd22556 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Thu, 25 Jun 2026 22:43:27 +0800 Subject: [PATCH 2/7] feat(#101): SwapMedia command (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(swap-media): 实现 SwapMedia 编辑命令,支持替换 clip 媒体 (#101) 后端: - 新增 EditCommand::SwapMedia 变体,替换 clip 的 media_ref - 校验新媒体存在于 manifest,若时长不足自动截断 duration + 调整 trim_end - 保留所有编辑属性(transform/crop/keyframe tracks/grade/masks/effects/fade) - media_type 隐含 source_clip_type(spec "sync media_type" 场景) - 新增 EditRequest::SwapMedia DTO + into_command 映射 - 6 个单元测试:等长替换/较短截断/媒体不存在/同步 media_type/clip 不存在/undo 前端: - types.ts 新增 swapMedia EditRequest 变体 - editActions.ts 新增 swapMedia(clipId, mediaRef, options?) action - Inspector 新增「替换媒体」section + 内联媒体选择器 - i18n 中英文翻译 Closes #101 * style: fix cargo fmt in command.rs and tests (#101) * fix: correct cargo fmt in command_apply.rs (#101) * fix: align trailing comment with 43 spaces (#101) * chore: trim playback whitespace * fix(swap-media): simplify DTO to 2-arg + frontend type-consistency filter (review #121) * fix(swap-media): singleLinkGroup gate + extract SwapMediaSection out of Inspector (#101) --------- Co-authored-by: baiqing --- crates/opentake-ops/src/command.rs | 133 +++++++ crates/opentake-ops/tests/command_apply.rs | 356 +++++++++++++++++- src-tauri/src/commands.rs | 52 ++- web/src/components/inspector/Inspector.tsx | 2 + .../components/inspector/SwapMediaSection.tsx | 173 +++++++++ web/src/i18n/dict.ts | 8 + web/src/lib/types.ts | 3 +- web/src/store/editActions.ts | 11 + 8 files changed, 725 insertions(+), 13 deletions(-) create mode 100644 web/src/components/inspector/SwapMediaSection.tsx diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index f9c75a9..d3423cb 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -281,6 +281,23 @@ pub enum EditCommand { /// Delete folders recursively (subfolders + their assets) and cascade-remove /// clips referencing any deleted asset. DeleteFolder { folder_ids: Vec }, + /// Replace a clip's `media_ref` in place, preserving all editing attributes + /// (transform / crop / keyframe tracks / grade / masks / effects / fade / + /// trim / speed / start / duration). 1:1 port of upstream + /// `replaceClipMediaRef(resetTrim: false)`: + /// + /// * **Type-must-match**: the candidate asset's `kind` must strictly equal + /// the clip's `media_type` (no `isVisual` leniency, no `media_type` + /// override). A mismatch is refused without mutating state. + /// * **Link-group cascade**: clips that share the seed clip's link group + /// AND its old `media_ref` are swapped together, so a linked audio/video + /// pair pointing at the same file stays in sync. + /// * **No-op on identical ref**: swapping to the same `media_ref` returns + /// `changed = false` (no undo entry, no version bump). + /// * **No trim/duration rewrites**: trim / speed / start / duration are + /// kept verbatim. The render layer is responsible for any overshoot + /// sampling when the new media is shorter. + SwapMedia { clip_id: String, media_ref: String }, /// Undo the last committed command. Undo, /// Redo the last undone command. @@ -400,6 +417,7 @@ pub fn apply( EditCommand::RenameFolder { entries } => rename_folder(state, entries), EditCommand::DeleteMedia { asset_ids } => delete_media(state, asset_ids), EditCommand::DeleteFolder { folder_ids } => delete_folder(state, folder_ids), + EditCommand::SwapMedia { clip_id, media_ref } => swap_media(state, clip_id, media_ref), } } @@ -1742,6 +1760,121 @@ fn delete_folder( ) } +/// Replace a clip's `media_ref` in place, preserving every editing attribute +/// (transform / crop / keyframe tracks / grade / masks / effects / fade / text +/// / trim / speed / start / duration). 1:1 port of upstream +/// `replaceClipMediaRef(resetTrim: false)`: +/// +/// 1. Validate the seed clip exists and the candidate asset exists in the +/// manifest, then refuse unless `clip.media_type == asset.kind` (strict +/// equality — no `isVisual` leniency). A video clip can only be swapped to +/// a video asset, an audio clip only to an audio asset, etc. +/// 2. Walk the seed clip's link group, picking every clip that shares the +/// same `media_ref`. Each one is updated to the new ref in the same +/// transaction, so a linked audio/video pair pointing at the same file +/// stays in sync (and `Undo` restores every old ref atomically). +/// 3. **No** trim / duration / start rewrites — `resetTrim: false`. The render +/// layer is responsible for any overshoot sampling when the new media is +/// shorter. +/// 4. Same `media_ref` is a no-op (`changed = false`, no undo entry, no +/// version bump). +fn swap_media( + state: &mut EditorState, + clip_id: String, + media_ref: String, +) -> Result { + // 1. Seed clip must exist. + let seed_loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + + // 2. Candidate asset must exist in the manifest. + let new_asset = state + .manifest + .entries + .iter() + .find(|e| e.id == media_ref) + .ok_or_else(|| EditError::Invalid(format!("Media not found: {media_ref}")))?; + + // 3. Strict type-match: clip.media_type == asset.kind. No isVisual leniency, + // no media_type override. A video clip can only swap to a video asset, + // an audio clip only to an audio asset. + let seed_media_type = + state.timeline.tracks[seed_loc.track_index].clips[seed_loc.clip_index].media_type; + if seed_media_type != new_asset.kind { + return Err(EditError::Refused(format!( + "Type mismatch: clip is {:?}, asset is {:?}", + seed_media_type, new_asset.kind + ))); + } + + // 4. No-op when the seed already references the new media. + let seed_old_ref = state.timeline.tracks[seed_loc.track_index].clips[seed_loc.clip_index] + .media_ref + .clone(); + if seed_old_ref == media_ref { + let version = state.version(); + return Ok(EditResult { + changed: false, + action_name: "Swap Media".to_string(), + affected_clip_ids: vec![clip_id.clone()], + timeline_version: version, + summary: format!("No-op: {clip_id} already references {media_ref}"), + }); + } + + // 5. Collect every link-group partner that also references the old ref. + // `expand_to_link_group` returns the whole group; we then keep only + // the members whose `media_ref` matches the seed's old ref. + let link_group = ops::expand_to_link_group(&state.timeline, &{ + let mut s = HashSet::new(); + s.insert(clip_id.clone()); + s + }); + let mut targets: Vec = Vec::new(); + for member_id in &link_group { + if let Some(loc) = state.find_clip(member_id) { + let c = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + if c.media_ref == seed_old_ref { + targets.push(member_id.clone()); + } + } + } + if !targets.iter().any(|id| id == &clip_id) { + // Defensive: the seed itself must always be in the target set. + targets.push(clip_id.clone()); + } + + let summary_old = seed_old_ref; + let summary_new = media_ref.clone(); + let target_count = targets.len(); + transact( + state, + "Swap Media", + move |affected| { + if affected.len() <= 1 { + format!("Swapped {clip_id}: {summary_old} -> {summary_new}") + } else { + format!( + "Swapped {n} linked clips: {summary_old} -> {summary_new}", + n = affected.len() + ) + } + }, + move |st| { + let mut affected = Vec::with_capacity(target_count); + for tid in &targets { + if let Some(loc) = st.find_clip(tid) { + st.timeline.tracks[loc.track_index].clips[loc.clip_index].media_ref = + media_ref.clone(); + affected.push(tid.clone()); + } + } + Ok(affected) + }, + ) +} + // MARK: - Small local helpers fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result<(), EditError> { diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 91cf426..e77f238 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -5,7 +5,9 @@ use opentake_domain::{AnimPair, Interpolation, Keyframe, KeyframeTrack}; use opentake_domain::{ChromaKey, ColorGrade, Effect, Mask, MaskShape, Point2}; -use opentake_domain::{Clip, ClipType, MediaManifest, Timeline, Track, Transform}; +use opentake_domain::{ + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, Transform, +}; use opentake_ops::{ apply, ClipEntry, ClipMove, ClipProperties, EditCommand, EditError, EditorState, FrameRange, KeyframePayload, KeyframeProperty, SeqIdGen, TextEntry, @@ -1054,3 +1056,355 @@ fn ripple_delete_clips_rejects_unknown_clip() { )); assert_eq!(st.version(), 0); } + +// ---- swap_media ------------------------------------------------------------ + +/// Build a manifest entry with `duration` in seconds and an External source. +fn media_entry(id: &str, kind: ClipType, duration_secs: f64) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: id.into(), + kind, + source: MediaSource::External { + absolute_path: format!("/abs/{id}"), + }, + duration: duration_secs, + generation_input: None, + source_width: None, + source_height: None, + source_fps: None, + has_audio: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } +} + +/// Build a state with the given tracks and manifest entries (fps defaults to 30). +fn state_with_media(tracks: Vec, entries: Vec) -> EditorState { + let mut tl = Timeline::new(); + tl.tracks = tracks; + let mut manifest = MediaManifest::new(); + manifest.entries = entries; + EditorState::new(tl, manifest) +} + +#[test] +fn swap_media_replaces_ref_and_preserves_attributes() { + // Clip duration 100 frames (fps=30 -> 100/30 secs). New media same length. + let mut c = clip("c", 0, 100); + c.opacity = 0.7; + c.transform = Transform { + center_x: 0.3, + center_y: 0.4, + width: 0.5, + height: 0.6, + rotation: 15.0, + flip_horizontal: true, + flip_vertical: false, + }; + c.trim_start_frame = 5; + c.trim_end_frame = 7; + c.speed = 1.5; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "new".into(), + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + assert_eq!(res.action_name, "Swap Media"); + assert_eq!(res.affected_clip_ids, vec!["c".to_string()]); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "new"); + assert_eq!(clip.duration_frames, 100); // unchanged + // Preserved editing attributes + assert!((clip.opacity - 0.7).abs() < 1e-9); + assert!((clip.transform.center_x - 0.3).abs() < 1e-9); + assert!((clip.transform.rotation - 15.0).abs() < 1e-9); + assert!(clip.transform.flip_horizontal); + // trim / speed untouched (resetTrim=false) + assert_eq!(clip.trim_start_frame, 5); + assert_eq!(clip.trim_end_frame, 7); + assert!((clip.speed - 1.5).abs() < 1e-9); +} + +#[test] +fn swap_media_does_not_truncate_when_new_media_shorter() { + // resetTrim=false: clip duration is preserved even when the new media is + // shorter. The render layer is responsible for any overshoot sampling. + let mut c = clip("c", 0, 100); + c.start_frame = 20; + c.trim_start_frame = 2; + c.trim_end_frame = 3; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("short", ClipType::Video, 50.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "short".into(), + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "short"); + // Start / duration / trim all untouched. + assert_eq!(clip.start_frame, 20); + assert_eq!(clip.duration_frames, 100); + assert_eq!(clip.trim_start_frame, 2); + assert_eq!(clip.trim_end_frame, 3); +} + +#[test] +fn swap_media_rejects_missing_media_ref() { + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![media_entry("old", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "nonexistent".into(), + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); // unchanged + // Original media_ref preserved. + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); +} + +#[test] +fn swap_media_rejects_type_mismatch() { + // Clip is video; asset is audio. Must refuse (no isVisual leniency). + let mut c = clip("c", 0, 100); + c.media_type = ClipType::Video; + c.source_clip_type = ClipType::Video; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("audio1", ClipType::Audio, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "audio1".into(), + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Refused(_))); + assert_eq!(st.version(), 0); // unchanged + // Original media_ref preserved. + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); + assert_eq!(st.timeline.tracks[0].clips[0].media_type, ClipType::Video); +} + +#[test] +fn swap_media_rejects_missing_clip() { + let v = video_track("v", true, vec![]); + let entries = vec![media_entry("new", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "missing".into(), + media_ref: "new".into(), + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); +} + +#[test] +fn swap_media_no_op_on_same_ref() { + // Seed clip references "asset" (builder default); swapping to "asset" must + // be a no-op (no undo entry, no version bump). + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![media_entry("asset", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + let version_before = st.version(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "asset".into(), + }, + &g, + ) + .unwrap(); + + assert!(!res.changed); + assert_eq!(st.version(), version_before); + assert!(!st.can_undo()); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); +} + +#[test] +fn swap_media_is_undoable() { + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "new".into(), + }, + &g, + ) + .unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "new"); + assert!(st.can_undo()); + + // Undo via the command (undo() is pub(crate), so we route through apply). + apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); // restored +} + +#[test] +fn swap_media_cascades_to_link_group_with_same_ref() { + // A linked V1/A1 pair both reference "old". Swapping the video clip must + // also swap the audio clip's ref so the pair stays in sync. + let mut vc = clip("v", 0, 100); + vc.media_type = ClipType::Video; + vc.source_clip_type = ClipType::Video; + vc.link_group_id = Some("g1".into()); + let mut ac = clip("a", 0, 100); + ac.media_type = ClipType::Audio; + ac.source_clip_type = ClipType::Audio; + ac.link_group_id = Some("g1".into()); + let v = video_track("v", true, vec![vc]); + let a = audio_track("a", true, vec![ac]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new_v", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v, a], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "v".into(), + media_ref: "new_v".into(), + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + // Both V1 and A1 updated. + let v_clip = st + .find_clip("v") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + let a_clip = st + .find_clip("a") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + assert_eq!(v_clip.media_ref, "new_v"); + assert_eq!(a_clip.media_ref, "new_v"); + + // Undo restores both. + apply(&mut st, EditCommand::Undo, &g).unwrap(); + let v_clip = st + .find_clip("v") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + let a_clip = st + .find_clip("a") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + assert_eq!(v_clip.media_ref, "asset"); + assert_eq!(a_clip.media_ref, "asset"); +} + +#[test] +fn swap_media_does_not_cascade_to_link_group_with_different_ref() { + // V1 references "old", A1 (its linked partner) references a DIFFERENT + // asset. Swapping V1 must NOT touch A1 — the swap is only meant to + // update clips that share the old ref. + let mut vc = clip("v", 0, 100); + vc.media_type = ClipType::Video; + vc.source_clip_type = ClipType::Video; + vc.link_group_id = Some("g1".into()); + let mut ac = clip("a", 0, 100); + ac.media_type = ClipType::Audio; + ac.source_clip_type = ClipType::Audio; + ac.link_group_id = Some("g1".into()); + ac.media_ref = "other".into(); + let v = video_track("v", true, vec![vc]); + let a = audio_track("a", true, vec![ac]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("other", ClipType::Audio, 100.0 / 30.0), + media_entry("new_v", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v, a], entries); + let g = SeqIdGen::default(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "v".into(), + media_ref: "new_v".into(), + }, + &g, + ) + .unwrap(); + + let v_clip = st + .find_clip("v") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + let a_clip = st + .find_clip("a") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + assert_eq!(v_clip.media_ref, "new_v"); + assert_eq!(a_clip.media_ref, "other"); // untouched +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fe96489..8387bab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -131,7 +131,9 @@ fn msg(e: CmdError) -> String { #[serde(tag = "type", rename_all = "camelCase")] pub enum EditRequest { #[serde(rename_all = "camelCase")] - AddClips { entries: Vec }, + AddClips { + entries: Vec, + }, #[serde(rename_all = "camelCase")] InsertClips { track_index: usize, @@ -139,13 +141,22 @@ pub enum EditRequest { entries: Vec, }, #[serde(rename_all = "camelCase")] - MoveClips { moves: Vec }, + MoveClips { + moves: Vec, + }, #[serde(rename_all = "camelCase")] - RemoveClips { clip_ids: Vec }, + RemoveClips { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - SplitClip { clip_id: String, at_frame: i32 }, + SplitClip { + clip_id: String, + at_frame: i32, + }, #[serde(rename_all = "camelCase")] - TrimClips { edits: Vec }, + TrimClips { + edits: Vec, + }, #[serde(rename_all = "camelCase")] SetClipProperties { clip_ids: Vec, @@ -189,17 +200,29 @@ pub enum EditRequest { ranges: Vec, }, #[serde(rename_all = "camelCase")] - RippleDeleteClips { clip_ids: Vec }, + RippleDeleteClips { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - AddTexts { entries: Vec }, + AddTexts { + entries: Vec, + }, #[serde(rename_all = "camelCase")] - Link { clip_ids: Vec }, + Link { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - Unlink { clip_ids: Vec }, + Unlink { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - RemoveTracks { track_indexes: Vec }, + RemoveTracks { + track_indexes: Vec, + }, #[serde(rename_all = "camelCase")] - InsertTrack { kind: ClipType }, + InsertTrack { + kind: ClipType, + }, #[serde(rename_all = "camelCase")] SetTrackProps { track_index: usize, @@ -217,6 +240,10 @@ pub enum EditRequest { asset_ids: Vec, folder_id: Option, }, + SwapMedia { + clip_id: String, + media_ref: String, + }, } impl EditRequest { @@ -344,6 +371,9 @@ impl EditRequest { asset_ids, folder_id, }, + EditRequest::SwapMedia { clip_id, media_ref } => { + EditCommand::SwapMedia { clip_id, media_ref } + } }) } } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 066ed38..e3156f3 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -11,6 +11,7 @@ import { Icon } from "../ui/Icon"; import { ScrubbableNumberField } from "./ScrubbableNumberField"; import { TextTab } from "./TextTab"; import { KeyframesPanel } from "./KeyframesPanel"; +import { SwapMediaSection } from "./SwapMediaSection"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import * as edit from "../../store/editActions"; @@ -192,6 +193,7 @@ function ClipInspector({ )}
+ {clip.mediaType !== "text" && } {activeTab === "text" ? ( ) : activeTab === "audio" ? ( diff --git a/web/src/components/inspector/SwapMediaSection.tsx b/web/src/components/inspector/SwapMediaSection.tsx new file mode 100644 index 0000000..0977d1d --- /dev/null +++ b/web/src/components/inspector/SwapMediaSection.tsx @@ -0,0 +1,173 @@ +/** + * SwapMediaSection — Inspector's "替换媒体" picker (SPEC §5.10, §6). + * + * Isolated into its own file so Inspector.tsx stays free of mediaStore + * coupling (review #121 point 4: avoid touching the media area from the + * Inspector). When #91 rewrites the media system, this is the single + * touchpoint to update. + * + * Opens an inline media picker that lists every library asset of the SAME + * type as the clip (strict type match, no isVisual leniency). Selecting one + * fires `edit.swapMedia`, which preserves all editing attributes + * (resetTrim=false: trim / speed / start / duration are untouched). Text + * clips don't render this section (they have no source media to swap), and + * the backend refuses type mismatches. + * + * Gate (SPEC §5.10, frontend-UI-1to1-SPEC.md:665): "非 text 且单链组" — the + * entry is hidden when the clip is part of a multi-clip link group, because + * swapping would cascade to every partner sharing the old mediaRef. Linked + * clips should be swapped via the timeline right-click menu where the group + * selection is explicit. + */ + +import { useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { Icon } from "../ui/Icon"; +import { useProjectStore } from "../../store/projectStore"; +import { useMediaStore } from "../../store/mediaStore"; +import * as edit from "../../store/editActions"; +import { type TFunction } from "../../i18n"; +import type { Clip, MediaItem } from "../../lib/types"; + +/** A compact media-type badge label. */ +function mediaTypeLabel(type: MediaItem["type"]): string { + switch (type) { + case "video": + return "Video"; + case "audio": + return "Audio"; + case "image": + return "Image"; + case "text": + return "Text"; + case "lottie": + return "Lottie"; + } +} + +export function SwapMediaSection({ clip, t }: { clip: Clip; t: TFunction }) { + const [open, setOpen] = useState(false); + const items = useMediaStore((s) => s.items); + const timeline = useProjectStore((s) => s.timeline); + + // "单链组" gate: hide when the clip belongs to a link group with > 1 member. + if (clip.linkGroupId) { + const groupSize = timeline.tracks.reduce( + (n, tr) => n + tr.clips.filter((c) => c.linkGroupId === clip.linkGroupId).length, + 0, + ); + if (groupSize > 1) return null; + } + + // Exclude the current media source; only assets of the SAME type are + // candidates (the backend will refuse any other kind anyway). + const candidates = items.filter( + (m) => m.id !== clip.mediaRef && m.type === clip.mediaType, + ); + + const handlePick = (item: MediaItem) => { + void edit.swapMedia(clip.id, item.id); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ {t("inspector.swapMediaTitle")} +
+ {candidates.length === 0 ? ( +
+ {t("inspector.swapMediaEmpty")} +
+ ) : ( + candidates.map((item) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index b26da6a..f7f8a5c 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -116,6 +116,10 @@ const zh: Dict = { "inspector.keyframes.property.volume": "音量", "inspector.keyframes.empty": "无关键帧", "inspector.textPlaceholder": "输入文本…", + "inspector.swapMedia": "替换媒体", + "inspector.swapMediaTitle": "选择新媒体", + "inspector.swapMediaEmpty": "媒体库为空,请先导入媒体。", + "inspector.swapMediaCurrent": "当前", // Toolbar "toolbar.undo": "撤销 (⌘Z)", @@ -321,6 +325,10 @@ const en: Dict = { "inspector.keyframes.property.volume": "Volume", "inspector.keyframes.empty": "No keyframes", "inspector.textPlaceholder": "Enter text…", + "inspector.swapMedia": "Swap Media", + "inspector.swapMediaTitle": "Select New Media", + "inspector.swapMediaEmpty": "Media library is empty. Import media first.", + "inspector.swapMediaCurrent": "Current", "toolbar.undo": "Undo (⌘Z)", "toolbar.redo": "Redo (⇧⌘Z)", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4c64386..5c2ad67 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -176,7 +176,8 @@ export type EditRequest = syncLocked?: boolean; } | { type: "createFolder"; name: string; parentFolderId?: string } - | { type: "moveToFolder"; assetIds: string[]; folderId?: string }; + | { type: "moveToFolder"; assetIds: string[]; folderId?: string } + | { type: "swapMedia"; clipId: string; mediaRef: string }; export interface TextEntryReq { trackIndex: number; diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index a27f690..e37a313 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -152,6 +152,17 @@ export async function moveToFolder(assetIds: string[], folderId?: string) { await applyAndRefresh({ type: "moveToFolder", assetIds, folderId }); } +/** Replace a clip's media source in place, preserving all editing attributes + * (transform / crop / keyframe tracks / grade / masks / effects / fade / trim / + * speed / start / duration). 1:1 port of upstream + * `replaceClipMediaRef(resetTrim: false)`. The backend enforces a strict type + * match (`clip.mediaType == asset.type`) and cascades the swap across the + * link-group members that share the same old `mediaRef`; callers only need to + * pass the seed clip id and the candidate asset id. */ +export async function swapMedia(clipId: string, mediaRef: string) { + await applyAndRefresh({ type: "swapMedia", clipId, mediaRef }); +} + export async function undo() { await api.undo(); if (!isTauri) await forceRefresh(); From ea8b015a195c9007d7da3f9dc32dda0f74d2fa5b Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Thu, 25 Jun 2026 22:44:19 +0800 Subject: [PATCH 3/7] feat(#97): Inspector live missing fields (#5) * feat(inspector): live sampling + missing fields (crop/fade/flip) (#97) Backend (opentake-ops + src-tauri): - Extend ClipProperties with crop, fade_in/out_frames, fade_in/out_interpolation, flip_horizontal, flip_vertical - set_clip_properties writes new fields; fade clamps to clip duration; flip_* writes to transform.flip_* - ClipPropertiesDto mirrors fields with serde camelCase - 5 unit tests: crop sets+clears track, fade frames+interp, fade clamps, flip writes to transform, multiple fields at once Frontend (web): - clip.ts: 1:1 port of Rust Clip::*_at sampling methods (opacity/volume/ rotation/size/topLeft/crop), fadeMultiplier, db<->linear, generic sampleKeyframeTrack with number/AnimPair/Crop lerp - Inspector.tsx: read activeFrame from uiStore; show sampled values at playhead; switch to ReadOnlyValue + AnimatedHint when a track is active - 4 new sections: Position (top-left x/y), Crop (4 edge insets 0-1), Flip (2 checkboxes), Fade (in/out frames + interpolation selects) - Fade section appears on both video and audio tabs - types.ts: extend ClipPropertiesReq with camelCase fields - dict.ts: i18n keys for new sections (zh-CN + en) Closes #97 * style: fix cargo fmt import in command.rs (#97) * fix: add ..Default::default() for new ClipProperties fields (#97) * fix(#97): use clip.opacity/volume for editable fields, sampled* only for animated (review #122) --- crates/opentake-agent/src/mcp/dispatch.rs | 1 + crates/opentake-ops/src/command.rs | 42 +- crates/opentake-ops/tests/command_apply.rs | 147 +++++++ src-tauri/src/commands.rs | 21 + web/src/components/inspector/Inspector.tsx | 447 +++++++++++++++++++-- web/src/i18n/dict.ts | 40 ++ web/src/lib/clip.ts | 220 +++++++++- web/src/lib/types.ts | 12 + 8 files changed, 884 insertions(+), 46 deletions(-) diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 450ad6e..02dfc09 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -437,6 +437,7 @@ impl Dispatcher { opacity: a.opacity, transform: a.transform.map(transform_from_arg), text_content: a.content.clone(), + ..Default::default() }; let res = self.apply(EditCommand::SetClipProperties { clip_ids, diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index d3423cb..c5ef5c5 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -18,7 +18,9 @@ use std::collections::HashSet; -use opentake_domain::{ChromaKey, ClipType, ColorGrade, Effect, Mask, Timeline, Transform}; +use opentake_domain::{ + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Mask, Timeline, Transform, +}; use crate::editor_state::EditorState; use crate::engines::FrameRange; @@ -130,6 +132,20 @@ pub struct ClipProperties { pub opacity: Option, pub transform: Option, pub text_content: Option, + /// Per-clip crop insets (normalized 0–1). Setting this clears `crop_track`. + pub crop: Option, + /// Fade-in length in frames. Setting this clamps to the clip duration. + pub fade_in_frames: Option, + /// Fade-out length in frames. Setting this clamps to the clip duration. + pub fade_out_frames: Option, + /// Fade-in interpolation mode. + pub fade_in_interpolation: Option, + /// Fade-out interpolation mode. + pub fade_out_interpolation: Option, + /// Horizontal flip flag (writes to `transform.flip_horizontal`). + pub flip_horizontal: Option, + /// Vertical flip flag (writes to `transform.flip_vertical`). + pub flip_vertical: Option, } /// Which keyframe track [`EditCommand::SetKeyframes`] targets. @@ -850,6 +866,30 @@ fn apply_property_changes( if let Some(t) = props.transform { clip.transform = t; } + if let Some(c) = props.crop { + clip.crop = c; + clip.crop_track = None; + } + if let Some(v) = props.fade_in_frames { + clip.fade_in_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(v) = props.fade_out_frames { + clip.fade_out_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(i) = props.fade_in_interpolation { + clip.fade_in_interpolation = i; + } + if let Some(i) = props.fade_out_interpolation { + clip.fade_out_interpolation = i; + } + if let Some(f) = props.flip_horizontal { + clip.transform.flip_horizontal = f; + } + if let Some(f) = props.flip_vertical { + clip.transform.flip_vertical = f; + } if let Some(c) = &props.text_content { clip.text_content = Some(c.clone()); } diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index e77f238..a5b8ba0 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -521,6 +521,153 @@ fn set_clip_properties_scalar_clears_keyframe_track() { assert!(c.opacity_track.is_none()); // cleared by setting the scalar } +#[test] +fn set_clip_properties_crop_sets_and_clears_track() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + // Pre-existing crop track should be cleared when a static crop is set. + let mut existing = st.timeline.tracks[0].clips[0].clone(); + existing.crop_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + opentake_domain::Crop { + left: 0.1, + top: 0.0, + right: 0.0, + bottom: 0.0, + }, + )])); + st.timeline.tracks[0].clips[0] = existing; + + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + crop: Some(opentake_domain::Crop { + left: 0.2, + top: 0.1, + right: 0.0, + bottom: 0.0, + }), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + + let c = &st.timeline.tracks[0].clips[0]; + assert!((c.crop.left - 0.2).abs() < 1e-9); + assert!((c.crop.top - 0.1).abs() < 1e-9); + assert!(c.crop_track.is_none()); // cleared by setting the static value +} + +#[test] +fn set_clip_properties_fade_sets_frames_and_interpolation() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + fade_in_frames: Some(10), + fade_out_frames: Some(15), + fade_in_interpolation: Some(Interpolation::Smooth), + fade_out_interpolation: Some(Interpolation::Hold), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + + let c = &st.timeline.tracks[0].clips[0]; + assert_eq!(c.fade_in_frames, 10); + assert_eq!(c.fade_out_frames, 15); + assert_eq!(c.fade_in_interpolation, Interpolation::Smooth); + assert_eq!(c.fade_out_interpolation, Interpolation::Hold); +} + +#[test] +fn set_clip_properties_fade_clamps_to_duration() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 30)])]); + let g = SeqIdGen::default(); + // fade_in 100 on a 30-frame clip should clamp to 30, fade_out to 0. + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + fade_in_frames: Some(100), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert_eq!(c.fade_in_frames, 30); + assert_eq!(c.fade_out_frames, 0); +} + +#[test] +fn set_clip_properties_flip_writes_to_transform() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + flip_horizontal: Some(true), + flip_vertical: Some(true), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert!(c.transform.flip_horizontal); + assert!(c.transform.flip_vertical); +} + +#[test] +fn set_clip_properties_multiple_fields_at_once() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + crop: Some(opentake_domain::Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }), + fade_in_frames: Some(5), + fade_in_interpolation: Some(Interpolation::Smooth), + flip_horizontal: Some(true), + opacity: Some(0.8), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert!((c.crop.left - 0.1).abs() < 1e-9); + assert!((c.crop.bottom - 0.4).abs() < 1e-9); + assert_eq!(c.fade_in_frames, 5); + assert_eq!(c.fade_in_interpolation, Interpolation::Smooth); + assert!(c.transform.flip_horizontal); + assert!((c.opacity - 0.8).abs() < 1e-9); + assert!(c.opacity_track.is_none()); // opacity scalar cleared its track +} + // ---- set_keyframes -------------------------------------------------------- #[test] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8387bab..6a38cc8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -479,6 +479,20 @@ pub struct ClipPropertiesDto { pub transform: Option, #[serde(default)] pub text_content: Option, + #[serde(default)] + pub crop: Option, + #[serde(default)] + pub fade_in_frames: Option, + #[serde(default)] + pub fade_out_frames: Option, + #[serde(default)] + pub fade_in_interpolation: Option, + #[serde(default)] + pub fade_out_interpolation: Option, + #[serde(default)] + pub flip_horizontal: Option, + #[serde(default)] + pub flip_vertical: Option, } impl ClipPropertiesDto { @@ -492,6 +506,13 @@ impl ClipPropertiesDto { opacity: self.opacity, transform: self.transform, text_content: self.text_content, + crop: self.crop, + fade_in_frames: self.fade_in_frames, + fade_out_frames: self.fade_out_frames, + fade_in_interpolation: self.fade_in_interpolation, + fade_out_interpolation: self.fade_out_interpolation, + flip_horizontal: self.flip_horizontal, + flip_vertical: self.flip_vertical, } } } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index e3156f3..479ef10 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -16,8 +16,16 @@ import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; +import { + cropAt, + opacityAt, + rotationAt, + sizeAt, + topLeftAt, + volumeAt, +} from "../../lib/clip"; import { useT, type TFunction } from "../../i18n"; -import type { Clip, Timeline } from "../../lib/types"; +import type { Clip, Crop, Interpolation, Timeline } from "../../lib/types"; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -128,6 +136,79 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) ); } +/** A non-interactive numeric value shown when a property is keyframe-animated. + * Mirrors ScrubbableNumberField's typography but without drag/click handlers. */ +function ReadOnlyValue({ text, width = 56 }: { text: string; width?: number }) { + return ( + + {text} + + ); +} + +/** Inline hint shown beside a read-only field when a property is animated. */ +function AnimatedHint({ t }: { t: TFunction }) { + return ( + + {t("inspector.animatedHint")} + + ); +} + +const INTERPOLATION_KEYS: Record = { + linear: "inspector.interpolation.linear", + hold: "inspector.interpolation.hold", + smooth: "inspector.interpolation.smooth", +}; + +/** A compact native ` onChange(e.target.value as Interpolation)} + style={{ + fontSize: "var(--fs-sm)", + color: "var(--accent-primary)", + background: "var(--bg-raised)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-xs)", + padding: "1px 4px", + }} + > + {(Object.keys(INTERPOLATION_KEYS) as Interpolation[]).map((k) => ( + + ))} + + ); +} + const TAB_LABEL_KEY: Record<"text" | "video" | "audio" | "aiEdit", string> = { text: "inspector.tab.text", video: "inspector.tab.video", @@ -160,9 +241,29 @@ function ClipInspector({ const activeTab = tabs.includes(tab as never) ? tab : tabs[0]; + // Live sampling: read the current playhead frame so every numeric field shows + // the value at the playhead (upstream `InspectorView.livePreview`). + const activeFrame = useEditorUiStore((s) => s.activeFrame); + const commit = (props: Parameters[1]) => edit.setClipProperties([clip.id], props); + // Track-active checks (a track is active iff it holds ≥1 keyframe). + const opacityAnimated = !!clip.opacityTrack && clip.opacityTrack.keyframes.length > 0; + const volumeAnimated = !!clip.volumeTrack && clip.volumeTrack.keyframes.length > 0; + const rotationAnimated = !!clip.rotationTrack && clip.rotationTrack.keyframes.length > 0; + const scaleAnimated = !!clip.scaleTrack && clip.scaleTrack.keyframes.length > 0; + const positionAnimated = !!clip.positionTrack && clip.positionTrack.keyframes.length > 0; + const cropAnimated = !!clip.cropTrack && clip.cropTrack.keyframes.length > 0; + + // Sampled values at the playhead. + const sampledOpacity = opacityAt(clip, activeFrame); + const sampledVolume = volumeAt(clip, activeFrame); + const sampledRotation = rotationAt(clip, activeFrame); + const sampledScale = sizeAt(clip, activeFrame)[0]; + const sampledTopLeft = topLeftAt(clip, activeFrame); + const sampledCrop = cropAt(clip, activeFrame); + return (
{tabs.length > 1 && ( @@ -200,18 +301,28 @@ function ClipInspector({
- (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} - suffix=" dB" - width={56} - displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} - onCommit={(v) => commit({ volume: v })} - /> + {volumeAnimated ? ( + <> + + + + ) : ( + (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} + suffix=" dB" + width={56} + displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} + onCommit={(v) => commit({ volume: v })} + /> + )} +
) : ( <> @@ -220,45 +331,86 @@ function ClipInspector({
- Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => - commit({ transform: { ...clip.transform, width: v, height: v } }) - } - /> + {scaleAnimated ? ( + <> + + + + ) : ( + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, width: v, height: v } }) + } + /> + )} - v.toFixed(0)} - suffix="°" - width={56} - onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} - /> + {rotationAnimated ? ( + <> + + + + ) : ( + v.toFixed(0)} + suffix="°" + width={56} + onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} + /> + )} - Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => commit({ opacity: v })} - /> + {opacityAnimated ? ( + <> + + + + ) : ( + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => commit({ opacity: v })} + /> + )} + + + + + + + +
@@ -307,6 +459,213 @@ function ClipInspector({ ); } +// MARK: - Position section (top-left x/y) + +function PositionSection({ + clip, + sampledTopLeft, + animated, + commit, + t, +}: { + clip: Clip; + sampledTopLeft: { x: number; y: number }; + animated: boolean; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + // Editing top-left x/y writes back through `transform.centerX/centerY`. The + // size is preserved from the current transform (scale track writes via scale). + const [w, h] = [clip.transform.width, clip.transform.height]; + return ( +
+ + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, centerX: v + w / 2 } }) + } + /> + )} + + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, centerY: v + h / 2 } }) + } + /> + )} + +
+ ); +} + +// MARK: - Crop section (4 edge insets, 0–1) + +function CropSection({ + clip, + sampledCrop, + animated, + commit, + t, +}: { + clip: Clip; + sampledCrop: Crop; + animated: boolean; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + const commitEdge = (edge: keyof Crop, v: number) => { + const next: Crop = { ...clip.crop, [edge]: v }; + commit({ crop: next }); + }; + const renderEdge = (label: string, edge: keyof Crop, value: number) => ( + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => commitEdge(edge, v)} + /> + )} + + ); + return ( +
+ + {renderEdge(t("inspector.field.cropLeft"), "left", sampledCrop.left)} + {renderEdge(t("inspector.field.cropTop"), "top", sampledCrop.top)} + {renderEdge(t("inspector.field.cropRight"), "right", sampledCrop.right)} + {renderEdge(t("inspector.field.cropBottom"), "bottom", sampledCrop.bottom)} +
+ ); +} + +// MARK: - Flip section (horizontal / vertical checkboxes) + +function FlipSection({ + clip, + commit, + t, +}: { + clip: Clip; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + const checkboxStyle: React.CSSProperties = { + accentColor: "var(--accent-primary)", + cursor: "pointer", + }; + return ( +
+ + + commit({ flipHorizontal: e.target.checked })} + /> + + + commit({ flipVertical: e.target.checked })} + /> + +
+ ); +} + +// MARK: - Fade section (fade in/out frames + interpolation) + +function FadeSection({ + clip, + commit, + t, +}: { + clip: Clip; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + return ( +
+ + + v.toFixed(0)} + width={56} + onCommit={(v) => commit({ fadeInFrames: Math.round(v) })} + /> + + + commit({ fadeInInterpolation: v })} + t={t} + /> + + + v.toFixed(0)} + width={56} + onCommit={(v) => commit({ fadeOutFrames: Math.round(v) })} + /> + + + commit({ fadeOutInterpolation: v })} + t={t} + /> + +
+ ); +} + function ProjectMetadata({ timeline, t }: { timeline: Timeline; t: TFunction }) { const g = gcd(timeline.width, timeline.height) || 1; const total = timeline.tracks.reduce( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index f7f8a5c..9f89217 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -91,6 +91,10 @@ const zh: Dict = { "inspector.section.playback": "播放", "inspector.section.format": "格式", "inspector.section.text": "文本内容", + "inspector.section.position": "位置", + "inspector.section.crop": "裁剪", + "inspector.section.flip": "翻转", + "inspector.section.fade": "淡入淡出", "inspector.field.volume": "音量", "inspector.field.scale": "缩放", "inspector.field.rotation": "旋转", @@ -100,6 +104,22 @@ const zh: Dict = { "inspector.field.frameRate": "帧率", "inspector.field.aspectRatio": "宽高比", "inspector.field.duration": "时长", + "inspector.field.positionX": "X 位置", + "inspector.field.positionY": "Y 位置", + "inspector.field.cropLeft": "左侧", + "inspector.field.cropTop": "顶部", + "inspector.field.cropRight": "右侧", + "inspector.field.cropBottom": "底部", + "inspector.field.flipHorizontal": "水平翻转", + "inspector.field.flipVertical": "垂直翻转", + "inspector.field.fadeInFrames": "淡入帧数", + "inspector.field.fadeOutFrames": "淡出帧数", + "inspector.field.fadeInInterpolation": "淡入插值", + "inspector.field.fadeOutInterpolation": "淡出插值", + "inspector.interpolation.linear": "线性", + "inspector.interpolation.hold": "保持", + "inspector.interpolation.smooth": "平滑", + "inspector.animatedHint": "已在关键帧面板动画化", "inspector.keyframes": "关键帧", "inspector.keyframes.stamp": "在播放头处盖章", "inspector.keyframes.clear": "清除动画", @@ -300,6 +320,10 @@ const en: Dict = { "inspector.section.playback": "Playback", "inspector.section.format": "Format", "inspector.section.text": "Text Content", + "inspector.section.position": "Position", + "inspector.section.crop": "Crop", + "inspector.section.flip": "Flip", + "inspector.section.fade": "Fade", "inspector.field.volume": "Volume", "inspector.field.scale": "Scale", "inspector.field.rotation": "Rotation", @@ -309,6 +333,22 @@ const en: Dict = { "inspector.field.frameRate": "Frame Rate", "inspector.field.aspectRatio": "Aspect Ratio", "inspector.field.duration": "Duration", + "inspector.field.positionX": "X Position", + "inspector.field.positionY": "Y Position", + "inspector.field.cropLeft": "Left", + "inspector.field.cropTop": "Top", + "inspector.field.cropRight": "Right", + "inspector.field.cropBottom": "Bottom", + "inspector.field.flipHorizontal": "Flip Horizontal", + "inspector.field.flipVertical": "Flip Vertical", + "inspector.field.fadeInFrames": "Fade In Frames", + "inspector.field.fadeOutFrames": "Fade Out Frames", + "inspector.field.fadeInInterpolation": "Fade In Interpolation", + "inspector.field.fadeOutInterpolation": "Fade Out Interpolation", + "inspector.interpolation.linear": "Linear", + "inspector.interpolation.hold": "Hold", + "inspector.interpolation.smooth": "Smooth", + "inspector.animatedHint": "Animated in the keyframes panel", "inspector.keyframes": "Keyframes", "inspector.keyframes.stamp": "Stamp at Playhead", "inspector.keyframes.clear": "Clear Animation", diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 6947974..1537b10 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -6,7 +6,14 @@ import { TRACK_COLOR } from "./theme"; import { formatClipDuration } from "./geometry"; -import type { Clip, ClipType, TrimEditReq } from "./types"; +import type { + AnimPair, + Clip, + ClipType, + Crop, + KeyframeTrack, + TrimEditReq, +} from "./types"; export function trackColor(type: ClipType): string { return TRACK_COLOR[type] ?? TRACK_COLOR.video; @@ -112,3 +119,214 @@ export function trimToPlayheadEdits(clips: Clip[], frame: number, edge: TrimEdge } return edits; } + +// MARK: - Live sampling (1:1 port of opentake-domain::Clip::*_at) +// +// These mirror the Rust `Clip` sampling methods so the Inspector can display +// the value at the current playhead frame (live preview), matching upstream +// `InspectorView.livePreview`. Frames are absolute timeline frames; the helpers +// convert to clip-relative offsets internally. See `crates/opentake-domain/src/clip.rs`. + +/** `smoothstep(t) = t*t*(3 - 2t)`. 1:1 with `keyframe::smoothstep`. */ +function smoothstep(t: number): number { + return t * t * (3.0 - 2.0 * t); +} + +/** Linear amplitude <-> dB mapping (1:1 port of `VolumeScale`). */ +const VOLUME_FLOOR_DB = -60.0; +const VOLUME_CEILING_DB = 15.0; + +export function dbFromLinear(linear: number): number { + if (linear > 0.0) { + return Math.min(VOLUME_CEILING_DB, Math.max(VOLUME_FLOOR_DB, 20.0 * Math.log10(linear))); + } + return VOLUME_FLOOR_DB; +} + +export function linearFromDb(db: number): number { + if (db > VOLUME_FLOOR_DB) { + return Math.pow(10, Math.min(db, VOLUME_CEILING_DB) / 20.0); + } + return 0.0; +} + +/** Interpolate between two scalar keyframe values. */ +function lerpNumber(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Interpolate between two `AnimPair` values component-wise. */ +function lerpAnimPair(a: AnimPair, b: AnimPair, t: number): AnimPair { + return { a: lerpNumber(a.a, b.a, t), b: lerpNumber(a.b, b.b, t) }; +} + +/** Interpolate between two `Crop` values component-wise. */ +function lerpCrop(a: Crop, b: Crop, t: number): Crop { + return { + left: lerpNumber(a.left, b.left, t), + top: lerpNumber(a.top, b.top, t), + right: lerpNumber(a.right, b.right, t), + bottom: lerpNumber(a.bottom, b.bottom, t), + }; +} + +/** + * Sample a keyframe track at clip-relative `frame`, clamping at the endpoints + * (no extrapolation). Inside a span, the *left* keyframe's `interpolationOut` + * selects hold / linear / smooth. 1:1 port of `KeyframeTrack::sample`. + */ +export function sampleKeyframeTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: V, + lerp: (a: V, b: V, t: number) => V, +): V { + if (!track || track.keyframes.length === 0) return fallback; + const kfs = track.keyframes; + if (kfs.length === 1) return kfs[0].value; + if (frame <= kfs[0].frame) return kfs[0].value; + const last = kfs[kfs.length - 1]; + if (frame >= last.frame) return last.value; + + let bIdx = kfs.findIndex((k) => k.frame > frame); + if (bIdx === -1) return last.value; + const a = kfs[bIdx - 1]; + const b = kfs[bIdx]; + const raw = (frame - a.frame) / (b.frame - a.frame); + switch (a.interpolationOut) { + case "hold": + return a.value; + case "linear": + return lerp(a.value, b.value, raw); + case "smooth": + return lerp(a.value, b.value, smoothstep(raw)); + } +} + +/** Sample a scalar (`number`) keyframe track. */ +function sampleScalarTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: number, +): number { + return sampleKeyframeTrack(track, frame, fallback, lerpNumber); +} + +/** Sample an `AnimPair` keyframe track. */ +function samplePairTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: AnimPair, +): AnimPair { + return sampleKeyframeTrack(track, frame, fallback, lerpAnimPair); +} + +/** Sample a `Crop` keyframe track. */ +function sampleCropTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: Crop, +): Crop { + return sampleKeyframeTrack(track, frame, fallback, lerpCrop); +} + +/** Absolute timeline frame -> clip-relative offset used by track storage. */ +function keyframeOffset(clip: Clip, frame: number): number { + return frame - clip.startFrame; +} + +/** A track is active iff it holds at least one keyframe. */ +function trackIsActive(track: KeyframeTrack | undefined): boolean { + return !!track && track.keyframes.length > 0; +} + +/** + * 0..=1 envelope from the fade head/tail ramps. `min(in, out)`. Returns 0 + * outside `[0, durationFrames]` (closed interval, as upstream). 1:1 port of + * `Clip::fade_multiplier`. + */ +export function fadeMultiplier(clip: Clip, frame: number): number { + const rel = frame - clip.startFrame; + if (rel < 0 || rel > clip.durationFrames) return 0.0; + const inMul = + clip.fadeInFrames > 0 + ? clip.fadeInInterpolation === "smooth" + ? smoothstep(Math.min(rel / clip.fadeInFrames, 1.0)) + : Math.min(rel / clip.fadeInFrames, 1.0) + : 1.0; + const outRem = clip.durationFrames - rel; + const outMul = + clip.fadeOutFrames > 0 + ? clip.fadeOutInterpolation === "smooth" + ? smoothstep(Math.min(outRem / clip.fadeOutFrames, 1.0)) + : Math.min(outRem / clip.fadeOutFrames, 1.0) + : 1.0; + return Math.min(inMul, outMul); +} + +/** Authored opacity without the fade envelope. 1:1 port of `Clip::raw_opacity_at`. */ +export function rawOpacityAt(clip: Clip, frame: number): number { + return sampleScalarTrack(clip.opacityTrack, keyframeOffset(clip, frame), clip.opacity); +} + +/** + * Effective opacity at `frame`: authored value × fade envelope (visual clips + * only; audio short-circuits before fade). 1:1 port of `Clip::opacity_at`. + */ +export function opacityAt(clip: Clip, frame: number): number { + const base = rawOpacityAt(clip, frame); + if (clip.mediaType === "audio" || (clip.fadeInFrames === 0 && clip.fadeOutFrames === 0)) { + return base; + } + return base * fadeMultiplier(clip, frame); +} + +/** + * Effective linear volume: keyframe envelope (dB) first, fade ramp on top, + * static volume as outer gain. 1:1 port of `Clip::volume_at`. + */ +export function volumeAt(clip: Clip, frame: number): number { + const kfGain = trackIsActive(clip.volumeTrack) + ? linearFromDb(sampleScalarTrack(clip.volumeTrack, keyframeOffset(clip, frame), 0.0)) + : 1.0; + return clip.volume * kfGain * fadeMultiplier(clip, frame); +} + +/** Sampled rotation (degrees) at `frame`. 1:1 port of `Clip::rotation_at`. */ +export function rotationAt(clip: Clip, frame: number): number { + return sampleScalarTrack(clip.rotationTrack, keyframeOffset(clip, frame), clip.transform.rotation); +} + +/** Sampled `(width, height)` at `frame`. 1:1 port of `Clip::size_at`. */ +export function sizeAt(clip: Clip, frame: number): [number, number] { + const fallback: AnimPair = { a: clip.transform.width, b: clip.transform.height }; + const s = samplePairTrack(clip.scaleTrack, keyframeOffset(clip, frame), fallback); + return [s.a, s.b]; +} + +/** Sampled top-left (normalized canvas space) at `frame`. 1:1 port of `Clip::top_left_at`. */ +export function topLeftAt(clip: Clip, frame: number): { x: number; y: number } { + if (trackIsActive(clip.positionTrack)) { + const p = samplePairTrack(clip.positionTrack, keyframeOffset(clip, frame), { a: 0, b: 0 }); + return { x: p.a, y: p.b }; + } + const [w, h] = sizeAt(clip, frame); + return { + x: clip.transform.centerX - w / 2.0, + y: clip.transform.centerY - h / 2.0, + }; +} + +/** Sampled crop insets at `frame`. 1:1 port of `Clip::crop_at`. */ +export function cropAt(clip: Clip, frame: number): Crop { + return sampleCropTrack(clip.cropTrack, keyframeOffset(clip, frame), clip.crop); +} + +/** Whether any transform-related track is active. 1:1 port of `Clip::has_transform_animation`. */ +export function hasTransformAnimation(clip: Clip): boolean { + return ( + trackIsActive(clip.positionTrack) || + trackIsActive(clip.scaleTrack) || + trackIsActive(clip.rotationTrack) + ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5c2ad67..a35a19f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -123,6 +123,18 @@ export interface ClipPropertiesReq { opacity?: number; transform?: Transform; textContent?: string; + /** Per-clip crop insets (normalized 0–1). Clears `cropTrack` on the backend. */ + crop?: Crop; + /** Fade-in length in frames. Clamped to clip duration on the backend. */ + fadeInFrames?: number; + /** Fade-out length in frames. Clamped to clip duration on the backend. */ + fadeOutFrames?: number; + fadeInInterpolation?: Interpolation; + fadeOutInterpolation?: Interpolation; + /** Writes to `transform.flipHorizontal` on the backend. */ + flipHorizontal?: boolean; + /** Writes to `transform.flipVertical` on the backend. */ + flipVertical?: boolean; } /** Which property a keyframe track targets (mirror of `KeyframeProperty`). */ From ee0baf5bbee1529382547475dd43dc83a8f21562 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Thu, 25 Jun 2026 23:04:01 +0800 Subject: [PATCH 4/7] feat(#93): clip context menu (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#93): add clip right-click context menu Closes #93. - New ClipContextMenu component with Split / Delete / Link-Unlink - TimelineContainer: onContextMenu hit-tests the clip, selects it if needed, and opens the menu; closes on outside click or Escape - i18n: contextMenu.split/delete/link/unlink (zh-CN + en) * fix(#93): menu cursor positioning + viewport flip; remove render-phase onClose() Blocking items from review: 1. Menu now follows cursor (x/y from onContextMenu -> ClipContextMenu left/top) with useLayoutEffect viewport-boundary flip (right/bottom overflow -> open left/up). 2. Removed onClose() call during render; clip-missing now returns null and reports close via useEffect (no parent setState mid-render). Minor items: - Added disabled placeholder items: Swap Media / Save as Media / Extract Audio. - Replaced key={i} with stable key={item.id}. - Replaced imperative onMouseEnter/Leave DOM mutation with CSS :hover. * fix(timeline): remove duplicate context menu handler * feat(inspector/swap-media): gate + picker modal for Swap Media entry Wire the Swap Media context-menu action in ClipContextMenu.tsx: - Availability gate: enabled only when the clip is non-text AND alone in its link group (SPEC §5.10 "非 text 且单链组" = upstream TimelineView.menu). Multi-clip link groups (e.g. linked A/V pairs) stay disabled to avoid desyncing partners. - On click, opens a media-picker modal pre-filtered by strict type equality (item.type === clip.mediaType, mirroring upstream isAssetCompatibleWithPendingSwap). Backend re-validates as a safety net. New files: - web/src/components/timeline/SwapMediaPicker.tsx: modal list of compatible library assets; calls edit.swapMedia() on selection; shows backend error message (e.g. type-mismatch refusal) inline; Esc-to-close. New helpers / state: - web/src/lib/clip.ts: isSingleLinkGroup(clip, timeline) helper. - web/src/store/uiStore.ts: pendingSwapClipId + setPendingSwapClipId. Touched: - web/src/components/timeline/ClipContextMenu.tsx: gate + open picker. - web/src/components/timeline/TimelineContainer.tsx: render SwapMediaPicker. - web/src/i18n/dict.ts: swapMedia.noCandidates (zh + en). - web/src/lib/types.ts: swapMedia EditRequest variant. - web/src/store/editActions.ts: 2-arg swapMedia wrapper around editApply. Pairs with feat-101-swap-media (the backend `replaceClipMediaRef( resetTrim=false)` route). tsc --noEmit + pnpm build green. * fix(#93): bind onContextMenu to content canvas (TS6133 unused) --------- Co-authored-by: baiqing --- .../components/timeline/ClipContextMenu.tsx | 235 ++++++++++++++++++ .../components/timeline/SwapMediaPicker.tsx | 211 ++++++++++++++++ .../components/timeline/TimelineContainer.tsx | 36 +++ web/src/i18n/dict.ts | 22 ++ web/src/lib/clip.ts | 16 ++ web/src/store/editActions.ts | 7 +- web/src/store/uiStore.ts | 10 + 7 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 web/src/components/timeline/ClipContextMenu.tsx create mode 100644 web/src/components/timeline/SwapMediaPicker.tsx diff --git a/web/src/components/timeline/ClipContextMenu.tsx b/web/src/components/timeline/ClipContextMenu.tsx new file mode 100644 index 0000000..d16b01d --- /dev/null +++ b/web/src/components/timeline/ClipContextMenu.tsx @@ -0,0 +1,235 @@ +/** + * ClipContextMenu (SPEC §5.8). Right-click menu for timeline clips. MVP items: + * Split at Playhead / Delete / Link or Unlink. Copy/Cut/Paste will be added + * once the clipboard PR (#94) lands. Closes on outside click or item action. + * + * Positioning (#93 review #108): the root element is position:fixed and placed + * at the viewport coords (left/top) passed in from TimelineContainer, which + * captured them from the contextmenu event's clientX/clientY. If the menu would + * overflow the viewport edge it flips to the opposite side of the click point. + */ + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useProjectStore } from "../../store/projectStore"; +import { useEditorUiStore } from "../../store/uiStore"; +import * as edit from "../../store/editActions"; +import { useT } from "../../i18n"; +import { isSingleLinkGroup } from "../../lib/clip"; +import type { Clip } from "../../lib/types"; + +// Fixed size estimate for viewport-boundary flipping before the menu is +// measured. Close to the rendered size so the flip decision is correct on the +// first paint; the actual size is re-measured in a layout effect below. +const MENU_ESTIMATE = { width: 180, height: 240 }; + +export function ClipContextMenu({ + clipId, + left, + top, + onClose, +}: { + clipId: string; + left: number; + top: number; + onClose: () => void; +}) { + const t = useT(); + const timeline = useProjectStore((s) => s.timeline); + const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds); + const selectClips = useEditorUiStore((s) => s.selectClips); + const setPendingSwapClipId = useEditorUiStore((s) => s.setPendingSwapClipId); + const ref = useRef(null); + + // Compute the final position with viewport-boundary flipping. Start from the + // estimate so the first paint is already correct; re-measure after mount. + const [pos, setPos] = useState(() => ({ + left: flipLeft(left, MENU_ESTIMATE.width), + top: flipTop(top, MENU_ESTIMATE.height), + })); + + // Re-measure with the real DOM size once mounted (before paint, no flicker). + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const w = el.offsetWidth; + const h = el.offsetHeight; + setPos({ left: flipLeft(left, w), top: flipTop(top, h) }); + }, [left, top]); + + // Close on outside click or Escape. + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + // Locate the clip to read linkGroupId + mediaType for menu gating. The parent + // (TimelineContainer) already validates the clip exists in onContextMenu before + // opening the menu, so a missing clip here is a stale-state edge case — just + // render nothing. Do NOT call onClose() during render (React render purity, + // review #108 item 2). + let clip: Clip | null = null; + for (const track of timeline.tracks) { + const found = track.clips.find((c) => c.id === clipId); + if (found) { + clip = found; + break; + } + } + if (!clip) return null; + + // The menu acts on the current selection; if the right-clicked clip isn't + // selected, select just it (mirrors typical NLE behavior). + const isSelected = selectedClipIds.has(clipId); + const ensureSelected = () => { + if (!isSelected) selectClips(new Set([clipId])); + }; + + const items: Array<{ id: string; label: string; action: () => void; danger?: boolean; disabled?: boolean }> = [ + { + id: "split", + label: t("contextMenu.split"), + action: () => { + ensureSelected(); + void edit.splitAtPlayhead(); + }, + }, + { + id: "delete", + label: t("contextMenu.delete"), + action: () => { + ensureSelected(); + void edit.deleteSelectedClips(); + }, + danger: true, + }, + ]; + + // Link/Unlink: operate on the full selection (>= 2 clips to link). + if (clip.linkGroupId) { + items.push({ + id: "unlink", + label: t("contextMenu.unlink"), + action: () => { + ensureSelected(); + const ids = [...useEditorUiStore.getState().selectedClipIds]; + if (ids.length > 0) void edit.unlinkClips(ids); + }, + }); + } else { + items.push({ + id: "link", + label: t("contextMenu.link"), + action: () => { + ensureSelected(); + const ids = [...useEditorUiStore.getState().selectedClipIds]; + if (ids.length >= 2) void edit.linkClips(ids); + }, + }); + } + + // Swap Media: enabled only for non-text clips that are alone in their link + // group (SPEC §5.10 "非 text 且单链组"). A multi-clip link group (e.g. linked + // A/V pair) is disabled to avoid desyncing partners. On click, opens the + // SwapMediaPicker modal (pre-filters candidates by strict type equality). + const swapDisabled = clip.mediaType === "text" || !isSingleLinkGroup(clip, timeline); + + // Disabled placeholders for upcoming features (no action on click). + items.push( + { + id: "swapMedia", + label: t("contextMenu.swapMedia"), + action: () => { + ensureSelected(); + setPendingSwapClipId(clipId); + }, + disabled: swapDisabled, + }, + { id: "saveAsMedia", label: t("contextMenu.saveAsMedia"), action: () => {}, disabled: true }, + { id: "extractAudio", label: t("contextMenu.extractAudio"), action: () => {}, disabled: true }, + ); + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +// Flip the menu to stay within the viewport horizontally. If the menu would +// overflow the right edge, render it to the left of the click point instead. +function flipLeft(left: number, menuWidth: number): number { + if (left + menuWidth > window.innerWidth) { + return Math.max(0, left - menuWidth); + } + return left; +} + +// Flip the menu to stay within the viewport vertically. If the menu would +// overflow the bottom edge, render it above the click point instead. +function flipTop(top: number, menuHeight: number): number { + if (top + menuHeight > window.innerHeight) { + return Math.max(0, top - menuHeight); + } + return top; +} diff --git a/web/src/components/timeline/SwapMediaPicker.tsx b/web/src/components/timeline/SwapMediaPicker.tsx new file mode 100644 index 0000000..acefe37 --- /dev/null +++ b/web/src/components/timeline/SwapMediaPicker.tsx @@ -0,0 +1,211 @@ +/** + * SwapMediaPicker (SPEC §5.10). Modal media picker shown when the user invokes + * Swap Media from the clip context menu. Lists library assets whose `type` + * strictly equals the target clip's `mediaType` (1:1 with upstream + * `isAssetCompatibleWithPendingSwap`), so type mismatch is prevented at the UI + * layer; the backend re-validates as a safety net. On selection, fires + * `edit.swapMedia` (which preserves trim/speed/keyframes/transform — + * `resetTrim=false` semantics) and cascades to linked clips sharing the same + * old mediaRef. + */ + +import { useEffect, useMemo, useState } from "react"; +import { useEditorUiStore } from "../../store/uiStore"; +import { useProjectStore } from "../../store/projectStore"; +import { useMediaStore } from "../../store/mediaStore"; +import * as edit from "../../store/editActions"; +import { useT } from "../../i18n"; +import { clipDisplayName } from "../../lib/clip"; +import { formatTimecode } from "../../lib/geometry"; +import type { Clip, MediaItem } from "../../lib/types"; + +export function SwapMediaPicker() { + const t = useT(); + const pendingSwapClipId = useEditorUiStore((s) => s.pendingSwapClipId); + const setPendingSwapClipId = useEditorUiStore((s) => s.setPendingSwapClipId); + const timeline = useProjectStore((s) => s.timeline); + const items = useMediaStore((s) => s.items); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const fps = timeline.fps; + + // Resolve the target clip from the pending id. + const clip: Clip | null = useMemo(() => { + if (!pendingSwapClipId) return null; + for (const track of timeline.tracks) { + const found = track.clips.find((c) => c.id === pendingSwapClipId); + if (found) return found; + } + return null; + }, [pendingSwapClipId, timeline]); + + // Close on Escape. + useEffect(() => { + if (!clip) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setPendingSwapClipId(null); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [clip, setPendingSwapClipId]); + + if (!clip) return null; + + // Pre-filter candidates by strict type equality (backend re-validates). + const candidates: MediaItem[] = items.filter( + (m) => m.type === clip.mediaType && m.id !== clip.mediaRef, + ); + + async function pick(item: MediaItem) { + if (busy) return; + setBusy(true); + setError(null); + try { + await edit.swapMedia(clip!.id, item.id); + setPendingSwapClipId(null); + } catch (e) { + // Backend refuses on type mismatch / missing clip (EditError::Refused). + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
setPendingSwapClipId(null)} + > +
e.stopPropagation()} + > +
+ + {t("contextMenu.swapMedia")} · {clipDisplayName(clip)} + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {candidates.length === 0 ? ( +
+ {t("swapMedia.noCandidates")} +
+ ) : ( + candidates.map((m) => ( + + )) + )} +
+
+
+ ); +} diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 5cb4d35..e7973e5 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -25,6 +25,8 @@ import { TrackHeaderColumn } from "./TrackHeaderColumn"; import { Playhead } from "./Playhead"; import { SnapIndicator } from "./SnapIndicator"; import { hitTestClip, expandLinkGroup, clipsInRect, type ClipHit } from "./hitTest"; +import { ClipContextMenu } from "./ClipContextMenu"; +import { SwapMediaPicker } from "./SwapMediaPicker"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; @@ -83,6 +85,9 @@ export function TimelineContainer() { const mountedRef = useRef(true); useEffect(() => () => { mountedRef.current = false; }, []); const [waveformVersion, setWaveformVersion] = useState(0); + // Right-click context menu state. `x/y` are viewport coords (clientX/clientY) + // so ClipContextMenu can position itself with position:fixed (#93 review #108). + const [menu, setMenu] = useState<{ clipId: string; x: number; y: number } | null>(null); const total = useMemo(() => totalFrames(timeline), [timeline]); const docWidth = useMemo( @@ -576,6 +581,23 @@ export function TimelineContainer() { // Ghost preview offsets for the active drag (read from dragRef during render). const drag = dragRef.current; + // Right-click on a clip -> context menu. + const onContextMenu = useCallback( + (e: React.MouseEvent) => { + const { docX, docY } = toDoc(e); + const hit = hitTestClip(timeline, docX, docY, zoomScale, trackHeights); + if (!hit) return; // empty space: keep the default (suppressed) menu + e.preventDefault(); + // If the clip isn't already selected, select just it so menu actions + // target the right clip. + if (!selectedClipIds.has(hit.clip.id)) { + selectClips(new Set([hit.clip.id])); + } + setMenu({ clipId: hit.clip.id, x: e.clientX, y: e.clientY }); + }, + [toDoc, timeline, zoomScale, trackHeights, selectedClipIds, selectClips], + ); + return (
)} + {/* Clip right-click context menu. */} + {menu && ( + setMenu(null)} + /> + )} + + {/* Swap Media picker modal (SPEC §5.10). */} + + {/* Horizontal scrollbar proxy (thin) — drag handled via wheel; kept minimal. */}
); diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 9f89217..b25d2d2 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -158,6 +158,17 @@ const zh: Dict = { "timeline.syncLock": "同步锁定", "timeline.dropHint": "将媒体拖到此处开始", + // Clip context menu (right-click) + "contextMenu.split": "在播放头处分割", + "contextMenu.delete": "删除", + "contextMenu.link": "链接", + "contextMenu.unlink": "取消链接", + // Disabled placeholders (issue #93 acceptance: menu must list these even if stub) + "contextMenu.swapMedia": "替换媒体", + "contextMenu.saveAsMedia": "另存为媒体", + "contextMenu.extractAudio": "提取音频", + "swapMedia.noCandidates": "没有同类型素材可替换", + // Preview "preview.fit": "适应", "preview.timelineTab": "时间线", @@ -385,6 +396,17 @@ const en: Dict = { "timeline.syncLock": "Sync lock", "timeline.dropHint": "Drop media here to start", + // Clip context menu (right-click) + "contextMenu.split": "Split at Playhead", + "contextMenu.delete": "Delete", + "contextMenu.link": "Link", + "contextMenu.unlink": "Unlink", + // Disabled placeholders (issue #93 acceptance: menu must list these even if stub) + "contextMenu.swapMedia": "Swap Media", + "contextMenu.saveAsMedia": "Save as Media", + "contextMenu.extractAudio": "Extract Audio", + "swapMedia.noCandidates": "No compatible media to swap", + "preview.fit": "Fit", "preview.timelineTab": "Timeline", "preview.noMedia": "No media", diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 1537b10..9aee63b 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -12,6 +12,7 @@ import type { ClipType, Crop, KeyframeTrack, + Timeline, TrimEditReq, } from "./types"; @@ -38,6 +39,21 @@ export function isLinked(clip: Clip): boolean { return clip.linkGroupId != null; } +/** Whether `clip` is alone in its link group (SPEC §5.10 "单链组" gate for + * Swap Media). True when the clip has no `linkGroupId`, or when no OTHER clip + * in the timeline shares the same `linkGroupId`. A multi-clip link group + * (e.g. linked A/V pair) disables Swap Media to avoid desyncing the partners. + * 1:1 with upstream `TimelineView.menu` Swap Media availability condition. */ +export function isSingleLinkGroup(clip: Clip, timeline: Timeline): boolean { + if (!clip.linkGroupId) return true; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.id !== clip.id && c.linkGroupId === clip.linkGroupId) return false; + } + } + return true; +} + /** Which edge a trim drag grabs. */ export type TrimEdge = "left" | "right"; diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index e37a313..38e0f5e 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -156,9 +156,10 @@ export async function moveToFolder(assetIds: string[], folderId?: string) { * (transform / crop / keyframe tracks / grade / masks / effects / fade / trim / * speed / start / duration). 1:1 port of upstream * `replaceClipMediaRef(resetTrim: false)`. The backend enforces a strict type - * match (`clip.mediaType == asset.type`) and cascades the swap across the - * link-group members that share the same old `mediaRef`; callers only need to - * pass the seed clip id and the candidate asset id. */ + * match (`clip.mediaType == asset.type`), no-ops on same mediaRef, and + * cascades the swap across the link-group members that share the same old + * `mediaRef`; callers only need to pass the seed clip id and the candidate + * asset id. */ export async function swapMedia(clipId: string, mediaRef: string) { await applyAndRefresh({ type: "swapMedia", clipId, mediaRef }); } diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 99e5715..c333062 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -104,6 +104,13 @@ interface UiState { // Media panel navigation mediaPanelCurrentFolderId: string | null; + /** Pending Swap Media flow (SPEC §5.10). When set, a media-picker modal is + * shown for the clip with this id; the picker pre-filters candidates by + * `item.type === clip.mediaType` (strict, mirroring backend + * `isAssetCompatibleWithPendingSwap`). `null` = no swap in flight. */ + pendingSwapClipId: string | null; + setPendingSwapClipId: (id: string | null) => void; + // Actions setActiveFrame: (frame: number) => void; setCurrentFrame: (frame: number) => void; @@ -191,6 +198,9 @@ export const useEditorUiStore = create((set, get) => ({ mediaPanelCurrentFolderId: null, + pendingSwapClipId: null, + setPendingSwapClipId: (pendingSwapClipId) => set({ pendingSwapClipId }), + setActiveFrame: (activeFrame) => set({ activeFrame }), setCurrentFrame: (currentFrame) => set({ currentFrame, activeFrame: currentFrame }), setPlaying: (isPlaying) => set({ isPlaying }), From 28a218c736321a408f967ff9178c2db5b2e1f4f7 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Thu, 25 Jun 2026 23:11:21 +0800 Subject: [PATCH 5/7] feat(#99): snap/offset/volume envelope (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(timeline): 吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋 (#99) 1. 吸附迟滞 + 多探针 - snap.ts: findSnapDelta 扩展接受 currentlySnapped + probeOffsets, 返回 probeOffset,支持 sticky band 跨 pointer 事件保持 - TimelineContainer.tsx: 新增 snapStateRef 跨事件保持吸附状态; onPointerMove move 分支收集所有 companions 的 start+end 作为探针组, 改用 findSnapDelta(不再传 null);onPointerUp 清空 snapStateRef 2. 链接 offset 角标 - clip.ts: 新增 linkOffsetForClip 计算链接组内帧偏移(相对 lead clip) - clipRenderer.ts: 新增 drawOffsetBadge 绘制红色圆角徽章 "+N"/"-N" - timelineCanvas.ts: clip 绘制参数增加 linkOffset,调用 drawOffsetBadge 3. 音量橡皮筋 - clipRenderer.ts: 新增 drawVolumeEnvelope 绘制 volumeTrack 折线 + kf 圆点 (半径 5px,黄色填充白色边框);拖拽时 ghost dot 跟随光标 - hitTest.ts: 新增 audioVolumeKfHit 命中测试(8px 容差) - TimelineContainer.tsx: 新增 audioVolumeKf DragState + 拖拽逻辑; Cmd+click 空白处调 stampKeyframe - editActions.ts: moveKeyframe / stampKeyframe 实现为前端 wrapper (read-modify-write over setKeyframes,因后端仅暴露 SetKeyframes) 验证:pnpm tsc --noEmit 通过;pnpm build 通过;52 项测试全通过 * fix(pr-120): offset badge top-right + move drag excludes playhead (review #120) Two PR #120 review request-changes fixes, both for spec 5.7 / 5.4 1:1 port correctness: 1. drawOffsetBadge anchored to the right edge of the clip, just inside the right trim handle (ClipRenderer.swift:640-644). The old top-left position sat on top of the color strip and label, and the new width-guard reserves room for the trim handle so the badge never overlaps it. 2. Move drag no longer includes the playhead in the snap target set. The old collectTargets(timeline, excluded, activeFrame) made moving clips stick to the playhead, which felt like a bug. Pass null (the same exclusion the trim path uses) so a move only snaps to other clip edges and the playhead stays a passive reference. pnpm tsc --noEmit + pnpm build + pnpm test 52/52 green. --- .../components/timeline/TimelineContainer.tsx | 130 ++++++++++++++-- web/src/components/timeline/clipRenderer.ts | 146 ++++++++++++++++++ web/src/components/timeline/hitTest.ts | 57 ++++++- web/src/components/timeline/timelineCanvas.ts | 13 +- web/src/lib/clip.ts | 36 +++++ web/src/lib/snap.ts | 41 ++++- 6 files changed, 403 insertions(+), 20 deletions(-) diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index e7973e5..af17efc 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -17,14 +17,14 @@ import { } from "../../lib/geometry"; import { firstAudioIndex } from "../../lib/zones"; import { clampTrimDeltaFrames, trimSourceValues } from "../../lib/clip"; -import { collectTargets, findSnap } from "../../lib/snap"; +import { collectTargets, findSnap, findSnapDelta } from "../../lib/snap"; import { paintTimeline, type DragPaint } from "./timelineCanvas"; import { useT } from "../../i18n"; import { paintRuler } from "./rulerCanvas"; import { TrackHeaderColumn } from "./TrackHeaderColumn"; import { Playhead } from "./Playhead"; import { SnapIndicator } from "./SnapIndicator"; -import { hitTestClip, expandLinkGroup, clipsInRect, type ClipHit } from "./hitTest"; +import { hitTestClip, expandLinkGroup, clipsInRect, audioVolumeKfHit, type ClipHit } from "./hitTest"; import { ClipContextMenu } from "./ClipContextMenu"; import { SwapMediaPicker } from "./SwapMediaPicker"; import { useProjectStore } from "../../store/projectStore"; @@ -38,6 +38,7 @@ type DragState = | { kind: "move"; hit: ClipHit; grabFrame: number; deltaFrames: number; startTrack: number; targetTrack: number; companions: string[] } | { kind: "trimLeft" | "trimRight"; hit: ClipHit; startTrim: number; deltaFrames: number } | { kind: "marquee"; startDocX: number; startDocY: number; curDocX: number; curDocY: number } + | { kind: "audioVolumeKf"; clipId: string; fromFrame: number; ghostFrame: number } | null; export function TimelineContainer() { @@ -71,6 +72,10 @@ export function TimelineContainer() { const rulerCanvasRef = useRef(null); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const dragRef = useRef(null); + // Snap hysteresis: keeps the snapped {frame, probeOffset} across pointer + // events so the sticky band (1.5x threshold) holds the clip on its target + // instead of jittering at the edge (SPEC §5.7). Cleared on pointerUp. + const snapStateRef = useRef<{ frame: number; probeOffset: number } | null>(null); const [snapFrame, setSnapFrame] = useState(null); const [dragTick, forceTick] = useState(0); const t = useT(); @@ -168,6 +173,13 @@ export function TimelineContainer() { edge: d.kind === "trimLeft" ? "left" : "right", deltaFrames: d.deltaFrames, }; + } else if (d?.kind === "audioVolumeKf") { + drag = { + kind: "volumeKf", + clipId: d.clipId, + fromFrame: d.fromFrame, + ghostFrame: d.ghostFrame, + }; } paintTimeline(ctx, { timeline, @@ -355,6 +367,40 @@ export function TimelineContainer() { return; } + // Volume-keyframe dot drag (non-Cmd, non-shift): grab a volume kf dot to + // move it (SPEC §5.4 volume envelope). Checked before the clip-body hit so + // a dot click drags the kf instead of starting a clip move. + if (!e.metaKey && !e.shiftKey) { + const kfHit = audioVolumeKfHit(timeline, docX, docY, zoomScale, trackHeights); + if (kfHit) { + selectClips(new Set([kfHit.clipId])); + dragRef.current = { + kind: "audioVolumeKf", + clipId: kfHit.clipId, + fromFrame: kfHit.frame, + ghostFrame: kfHit.frame, + }; + return; + } + } + + // Cmd+click on an audio clip's volume line (not a kf dot) → stamp a new + // volume keyframe at the clicked frame (SPEC §5.4). A click landing on an + // existing dot is a no-op (the kf already exists there). + if (e.metaKey && hit && hit.clip.mediaType === "audio") { + const onDot = audioVolumeKfHit(timeline, docX, docY, zoomScale, trackHeights) !== null; + if (!onDot) { + const clipFrame = Math.max( + 0, + Math.min(hit.clip.durationFrames, frameAt(docX, zoomScale) - hit.clip.startFrame), + ); + void edit.stampKeyframe(hit.clip.id, "volume", clipFrame); + } + selectClips(new Set([hit.clip.id])); + dragRef.current = null; + return; + } + if (hit) { // Selection logic (linkedOn = !Option). const linked = !e.altKey; @@ -435,25 +481,48 @@ export function TimelineContainer() { if (d.kind === "move") { const rawFrame = frameAt(docX, zoomScale); let deltaFrames = rawFrame - d.grabFrame; - // Snap: probe the moved clip's edges. + // Snap: probe every companion's start+end (multi-probe, SPEC §5.8) and + // keep the snap engaged across moves via snapStateRef (sticky band). + // Pass `null` for the playhead so a move drag never snaps to the + // playhead — that would feel like the clips are "pinned" to it (the + // PR #120 review request-changes fix). const excluded = new Set(d.companions); - const targets = collectTargets(timeline, excluded, activeFrame); - const movedStart = d.hit.clip.startFrame + deltaFrames; - const movedEnd = movedStart + d.hit.clip.durationFrames; - const snapStart = findSnap(movedStart, targets, zoomScale, null); - const snapEnd = findSnap(movedEnd, targets, zoomScale, null); + const targets = collectTargets(timeline, excluded, null); + const leadStart = d.hit.clip.startFrame; + const probes: number[] = []; + const probeOffsets: number[] = []; + for (const id of d.companions) { + const loc = findClipLoc(timeline, id); + if (!loc) continue; + const c = timeline.tracks[loc[0]].clips[loc[1]]; + const startOff = c.startFrame - leadStart; + const endOff = startOff + c.durationFrames; + // Moved absolute frame = lead's moved start + this probe's offset. + probes.push(leadStart + deltaFrames + startOff); + probeOffsets.push(startOff); + probes.push(leadStart + deltaFrames + endOff); + probeOffsets.push(endOff); + } + const snap = findSnapDelta( + probes, + targets, + zoomScale, + snapStateRef.current, + probeOffsets, + ); let snapped: number | null = null; - if (snapStart && (!snapEnd || Math.abs(snapStart.frame - movedStart) <= Math.abs(snapEnd.frame - movedEnd))) { - deltaFrames += snapStart.frame - movedStart; - snapped = snapStart.frame; - } else if (snapEnd) { - deltaFrames += snapEnd.frame - movedEnd; - snapped = snapEnd.frame; + if (snap) { + deltaFrames += snap.delta; + snapStateRef.current = { frame: snap.snappedFrame, probeOffset: snap.probeOffset }; + snapped = snap.snappedFrame; + } else { + snapStateRef.current = null; } // Clamp so the clip can't go before frame 0. if (d.hit.clip.startFrame + deltaFrames < 0) { deltaFrames = -d.hit.clip.startFrame; snapped = null; + snapStateRef.current = null; } const targetTrack = trackAt(timeline, docY, trackHeights) ?? d.startTrack; dragRef.current = { ...d, deltaFrames, targetTrack }; @@ -462,6 +531,27 @@ export function TimelineContainer() { return; } + if (d.kind === "audioVolumeKf") { + const loc = findClipLoc(timeline, d.clipId); + if (!loc) return; + const clip = timeline.tracks[loc[0]].clips[loc[1]]; + // Cursor → clip-relative frame, clamped to the clip's span. + let ghostFrame = frameAt(docX, zoomScale) - clip.startFrame; + // Snap to the playhead (±5 frames, clip-relative) so a kf can be parked + // exactly on the playhead for precise editing. + const playheadRel = activeFrame - clip.startFrame; + if (Math.abs(ghostFrame - playheadRel) <= 5) { + ghostFrame = playheadRel; + setSnapFrame(activeFrame); + } else { + setSnapFrame(null); + } + ghostFrame = Math.max(0, Math.min(clip.durationFrames, ghostFrame)); + dragRef.current = { ...d, ghostFrame }; + forceTick((n) => n + 1); + return; + } + if (d.kind === "trimLeft" || d.kind === "trimRight") { const rawFrame = frameAt(docX, zoomScale); const edge = d.kind === "trimLeft" ? d.hit.clip.startFrame : d.hit.clip.startFrame + d.hit.clip.durationFrames; @@ -510,6 +600,7 @@ export function TimelineContainer() { (e: React.PointerEvent) => { const d = dragRef.current; dragRef.current = null; + snapStateRef.current = null; setSnapFrame(null); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); if (!d) return; @@ -556,6 +647,17 @@ export function TimelineContainer() { return; } + if (d.kind === "audioVolumeKf") { + // Commit the keyframe move only when the frame actually changed (a bare + // click on a dot is a no-op). The backend `moveKeyframe` is idempotent + // for fromFrame === toFrame, but skipping the round-trip avoids an + // unnecessary history entry. + if (d.ghostFrame !== d.fromFrame) { + void edit.moveKeyframe(d.clipId, "volume", d.fromFrame, d.ghostFrame); + } + return; + } + if (d.kind === "trimLeft" || d.kind === "trimRight") { if (d.deltaFrames === 0) return; const edge = d.kind === "trimLeft" ? "left" : "right"; diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index 200070d..1190b0c 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -23,8 +23,19 @@ interface DrawOpts { /** This clip is being dragged (move/trim ghost): drawn semi-transparent at its * live position so it follows the cursor. */ ghost?: boolean; + /** Link-group frame offset vs the lead clip (null = unlinked or is lead). + * When non-null/non-zero, a red badge "+N"/"-N" is drawn at the top-left. */ + linkOffset?: number | null; + /** Volume-keyframe drag ghost: when set, the dot at `fromFrame` is hidden and + * a ghost dot is drawn at `ghostFrame` (same value) so the grabbed keyframe + * follows the cursor (SPEC §5.4). Only set on the dragged clip. */ + volumeKfGhost?: { fromFrame: number; ghostFrame: number }; } +/** Radius of the draggable volume-keyframe dots drawn by `drawVolumeEnvelope`. + * Kept in sync with the hit-test tolerance in `hitTest.ts` (8px incl. tol). */ +export const VOLUME_KF_DOT_RADIUS = 5; + /** Linear amplitude → dB, clamped to the volume slider range. 1:1 port of * `VolumeScale.dbFromLinear` (opentake-domain clip.rs). */ export function dbFromLinear(linear: number): number { @@ -200,6 +211,19 @@ export function drawClip( ctx.restore(); } + // 8. Volume envelope (audio only): a rubber-band polyline over the body plus + // draggable keyframe dots (SPEC §5.4 volume envelope). Drawn before the + // bottom keyframe diamonds so the dots sit above the waveform fill. + if (clip.mediaType === "audio") { + drawVolumeEnvelope(ctx, clip, rect, opts.volumeKfGhost); + } + + // 8b. Link-offset badge: red "+N"/"-N" at the top-left when this clip is out + // of step with its link-group lead (SPEC §5.4 linked-offset indicator). + if (opts.linkOffset != null && opts.linkOffset !== 0) { + drawOffsetBadge(ctx, opts.linkOffset, rect); + } + // 9. Keyframe diamonds along the bottom (ClipRenderer:163-191), y = maxY-5. drawKeyframeMarkers(ctx, clip, rect); @@ -408,3 +432,125 @@ function drawKeyframeMarkers(ctx: CanvasRenderingContext2D, clip: Clip, rect: Cl function cssFontStack(): string { return '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", system-ui, sans-serif'; } + +/** + * Volume-envelope rubber band for audio clips (SPEC §5.4). Draws a polyline + * through `clip.volumeTrack` keyframes (linear amplitude → body y), with a + * draggable dot at each keyframe. When no track exists, a flat line at + * `clip.volume` is drawn so the user still sees the static level. Frames are + * clip-relative (0 = clip start); x mapping matches `drawKeyframeMarkers` so the + * envelope dots align vertically with the bottom keyframe diamonds. + */ +function drawVolumeEnvelope( + ctx: CanvasRenderingContext2D, + clip: Clip, + rect: ClipRect, + ghost?: { fromFrame: number; ghostFrame: number }, +) { + if (clip.durationFrames <= 0) return; + const ppf = (rect.width - 2 * TRIM.handleWidth) / clip.durationFrames; + if (ppf <= 0) return; + const baseX = rect.x + TRIM.handleWidth; + const bodyTop = rect.y + CLIP.labelBarHeight; + const bodyH = rect.height - CLIP.labelBarHeight; + if (bodyH <= 6) return; + // Map linear volume [0,1] → body [bottom, top]; clamp for display only. + const yForVol = (v: number) => { + const c = Math.max(0, Math.min(1, v)); + return bodyTop + bodyH * (1 - c); + }; + const track = clip.volumeTrack; + const kfs = track ? [...track.keyframes].sort((a, b) => a.frame - b.frame) : []; + ctx.save(); + ctx.beginPath(); + if (kfs.length === 0) { + const y = yForVol(clip.volume); + ctx.moveTo(baseX, y); + ctx.lineTo(baseX + clip.durationFrames * ppf, y); + } else { + // Extend the first/last keyframe value across the clip's full span so the + // line spans edge to edge (matches upstream's sampled envelope). + ctx.moveTo(baseX, yForVol(kfs[0].value)); + for (const kf of kfs) { + const f = Math.max(0, Math.min(clip.durationFrames, kf.frame)); + ctx.lineTo(baseX + f * ppf, yForVol(kf.value)); + } + ctx.lineTo(baseX + clip.durationFrames * ppf, yForVol(kfs[kfs.length - 1].value)); + } + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 1.25; + ctx.stroke(); + // Draggable keyframe dots. While dragging (ghost set), hide the original dot + // at fromFrame and draw a ghost dot at ghostFrame (same value) so the grabbed + // keyframe follows the cursor without leaving a stale dot behind. + if (kfs.length > 0) { + const fromKf = ghost ? kfs.find((k) => k.frame === ghost.fromFrame) : undefined; + for (const kf of kfs) { + if (ghost && kf.frame === ghost.fromFrame) continue; // hidden — drawn as ghost below + if (kf.frame < 0 || kf.frame > clip.durationFrames) continue; + const kx = baseX + kf.frame * ppf; + const ky = yForVol(kf.value); + ctx.beginPath(); + ctx.arc(kx, ky, VOLUME_KF_DOT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemYellow; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.95)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + if (ghost && fromKf) { + const gf = Math.max(0, Math.min(clip.durationFrames, ghost.ghostFrame)); + const gx = baseX + gf * ppf; + const gy = yForVol(fromKf.value); + ctx.beginPath(); + ctx.arc(gx, gy, VOLUME_KF_DOT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemOrange; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,1)"; + ctx.lineWidth = 1.25; + ctx.stroke(); + } + } + ctx.restore(); +} + +/** + * Link-offset badge: a small red rounded pill at the clip's top-left showing the + * frame offset vs the link-group lead ("+N" when this clip trails, "-N" when it + * leads in time beyond the lead start). Drawn inside the body so it doesn't + * overlap the label bar (SPEC §5.4 linked-offset indicator). + */ +function drawOffsetBadge(ctx: CanvasRenderingContext2D, offsetFrames: number, rect: ClipRect) { + const n = Math.abs(offsetFrames); + const sign = offsetFrames > 0 ? "+" : "-"; + const label = `${sign}${n}`; + ctx.save(); + ctx.font = `600 9px ${cssFontStack()}`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + const textW = ctx.measureText(label).width; + const padX = 4; + const badgeH = 13; + const badgeW = Math.ceil(textW + padX * 2); + // Skip when the clip is too small to legibly hold the badge (now reserves + // room for the right trim handle too — the badge must never overlap it). + if (rect.width < badgeW + CLIP.stripWidth + TRIM.handleWidth + 6 || rect.height < CLIP.labelBarHeight + badgeH + 2) { + ctx.restore(); + return; + } + // Anchor to the right edge, just inside the right trim handle. Upstream + // `ClipRenderer.swift:640-644` draws the offset pill in the top-right so + // it doesn't sit on top of the color strip or the trim handle (the PR + // #120 review request-changes fix). + const bx = rect.x + rect.width - TRIM.handleWidth - badgeW - 2; + const by = rect.y + CLIP.labelBarHeight + 2; + roundRectPath(ctx, bx, by, badgeW, badgeH, 3); + ctx.fillStyle = ACCENT.offsetBadge; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 0.5; + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.fillText(label, bx + padX, by + badgeH / 2 + 0.5); + ctx.restore(); +} diff --git a/web/src/components/timeline/hitTest.ts b/web/src/components/timeline/hitTest.ts index a5008d8..16dcc89 100644 --- a/web/src/components/timeline/hitTest.ts +++ b/web/src/components/timeline/hitTest.ts @@ -5,7 +5,7 @@ * (already offset for the header column and scroll by the caller). */ -import { TRIM } from "../../lib/theme"; +import { TRIM, CLIP } from "../../lib/theme"; import { clipRect } from "../../lib/geometry"; import type { Timeline, Clip } from "../../lib/types"; @@ -95,3 +95,58 @@ export function clipsInRect( } return out; } + +/** Hit radius (px) for a volume-keyframe dot — the dot is drawn at 5px radius, + * plus 3px of grab tolerance so a fast click still grabs it. */ +const VOLUME_KF_HIT_RADIUS = 8; + +/** Result of hitting a draggable volume-keyframe dot. `frame` is clip-relative + * (0 = clip start), matching `Keyframe.frame` storage. */ +export interface VolumeKfHit { + clipId: string; + /** Clip-relative keyframe frame. */ + frame: number; +} + +/** + * Hit-test the draggable volume-keyframe dots drawn by `drawVolumeEnvelope` + * (SPEC §5.4). Returns the first audio clip's volume kf within the grab radius, + * or null. The dot position math mirrors `drawVolumeEnvelope` exactly so a + * visible dot is always grabbable. `docX`/`docY` are document-space (already + * scroll-adjusted by the caller, same convention as `hitTestClip`). + */ +export function audioVolumeKfHit( + timeline: Timeline, + docX: number, + docY: number, + pixelsPerFrame: number, + trackHeights: Record, +): VolumeKfHit | null { + for (let ti = 0; ti < timeline.tracks.length; ti++) { + const track = timeline.tracks[ti]; + for (const clip of track.clips) { + if (clip.mediaType !== "audio") continue; + const track2 = clip.volumeTrack; + if (!track2 || track2.keyframes.length === 0) continue; + const rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); + if (clip.durationFrames <= 0) continue; + const ppf = (rect.width - 2 * TRIM.handleWidth) / clip.durationFrames; + if (ppf <= 0) continue; + const baseX = rect.x + TRIM.handleWidth; + const bodyTop = rect.y + CLIP.labelBarHeight; + const bodyH = rect.height - CLIP.labelBarHeight; + for (const kf of track2.keyframes) { + if (kf.frame < 0 || kf.frame > clip.durationFrames) continue; + const kx = baseX + kf.frame * ppf; + const c = Math.max(0, Math.min(1, kf.value)); + const ky = bodyTop + bodyH * (1 - c); + const dx = docX - kx; + const dy = docY - ky; + if (dx * dx + dy * dy <= VOLUME_KF_HIT_RADIUS * VOLUME_KF_HIT_RADIUS) { + return { clipId: clip.id, frame: kf.frame }; + } + } + } + } + return null; +} diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index be676b2..1781793 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -7,6 +7,7 @@ import { BG, BORDER, TEXT } from "../../lib/theme"; import { clipRect, trackDisplayHeight, trackY } from "../../lib/geometry"; +import { linkOffsetForClip } from "../../lib/clip"; import { drawClip } from "./clipRenderer"; import type { Timeline } from "../../lib/types"; @@ -43,7 +44,8 @@ export interface PaintState { /** A live move/trim, projected for ghost rendering. */ export type DragPaint = | { kind: "move"; ids: Set; deltaFrames: number; trackDelta: number } - | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number }; + | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number } + | { kind: "volumeKf"; clipId: string; fromFrame: number; ghostFrame: number }; export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { const { timeline, pixelsPerFrame, trackHeights, width, dpr, scrollLeft, scrollTop } = s; @@ -101,6 +103,13 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { ghost = true; } if (rect.x + rect.width < scrollLeft || rect.x > visRight) continue; + // Volume-kf drag ghost: when this clip is the one being dragged, tell the + // renderer to draw the grabbed dot at its ghost frame instead of the + // original, so the dot follows the cursor (SPEC §5.4). + const volumeKfGhost = + drag?.kind === "volumeKf" && drag.clipId === clip.id + ? { fromFrame: drag.fromFrame, ghostFrame: drag.ghostFrame } + : undefined; drawClip(ctx, clip, rect, { isSelected: s.selectedClipIds.has(clip.id), fps: timeline.fps, @@ -109,6 +118,8 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // asset's file is offline. missing: clip.mediaType !== "text" && s.missingMediaRefs.has(clip.mediaRef), ghost, + linkOffset: linkOffsetForClip(timeline, clip.id), + volumeKfGhost, }); } } diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 9aee63b..cfb3c93 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -346,3 +346,39 @@ export function hasTransformAnimation(clip: Clip): boolean { trackIsActive(clip.rotationTrack) ); } + + +/** + * Frame offset of `clipId` within its link group, relative to the group's lead + * (earliest-starting) clip. Returns `null` when the clip isn't linked, or when + * it IS the lead (offset 0 → no badge needed). A positive result means this + * clip starts LATER than the lead (e.g. audio trailing video by 3 frames → 3); + * negative means it starts earlier. Used by the offset badge renderer (SPEC + * §5.4 linked-offset indicator). + */ +export function linkOffsetForClip(timeline: Timeline, clipId: string): number | null { + let target: Clip | null = null; + let groupId: string | undefined; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.id === clipId) { + target = c; + groupId = c.linkGroupId; + } + } + } + if (!target || !groupId) return null; + // Collect every clip in the same link group, find the lead (min startFrame). + let leadStart = Number.POSITIVE_INFINITY; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.linkGroupId === groupId && c.startFrame < leadStart) { + leadStart = c.startFrame; + } + } + } + if (!Number.isFinite(leadStart)) return null; + const offset = target.startFrame - leadStart; + if (offset === 0) return null; // lead clip → no badge + return offset; +} diff --git a/web/src/lib/snap.ts b/web/src/lib/snap.ts index 466c687..4b918b9 100644 --- a/web/src/lib/snap.ts +++ b/web/src/lib/snap.ts @@ -85,21 +85,54 @@ export function findSnap( * Multi-probe snap (SPEC §5.8 `findSnap probeOffsets`): for a set of probe * offsets (e.g. start + end edges of all selected clips), find the snap that * yields the smallest correction, returning the delta to apply. + * + * `currentlySnapped` carries the previously snapped `{frame, probeOffset}` so + * the sticky band (1.5x) keeps the same probe engaged across pointer events + * (SnapEngine.swift:64-93) — without it, the snap would toggle off/on near the + * threshold edge and the clip would jitter. `probeOffsets` is a parallel array + * of stable per-probe identifiers (e.g. the frame offset from the lead clip's + * start); when omitted the probe index is used. The snapped `probeOffset` is + * returned so the caller can feed it back in on the next move. */ export function findSnapDelta( probeFrames: number[], targets: SnapTarget[], pixelsPerFrame: number, -): { delta: number; snappedFrame: number } | null { - let best: { delta: number; snappedFrame: number } | null = null; + currentlySnapped: { frame: number; probeOffset: number } | null = null, + probeOffsets?: number[], +): { delta: number; snappedFrame: number; probeOffset: number } | null { + if (probeFrames.length === 0) return null; + const offsets = probeOffsets ?? probeFrames.map((_, i) => i); + const baseThresholdFrames = SNAP.thresholdPixels / pixelsPerFrame; + const stickyBand = baseThresholdFrames * SNAP.stickyMultiplier; + + // Sticky: keep the held target engaged while its owning probe stays within + // the sticky band (1.5x). This mirrors findSnap's sticky branch but tracks + // WHICH probe was snapped via probeOffset. + if (currentlySnapped !== null) { + const idx = offsets.indexOf(currentlySnapped.probeOffset); + if (idx >= 0) { + const probe = probeFrames[idx]; + if (Math.abs(probe - currentlySnapped.frame) <= stickyBand) { + return { + delta: currentlySnapped.frame - probe, + snappedFrame: currentlySnapped.frame, + probeOffset: currentlySnapped.probeOffset, + }; + } + } + } + + let best: { delta: number; snappedFrame: number; probeOffset: number } | null = null; let bestDist = Number.POSITIVE_INFINITY; - for (const probe of probeFrames) { + for (let i = 0; i < probeFrames.length; i++) { + const probe = probeFrames[i]; const res = findSnap(probe, targets, pixelsPerFrame, null); if (!res) continue; const dist = Math.abs(res.frame - probe); if (dist < bestDist) { bestDist = dist; - best = { delta: res.frame - probe, snappedFrame: res.frame }; + best = { delta: res.frame - probe, snappedFrame: res.frame, probeOffset: offsets[i] }; } } return best; From 83482cf365f113241182d0504138751d569dd359 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Thu, 25 Jun 2026 23:18:30 +0800 Subject: [PATCH 6/7] feat(#98): drag-drop new track + Option/Alt-drag duplicate (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(timeline): drag-drop new track + Option/Alt-drag duplicate (#98) Backend (Rust): - Add `opentake_ops::ops::duplicate::duplicate_clips` — deep-copies each clip (keyframe tracks / grade / chroma / masks / effects / text / transform / crop / fades via `Clip: Clone`), mints a fresh id, shifts `start_frame` by `offset_frames`, lands on `target_track_indexes[i]`, clears `link_group_id`, and clears the destination range overwrite-style first (mirrors `move_clips`). 11 unit tests cover original retention, link-group clearing, keyframe deep copy, grade/masks/effects deep copy, multi-track targets, relative spacing, overwrite blocking, frame clamping, missing-clip skip, incompatible-track skip, and text/transform. - Add `EditCommand::DuplicateClips` variant + `duplicate_clips_cmd` apply dispatch with validation (empty ids / length mismatch / missing clips) and the standard transact wrapper (snapshot -> mutate -> commit-if-changed -> version++). 7 command-level tests (creates copy, deep copies keyframes, clears link_group_id, missing clip errors, length mismatch errors, empty ids errors, undoable). - Add `EditRequest::DuplicateClips` DTO in `src-tauri/src/commands.rs` + `into_command` mapping (direct field pass-through). Frontend (React + TypeScript): - Add `duplicateClips` variant to `EditRequest` in `types.ts`. - Add `duplicateClips()` action in `editActions.ts` (applyAndRefresh). - `TimelineContainer.tsx`: add `isDuplicate` flag (Alt key detection at pointer-down), `DropTarget` discriminated union (`existing` | `newTrack`), `newTrackTypeFor` helper (audio -> "audio", else -> "video"). `onPointerMove` computes `dropTarget` (existing track via `trackAt`, or `newTrack` when below the last track bottom). `onPointerUp` branches: newTrack -> `edit.insertTrack` -> `forceRefresh` -> `edit.duplicateClips`/`moveClips` with the new track index; existing track -> group-floor-clamped move or duplicate. - `timelineCanvas.ts`: extend `DragPaint` move variant with `isDuplicate?` and `newTrackType?`. Render a dashed new-track drop indicator below the last track; render the ghost at the new-track Y when `newTrackType` is set; pass `isDuplicate` to `drawClip`. - `clipRenderer.ts`: add `isDuplicate?` to `DrawOpts`; draw a yellow "+" badge in the top-right corner when `ghost && isDuplicate` so the user sees the gesture will copy rather than move. Closes #98. * style: fix cargo fmt in duplicate.rs (#98) * fix: correct cargo fmt in duplicate.rs - split long assert lines (#98) * fix: split long assert/assert_eq lines for cargo fmt (#98) * fix: compile errors - rotation_track type + borrow conflict (#98) * style: fix cargo fmt - import wrap + assert_eq single line (#98) * style: fix import wrapping for cargo fmt (#98) * fix: clippy errors - remove redundant clone on Copy types (#98) * fix(#98): implement groupCounts/groupRemap for link group remapping (review #123) --- crates/opentake-ops/src/command.rs | 249 ++++++++- crates/opentake-ops/src/ops/duplicate.rs | 514 ++++++++++++++++++ crates/opentake-ops/src/ops/mod.rs | 2 + crates/opentake-ops/tests/command_apply.rs | 34 ++ src-tauri/src/commands.rs | 15 + .../components/timeline/TimelineContainer.tsx | 134 ++++- web/src/components/timeline/clipRenderer.ts | 39 ++ web/src/components/timeline/timelineCanvas.ts | 62 ++- web/src/lib/types.ts | 6 + web/src/store/editActions.ts | 20 + 10 files changed, 1045 insertions(+), 30 deletions(-) create mode 100644 crates/opentake-ops/src/ops/duplicate.rs diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index c5ef5c5..130d16b 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -2,7 +2,7 @@ //! //! UI gestures, the in-app agent, and the external MCP server all funnel through //! one command enum, so undo / validation / versioning are written once -//! (`ARCHITECTURE.md §5`, upstream `ToolExecutor`). +//! (`ARCHITECTURE.md 搂5`, upstream `ToolExecutor`). //! //! `apply` is the `withTimelineSwap` transaction, generalized to the whole //! document (timeline + manifest): @@ -51,7 +51,7 @@ impl std::fmt::Display for EditError { impl std::error::Error for EditError {} -/// Outcome of a successfully-attempted command. 1:1 shape from `ARCHITECTURE.md §5`. +/// Outcome of a successfully-attempted command. 1:1 shape from `ARCHITECTURE.md 搂5`. #[derive(Clone, PartialEq, Eq, Debug)] pub struct EditResult { /// Whether the document actually changed (drives undo-stack push + version bump). @@ -181,6 +181,16 @@ pub enum EditCommand { }, /// Move clips (expanded to linked partners by the caller) to new tracks/frames. MoveClips { moves: Vec }, + /// Deep-copy clips (Option/Alt-drag duplicate) to new positions. Each clip + /// is cloned with all its fields (keyframe tracks / grade / chroma / masks / + /// effects / text / transform / crop / fades), gets a fresh id, is shifted + /// by `offset_frames`, lands on `target_track_indexes[i]`, and has its + /// `link_group_id` cleared (a copy is not linked to the original's group). + DuplicateClips { + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + }, /// Remove clips (expanded to linked partners), pruning emptied tracks. RemoveClips { clip_ids: Vec }, /// Split a clip at a frame (splits linked partners too). @@ -266,7 +276,7 @@ pub enum EditCommand { RemoveTracks { track_indexes: Vec }, /// Insert a new empty track of `kind` (clamped into its zone). Lets the drop /// flow create a track on demand when the timeline has no compatible one - /// (upstream `placeClip` / `add_clips` with omitted `trackIndex` → + /// (upstream `placeClip` / `add_clips` with omitted `trackIndex` 鈫? /// `insertTrack`), so dragging media onto an empty timeline produces a clip. InsertTrack { kind: ClipType }, /// Toggle track-head properties (mute / hide / sync-lock). `None` leaves a @@ -364,6 +374,11 @@ pub fn apply( entries, } => insert_clips(state, track_index, at_frame, entries, ids), EditCommand::MoveClips { moves } => move_clips(state, moves, ids), + EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => duplicate_clips_cmd(state, clip_ids, offset_frames, target_track_indexes, ids), EditCommand::RemoveClips { clip_ids } => remove_clips(state, clip_ids), EditCommand::SplitClip { clip_id, at_frame } => split(state, clip_id, at_frame, ids), EditCommand::TrimClips { edits } => trim(state, edits), @@ -659,6 +674,54 @@ fn move_clips( ) } +/// Option/Alt-drag duplicate: deep-copy each clip to a new position. See +/// [`EditCommand::DuplicateClips`]. +fn duplicate_clips_cmd( + state: &mut EditorState, + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + ids: &dyn IdGen, +) -> Result { + if clip_ids.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'clipIds' array".into(), + )); + } + if target_track_indexes.len() != clip_ids.len() { + return Err(EditError::Invalid(format!( + "targetTrackIndexes length ({}) must match clipIds length ({})", + target_track_indexes.len(), + clip_ids.len() + ))); + } + for id in &clip_ids { + if state.find_clip(id).is_none() { + return Err(EditError::Invalid(format!("Clip not found: {id}"))); + } + } + let action_name = if clip_ids.len() == 1 { + "Duplicate Clip" + } else { + "Duplicate Clips" + }; + let n = clip_ids.len(); + transact( + state, + action_name, + move |_| format!("Duplicated {n} clip(s)"), + |st| { + Ok(ops::duplicate_clips( + &mut st.timeline, + &clip_ids, + offset_frames, + &target_track_indexes, + ids, + )) + }, + ) +} + fn remove_clips(state: &mut EditorState, clip_ids: Vec) -> Result { if clip_ids.is_empty() { return Err(EditError::Invalid( @@ -1369,7 +1432,7 @@ fn ripple_delete_ranges( match outcome { RippleOutcome::Refused(reason) => { // Restore in case clear_region partially mutated before a later refusal - // (it can't here — refusal is dry-run first — but keep it airtight). + // (it can't here 鈥?refusal is dry-run first 鈥?but keep it airtight). state.restore(before); Err(EditError::Refused(reason)) } @@ -2210,7 +2273,7 @@ mod keyframe_edit_tests { EditCommand::StampKeyframe { clip_id: clip_id.clone(), property: KeyframeProperty::Opacity, - frame: 110, // rel 10 — same as existing kf + frame: 110, // rel 10 閳?same as existing kf }, &ids, ) @@ -2288,7 +2351,7 @@ mod keyframe_edit_tests { EditCommand::RemoveKeyframe { clip_id, property: KeyframeProperty::Opacity, - frame: 110, // rel 10 — no kf here + frame: 110, // rel 10 閳?no kf here }, &ids, ); @@ -2334,7 +2397,7 @@ mod keyframe_edit_tests { clip_id, property: KeyframeProperty::Opacity, from_frame: 100, // rel 0 - to_frame: 110, // rel 10 — occupied + to_frame: 110, // rel 10 閳?occupied }, &ids, ); @@ -2351,7 +2414,7 @@ mod keyframe_edit_tests { EditCommand::MoveKeyframe { clip_id, property: KeyframeProperty::Opacity, - from_frame: 115, // rel 15 — no kf + from_frame: 115, // rel 15 閳?no kf to_frame: 120, // rel 20 }, &ids, @@ -2415,7 +2478,7 @@ mod keyframe_edit_tests { EditCommand::SetKeyframeInterpolation { clip_id, property: KeyframeProperty::Opacity, - frame: 115, // rel 15 — no kf + frame: 115, // rel 15 閳?no kf interpolation: Interpolation::Linear, }, &ids, @@ -2427,3 +2490,171 @@ mod keyframe_edit_tests { assert!((a - b).abs() < 1e-9, "{a} != {b}"); } } + +#[cfg(test)] +mod duplicate_clips_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{Clip, ClipType, Keyframe, KeyframeTrack, Track}; + + fn state_with_clip() -> EditorState { + let mut tl = Timeline::new(); + let mut t = Track::new("v1", ClipType::Video); + t.clips.push(Clip::new("c1", "asset", 0, 30)); + tl.tracks.push(t); + EditorState::from_timeline(tl) + } + + #[test] + fn duplicate_clips_creates_copy_with_new_id() { + let mut state = state_with_clip(); + let ids = SeqIdGen::new("d-"); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert!(res.changed); + assert_eq!(res.action_name, "Duplicate Clip"); + assert_eq!(res.affected_clip_ids.len(), 1); + // Original retained, copy present at frame 100. + assert!(state.timeline.tracks[0].clips.iter().any(|c| c.id == "c1")); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + assert_eq!(copy.start_frame, 100); + assert_ne!(copy.id, "c1"); + } + + #[test] + fn duplicate_clips_deep_copies_keyframe_tracks() { + let mut state = state_with_clip(); + state.timeline.tracks[0].clips[0].opacity_track = + Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, 0.0), + Keyframe::new(30, 1.0), + ])); + let ids = SeqIdGen::default(); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + let op = copy.opacity_track.as_ref().unwrap(); + assert_eq!( + op.keyframes.iter().map(|k| k.frame).collect::>(), + vec![0, 30] + ); + } + + #[test] + fn duplicate_clips_clears_link_group_id() { + let mut state = state_with_clip(); + state.timeline.tracks[0].clips[0].link_group_id = Some("grp".into()); + let ids = SeqIdGen::default(); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 50, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + assert!(copy.link_group_id.is_none()); + } + + #[test] + fn duplicate_clips_missing_clip_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["nope".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_length_mismatch_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0, 1], // wrong length + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_empty_ids_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec![], + offset_frames: 100, + target_track_indexes: vec![], + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_is_undoable() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let version_before = state.version(); + apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert_eq!(state.timeline.tracks[0].clips.len(), 2); + assert!(state.can_undo()); + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.timeline.tracks[0].clips.len(), 1); + assert_eq!(state.timeline.tracks[0].clips[0].id, "c1"); + assert_eq!(state.version(), version_before + 2); // commit + undo + } +} diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs new file mode 100644 index 0000000..9097a5b --- /dev/null +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -0,0 +1,514 @@ +//! Clip duplication (Option/Alt-drag copy). Deep-clips each source clip — +//! including all keyframe tracks / grade / chroma / masks / effects / text / +//! transform / crop / fades — mints a fresh id, shifts `start_frame` by +//! `offset_frames`, places it on `target_track_indexes[i]`, and remaps the +//! link group (a multi-clip group — e.g. an A/V linked pair — gets a fresh +//! shared id so the copies stay linked; a single-clip group is cleared). +//! The destination range is cleared overwrite-style first (mirrors +//! `move_clips`), so a duplicate landing on an existing clip overwrites it. +//! +//! Companion to [`crate::ops::move_clips`]: same destination-clearing + +//! pin-by-id + sort + prune flow, but the source clip stays put and a deep +//! copy is dropped at the target. + +use std::collections::HashMap; + +use opentake_domain::{Clip, Timeline}; + +use crate::id::IdGen; +use crate::ops::clear_region::clear_region; +use crate::ops::place::sort_clips; +use crate::ops::tracks::prune_empty_tracks; + +/// Deep-copy each clip in `clip_ids` to a new position: `start_frame` shifted +/// by `offset_frames`, placed on `target_track_indexes[i]` (one target per +/// source, by index). Returns the ids of the newly created clips (in input +/// order). Missing clips or out-of-range / type-incompatible targets are +/// silently skipped (mirrors `move_clips`'s "guard ... continue"). +/// +/// Each duplicate: +/// - gets a fresh id from `ids`, +/// - keeps every field of the source (keyframe tracks, grade, chroma, masks, +/// effects, text, transform, crop, fades — `Clip: Clone` is a deep copy), +/// - has its `link_group_id` remapped (a group shared by multiple copied +/// clips gets a fresh shared id so the copies stay linked; a single-clip +/// group is cleared to `None`), +/// - has `start_frame = source.start_frame + offset_frames` (clamped `>= 0`). +pub fn duplicate_clips( + timeline: &mut Timeline, + clip_ids: &[String], + offset_frames: i32, + target_track_indexes: &[usize], + ids: &dyn IdGen, +) -> Vec { + if clip_ids.is_empty() { + return Vec::new(); + } + + // Resolve each source clip + validate its target track. Collect up front so + // the mutation phase can pin tracks by id (pruning could shift indices). + struct Plan { + clone: Clip, + to_track_id: String, + to_frame: i32, + } + let mut plans: Vec = Vec::new(); + for (i, id) in clip_ids.iter().enumerate() { + let Some((ti, ci)) = find(timeline, id) else { + continue; + }; + let Some(&to_track) = target_track_indexes.get(i) else { + continue; + }; + if to_track >= timeline.tracks.len() { + continue; + } + let src_type = timeline.tracks[ti].kind; + let dest_type = timeline.tracks[to_track].kind; + if !dest_type.is_compatible(src_type) { + continue; + } + let clone = timeline.tracks[ti].clips[ci].clone(); + let to_frame = (clone.start_frame + offset_frames).max(0); + plans.push(Plan { + clone, + to_track_id: timeline.tracks[to_track].id.clone(), + to_frame, + }); + } + if plans.is_empty() { + return Vec::new(); + } + + // Clear each destination range (pin by track id) so the duplicate overwrites + // whatever was there, exactly like `move_clips` / `place_clip` do. + for plan in &plans { + if let Some(idx) = timeline + .tracks + .iter() + .position(|t| t.id == plan.to_track_id) + { + clear_region( + timeline, + idx, + plan.to_frame, + plan.to_frame + plan.clone.duration_frames, + false, + ids, + ); + } + } + + // Build the link-group remap table (mirrors upstream's `groupCounts` / + // `groupRemap` in EditorViewModel+Clipboard.swift): a group shared by + // multiple copied clips (e.g. an A/V linked pair) maps to a fresh shared id + // so the copies stay linked to each other; a group with only one clip (or + // no group) maps to None — that copy stands alone. + let mut group_counts: HashMap, usize> = HashMap::new(); + for plan in &plans { + *group_counts + .entry(plan.clone.link_group_id.clone()) + .or_insert(0) += 1; + } + let mut group_remap: HashMap, Option> = HashMap::new(); + for (group_id, &count) in &group_counts { + let new_id = if count > 1 && group_id.is_some() { + Some(ids.next_id()) + } else { + None + }; + group_remap.insert(group_id.clone(), new_id); + } + + // Drop each deep copy at its target frame with a fresh id + remapped link. + let mut created = Vec::new(); + for plan in plans { + if let Some(idx) = timeline + .tracks + .iter() + .position(|t| t.id == plan.to_track_id) + { + let mut clip = plan.clone; + clip.id = ids.next_id(); + clip.start_frame = plan.to_frame; + // Remap the link group: multi-clip groups get the fresh shared id, + // single-clip groups (and None) clear to None. + let remapped = group_remap.get(&clip.link_group_id).cloned().flatten(); + clip.link_group_id = remapped; + created.push(clip.id.clone()); + timeline.tracks[idx].clips.push(clip); + sort_clips(&mut timeline.tracks[idx]); + } + } + prune_empty_tracks(timeline); + created +} + +fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { + for (ti, t) in timeline.tracks.iter().enumerate() { + if let Some(ci) = t.clips.iter().position(|c| c.id == clip_id) { + return Some((ti, ci)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{ + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, KeyframeTrack, + Mask, MaskShape, Point2, Track, + }; + + fn clip(id: &str, start: i32, dur: i32) -> Clip { + Clip::new(id, "asset", start, dur) + } + + fn two_video_tracks() -> Timeline { + let mut tl = Timeline::new(); + let mut v1 = Track::new("v1", ClipType::Video); + v1.clips.push(clip("a", 0, 30)); + let v2 = Track::new("v2", ClipType::Video); + tl.tracks.push(v1); + tl.tracks.push(v2); + tl + } + + #[test] + fn duplicate_keeps_original_and_creates_copy_at_offset() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::new("d-"); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + assert_eq!(created.len(), 1); + // Original stays put. + assert!(tl.tracks[0] + .clips + .iter() + .any(|c| c.id == "a" && c.start_frame == 0)); + // Copy lands at frame 100 on the same track with a fresh id. + let copy = tl.tracks[0].clips.iter().find(|c| c.id == "d-1").unwrap(); + assert_eq!(copy.start_frame, 100); + assert_eq!(copy.duration_frames, 30); + assert_eq!(copy.media_ref, "asset"); + } + + #[test] + fn duplicate_clears_link_group_id() { + let mut tl = two_video_tracks(); + // Mark the source as linked. + tl.tracks[0].clips[0].link_group_id = Some("grp".into()); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert!( + copy.link_group_id.is_none(), + "duplicate must not inherit link" + ); + // Original keeps its link group. + assert_eq!(tl.tracks[0].clips[0].link_group_id.as_deref(), Some("grp")); + } + + fn find_clip<'a>(tl: &'a Timeline, id: &str) -> &'a Clip { + tl.tracks + .iter() + .flat_map(|t| t.clips.iter()) + .find(|c| c.id == id) + .expect("clip exists") + } + + #[test] + fn duplicate_clips_remaps_link_group_for_multi_clip_group() { + // A/V linked pair (shared group "g1") + a lone clip in its own group + // ("g2"). Mirrors upstream's groupCounts/groupRemap: the pair's copies + // must share a fresh group id; the lone clip's copy clears to None. + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + let mut va = clip("va", 0, 30); + va.link_group_id = Some("g1".into()); + let mut sv = clip("sv", 60, 30); + sv.link_group_id = Some("g2".into()); + v.clips.push(va); + v.clips.push(sv); + let mut a = Track::new("a", ClipType::Audio); + let mut aa = clip("aa", 0, 30); + aa.media_type = ClipType::Audio; + aa.link_group_id = Some("g1".into()); + a.clips.push(aa); + tl.tracks.push(v); + tl.tracks.push(a); + + let g = SeqIdGen::default(); + let created = duplicate_clips( + &mut tl, + &["va".into(), "aa".into(), "sv".into()], + 200, + &[0, 1, 0], + &g, + ); + assert_eq!(created.len(), 3); + + // The A/V pair copies share a NEW link_group_id (same as each other, + // different from the source "g1"). + let va_copy = find_clip(&tl, &created[0]); + let aa_copy = find_clip(&tl, &created[1]); + assert_eq!(va_copy.link_group_id, aa_copy.link_group_id); + assert_ne!(va_copy.link_group_id.as_deref(), Some("g1")); + assert!( + va_copy.link_group_id.is_some(), + "multi-clip group copies must stay linked" + ); + + // The lone clip's group ("g2", count == 1) clears to None. + let sv_copy = find_clip(&tl, &created[2]); + assert!( + sv_copy.link_group_id.is_none(), + "single-clip group must clear to None" + ); + + // Originals keep their original group ids. + assert_eq!(find_clip(&tl, "va").link_group_id.as_deref(), Some("g1")); + assert_eq!(find_clip(&tl, "aa").link_group_id.as_deref(), Some("g1")); + } + + #[test] + fn duplicate_deep_copies_keyframe_tracks() { + let mut tl = two_video_tracks(); + // Give the source an opacity track + volume track with keyframes. + tl.tracks[0].clips[0].opacity_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, 0.0), + Keyframe::new(30, 1.0), + ])); + tl.tracks[0].clips[0].volume_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, -6.0), + Keyframe::new(30, 0.0), + ])); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + // Keyframe offsets are clip-relative, so they're identical to the source + // (the copy's start_frame moved, but offsets stay). + let op = copy.opacity_track.as_ref().unwrap(); + assert_eq!( + op.keyframes.iter().map(|k| k.frame).collect::>(), + vec![0, 30] + ); + let vol = copy.volume_track.as_ref().unwrap(); + assert_eq!( + vol.keyframes.iter().map(|k| k.value).collect::>(), + vec![-6.0, 0.0] + ); + // Mutating the copy's track must not touch the original (deep copy). + let copy_op = copy.opacity_track.as_ref().unwrap().clone(); + tl.tracks[0] + .clips + .iter_mut() + .find(|c| c.id == created[0]) + .unwrap() + .opacity_track = None; + assert!(tl.tracks[0].clips[0].opacity_track.is_some()); + assert_eq!( + tl.tracks[0].clips[0].opacity_track.as_ref().unwrap(), + ©_op + ); + } + + #[test] + fn duplicate_deep_copies_grade_masks_effects() { + let mut tl = two_video_tracks(); + let src = &mut tl.tracks[0].clips[0]; + src.color_grade = Some(ColorGrade { + exposure: 0.5, + ..Default::default() + }); + src.chroma_key = Some(ChromaKey::default()); + src.masks = vec![Mask { + shape: MaskShape::Circle { + center: Point2::new(0.5, 0.5), + radius: Point2::new(0.3, 0.3), + }, + feather: 0.05, + invert: false, + }]; + src.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; + let orig_color_grade = src.color_grade; + let orig_chroma_key = src.chroma_key; + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.color_grade, orig_color_grade); + assert_eq!(copy.chroma_key, orig_chroma_key); + assert_eq!(copy.masks.len(), 1); + assert_eq!(copy.effects.len(), 1); + // Mutate the copy's masks; the original must be unaffected (no shared ref). + let copy_masks = copy.masks.clone(); + tl.tracks[0] + .clips + .iter_mut() + .find(|c| c.id == created[0]) + .unwrap() + .masks + .clear(); + assert_eq!(tl.tracks[0].clips[0].masks, copy_masks); + } + + #[test] + fn duplicate_to_different_track_uses_target_index() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + // Copy lands on v2 (index 1). + let copy = tl.tracks[1] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.start_frame, 100); + // Original still on v1. + assert!(tl.tracks[0].clips.iter().any(|c| c.id == "a")); + } + + #[test] + fn duplicate_multiple_clips_preserve_relative_spacing() { + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + v.clips.push(clip("a", 0, 30)); + v.clips.push(clip("b", 60, 30)); // 30-frame gap + tl.tracks.push(v); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into(), "b".into()], 100, &[0, 0], &g); + assert_eq!(created.len(), 2); + let c0 = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + let c1 = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[1]) + .unwrap(); + // a@0 -> 100, b@60 -> 160; gap of 30 preserved. + assert_eq!(c0.start_frame, 100); + assert_eq!(c1.start_frame, 160); + } + + #[test] + fn duplicate_overwrites_blocking_clip_at_destination() { + let mut tl = two_video_tracks(); + // Place a blocker on v2 at [90,150); duplicating a to v2@100 overwrites the overlap. + tl.tracks[1].clips.push(clip("blocker", 90, 60)); + let g = SeqIdGen::new("r-"); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + let v2 = tl.tracks.iter().find(|t| t.id == "v2").unwrap(); + let copy = v2.clips.iter().find(|c| c.id == created[0]).unwrap(); + assert_eq!((copy.start_frame, copy.end_frame()), (100, 130)); + // No clip other than the copy covers [100,130). + let covering = v2 + .clips + .iter() + .filter(|c| c.id != created[0] && c.start_frame < 130 && c.end_frame() > 100) + .count(); + assert_eq!(covering, 0); + } + + #[test] + fn duplicate_clamps_start_frame_to_zero() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + // a starts at 0; offset -50 would put it at -50 -> clamped to 0. + let created = duplicate_clips(&mut tl, &["a".into()], -50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.start_frame, 0); + } + + #[test] + fn duplicate_skips_missing_clip() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["nope".into()], 100, &[0], &g); + assert!(created.is_empty()); + } + + #[test] + fn duplicate_skips_incompatible_target_track() { + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + v.clips.push(clip("a", 0, 30)); + let a = Track::new("a", ClipType::Audio); + tl.tracks.push(v); + tl.tracks.push(a); + let g = SeqIdGen::default(); + // Duplicating a video clip onto an audio track -> incompatible -> skipped. + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + assert!(created.is_empty()); + assert!(tl.tracks[1].clips.is_empty()); + } + + #[test] + fn duplicate_skips_out_of_range_target() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[99], &g); + assert!(created.is_empty()); + } + + #[test] + fn duplicate_copies_text_and_transform_fields() { + let mut tl = Timeline::new(); + let mut t = Track::new("t", ClipType::Text); + let mut c = Clip::new("txt", "", 0, 30); + c.media_type = ClipType::Text; + c.source_clip_type = ClipType::Text; + c.text_content = Some("Hello".into()); + c.transform = opentake_domain::Transform::from_center( + opentake_domain::Point { x: 0.25, y: 0.75 }, + 0.5, + 0.5, + ); + c.crop = Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }; + c.fade_in_frames = 5; + c.fade_in_interpolation = Interpolation::Smooth; + c.rotation_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::with_interpolation(0, 0.0, Interpolation::Linear), + Keyframe::new(10, 0.2), + ])); + t.clips.push(c); + tl.tracks.push(t); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["txt".into()], 50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.text_content.as_deref(), Some("Hello")); + assert_eq!(copy.transform.center_x, 0.25); + assert_eq!(copy.crop.left, 0.1); + assert_eq!(copy.fade_in_frames, 5); + assert_eq!(copy.fade_in_interpolation, Interpolation::Smooth); + assert!(copy.rotation_track.is_some()); + } +} diff --git a/crates/opentake-ops/src/ops/mod.rs b/crates/opentake-ops/src/ops/mod.rs index dcaf88e..7ea074c 100644 --- a/crates/opentake-ops/src/ops/mod.rs +++ b/crates/opentake-ops/src/ops/mod.rs @@ -4,6 +4,7 @@ //! in place, and the command layer snapshots/commits around them. pub mod clear_region; +pub mod duplicate; pub mod folders; pub mod linking; pub mod move_clips; @@ -14,6 +15,7 @@ pub mod tracks; pub mod trim; pub use clear_region::clear_region; +pub use duplicate::duplicate_clips; pub use folders::{ create_folder, delete_folder, delete_media, move_to_folder, rename_folder, rename_media, }; diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index a5b8ba0..cbce363 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -227,6 +227,40 @@ fn split_linked_pair_splits_partner_and_regroups() { assert_ne!(rights[0].link_group_id.as_deref(), Some("g1")); } +#[test] +fn duplicate_linked_pair_keeps_copies_linked() { + // Option/Alt-drag duplicating an A/V linked pair must keep the copies linked + // to each other under a fresh group id (groupCounts/groupRemap semantics). + let mut st = linked_av_state(); + let g = SeqIdGen::default(); + let res = apply( + &mut st, + EditCommand::DuplicateClips { + clip_ids: vec!["v1".into(), "a1".into()], + offset_frames: 200, + target_track_indexes: vec![0, 1], + }, + &g, + ) + .unwrap(); + assert_eq!(res.affected_clip_ids.len(), 2); + + // The copies share a NEW link_group_id (same as each other, different from + // the source "g1") — the A/V link survives the duplicate. + let vc = find_clip(&st, &res.affected_clip_ids[0]); + let ac = find_clip(&st, &res.affected_clip_ids[1]); + assert_eq!(vc.link_group_id, ac.link_group_id); + assert_ne!(vc.link_group_id.as_deref(), Some("g1")); + assert!( + vc.link_group_id.is_some(), + "linked pair copies must stay linked" + ); + + // Originals keep "g1". + assert_eq!(find_clip(&st, "v1").link_group_id.as_deref(), Some("g1")); + assert_eq!(find_clip(&st, "a1").link_group_id.as_deref(), Some("g1")); +} + #[test] fn remove_clips_expands_to_linked_partner() { let mut st = linked_av_state(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6a38cc8..6e8a2de 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -145,6 +145,12 @@ pub enum EditRequest { moves: Vec, }, #[serde(rename_all = "camelCase")] + DuplicateClips { + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + }, + #[serde(rename_all = "camelCase")] RemoveClips { clip_ids: Vec, }, @@ -264,6 +270,15 @@ impl EditRequest { EditRequest::MoveClips { moves } => EditCommand::MoveClips { moves: moves.into_iter().map(ClipMoveDto::into_move).collect(), }, + EditRequest::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + }, EditRequest::RemoveClips { clip_ids } => EditCommand::RemoveClips { clip_ids }, EditRequest::SplitClip { clip_id, at_frame } => { EditCommand::SplitClip { clip_id, at_frame } diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index af17efc..0766816 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -14,6 +14,7 @@ import { frameAt, totalFrames, trackAt, + trackY, } from "../../lib/geometry"; import { firstAudioIndex } from "../../lib/zones"; import { clampTrimDeltaFrames, trimSourceValues } from "../../lib/clip"; @@ -31,16 +32,46 @@ import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; import * as edit from "../../store/editActions"; +import { forceRefresh } from "../../store/sync"; +import { isTauri } from "../../lib/api"; import { getWaveform } from "../../lib/api"; +import type { ClipType } from "../../lib/types"; + +/** Where a move/duplicate drag will land. `newTrack` creates a fresh track of + * `trackType` below the last existing one (upstream `placeClip` auto-track- + * creation), so dragging into the empty area below the track region still + * produces a clip. */ +type DropTarget = + | { kind: "existing"; trackIndex: number } + | { kind: "newTrack"; trackType: ClipType }; type DragState = | { kind: "scrub" } - | { kind: "move"; hit: ClipHit; grabFrame: number; deltaFrames: number; startTrack: number; targetTrack: number; companions: string[] } + | { + kind: "move"; + hit: ClipHit; + grabFrame: number; + deltaFrames: number; + startTrack: number; + targetTrack: number; + companions: string[]; + /** Option/Alt held at pointer-down: duplicate instead of move. */ + isDuplicate: boolean; + /** Where the drag will land (existing track or a new track below). */ + dropTarget: DropTarget; + } | { kind: "trimLeft" | "trimRight"; hit: ClipHit; startTrim: number; deltaFrames: number } | { kind: "marquee"; startDocX: number; startDocY: number; curDocX: number; curDocY: number } | { kind: "audioVolumeKf"; clipId: string; fromFrame: number; ghostFrame: number } | null; +/** New-track kind for a dragged clip: audio clips → "audio", everything else + * → "video" (visual types share the video zone, matching `addMediaToTimeline` + * and upstream `placeClip`). */ +function newTrackTypeFor(clip: { mediaType: ClipType }): ClipType { + return clip.mediaType === "audio" ? "audio" : "video"; +} + export function TimelineContainer() { const timeline = useProjectStore((s) => s.timeline); const zoomScale = useEditorUiStore((s) => s.zoomScale); @@ -165,6 +196,8 @@ export function TimelineContainer() { ids: new Set(d.companions), deltaFrames: d.deltaFrames, trackDelta: d.targetTrack - d.startTrack, + isDuplicate: d.isDuplicate, + newTrackType: d.dropTarget.kind === "newTrack" ? d.dropTarget.trackType : undefined, }; } else if (d?.kind === "trimLeft" || d?.kind === "trimRight") { drag = { @@ -449,6 +482,8 @@ export function TimelineContainer() { startTrack: hit.trackIndex, targetTrack: hit.trackIndex, companions: [...nextSel], + isDuplicate: e.altKey, + dropTarget: { kind: "existing", trackIndex: hit.trackIndex }, }; } return; @@ -524,8 +559,35 @@ export function TimelineContainer() { snapped = null; snapStateRef.current = null; } - const targetTrack = trackAt(timeline, docY, trackHeights) ?? d.startTrack; - dragRef.current = { ...d, deltaFrames, targetTrack }; + // Drop target: existing track under the cursor, or a new track when the + // pointer is below the last track's bottom (upstream `placeClip` auto- + // track-creation). `trackAt` returns null both above and below the track + // region; only treat the below-last-track case as a new-track drop. + const hovered = trackAt(timeline, docY, trackHeights); + let targetTrack: number; + let dropTarget: DropTarget; + if (hovered !== null) { + targetTrack = hovered; + dropTarget = { kind: "existing", trackIndex: hovered }; + } else { + const lastTrackBottom = + timeline.tracks.length > 0 + ? trackY(timeline, timeline.tracks.length, trackHeights) + : LAYOUT.rulerHeight + LAYOUT.dropZoneHeight; + if (docY >= lastTrackBottom) { + // Below the last track → create a new track on drop. + const trackType = newTrackTypeFor(d.hit.clip); + dropTarget = { kind: "newTrack", trackType }; + // Clamp the ghost to the last existing track for rendering; the + // actual new track is created on pointer-up. + targetTrack = Math.max(0, timeline.tracks.length - 1); + } else { + // Above the first track (ruler/drop zone) → keep the previous target. + targetTrack = d.targetTrack; + dropTarget = d.dropTarget; + } + } + dragRef.current = { ...d, deltaFrames, targetTrack, dropTarget }; setSnapFrame(snapped); forceTick((n) => n + 1); return; @@ -606,7 +668,14 @@ export function TimelineContainer() { if (!d) return; if (d.kind === "move") { - if (d.deltaFrames === 0 && d.targetTrack === d.startTrack) return; // no-op + // No-op: no movement, no track change, and not dropping on a new track. + if ( + d.deltaFrames === 0 && + d.dropTarget.kind === "existing" && + d.dropTarget.trackIndex === d.startTrack + ) { + return; + } // Resolve every dragged clip's current location. const locs = d.companions .map((id) => { @@ -623,9 +692,41 @@ export function TimelineContainer() { const minStart = Math.min(...locs.map((l) => l.clip.startFrame)); const frameDelta = Math.max(d.deltaFrames, -minStart); + // Drop below the last track → create a new track first, then move/dup. + if (d.dropTarget.kind === "newTrack") { + const trackType = d.dropTarget.trackType; + const isDup = d.isDuplicate; + const locSnapshot = locs.map((l) => ({ id: l.id, ti: l.ti, startFrame: l.clip.startFrame })); + void (async () => { + await edit.insertTrack(trackType); + // Ensure the mirror reflects the new track before computing the + // target index (Tauri's timeline_changed is async; browser mode + // already refreshed inside applyAndRefresh). + if (isTauri) await forceRefresh(); + const tl = useProjectStore.getState().timeline; + const newTrackIndex = tl.tracks.length - 1; // appended at end + if (isDup) { + await edit.duplicateClips( + locSnapshot.map((l) => l.id), + frameDelta, + locSnapshot.map(() => newTrackIndex), + ); + } else { + await edit.moveClips( + locSnapshot.map((l) => ({ + clipId: l.id, + toTrack: newTrackIndex, + toFrame: l.startFrame + frameDelta, + })), + ); + } + })(); + return; + } + // One group TRACK delta: step toward 0 until every clip stays in-bounds // and lands on a type-compatible track (rigid group, not per-clip clamp). - const rawTrackDelta = d.targetTrack - d.startTrack; + const rawTrackDelta = d.dropTarget.trackIndex - d.startTrack; let trackDelta = rawTrackDelta; const step = rawTrackDelta > 0 ? -1 : 1; while (trackDelta !== 0) { @@ -638,12 +739,23 @@ export function TimelineContainer() { } if (frameDelta === 0 && trackDelta === 0) return; // nothing actually moves - const moves = locs.map((l) => ({ - clipId: l.id, - toTrack: l.ti + trackDelta, - toFrame: l.clip.startFrame + frameDelta, - })); - void edit.moveClips(moves); + if (d.isDuplicate) { + // Option/Alt-drag duplicate: deep-copy each clip to its target. The + // backend mints fresh ids, shifts start_frame by offsetFrames, and + // clears link_group_id (copies aren't linked to the originals). + void edit.duplicateClips( + locs.map((l) => l.id), + frameDelta, + locs.map((l) => l.ti + trackDelta), + ); + } else { + const moves = locs.map((l) => ({ + clipId: l.id, + toTrack: l.ti + trackDelta, + toFrame: l.clip.startFrame + frameDelta, + })); + void edit.moveClips(moves); + } return; } diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index 1190b0c..f2ebb25 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -30,6 +30,10 @@ interface DrawOpts { * a ghost dot is drawn at `ghostFrame` (same value) so the grabbed keyframe * follows the cursor (SPEC §5.4). Only set on the dragged clip. */ volumeKfGhost?: { fromFrame: number; ghostFrame: number }; + /** This ghost is an Option/Alt-drag duplicate preview (issue #98): draws a + * "+" badge in the top-right corner so the user sees the gesture will copy + * rather than move. Only meaningful when `ghost` is true. */ + isDuplicate?: boolean; } /** Radius of the draggable volume-keyframe dots drawn by `drawVolumeEnvelope`. @@ -232,6 +236,41 @@ export function drawClip( ctx.fillRect(x, y, TRIM.handleWidth, height); ctx.fillRect(x + width - TRIM.handleWidth, y, TRIM.handleWidth, height); + // 11. Duplicate badge (issue #98): when this ghost is an Option/Alt-drag + // duplicate preview, draw a "+" badge in the top-right corner so the + // user sees the gesture will copy rather than move. Mirrors the + // upstream `+` overlay on option-drag ghosts. + if (opts.ghost && opts.isDuplicate) { + drawDuplicateBadge(ctx, x, y, width); + } + + ctx.restore(); +} + +/** Draw a "+" duplicate badge in the top-right corner of a ghost clip (issue #98). + * Yellow circle with a black "+" — high contrast against any track color, and + * matches the systemYellow used for keyframe diamonds so it reads as "active". */ +function drawDuplicateBadge( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, +) { + const radius = 7; + const cx = x + width - radius - 2; + const cy = y + radius + 2; + ctx.save(); + // Solid yellow disc. + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemYellow; + ctx.fill(); + // Black "+" glyph, centered. + ctx.fillStyle = "rgba(0,0,0,0.9)"; + ctx.font = `700 11px ${cssFontStack()}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("+", cx, cy + 0.5); ctx.restore(); } diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index 1781793..f5fd619 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -9,7 +9,7 @@ import { BG, BORDER, TEXT } from "../../lib/theme"; import { clipRect, trackDisplayHeight, trackY } from "../../lib/geometry"; import { linkOffsetForClip } from "../../lib/clip"; import { drawClip } from "./clipRenderer"; -import type { Timeline } from "../../lib/types"; +import type { Timeline, ClipType } from "../../lib/types"; export interface PaintState { timeline: Timeline; @@ -43,7 +43,18 @@ export interface PaintState { /** A live move/trim, projected for ghost rendering. */ export type DragPaint = - | { kind: "move"; ids: Set; deltaFrames: number; trackDelta: number } + | { + kind: "move"; + ids: Set; + deltaFrames: number; + trackDelta: number; + /** Option/Alt-drag duplicate: ghost renders with a "+" badge. */ + isDuplicate?: boolean; + /** Dropping below the last track creates a new track of this type; the + * ghost renders at the new-track position and a dashed indicator is + * drawn below the last track. `undefined` = drop on an existing track. */ + newTrackType?: ClipType; + } | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number } | { kind: "volumeKf"; clipId: string; fromFrame: number; ghostFrame: number }; @@ -79,21 +90,51 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // 3. Clips (skip those fully outside the visible window). A clip being dragged // is drawn at its live (offset) position as a ghost so it follows the cursor. const drag = s.drag; + // New-track drop indicator: a dashed zone below the last track showing where + // the new track will be created (upstream `placeClip` auto-track-creation). + if (drag?.kind === "move" && drag.newTrackType && timeline.tracks.length > 0) { + const newTrackY = trackY(timeline, timeline.tracks.length, trackHeights); + const newTrackH = trackDisplayHeight(timeline.tracks[0], trackHeights); // default height + if (newTrackY + newTrackH > scrollTop && newTrackY < scrollTop + s.viewHeight) { + ctx.fillStyle = "rgba(255,255,255,0.04)"; + ctx.fillRect(scrollLeft, newTrackY, s.viewWidth, newTrackH); + ctx.strokeStyle = "rgba(255,255,255,0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.strokeRect(scrollLeft + 0.5, newTrackY + 0.5, s.viewWidth - 1, newTrackH - 1); + ctx.setLineDash([]); + } + } for (let ti = 0; ti < timeline.tracks.length; ti++) { const track = timeline.tracks[ti]; for (const clip of track.clips) { let rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); let ghost = false; + let isDuplicate = false; if (drag?.kind === "move" && drag.ids.has(clip.id)) { - const nti = Math.max(0, Math.min(timeline.tracks.length - 1, ti + drag.trackDelta)); - rect = clipRect( - timeline, - nti, - { ...clip, startFrame: clip.startFrame + drag.deltaFrames }, - pixelsPerFrame, - trackHeights, - ); + if (drag.newTrackType) { + // Dropping on a new track: render the ghost at the new-track Y + // (below the last existing track) using a default track height. + const newTrackY = trackY(timeline, timeline.tracks.length, trackHeights); + const ghostH = trackDisplayHeight(timeline.tracks[0], trackHeights) - 4; + rect = { + x: (clip.startFrame + drag.deltaFrames) * pixelsPerFrame, + y: newTrackY + 2, + width: clip.durationFrames * pixelsPerFrame, + height: ghostH, + }; + } else { + const nti = Math.max(0, Math.min(timeline.tracks.length - 1, ti + drag.trackDelta)); + rect = clipRect( + timeline, + nti, + { ...clip, startFrame: clip.startFrame + drag.deltaFrames }, + pixelsPerFrame, + trackHeights, + ); + } ghost = true; + isDuplicate = drag.isDuplicate === true; } else if (drag?.kind === "trim" && drag.clipId === clip.id) { const dx = drag.deltaFrames * pixelsPerFrame; rect = @@ -120,6 +161,7 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { ghost, linkOffset: linkOffsetForClip(timeline, clip.id), volumeKfGhost, + isDuplicate, }); } } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index a35a19f..5f3de74 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -164,6 +164,12 @@ export type EditRequest = | { type: "addClips"; entries: ClipEntryReq[] } | { type: "insertClips"; trackIndex: number; atFrame: number; entries: ClipEntryReq[] } | { type: "moveClips"; moves: ClipMoveReq[] } + | { + type: "duplicateClips"; + clipIds: string[]; + offsetFrames: number; + targetTrackIndexes: number[]; + } | { type: "removeClips"; clipIds: string[] } | { type: "splitClip"; clipId: string; atFrame: number } | { type: "trimClips"; edits: TrimEditReq[] } diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 38e0f5e..f892efc 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -46,6 +46,26 @@ export async function moveClips(moves: ClipMoveReq[]) { await applyAndRefresh({ type: "moveClips", moves }); } +/** Option/Alt-drag duplicate: deep-copy each clip to a new position. The + * backend clones every field (keyframe tracks / grade / masks / effects / + * text / transform / crop / fades), mints a fresh id, shifts `startFrame` by + * `offsetFrames`, lands on `targetTrackIndexes[i]`, and clears the link group + * (a copy is not linked to the original's partners). Returns the new clip ids + * via the EditResult so the caller can select them. */ +export async function duplicateClips( + clipIds: string[], + offsetFrames: number, + targetTrackIndexes: number[], +) { + if (clipIds.length === 0) return; + return applyAndRefresh({ + type: "duplicateClips", + clipIds, + offsetFrames, + targetTrackIndexes, + }); +} + export async function removeClips(clipIds: string[]) { if (clipIds.length === 0) return; await applyAndRefresh({ type: "removeClips", clipIds }); From 12933d1e521a5e77b5fc722109a0bba5b24f8ea9 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Fri, 26 Jun 2026 20:29:15 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(media):=20=E6=8B=92=E7=BB=9D=E6=97=A0?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E8=BD=A8=E6=96=87=E4=BB=B6=20+=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E4=B8=AD=E6=AF=92=E6=B3=A2=E5=BD=A2=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/opentake-media/src/decode/pcm.rs | 6 ++++++ crates/opentake-media/src/waveform/store.rs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/opentake-media/src/decode/pcm.rs b/crates/opentake-media/src/decode/pcm.rs index 2820a3f..4bba61c 100644 --- a/crates/opentake-media/src/decode/pcm.rs +++ b/crates/opentake-media/src/decode/pcm.rs @@ -147,6 +147,12 @@ pub fn extract_pcm(path: &Path, spec: &PcmSpec, range: Option<(f64, f64)>) -> Re if !status.success() && raw.is_empty() { return Err(MediaError::no_track("audio", path)); } + // ffmpeg can exit 0 with empty stdout when metadata says audio exists but + // no decodable samples: treat as no audio track so the waveform cache + // isn't poisoned with all-1.0 silence. + if raw.is_empty() { + return Err(MediaError::no_track("audio", path)); + } let samples = raw_to_mono_f32(&raw, spec); Ok(PcmBuffer { diff --git a/crates/opentake-media/src/waveform/store.rs b/crates/opentake-media/src/waveform/store.rs index 8b86562..06df16b 100644 --- a/crates/opentake-media/src/waveform/store.rs +++ b/crates/opentake-media/src/waveform/store.rs @@ -34,6 +34,11 @@ pub fn load_waveform(cache_root: &Path, key: &str) -> Option> { while let Ok(v) = cursor.read_f32::() { out.push(v); } + // Reject poison files: every bucket == 1.0 silence is what an older build + // cached when extract_pcm returned an empty Vec. Force regeneration. + if !out.is_empty() && out.iter().all(|&v| v == 1.0) { + return None; + } Some(out) }