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/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/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..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 7885792..3d8104f 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -151,6 +151,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();