From 1f2c515fa5bae1aa7cc4d065a8a440258c2d0cc4 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 02:54:37 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(swap-media):=20=E5=AE=9E=E7=8E=B0=20Sw?= =?UTF-8?q?apMedia=20=E7=BC=96=E8=BE=91=E5=91=BD=E4=BB=A4=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9B=BF=E6=8D=A2=20clip=20=E5=AA=92?= =?UTF-8?q?=E4=BD=93=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 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 --- crates/opentake-ops/src/command.rs | 146 +++++++++++++ crates/opentake-ops/tests/command_apply.rs | 236 ++++++++++++++++++++- src-tauri/src/commands.rs | 27 +++ web/src/components/inspector/Inspector.tsx | 142 ++++++++++++- web/src/i18n/dict.ts | 8 + web/src/lib/types.ts | 11 +- web/src/store/editActions.ts | 26 +++ 7 files changed, 592 insertions(+), 4 deletions(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index f9c75a9..2614b29 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -281,6 +281,20 @@ 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). + /// When the new media is shorter than the clip's current duration, the + /// duration is truncated and `trim_end_frame` is clamped to fit. Optional + /// fields override the inferred defaults; `media_type` (when set) also + /// implies `source_clip_type` unless `source_clip_type` is explicitly given. + SwapMedia { + clip_id: String, + media_ref: String, + media_type: Option, + source_clip_type: Option, + duration_frames: Option, + trim_start_frame: Option, + }, /// Undo the last committed command. Undo, /// Redo the last undone command. @@ -400,6 +414,22 @@ 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, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + } => swap_media( + state, + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + ), } } @@ -1742,6 +1772,122 @@ fn delete_folder( ) } +/// Replace a clip's `media_ref` in place, preserving every editing attribute +/// (transform / crop / keyframe tracks / grade / masks / effects / fade / text). +/// The new `media_ref` must exist in the manifest; when the new media is shorter +/// than the clip's current duration, the duration is truncated and +/// `trim_end_frame` is clamped so the source span fits. `media_type`, when set, +/// also implies `source_clip_type` unless `source_clip_type` is explicitly given +/// (matches the spec's "sync media_type" scenario). +fn swap_media( + state: &mut EditorState, + clip_id: String, + media_ref: String, + media_type: Option, + source_clip_type: Option, + duration_frames: Option, + trim_start_frame: Option, +) -> Result { + // 1. Validate clip exists. + let loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + + // 2. Validate media_ref exists in manifest; read its duration (seconds). + 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. Convert new media duration (seconds) -> frames using the timeline fps. + let fps = state.timeline.fps; + let new_media_duration_frames = + ((new_asset.duration * fps as f64).round() as i32).max(1); + + // 4. Snapshot the current clip's timing fields for validation. + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let current_duration = clip.duration_frames; + let current_trim_start = clip.trim_start_frame; + let current_trim_end = clip.trim_end_frame; + let speed = clip.speed; + + // 5. Validate an explicitly-provided duration. + if let Some(d) = duration_frames { + if d < 1 { + return Err(EditError::Invalid(format!( + "durationFrames must be >= 1 (got {d})" + ))); + } + } + + // 6. Resolve final trim_start (explicit override or current value). + let final_trim_start = trim_start_frame.unwrap_or(current_trim_start); + if final_trim_start < 0 { + return Err(EditError::Invalid(format!( + "trimStartFrame must be >= 0 (got {final_trim_start})" + ))); + } + if final_trim_start >= new_media_duration_frames { + return Err(EditError::Invalid(format!( + "trimStartFrame {final_trim_start} must be < new media duration ({new_media_duration_frames})" + ))); + } + + // 7. Determine final duration: explicit > truncate-to-fit > keep current. + let final_duration = if let Some(d) = duration_frames { + d + } else if new_media_duration_frames < current_duration { + // New media is shorter: fit the clip into the available source span. + let available = (new_media_duration_frames - final_trim_start).max(1); + ((available as f64 / speed.max(0.0001)).round() as i32).max(1) + } else { + current_duration + }; + + // 8. Clamp trim_end so the total source span fits the new media. + let consumed = (final_duration as f64 * speed).round() as i32; + let max_trim_end = (new_media_duration_frames - final_trim_start - consumed).max(0); + let final_trim_end = current_trim_end.min(max_trim_end); + + // 9. media_type implies source_clip_type when the latter is not explicit. + let final_media_type = media_type; + let final_source_clip_type = source_clip_type.or(media_type); + + let summary_media_ref = media_ref.clone(); + let summary_clip_id = clip_id.clone(); + transact( + state, + "Swap Media", + move |_| { + format!( + "Swapped media on {} to {}", + summary_clip_id, summary_media_ref + ) + }, + move |st| { + let loc = st.find_clip(&clip_id).expect("validated above"); + let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; + clip.media_ref = media_ref.clone(); + if let Some(mt) = final_media_type { + clip.media_type = mt; + } + if let Some(sct) = final_source_clip_type { + clip.source_clip_type = sct; + } + if clip.duration_frames != final_duration { + clip.duration_frames = final_duration; + clip.clamp_keyframes_to_duration(); + clip.clamp_fades_to_duration(); + } + clip.trim_start_frame = final_trim_start; + clip.trim_end_frame = final_trim_end; + Ok(vec![clip_id.clone()]) + }, + ) +} + // 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..54b4879 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,235 @@ 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, + }; + 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(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &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); +} + +#[test] +fn swap_media_truncates_when_new_media_shorter() { + // Clip duration 100 frames; new media is 50 frames -> truncate to 50. + 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("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(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "short"); + assert_eq!(clip.duration_frames, 50); // truncated to new media length +} + +#[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(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &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_syncs_media_type_and_source_clip_type() { + // Original clip is video; swap to an audio asset with mediaType=Audio. + 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(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "audio1".into(), + media_type: Some(ClipType::Audio), + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap(); + + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "audio1"); + assert_eq!(clip.media_type, ClipType::Audio); + assert_eq!(clip.source_clip_type, ClipType::Audio); // implied by media_type +} + +#[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(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); +} + +#[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(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &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 +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fe96489..d6353e8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -217,6 +217,18 @@ pub enum EditRequest { asset_ids: Vec, folder_id: Option, }, + SwapMedia { + clip_id: String, + media_ref: String, + #[serde(default)] + media_type: Option, + #[serde(default)] + source_clip_type: Option, + #[serde(default)] + duration_frames: Option, + #[serde(default)] + trim_start_frame: Option, + }, } impl EditRequest { @@ -344,6 +356,21 @@ impl EditRequest { asset_ids, folder_id, }, + EditRequest::SwapMedia { + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + } => EditCommand::SwapMedia { + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + }, }) } } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 066ed38..b7acc31 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -5,7 +5,8 @@ * Text/AI-Edit tabs are scaffolded (TODO: full parity in a later pass). */ -import { Info, SlidersHorizontal, Diamond } from "lucide-react"; +import { useState } from "react"; +import { Info, SlidersHorizontal, Diamond, RefreshCw } from "lucide-react"; import { PanelHeaderBar } from "../ui/PanelShell"; import { Icon } from "../ui/Icon"; import { ScrubbableNumberField } from "./ScrubbableNumberField"; @@ -13,10 +14,11 @@ import { TextTab } from "./TextTab"; import { KeyframesPanel } from "./KeyframesPanel"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; +import { useMediaStore } from "../../store/mediaStore"; import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; import { useT, type TFunction } from "../../i18n"; -import type { Clip, Timeline } from "../../lib/types"; +import type { Clip, MediaItem, Timeline } from "../../lib/types"; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -134,6 +136,141 @@ const TAB_LABEL_KEY: Record<"text" | "video" | "audio" | "aiEdit", string> = { aiEdit: "inspector.tab.aiEdit", }; +/** 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"; + } +} + +/** "替换媒体" section: opens an inline media picker that lists every library + * asset except the clip's current `mediaRef`. Selecting one fires + * `edit.swapMedia`, which preserves all editing attributes and truncates the + * duration when the new media is shorter. Text clips don't render this section + * (they have no source media to swap). */ +function SwapMediaSection({ clip, t }: { clip: Clip; t: TFunction }) { + const [open, setOpen] = useState(false); + const items = useMediaStore((s) => s.items); + + // Exclude the current media source; text items aren't swappable targets. + const candidates = items.filter((m) => m.id !== clip.mediaRef && m.type !== "text"); + + const handlePick = (item: MediaItem) => { + void edit.swapMedia(clip.id, item.id, { mediaType: item.type }); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ {t("inspector.swapMediaTitle")} +
+ {candidates.length === 0 ? ( +
+ {t("inspector.swapMediaEmpty")} +
+ ) : ( + candidates.map((item) => ( + + )) + )} +
+ )} +
+ ); +} + function ClipInspector({ clip, tab, @@ -192,6 +329,7 @@ function ClipInspector({ )}
+ {clip.mediaType !== "text" && } {activeTab === "text" ? ( ) : activeTab === "audio" ? ( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7aac2fa..82f97f5 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)", @@ -315,6 +319,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..d62f1e6 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -176,7 +176,16 @@ 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; + mediaType?: ClipType; + sourceClipType?: ClipType; + durationFrames?: number; + trimStartFrame?: number; + }; export interface TextEntryReq { trackIndex: number; diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7885792..ca405e9 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -151,6 +151,32 @@ 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). When + * the new media is shorter than the clip's current duration, the backend + * truncates the duration and clamps `trim_end_frame` to fit. `mediaType`, when + * set, also implies `sourceClipType` unless `sourceClipType` is explicit. */ +export async function swapMedia( + clipId: string, + mediaRef: string, + options?: { + mediaType?: ClipType; + sourceClipType?: ClipType; + durationFrames?: number; + trimStartFrame?: number; + }, +) { + await applyAndRefresh({ + type: "swapMedia", + clipId, + mediaRef, + mediaType: options?.mediaType, + sourceClipType: options?.sourceClipType, + durationFrames: options?.durationFrames, + trimStartFrame: options?.trimStartFrame, + }); +} + export async function undo() { await api.undo(); if (!isTauri) await forceRefresh(); From 5607d8da3d4f8f932a25bf6d793e81a53f8f5f0b Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:04:35 +0800 Subject: [PATCH 2/7] style: fix cargo fmt in command.rs and tests (#101) --- crates/opentake-ops/src/command.rs | 3 +-- crates/opentake-ops/tests/command_apply.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 2614b29..fdb0994 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -1803,8 +1803,7 @@ fn swap_media( // 3. Convert new media duration (seconds) -> frames using the timeline fps. let fps = state.timeline.fps; - let new_media_duration_frames = - ((new_asset.duration * fps as f64).round() as i32).max(1); + let new_media_duration_frames = ((new_asset.duration * fps as f64).round() as i32).max(1); // 4. Snapshot the current clip's timing fields for validation. let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 54b4879..cbaf273 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1134,7 +1134,7 @@ fn swap_media_replaces_ref_and_preserves_attributes() { let clip = &st.timeline.tracks[0].clips[0]; assert_eq!(clip.media_ref, "new"); assert_eq!(clip.duration_frames, 100); // unchanged - // Preserved editing attributes + // 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); @@ -1195,7 +1195,7 @@ fn swap_media_rejects_missing_media_ref() { assert!(matches!(err, EditError::Invalid(_))); assert_eq!(st.version(), 0); // unchanged - // Original media_ref preserved. + // Original media_ref preserved. assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); } From dfde2fcaeed984a46e2978258e76c444302aebec Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:13:28 +0800 Subject: [PATCH 3/7] fix: correct cargo fmt in command_apply.rs (#101) --- crates/opentake-ops/tests/command_apply.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index cbaf273..1efce07 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1081,10 +1081,7 @@ fn media_entry(id: &str, kind: ClipType, duration_secs: f64) -> MediaManifestEnt } /// Build a state with the given tracks and manifest entries (fps defaults to 30). -fn state_with_media( - tracks: Vec, - entries: Vec, -) -> EditorState { +fn state_with_media(tracks: Vec, entries: Vec) -> EditorState { let mut tl = Timeline::new(); tl.tracks = tracks; let mut manifest = MediaManifest::new(); From 991c34a38509833cdf15dcabe596ed784396388b Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:21:04 +0800 Subject: [PATCH 4/7] fix: align trailing comment with 43 spaces (#101) --- crates/opentake-ops/tests/command_apply.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 1efce07..ab270b0 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1131,7 +1131,7 @@ fn swap_media_replaces_ref_and_preserves_attributes() { let clip = &st.timeline.tracks[0].clips[0]; assert_eq!(clip.media_ref, "new"); assert_eq!(clip.duration_frames, 100); // unchanged - // Preserved editing attributes + // 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); From 6f3228f6adcd2410edeb7ef57d3a3e81e2fbac68 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 11:21:33 +0800 Subject: [PATCH 5/7] chore: trim playback whitespace --- web/src/components/preview/TimelinePlaybackLayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From a4d92d65a347d4a59843761025b4a14dbedee592 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 23:27:58 +0800 Subject: [PATCH 6/7] fix(swap-media): simplify DTO to 2-arg + frontend type-consistency filter (review #121) --- crates/opentake-ops/src/command.rs | 210 ++++++++++----------- crates/opentake-ops/tests/command_apply.rs | 195 +++++++++++++++---- src-tauri/src/commands.rs | 16 -- web/src/components/inspector/Inspector.tsx | 18 +- web/src/lib/types.ts | 10 +- web/src/store/editActions.ts | 31 +-- 6 files changed, 281 insertions(+), 199 deletions(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index fdb0994..99636b3 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -282,18 +282,24 @@ pub enum EditCommand { /// 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). - /// When the new media is shorter than the clip's current duration, the - /// duration is truncated and `trim_end_frame` is clamped to fit. Optional - /// fields override the inferred defaults; `media_type` (when set) also - /// implies `source_clip_type` unless `source_clip_type` is explicitly given. + /// (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, - media_type: Option, - source_clip_type: Option, - duration_frames: Option, - trim_start_frame: Option, }, /// Undo the last committed command. Undo, @@ -417,19 +423,7 @@ pub fn apply( EditCommand::SwapMedia { clip_id, media_ref, - media_type, - source_clip_type, - duration_frames, - trim_start_frame, - } => swap_media( - state, - clip_id, - media_ref, - media_type, - source_clip_type, - duration_frames, - trim_start_frame, - ), + } => swap_media(state, clip_id, media_ref), } } @@ -1773,116 +1767,116 @@ fn delete_folder( } /// Replace a clip's `media_ref` in place, preserving every editing attribute -/// (transform / crop / keyframe tracks / grade / masks / effects / fade / text). -/// The new `media_ref` must exist in the manifest; when the new media is shorter -/// than the clip's current duration, the duration is truncated and -/// `trim_end_frame` is clamped so the source span fits. `media_type`, when set, -/// also implies `source_clip_type` unless `source_clip_type` is explicitly given -/// (matches the spec's "sync media_type" scenario). +/// (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, - media_type: Option, - source_clip_type: Option, - duration_frames: Option, - trim_start_frame: Option, ) -> Result { - // 1. Validate clip exists. - let loc = state + // 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. Validate media_ref exists in manifest; read its duration (seconds). + // 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. Convert new media duration (seconds) -> frames using the timeline fps. - let fps = state.timeline.fps; - let new_media_duration_frames = ((new_asset.duration * fps as f64).round() as i32).max(1); - - // 4. Snapshot the current clip's timing fields for validation. - let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let current_duration = clip.duration_frames; - let current_trim_start = clip.trim_start_frame; - let current_trim_end = clip.trim_end_frame; - let speed = clip.speed; - - // 5. Validate an explicitly-provided duration. - if let Some(d) = duration_frames { - if d < 1 { - return Err(EditError::Invalid(format!( - "durationFrames must be >= 1 (got {d})" - ))); - } + .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 + ))); } - // 6. Resolve final trim_start (explicit override or current value). - let final_trim_start = trim_start_frame.unwrap_or(current_trim_start); - if final_trim_start < 0 { - return Err(EditError::Invalid(format!( - "trimStartFrame must be >= 0 (got {final_trim_start})" - ))); + // 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 final_trim_start >= new_media_duration_frames { - return Err(EditError::Invalid(format!( - "trimStartFrame {final_trim_start} must be < new media duration ({new_media_duration_frames})" - ))); + if !targets.iter().any(|id| id == &clip_id) { + // Defensive: the seed itself must always be in the target set. + targets.push(clip_id.clone()); } - // 7. Determine final duration: explicit > truncate-to-fit > keep current. - let final_duration = if let Some(d) = duration_frames { - d - } else if new_media_duration_frames < current_duration { - // New media is shorter: fit the clip into the available source span. - let available = (new_media_duration_frames - final_trim_start).max(1); - ((available as f64 / speed.max(0.0001)).round() as i32).max(1) - } else { - current_duration - }; - - // 8. Clamp trim_end so the total source span fits the new media. - let consumed = (final_duration as f64 * speed).round() as i32; - let max_trim_end = (new_media_duration_frames - final_trim_start - consumed).max(0); - let final_trim_end = current_trim_end.min(max_trim_end); - - // 9. media_type implies source_clip_type when the latter is not explicit. - let final_media_type = media_type; - let final_source_clip_type = source_clip_type.or(media_type); - - let summary_media_ref = media_ref.clone(); - let summary_clip_id = 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 |_| { - format!( - "Swapped media on {} to {}", - summary_clip_id, summary_media_ref - ) + 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 loc = st.find_clip(&clip_id).expect("validated above"); - let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; - clip.media_ref = media_ref.clone(); - if let Some(mt) = final_media_type { - clip.media_type = mt; - } - if let Some(sct) = final_source_clip_type { - clip.source_clip_type = sct; - } - if clip.duration_frames != final_duration { - clip.duration_frames = final_duration; - clip.clamp_keyframes_to_duration(); - clip.clamp_fades_to_duration(); + 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()); + } } - clip.trim_start_frame = final_trim_start; - clip.trim_end_frame = final_trim_end; - Ok(vec![clip_id.clone()]) + Ok(affected) }, ) } diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index ab270b0..e77f238 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1103,6 +1103,9 @@ fn swap_media_replaces_ref_and_preserves_attributes() { 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), @@ -1116,10 +1119,6 @@ fn swap_media_replaces_ref_and_preserves_attributes() { EditCommand::SwapMedia { clip_id: "c".into(), media_ref: "new".into(), - media_type: None, - source_clip_type: None, - duration_frames: None, - trim_start_frame: None, }, &g, ) @@ -1136,12 +1135,21 @@ fn swap_media_replaces_ref_and_preserves_attributes() { 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_truncates_when_new_media_shorter() { - // Clip duration 100 frames; new media is 50 frames -> truncate to 50. - let v = video_track("v", true, vec![clip("c", 0, 100)]); +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), @@ -1154,10 +1162,6 @@ fn swap_media_truncates_when_new_media_shorter() { EditCommand::SwapMedia { clip_id: "c".into(), media_ref: "short".into(), - media_type: None, - source_clip_type: None, - duration_frames: None, - trim_start_frame: None, }, &g, ) @@ -1166,7 +1170,11 @@ fn swap_media_truncates_when_new_media_shorter() { assert!(res.changed); let clip = &st.timeline.tracks[0].clips[0]; assert_eq!(clip.media_ref, "short"); - assert_eq!(clip.duration_frames, 50); // truncated to new media length + // 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] @@ -1181,10 +1189,6 @@ fn swap_media_rejects_missing_media_ref() { EditCommand::SwapMedia { clip_id: "c".into(), media_ref: "nonexistent".into(), - media_type: None, - source_clip_type: None, - duration_frames: None, - trim_start_frame: None, }, &g, ) @@ -1197,8 +1201,8 @@ fn swap_media_rejects_missing_media_ref() { } #[test] -fn swap_media_syncs_media_type_and_source_clip_type() { - // Original clip is video; swap to an audio asset with mediaType=Audio. +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; @@ -1210,24 +1214,21 @@ fn swap_media_syncs_media_type_and_source_clip_type() { let mut st = state_with_media(vec![v], entries); let g = SeqIdGen::default(); - apply( + let err = apply( &mut st, EditCommand::SwapMedia { clip_id: "c".into(), media_ref: "audio1".into(), - media_type: Some(ClipType::Audio), - source_clip_type: None, - duration_frames: None, - trim_start_frame: None, }, &g, ) - .unwrap(); + .unwrap_err(); - let clip = &st.timeline.tracks[0].clips[0]; - assert_eq!(clip.media_ref, "audio1"); - assert_eq!(clip.media_type, ClipType::Audio); - assert_eq!(clip.source_clip_type, ClipType::Audio); // implied by media_type + 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] @@ -1242,10 +1243,6 @@ fn swap_media_rejects_missing_clip() { EditCommand::SwapMedia { clip_id: "missing".into(), media_ref: "new".into(), - media_type: None, - source_clip_type: None, - duration_frames: None, - trim_start_frame: None, }, &g, ) @@ -1255,6 +1252,32 @@ fn swap_media_rejects_missing_clip() { 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)]); @@ -1270,10 +1293,6 @@ fn swap_media_is_undoable() { EditCommand::SwapMedia { clip_id: "c".into(), media_ref: "new".into(), - media_type: None, - source_clip_type: None, - duration_frames: None, - trim_start_frame: None, }, &g, ) @@ -1285,3 +1304,107 @@ fn swap_media_is_undoable() { 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 d6353e8..1875cc9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -220,14 +220,6 @@ pub enum EditRequest { SwapMedia { clip_id: String, media_ref: String, - #[serde(default)] - media_type: Option, - #[serde(default)] - source_clip_type: Option, - #[serde(default)] - duration_frames: Option, - #[serde(default)] - trim_start_frame: Option, }, } @@ -359,17 +351,9 @@ impl EditRequest { EditRequest::SwapMedia { clip_id, media_ref, - media_type, - source_clip_type, - duration_frames, - trim_start_frame, } => EditCommand::SwapMedia { clip_id, media_ref, - media_type, - source_clip_type, - duration_frames, - trim_start_frame, }, }) } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index b7acc31..9c67933 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -153,19 +153,23 @@ function mediaTypeLabel(type: MediaItem["type"]): string { } /** "替换媒体" section: opens an inline media picker that lists every library - * asset except the clip's current `mediaRef`. Selecting one fires - * `edit.swapMedia`, which preserves all editing attributes and truncates the - * duration when the new media is shorter. Text clips don't render this section - * (they have no source media to swap). */ + * 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. */ function SwapMediaSection({ clip, t }: { clip: Clip; t: TFunction }) { const [open, setOpen] = useState(false); const items = useMediaStore((s) => s.items); - // Exclude the current media source; text items aren't swappable targets. - const candidates = items.filter((m) => m.id !== clip.mediaRef && m.type !== "text"); + // 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, { mediaType: item.type }); + void edit.swapMedia(clip.id, item.id); setOpen(false); }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d62f1e6..5c2ad67 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -177,15 +177,7 @@ export type EditRequest = } | { type: "createFolder"; name: string; parentFolderId?: string } | { type: "moveToFolder"; assetIds: string[]; folderId?: string } - | { - type: "swapMedia"; - clipId: string; - mediaRef: string; - mediaType?: ClipType; - sourceClipType?: ClipType; - durationFrames?: number; - trimStartFrame?: number; - }; + | { 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 ca405e9..3d8104f 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -152,29 +152,14 @@ export async function moveToFolder(assetIds: string[], folderId?: string) { } /** Replace a clip's media source in place, preserving all editing attributes - * (transform / crop / keyframe tracks / grade / masks / effects / fade). When - * the new media is shorter than the clip's current duration, the backend - * truncates the duration and clamps `trim_end_frame` to fit. `mediaType`, when - * set, also implies `sourceClipType` unless `sourceClipType` is explicit. */ -export async function swapMedia( - clipId: string, - mediaRef: string, - options?: { - mediaType?: ClipType; - sourceClipType?: ClipType; - durationFrames?: number; - trimStartFrame?: number; - }, -) { - await applyAndRefresh({ - type: "swapMedia", - clipId, - mediaRef, - mediaType: options?.mediaType, - sourceClipType: options?.sourceClipType, - durationFrames: options?.durationFrames, - trimStartFrame: options?.trimStartFrame, - }); + * (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() { From 02d484e599d94c7e7197dcc9dafb18884662bcdc Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 23:40:30 +0800 Subject: [PATCH 7/7] fix(swap-media): singleLinkGroup gate + extract SwapMediaSection out of Inspector (#101) --- crates/opentake-ops/src/command.rs | 14 +- src-tauri/src/commands.rs | 55 ++++-- web/src/components/inspector/Inspector.tsx | 146 +-------------- .../components/inspector/SwapMediaSection.tsx | 173 ++++++++++++++++++ 4 files changed, 217 insertions(+), 171 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 99636b3..d3423cb 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -297,10 +297,7 @@ pub enum EditCommand { /// * **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, - }, + SwapMedia { clip_id: String, media_ref: String }, /// Undo the last committed command. Undo, /// Redo the last undone command. @@ -420,10 +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), + EditCommand::SwapMedia { clip_id, media_ref } => swap_media(state, clip_id, media_ref), } } @@ -1805,8 +1799,8 @@ fn swap_media( // 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; + 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 {:?}", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1875cc9..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, @@ -348,13 +371,9 @@ impl EditRequest { asset_ids, folder_id, }, - EditRequest::SwapMedia { - clip_id, - media_ref, - } => EditCommand::SwapMedia { - clip_id, - media_ref, - }, + 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 9c67933..e3156f3 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -5,20 +5,19 @@ * Text/AI-Edit tabs are scaffolded (TODO: full parity in a later pass). */ -import { useState } from "react"; -import { Info, SlidersHorizontal, Diamond, RefreshCw } from "lucide-react"; +import { Info, SlidersHorizontal, Diamond } from "lucide-react"; import { PanelHeaderBar } from "../ui/PanelShell"; 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 { useMediaStore } from "../../store/mediaStore"; import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; import { useT, type TFunction } from "../../i18n"; -import type { Clip, MediaItem, Timeline } from "../../lib/types"; +import type { Clip, Timeline } from "../../lib/types"; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -136,145 +135,6 @@ const TAB_LABEL_KEY: Record<"text" | "video" | "audio" | "aiEdit", string> = { aiEdit: "inspector.tab.aiEdit", }; -/** 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"; - } -} - -/** "替换媒体" section: 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. */ -function SwapMediaSection({ clip, t }: { clip: Clip; t: TFunction }) { - const [open, setOpen] = useState(false); - const items = useMediaStore((s) => s.items); - - // 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) => ( - - )) - )} -
- )} -
- ); -} - function ClipInspector({ clip, tab, 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) => ( + + )) + )} +
+ )} +
+ ); +}