diff --git a/Cargo.lock b/Cargo.lock index 99c7b11..2a565b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3118,7 +3118,7 @@ dependencies = [ [[package]] name = "opentake-agent" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -3144,7 +3144,7 @@ dependencies = [ [[package]] name = "opentake-core" -version = "0.1.0" +version = "1.0.0" dependencies = [ "opentake-domain", "opentake-ops", @@ -3156,7 +3156,7 @@ dependencies = [ [[package]] name = "opentake-domain" -version = "0.1.0" +version = "1.0.0" dependencies = [ "serde", "serde_json", @@ -3164,7 +3164,7 @@ dependencies = [ [[package]] name = "opentake-gen" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -3182,7 +3182,7 @@ dependencies = [ [[package]] name = "opentake-media" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "byteorder", @@ -3209,7 +3209,7 @@ dependencies = [ [[package]] name = "opentake-motion" -version = "0.1.0" +version = "1.0.0" dependencies = [ "hex", "image", @@ -3224,7 +3224,7 @@ dependencies = [ [[package]] name = "opentake-ops" -version = "0.1.0" +version = "1.0.0" dependencies = [ "opentake-domain", "serde_json", @@ -3232,7 +3232,7 @@ dependencies = [ [[package]] name = "opentake-project" -version = "0.1.0" +version = "1.0.0" dependencies = [ "opentake-domain", "serde", @@ -3242,7 +3242,7 @@ dependencies = [ [[package]] name = "opentake-render" -version = "0.1.0" +version = "1.0.0" dependencies = [ "bytemuck", "cosmic-text", @@ -3255,7 +3255,7 @@ dependencies = [ [[package]] name = "opentake-tauri" -version = "0.1.0" +version = "1.0.0" dependencies = [ "base64 0.22.1", "image", diff --git a/Cargo.toml b/Cargo.toml index 965f903..311ab42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "1.0.0" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/appergb/OpenTake" diff --git a/README.md b/README.md index 6b959d2..97a5a92 100644 --- a/README.md +++ b/README.md @@ -152,13 +152,20 @@ crates/ ├── opentake-project # Project persistence / bundle / archive / export ├── opentake-media # FFmpeg codec / thumbnails / waveform / transcription / semantic search ├── opentake-render # wgpu compositor + text rasterizer -├── opentake-motion # Lottie / web motion graphics rendering +├── opentake-motion # Native motion fallback: RGBA frame cache / alpha source scaffold ├── opentake-agent # MCP Server + Agent chat + context signal system ├── opentake-gen # Generative AI clients (fal.ai / Replicate / OpenAI) ├── opentake-core # Session management / DI / event bus └── src-tauri # Tauri 2 desktop shell ``` +Planned external plugin: + +``` +plugins/ +└── motion-canvas-studio # Motion Canvas (MIT) fork/plugin for AI animation video output +``` + ```bash > cargo build --workspace # Build all crates > cargo test --workspace # Test all crates (≥80% coverage target) diff --git a/README.zh-CN.md b/README.zh-CN.md index 28cecbd..d329ff9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,13 +138,20 @@ crates/ ├── opentake-project # 项目持久化 / bundle / archive / export ├── opentake-media # FFmpeg 编解码 / 缩略图 / 波形 / 转写 / 语义搜索 ├── opentake-render # wgpu 帧合成器 + 文字光栅化 -├── opentake-motion # Lottie / Web 动效渲染 +├── opentake-motion # 原生动效 fallback:RGBA 帧缓存 / alpha source scaffold ├── opentake-agent # MCP Server + Agent chat + 上下文信号系统 ├── opentake-gen # 生成式 AI 客户端 (fal.ai / Replicate / OpenAI) ├── opentake-core # 会话管理 / 依赖注入 / 事件总线 └── src-tauri # Tauri 2 桌面外壳 ``` +计划中的外部插件: + +``` +plugins/ +└── motion-canvas-studio # Motion Canvas(MIT) fork/plugin,用于 AI 动画视频输出 +``` + --- ## 🏗️ 架构 diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 450ad6e..90001ce 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -151,7 +151,7 @@ impl Dispatcher { ToolName::AddTexts => self.add_texts(args), ToolName::CreateFolder => self.create_folder(args), ToolName::MoveToFolder => self.move_to_folder(args), - ToolName::SetClipProperties => self.set_clip_properties(args), + ToolName::SetClipProperties => self.set_clip_properties(args, before, manifest), ToolName::SetColorGrade => self.set_color_grade(args), ToolName::ChromaKey => self.chroma_key(args), ToolName::SetMask => self.set_mask(args), @@ -171,8 +171,9 @@ impl Dispatcher { // --- Not yet implementable in this phase (honest stubs) --- // Media reads (inspect/transcript/search) + import need the media // backend via a widened CoreHandle; generation/upscale need the async - // GenClient + BYOK auth; inspect_timeline needs the render+text path; - // motion graphics is #34. Each is wired in a later batch. + // GenClient + BYOK auth; inspect_timeline needs the render+text path. + // Motion graphics (#34) now routes through the planned Motion Canvas + // plugin: render mp4 -> import media -> place clip. ToolName::InspectMedia | ToolName::GetTranscript | ToolName::InspectTimeline @@ -231,6 +232,7 @@ impl Dispatcher { trim_end_frame: e.trim_end_frame, has_audio, add_linked_audio: false, + transform: None, }); } op.added_media_refs = media_refs; @@ -260,6 +262,7 @@ impl Dispatcher { trim_end_frame: e.trim_end_frame, has_audio, add_linked_audio: false, + transform: None, }); } let res = self.apply(EditCommand::InsertClips { @@ -425,7 +428,12 @@ impl Dispatcher { Ok(ToolResult::ok(res.summary)) } - fn set_clip_properties(&self, args: &Value) -> Result { + fn set_clip_properties( + &self, + args: &Value, + before: &Timeline, + manifest: &MediaManifest, + ) -> Result { let a: SetClipPropertiesArgs = decode_tool_args(args, "")?; let clip_ids = a.clip_ids.clone(); let properties = ClipProperties { @@ -435,14 +443,43 @@ impl Dispatcher { speed: a.speed, volume: a.volume, opacity: a.opacity, - transform: a.transform.map(transform_from_arg), + transform: None, text_content: a.content.clone(), + ..Default::default() }; - let res = self.apply(EditCommand::SetClipProperties { - clip_ids, - properties, - })?; - Ok(ToolResult::ok(res.summary)) + let Some(transform_patch) = a.transform else { + let res = self.apply(EditCommand::SetClipProperties { + clip_ids, + properties, + })?; + return Ok(ToolResult::ok(res.summary)); + }; + + let mut per_clip = Vec::new(); + for clip_id in &clip_ids { + let clip = find_clip(before, clip_id).ok_or_else(|| { + ToolError::new(format!("set_clip_properties: clip not found: {clip_id}")) + })?; + let aspect = media_canvas_aspect(before, manifest, clip) + .or_else(|| current_transform_aspect(clip.transform)); + let mut clip_properties = properties.clone(); + clip_properties.transform = Some(merge_transform_arg( + clip.transform, + transform_patch.clone(), + aspect, + )); + per_clip.push((clip_id.clone(), clip_properties)); + } + + let mut summaries = Vec::new(); + for (clip_id, clip_properties) in per_clip { + let res = self.apply(EditCommand::SetClipProperties { + clip_ids: vec![clip_id], + properties: clip_properties, + })?; + summaries.push(res.summary); + } + Ok(ToolResult::ok(summaries.join("; "))) } fn set_color_grade(&self, args: &Value) -> Result { @@ -734,6 +771,66 @@ fn transform_from_arg(t: args::TransformArg) -> Transform { } } +fn merge_transform_arg( + base: Transform, + patch: args::TransformArg, + media_canvas_aspect: Option, +) -> Transform { + let aspect = media_canvas_aspect + .filter(|a| a.is_finite() && *a > 0.0) + .unwrap_or_else(|| current_transform_aspect(base).unwrap_or(1.0)); + let (width, height) = match (patch.width, patch.height) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, w / aspect), + (None, Some(h)) => (h * aspect, h), + (None, None) => (base.width, base.height), + }; + Transform { + center_x: patch.center_x.unwrap_or(base.center_x), + center_y: patch.center_y.unwrap_or(base.center_y), + width, + height, + rotation: base.rotation, + flip_horizontal: patch.flip_horizontal.unwrap_or(base.flip_horizontal), + flip_vertical: patch.flip_vertical.unwrap_or(base.flip_vertical), + } +} + +fn current_transform_aspect(t: Transform) -> Option { + if t.width.is_finite() && t.height.is_finite() && t.width > 0.0 && t.height > 0.0 { + Some(t.width / t.height) + } else { + None + } +} + +fn find_clip<'a>(timeline: &'a Timeline, clip_id: &str) -> Option<&'a opentake_domain::Clip> { + timeline + .tracks + .iter() + .flat_map(|track| track.clips.iter()) + .find(|clip| clip.id == clip_id) +} + +fn media_canvas_aspect( + timeline: &Timeline, + manifest: &MediaManifest, + clip: &opentake_domain::Clip, +) -> Option { + let entry = manifest + .entries + .iter() + .find(|entry| entry.id == clip.media_ref)?; + let sw = entry.source_width?; + let sh = entry.source_height?; + if sw <= 0 || sh <= 0 || timeline.width <= 0 || timeline.height <= 0 { + return None; + } + let source_aspect = sw as f64 / sh as f64; + let canvas_aspect = timeline.width as f64 / timeline.height as f64; + Some(source_aspect / canvas_aspect) +} + /// Build a [`TextStyle`] from `add_texts` scalar fields, leaving unspecified /// fields at their defaults. Color accepts `#RGB`/`#RRGGBB`/`#RRGGBBAA`. fn build_text_style( @@ -1028,6 +1125,7 @@ mod tests { // Seed a video track via the editing entry point. core.apply(EditCommand::InsertTrack { kind: ClipType::Video, + at: None, }) .unwrap(); TestHandle { core } @@ -1297,6 +1395,13 @@ mod tests { } } + fn entry_with_size(id: &str, name: &str, width: i32, height: i32) -> MediaManifestEntry { + let mut e = entry(id, name); + e.source_width = Some(width); + e.source_height = Some(height); + e + } + /// One video track with `clip-1` referencing `asset-1`, and `asset-1` in the /// manifest named "Old Name". fn seeded_handle() -> Arc { @@ -1309,6 +1414,24 @@ mod tests { Arc::new(StateHandle::new(tl, m)) } + fn seeded_transform_handle( + transform: Transform, + media_size: Option<(i32, i32)>, + ) -> Arc { + let mut tl = Timeline::new(); + let mut t = Track::new("track-1", ClipType::Video); + let mut clip = Clip::new("clip-1", "asset-1", 0, 30); + clip.transform = transform; + t.clips.push(clip); + tl.tracks.push(t); + let mut m = MediaManifest::new(); + m.entries.push(match media_size { + Some((w, h)) => entry_with_size("asset-1", "Hero", w, h), + None => entry("asset-1", "Hero"), + }); + Arc::new(StateHandle::new(tl, m)) + } + #[test] fn rename_media_updates_manifest_name() { let h = seeded_handle(); @@ -1346,6 +1469,107 @@ mod tests { assert!(r.text_joined().contains("not found"), "{}", r.text_joined()); } + #[test] + fn set_clip_properties_partial_transform_width_preserves_media_aspect() { + let h = seeded_transform_handle(Transform::default(), Some((3840, 2160))); + let d = dispatcher_with(h.clone()); + let r = d.dispatch( + "set_clip_properties", + serde_json::json!({ + "clipIds": ["clip-1"], + "transform": { "width": 0.5 } + }), + ); + assert!(!r.is_error, "{}", r.text_joined()); + let c = &h.timeline().tracks[0].clips[0]; + assert!((c.transform.width - 0.5).abs() < 1e-9); + assert!((c.transform.height - 0.5).abs() < 1e-9); + assert!((c.transform.center_x - 0.5).abs() < 1e-9); + } + + #[test] + fn set_clip_properties_partial_transform_center_keeps_size() { + let h = seeded_transform_handle( + Transform { + center_x: 0.3, + center_y: 0.4, + width: 0.25, + height: 0.5, + ..Transform::default() + }, + Some((1080, 1920)), + ); + let d = dispatcher_with(h.clone()); + let r = d.dispatch( + "set_clip_properties", + serde_json::json!({ + "clipIds": ["clip-1"], + "transform": { "centerY": 0.6 } + }), + ); + assert!(!r.is_error, "{}", r.text_joined()); + let c = &h.timeline().tracks[0].clips[0]; + assert!((c.transform.center_x - 0.3).abs() < 1e-9); + assert!((c.transform.center_y - 0.6).abs() < 1e-9); + assert!((c.transform.width - 0.25).abs() < 1e-9); + assert!((c.transform.height - 0.5).abs() < 1e-9); + } + + #[test] + fn set_clip_properties_partial_transform_uses_current_aspect_without_media_size() { + let h = seeded_transform_handle( + Transform { + width: 0.4, + height: 0.2, + ..Transform::default() + }, + None, + ); + let d = dispatcher_with(h.clone()); + let r = d.dispatch( + "set_clip_properties", + serde_json::json!({ + "clipIds": ["clip-1"], + "transform": { "height": 0.1 } + }), + ); + assert!(!r.is_error, "{}", r.text_joined()); + let c = &h.timeline().tracks[0].clips[0]; + assert!((c.transform.width - 0.2).abs() < 1e-9); + assert!((c.transform.height - 0.1).abs() < 1e-9); + } + + #[test] + fn set_clip_properties_partial_transform_missing_clip_is_rejected_without_mutation() { + let h = seeded_transform_handle( + Transform { + width: 0.4, + height: 0.2, + ..Transform::default() + }, + None, + ); + let d = dispatcher_with(h.clone()); + + let r = d.dispatch( + "set_clip_properties", + serde_json::json!({ + "clipIds": ["clip-1", "ghost"], + "transform": { "height": 0.1 } + }), + ); + + assert!(r.is_error); + assert!( + r.text_joined().contains("clip not found"), + "{}", + r.text_joined() + ); + let c = &h.timeline().tracks[0].clips[0]; + assert!((c.transform.width - 0.4).abs() < 1e-9); + assert!((c.transform.height - 0.2).abs() < 1e-9); + } + // MARK: - Workflow plugin (Skills) tools fn manifest_json(id: &str, name: &str, desc: &str, vtype: &str) -> String { diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index 994fc5e..a606b67 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -230,6 +230,7 @@ mod tests { let core = AppCore::new(); core.apply(EditCommand::InsertTrack { kind: ClipType::Video, + at: None, }) .unwrap(); TestHandle { core } diff --git a/crates/opentake-agent/src/prompt/base.rs b/crates/opentake-agent/src/prompt/base.rs index 7c6a8c8..93c1337 100644 --- a/crates/opentake-agent/src/prompt/base.rs +++ b/crates/opentake-agent/src/prompt/base.rs @@ -24,7 +24,7 @@ pub const GENERATION: &str = "# Generation\n- Costs real money and is not undoab pub const AUDIO_GENERATION: &str = "# Audio generation\n- Two categories, distinguished by model (see list_models type='audio'):\n • TTS: the prompt is the exact text to speak. Pass a `voice` the model supports; some models accept `styleInstructions` for delivery (e.g. \"warm and slow\").\n • Music: the prompt describes style, mood, and genre. Some music models accept `lyrics` with [Verse]/[Chorus] section tags. For Lyria 3 Pro, include lyrics, tempo, language, and vocal style directly in the prompt. Set `instrumental` true only when the selected model supports it.\n- Generated audio lands on an audio track. add_clips with trackIndex omitted auto-creates one when none exists yet."; /// Section: prompt craft. -pub const PROMPT_CRAFT: &str = "# Prompt craft\n- Images: 15–30 words. Formula: subject + setting + shot type + lighting/mood. Concrete nouns beat adjectives.\n- Videos: 8–20 words. Formula: camera movement + subject action. When a startFrameMediaRef is set, don't re-describe what's in the frame — the model sees it; spend the words on motion and sound.\n- State dialogue, VO, SFX, and music explicitly in video prompts (tone, volume, pitch when persistent). Silent video is usually a bug, not a feature.\n- Never generate UI screenshots, app interfaces, logo animations, motion graphics, title cards, text overlays, or screen recordings. Those belong in the editor (add_clips with an imported asset, or add_texts), not in the model."; +pub const PROMPT_CRAFT: &str = "# Prompt craft\n- Images: 15–30 words. Formula: subject + setting + shot type + lighting/mood. Concrete nouns beat adjectives.\n- Videos: 8–20 words. Formula: camera movement + subject action. When a startFrameMediaRef is set, don't re-describe what's in the frame — the model sees it; spend the words on motion and sound.\n- State dialogue, VO, SFX, and music explicitly in video prompts (tone, volume, pitch when persistent). Silent video is usually a bug, not a feature.\n- Never ask generative video models to render readable UI, text overlays, logo animations, title cards, or screen recordings. Those belong in the editor: add_motion_graphic for Motion Canvas segments, add_texts for text overlays, or add_clips with an imported asset."; /// Section: communication. Kept verbatim — the calm/terse HIG voice is a strong /// behavior contract. diff --git a/crates/opentake-agent/src/tools/args.rs b/crates/opentake-agent/src/tools/args.rs index f47996e..663c3b8 100644 --- a/crates/opentake-agent/src/tools/args.rs +++ b/crates/opentake-agent/src/tools/args.rs @@ -430,13 +430,13 @@ impl ToolArgs for ApplyEffectArgs { const ALLOWED_KEYS: &'static [&'static str] = &["clipIds", "effects"]; } -// --- Web motion graphics (docs/MOTION-GRAPHICS-PLUGIN.md, Issue #14) --- +// --- Motion Canvas graphics (docs/MOTION-GRAPHICS-PLUGIN.md, Issue #34) --- /// The `source` object on `add_motion_graphic` — exactly one of `code` or -/// `templateId` set (mutual-exclusion is a business-level guard in the motion -/// dispatch path, like `import_media`'s source). `params` only applies with a -/// template; values are kept as raw JSON so the dispatch layer converts them to -/// `opentake_motion::ParamValue` without coupling the tool layer to that crate. +/// `templateId` set. In the Motion Canvas v1 path, `code` is a TS/TSX scene +/// snippet or project source, while `templateId` instantiates a vetted template. +/// `params` only applies with a template; values stay raw JSON so the dispatch +/// layer can pass them to the plugin without coupling this tool layer to Node. #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct MotionSourceArg { @@ -1086,12 +1086,12 @@ mod tests { ); } - // --- Web motion-graphic args --- + // --- Motion Canvas graphic args --- #[test] fn add_motion_graphic_decodes_code_source() { let v = serde_json::json!({ - "source": {"code": "

Title

"}, + "source": {"code": "export default makeScene2D(function* (view) { /* title */ });"}, "startFrame": 30, "durationFrames": 90, "transparent": true @@ -1103,7 +1103,10 @@ mod tests { assert!(a.track_index.is_none()); // The nested source decodes with its own per-object unknown-key guard. let src: MotionSourceArg = decode_tool_args(&a.source, "source").unwrap(); - assert_eq!(src.code.as_deref(), Some("

Title

")); + assert_eq!( + src.code.as_deref(), + Some("export default makeScene2D(function* (view) { /* title */ });") + ); assert!(src.template_id.is_none()); } @@ -1158,10 +1161,10 @@ mod tests { #[test] fn edit_motion_graphic_decodes_and_requires_clip_id() { - let ok = serde_json::json!({"clipId": "c1", "code": "new"}); + let ok = serde_json::json!({"clipId": "c1", "code": "export default scene;"}); let a: EditMotionGraphicArgs = decode_tool_args(&ok, "").unwrap(); assert_eq!(a.clip_id, "c1"); - assert_eq!(a.code.as_deref(), Some("new")); + assert_eq!(a.code.as_deref(), Some("export default scene;")); assert!(a.params.is_none()); let missing = serde_json::json!({"code": "x"}); diff --git a/crates/opentake-agent/src/tools/descriptions.rs b/crates/opentake-agent/src/tools/descriptions.rs index fdfb612..da09c56 100644 --- a/crates/opentake-agent/src/tools/descriptions.rs +++ b/crates/opentake-agent/src/tools/descriptions.rs @@ -39,7 +39,7 @@ pub fn description(tool: ToolName) -> &'static str { ToolName::MoveClips => "Moves one or more clips to a new track and/or frame position. Single undoable action. Each move specifies the clip ID and at least one of toTrack (must be compatible with the clip's media type) and toFrame. Overlap on the destination is resolved as in add_clips (existing clips on the destination track are trimmed/split/removed). Linked partners follow the named clip: startFrame propagates as a delta to preserve l-cut / j-cut offsets; tracks stay with the named clip.", - ToolName::SetClipProperties => "Apply the same property values to one or more clips in a single undoable action. Pass any combination of durationFrames, trimStartFrame, trimEndFrame, speed, volume, opacity, transform, or — for text clips only — content, fontName, fontSize, color, alignment. All values are applied to every clip in clipIds; for per-clip differences, make separate calls. trimStartFrame/trimEndFrame are offsets from the source media, not the timeline. speed 1.0 is normal, <1.0 slows (clip gets longer on the timeline), >1.0 speeds up. volume and opacity are 0.0–1.0. transform uses 0–1 normalized canvas coords, partial merge (pass only centerY to reposition vertically); flipHorizontal/flipVertical mirror the clip across the corresponding axis (no effect on text clips). When a text clip's content or font changes without an explicit transform, the bounding box auto-refits. Text-only fields with any non-text clip in clipIds are rejected.\n\nFor moves and start-frame changes, use move_clips. For animated values (keyframes), use set_keyframes — setting volume or opacity here clears any existing keyframe track on that property.\n\nTiming changes (durationFrames, trimStartFrame, trimEndFrame, speed) on a linked clip carry over to its linked partner so audio/video stay in sync — same as the timeline UI. Per-clip fields (volume, opacity, transform, text*) don't propagate. trim and speed are skipped for text partners.", + ToolName::SetClipProperties => "Apply the same property values to one or more clips in a single undoable action. Pass any combination of durationFrames, trimStartFrame, trimEndFrame, speed, volume, opacity, transform, or — for text clips only — content, fontName, fontSize, color, alignment. All values are applied to every clip in clipIds; for per-clip differences, make separate calls. transform is currently single-clip only because partial width/height merges depend on each clip's source aspect; make separate calls for multiple transform edits. trimStartFrame/trimEndFrame are offsets from the source media, not the timeline. speed 1.0 is normal, <1.0 slows (clip gets longer on the timeline), >1.0 speeds up. volume and opacity are 0.0–1.0. transform uses 0–1 normalized canvas coords, partial merge (pass only centerY to reposition vertically); flipHorizontal/flipVertical mirror the clip across the corresponding axis (no effect on text clips). When a text clip's content or font changes without an explicit transform, the bounding box auto-refits. Text-only fields with any non-text clip in clipIds are rejected.\n\nFor moves and start-frame changes, use move_clips. For animated values (keyframes), use set_keyframes — setting volume or opacity here clears any existing keyframe track on that property.\n\nTiming changes (durationFrames, trimStartFrame, trimEndFrame, speed) on a linked clip carry over to its linked partner so audio/video stay in sync — same as the timeline UI. Per-clip fields (volume, opacity, transform, text*) don't propagate. trim and speed are skipped for text partners.", ToolName::SetKeyframes => "Set animated keyframes on one property of one clip. Replaces the existing keyframe track for that property (pass an empty array to clear). Frames are CLIP-RELATIVE offsets (0 = first frame of the clip), so keyframes follow the clip when it moves. Rows are sorted by frame internally and the LAST row for any duplicate frame wins. Values must be finite numbers. Each row is `[frame, ...values, interp?]` where interp ∈ {linear, hold, smooth} (default smooth).\n\nProperties and their value layouts:\n • volume `[frame, value]` — value 0.0–1.0\n • opacity `[frame, value]` — value 0.0–1.0\n • rotation `[frame, degrees]` — clockwise degrees\n • position `[frame, topLeftX, topLeftY]` — TOP-LEFT corner in 0–1 normalized canvas coords. NOT the center. (Default static transform centers a full-canvas clip, so top-left of the static is (0, 0); a centered half-size clip has top-left (0.25, 0.25).)\n • scale `[frame, width, height]` — clip's normalized width and height in 0–1 canvas coords (1.0 = fills the canvas axis). NOT a scale factor.\n • crop `[frame, top, right, bottom, left]` — side insets in 0–1 of the source media.\n\nMotion keyframes (position/scale/rotation) override the static `transform` value when active.", @@ -95,10 +95,10 @@ pub fn description(tool: ToolName) -> &'static str { ToolName::ApplyEffect => "Sets the effect chain on one or more clips in one undoable action — an ordered list of named pixel effects, each a shader pass with named numeric parameters. Each effect is { name, params } where name selects the effect (e.g. 'gaussianBlur') and params are its scalar inputs (e.g. { radius: 4 }); pass enabled:false to keep a disabled effect in the chain. The list replaces the clip's current effects; pass an empty array to clear them. Applies to every clip in clipIds.", - // --- OpenTake Web motion graphics (docs/MOTION-GRAPHICS-PLUGIN.md, Issue #14) --- - ToolName::AddMotionGraphic => "Adds a Web motion graphic (animated title, lower-third, data callout, transition card) to the timeline as a single undoable action, and returns its clipId. You author the animation with Web technology and OpenTake renders it deterministically — every frame reproducible, preview == export — to a transparent RGBA overlay that composites over your other layers like any other clip.\n\nThe 'source' object is exactly one of:\n • { code: \"\" } — a self-contained HTML document (it may embed +
- - {t("settings.title")} - -
- - - -
-
- - - - - + + {t("settings.title")} + +
+ + +
+ + +
+
+ + + + + +
@@ -232,6 +282,9 @@ function AppearancePane() { const t = useT(); const theme = useSettingsStore((s) => s.theme); const setTheme = useSettingsStore((s) => s.setTheme); + const windowSize = useSettingsStore((s) => s.windowSize); + const setWindowSize = useSettingsStore((s) => s.setWindowSize); + return (
} /> + + value={windowSize} + options={[ + { id: "standard", label: t("settings.windowSize.standard") }, + { id: "compact", label: t("settings.windowSize.compact") }, + ]} + onChange={setWindowSize} + /> + } + />
); } diff --git a/web/src/components/shell/TitleBar.tsx b/web/src/components/shell/TitleBar.tsx index 081e735..199aa44 100644 --- a/web/src/components/shell/TitleBar.tsx +++ b/web/src/components/shell/TitleBar.tsx @@ -38,6 +38,7 @@ function defaultXmlName(projectPath: string | null): string { export function TitleBar() { const setView = useEditorUiStore((s) => s.setView); + const setSettingsOpen = useEditorUiStore((s) => s.setSettingsOpen); const projectPath = useProjectStore((s) => s.projectPath); const t = useT(); @@ -94,6 +95,8 @@ export function TitleBar() { alignItems: "center", justifyContent: "center", color: "var(--text-secondary)", + position: "relative", + top: -2, }} > @@ -102,7 +105,7 @@ export function TitleBar() { {/* §2.9 menu entry point (hosts Layout presets + Agent panel + visibility). */} -
+
{/* Trailing: Library + Settings + Export. */} + ))} +
+ ); +} 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.test.ts b/web/src/components/timeline/TimelineContainer.test.ts new file mode 100644 index 0000000..fb1898a --- /dev/null +++ b/web/src/components/timeline/TimelineContainer.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import * as timelineContainer from "./TimelineContainer"; +import type { Clip, ClipType, Timeline, Track } from "../../lib/types"; + +function clip(over: Partial & { id: string; mediaType: ClipType }): Clip { + return { + id: over.id, + mediaRef: over.mediaRef ?? "asset", + mediaType: over.mediaType, + sourceClipType: over.mediaType, + startFrame: over.startFrame ?? 0, + durationFrames: over.durationFrames ?? 30, + trimStartFrame: over.trimStartFrame ?? 0, + trimEndFrame: over.trimEndFrame ?? 0, + speed: over.speed ?? 1, + volume: over.volume ?? 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "smooth", + fadeOutInterpolation: "smooth", + opacity: over.opacity ?? 1, + transform: { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + ...over, + }; +} + +function track(id: string, clips: Clip[]): Track { + return { + id, + type: "video", + muted: false, + hidden: false, + syncLocked: true, + clips, + }; +} + +function timeline(tracks: Track[]): Timeline { + return { fps: 30, width: 1920, height: 1080, settingsConfigured: true, tracks }; +} + +describe("collectMoveSnapTargets", () => { + it("does not include the playhead when moving clips", () => { + const fn = ( + timelineContainer as { + collectMoveSnapTargets?: ( + timeline: Timeline, + excluded: Set, + activeFrame: number, + ) => Array<{ frame: number; kind: string }>; + } + ).collectMoveSnapTargets; + const tl = timeline([ + track("v1", [ + clip({ id: "dragged", mediaType: "video", startFrame: 10, durationFrames: 20 }), + clip({ id: "other", mediaType: "video", startFrame: 80, durationFrames: 10 }), + ]), + ]); + + expect(typeof fn).toBe("function"); + expect(fn?.(tl, new Set(["dragged"]), 42)).toEqual([ + { frame: 80, kind: "clipEdge" }, + { frame: 90, kind: "clipEdge" }, + ]); + }); +}); diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 5cb4d35..55499ed 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -11,33 +11,89 @@ import { LAYOUT, ZOOM } from "../../lib/theme"; import { contentHeight, contentWidth, + dropTargetAt, frameAt, totalFrames, - trackAt, } 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, + fadeKneeHit, + fadeFramesForDrag, + type ClipHit, + type FadeEdge, +} from "./hitTest"; +import { ClipContextMenu } from "./ClipContextMenu"; +import { MEDIA_DND_TYPE } from "../media/MediaPanel"; 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, Timeline } from "../../lib/types"; + +/** Where a move/duplicate drag will land. `newTrack` inserts before `index` + * (upstream `newTrackAt(index)`), clamped into visual/audio zones by the core. */ +type DropTarget = + | { kind: "existing"; trackIndex: number } + | { kind: "newTrack"; index: number; 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 } + | { + kind: "fadeKnee"; + clipId: string; + edge: FadeEdge; + originalFrames: number; + grabFrame: number; + currentFrames: 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 collectMoveSnapTargets( + timeline: Timeline, + excluded: Set, + _activeFrame: number, +) { + return collectTargets(timeline, excluded, null); +} + export function TimelineContainer() { const timeline = useProjectStore((s) => s.timeline); const zoomScale = useEditorUiStore((s) => s.zoomScale); @@ -51,6 +107,7 @@ export function TimelineContainer() { const activeFrame = useEditorUiStore((s) => s.activeFrame); const isPlaying = useEditorUiStore((s) => s.isPlaying); const setCurrentFrame = useEditorUiStore((s) => s.setCurrentFrame); + const setScrubbing = useEditorUiStore((s) => s.setScrubbing); const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds); const selectClips = useEditorUiStore((s) => s.selectClips); const clearSelection = useEditorUiStore((s) => s.clearSelection); @@ -69,8 +126,15 @@ 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 [menu, setMenu] = useState<{ clipId: string; x: number; y: number; fadeEdge?: FadeEdge } | null>( + null, + ); const t = useT(); // Waveform sample cache (media id → buckets), loaded on demand from Rust. const waveformsRef = useRef>(new Map()); @@ -155,6 +219,9 @@ 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, + newTrackIndex: d.dropTarget.kind === "newTrack" ? d.dropTarget.index : undefined, }; } else if (d?.kind === "trimLeft" || d?.kind === "trimRight") { drag = { @@ -163,6 +230,20 @@ 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, + }; + } else if (d?.kind === "fadeKnee") { + drag = { + kind: "fadeKnee", + clipId: d.clipId, + edge: d.edge, + currentFrames: d.currentFrames, + }; } paintTimeline(ctx, { timeline, @@ -272,16 +353,18 @@ export function TimelineContainer() { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const { docX } = toDoc(e); - const anchorFrame = docX / zoomScale; - const factor = Math.exp(e.deltaY * ZOOM.scrollSensitivity); + const pointerViewX = docX - scrollLeft; + const hasPointerAnchor = pointerViewX >= 0 && pointerViewX <= viewport.width; + const anchorFrame = hasPointerAnchor ? docX / zoomScale : activeFrame; + const viewX = hasPointerAnchor ? pointerViewX : activeFrame * zoomScale - scrollLeft; + const factor = Math.exp(-e.deltaY * ZOOM.scrollSensitivity); const newScale = Math.max( useEditorUiStore.getState().minZoomScale, Math.min(ZOOM.max, zoomScale * factor), ); setZoomScale(newScale); - // Keep the frame under the cursor stationary. + // Keep the frame under the cursor/playhead stationary. const newDocX = anchorFrame * newScale; - const viewX = docX - scrollLeft; setScroll(Math.max(0, newDocX - viewX), scrollTop); } else if (e.altKey) { e.preventDefault(); @@ -303,7 +386,7 @@ export function TimelineContainer() { ); } }, - [toDoc, zoomScale, scrollLeft, scrollTop, setZoomScale, setScroll, docWidth, docHeight, viewport], + [toDoc, zoomScale, activeFrame, scrollLeft, scrollTop, setZoomScale, setScroll, docWidth, docHeight, viewport], ); // Attach the wheel handler natively with { passive: false }. React's onWheel @@ -331,25 +414,64 @@ export function TimelineContainer() { // Ruler -> scrub playhead. if (inRuler) { dragRef.current = { kind: "scrub" }; + setScrubbing(true); const f = frameAt(docX, zoomScale); setCurrentFrame(f); return; } const hit = hitTestClip(timeline, docX, docY, zoomScale, trackHeights); + const fadeHit = + !e.metaKey && !e.shiftKey + ? fadeKneeHit(timeline, docX, docY, zoomScale, trackHeights) + : null; // Razor tool + clip -> split at the (snapped) click frame. Snapping to // clip edges / playhead matches upstream's razor (a cut landing on the // clip's own edge is a backend no-op, which is fine). if (toolMode === "razor" && hit) { const raw = frameAt(docX, zoomScale); - const targets = collectTargets(timeline, new Set(), activeFrame); + const targets = collectTargets(timeline, new Set(), activeFrame, true); const snap = findSnap(raw, targets, zoomScale, null); void edit.splitClip(hit.clip.id, snap ? snap.frame : raw); dragRef.current = null; 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; @@ -373,8 +495,18 @@ export function TimelineContainer() { } selectClips(nextSel); - // Sub-region: trim handles before body move. - if (hit.region === "trimLeft" && !e.altKey) { + // Fade knees sit in a 14px upstream hit square that can overlap the 4px + // trim handle, so they win before trim/body routing. + if (fadeHit && fadeHit.clipId === hit.clip.id && !e.altKey) { + dragRef.current = { + kind: "fadeKnee", + clipId: hit.clip.id, + edge: fadeHit.edge, + originalFrames: fadeHit.currentFrames, + grabFrame: frameAt(docX, zoomScale), + currentFrames: fadeHit.currentFrames, + }; + } else if (hit.region === "trimLeft" && !e.altKey) { dragRef.current = { kind: "trimLeft", hit, @@ -398,6 +530,8 @@ export function TimelineContainer() { startTrack: hit.trackIndex, targetTrack: hit.trackIndex, companions: [...nextSel], + isDuplicate: e.altKey, + dropTarget: { kind: "existing", trackIndex: hit.trackIndex }, }; } return; @@ -413,7 +547,7 @@ export function TimelineContainer() { curDocY: docY, }; }, - [toDoc, timeline, zoomScale, trackHeights, toolMode, selectedClipIds, selectClips, clearSelection, setCurrentFrame], + [toDoc, timeline, zoomScale, trackHeights, toolMode, selectedClipIds, selectClips, clearSelection, setCurrentFrame, setScrubbing], ); const onPointerMove = useCallback( @@ -424,44 +558,114 @@ export function TimelineContainer() { if (d.kind === "scrub") { setCurrentFrame(frameAt(docX, zoomScale)); + setScrubbing(false); return; } 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). 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 = collectMoveSnapTargets(timeline, excluded, activeFrame); + 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 }; + // Drop target: upstream insert zones can create a new track above, + // between, or below existing tracks. + const hovered = dropTargetAt(timeline, docY, trackHeights); + let targetTrack: number; + let dropTarget: DropTarget; + if (hovered.kind === "existing") { + targetTrack = hovered.trackIndex; + dropTarget = { kind: "existing", trackIndex: hovered.trackIndex }; + } else { + const trackType = newTrackTypeFor(d.hit.clip); + dropTarget = { kind: "newTrack", index: hovered.index, trackType }; + targetTrack = Math.max(0, Math.min(timeline.tracks.length - 1, hovered.index)); + } + dragRef.current = { ...d, deltaFrames, targetTrack, dropTarget }; setSnapFrame(snapped); forceTick((n) => n + 1); 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 === "fadeKnee") { + const loc = findClipLoc(timeline, d.clipId); + if (!loc) return; + const clip = timeline.tracks[loc[0]].clips[loc[1]]; + const currentFrames = fadeFramesForDrag( + clip, + d.edge, + d.originalFrames, + d.grabFrame, + frameAt(docX, zoomScale), + ); + dragRef.current = { ...d, currentFrames }; + 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; let deltaFrames = rawFrame - edge; - const targets = collectTargets(timeline, new Set([d.hit.clip.id]), activeFrame); + const targets = collectTargets(timeline, new Set([d.hit.clip.id]), activeFrame, true); const snap = findSnap(rawFrame, targets, zoomScale, null); if (snap) { deltaFrames = snap.frame - edge; @@ -497,6 +701,7 @@ export function TimelineContainer() { const endDrag = useCallback((e: React.PointerEvent) => { dragRef.current = null; setSnapFrame(null); + setScrubbing(false); const el = e.currentTarget as HTMLElement; if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId); }, []); @@ -505,12 +710,21 @@ export function TimelineContainer() { (e: React.PointerEvent) => { const d = dragRef.current; dragRef.current = null; + snapStateRef.current = null; setSnapFrame(null); + setScrubbing(false); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); 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) => { @@ -527,9 +741,49 @@ export function TimelineContainer() { const minStart = Math.min(...locs.map((l) => l.clip.startFrame)); const frameDelta = Math.max(d.deltaFrames, -minStart); + // Drop on an insert zone → create a new track first, then move/dup. + if (d.dropTarget.kind === "newTrack") { + const trackType = d.dropTarget.trackType; + const dropIndex = d.dropTarget.index; + const isDup = d.isDuplicate; + const locSnapshot = locs.map((l) => ({ id: l.id, ti: l.ti, startFrame: l.clip.startFrame })); + void (async () => { + const insertResult = await edit.insertTrack(trackType, dropIndex); + // 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 insertedTrackId = insertResult?.affectedClipIds[0]; + const insertedIndex = insertedTrackId + ? tl.tracks.findIndex((track) => track.id === insertedTrackId) + : -1; + const newTrackIndex = + insertedIndex >= 0 + ? insertedIndex + : Math.max(0, Math.min(dropIndex, tl.tracks.length - 1)); + 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) { @@ -542,12 +796,45 @@ 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; + } + + 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 === "fadeKnee") { + if (d.currentFrames !== d.originalFrames) { + const properties = + d.edge === "left" + ? { fadeInFrames: d.currentFrames } + : { fadeOutFrames: d.currentFrames }; + void edit.setClipProperties([d.clipId], properties); + } return; } @@ -570,16 +857,69 @@ export function TimelineContainer() { void edit.trimClips(edits); } }, - [timeline], + [timeline, setScrubbing], ); // 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(); + const fadeHit = fadeKneeHit(timeline, docX, docY, zoomScale, trackHeights); + if (fadeHit?.clipId === hit.clip.id) { + setMenu({ clipId: hit.clip.id, fadeEdge: fadeHit.edge, x: e.clientX, y: e.clientY }); + return; + } + // 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], + ); + + // Media dropped from the panel lands AT the cursor: its start frame = the drop + // X, on the track under the drop Y. `addMediaToTimelineAt` skips tracks where it + // would overlap an existing clip (and makes a new track if none is free), so a + // drop onto an occupied audio lane opens a second lane instead of overwriting. + const onMediaDragOver = useCallback((e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(MEDIA_DND_TYPE)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }, []); + + const onMediaDrop = useCallback( + (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(MEDIA_DND_TYPE)) return; + e.preventDefault(); + e.stopPropagation(); + const id = e.dataTransfer.getData(MEDIA_DND_TYPE); + const item = useMediaStore.getState().items.find((m) => m.id === id); + if (!item) return; + const { docX, docY } = toDoc(e); + const startFrame = Math.max(0, Math.round(frameAt(docX, zoomScale))); + const target = dropTargetAt(timeline, docY, trackHeights); + const preferredTrackIndex = target.kind === "existing" ? target.trackIndex : null; + const insertTrackAt = target.kind === "newTrack" ? target.index : undefined; + void edit.addMediaToTimelineAt(item, startFrame, preferredTrackIndex, insertTrackAt); + }, + [toDoc, zoomScale, timeline, trackHeights], + ); + return (
{/* Content canvas (clips + backgrounds), positioned right of header column. */} )} + {/* Clip right-click context menu. */} + {menu && ( + setMenu(null)} + /> + )} + {/* Horizontal scrollbar proxy (thin) — drag handled via wheel; kept minimal. */}
); diff --git a/web/src/components/timeline/clipRenderer.test.ts b/web/src/components/timeline/clipRenderer.test.ts index 4b0ce62..0c6108b 100644 --- a/web/src/components/timeline/clipRenderer.test.ts +++ b/web/src/components/timeline/clipRenderer.test.ts @@ -7,6 +7,10 @@ import type { Clip } from "../../lib/types"; function makeCtx() { const fills: string[] = []; const strokes: string[] = []; + const filledPaths: Array<{ fillStyle: string; firstMoveTo: [number, number] | null }> = []; + const fillRects: Array<{ fillStyle: string; x: number; y: number; width: number; height: number }> = []; + const arcs: Array<{ x: number; y: number; radius: number }> = []; + let firstMoveTo: [number, number] | null = null; const ctx = { fillStyle: "", strokeStyle: "", @@ -15,9 +19,16 @@ function makeCtx() { textBaseline: "", save() {}, restore() {}, - beginPath() {}, - moveTo() {}, + beginPath() { + firstMoveTo = null; + }, + moveTo(x: number, y: number) { + if (!firstMoveTo) firstMoveTo = [x, y]; + }, lineTo() {}, + arc(x: number, y: number, radius: number) { + arcs.push({ x, y, radius }); + }, arcTo() {}, closePath() {}, clip() {}, @@ -28,15 +39,21 @@ function makeCtx() { }, fill() { fills.push(String(this.fillStyle)); + filledPaths.push({ fillStyle: String(this.fillStyle), firstMoveTo }); + firstMoveTo = null; }, stroke() { strokes.push(String(this.strokeStyle)); }, - fillRect() { + fillRect(x: number, y: number, width: number, height: number) { fills.push(String(this.fillStyle)); + fillRects.push({ fillStyle: String(this.fillStyle), x, y, width, height }); + }, + strokeRect() { + strokes.push(String(this.strokeStyle)); }, }; - return { ctx: ctx as unknown as CanvasRenderingContext2D, fills, strokes }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, fills, strokes, filledPaths, fillRects, arcs }; } const testClip = { @@ -76,6 +93,124 @@ describe("drawClip missing wash", () => { }); }); +describe("drawClip linkOffset badge", () => { + const rect = { x: 0, y: 0, width: 200, height: 60 }; + + it("draws the red offset badge when linkOffset is nonzero", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 5 }); + expect(fills).toContain("rgb(255,71,71)"); + }); + + it("positions the red offset badge at the clip top-right", () => { + const { ctx, filledPaths } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 5 }); + + const badgePath = filledPaths.find((p) => p.fillStyle === "rgb(255,71,71)"); + expect(badgePath?.firstMoveTo?.[0]).toBeGreaterThan(170); + expect(badgePath?.firstMoveTo?.[1]).toBeLessThan(10); + }); + + it("skips the badge when linkOffset is zero", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 0 }); + expect(fills).not.toContain("rgb(255,71,71)"); + }); + + it("skips the badge when linkOffset is undefined", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30 }); + expect(fills).not.toContain("rgb(255,71,71)"); + }); + + it("skips the badge on narrow clips (badge would overlap trim handle)", () => { + const narrow = { x: 0, y: 0, width: 30, height: 40 }; + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, narrow, { isSelected: false, fps: 30, linkOffset: 5 }); + // The width guard suppresses badges that would collide with both trim handles. + expect(fills).not.toContain("rgb(255,71,71)"); + }); + + it("keeps the duplicate ghost badge from covering the link-offset badge", () => { + const { ctx, filledPaths, arcs } = makeCtx(); + drawClip(ctx, testClip, rect, { + isSelected: false, + fps: 30, + ghost: true, + isDuplicate: true, + linkOffset: 5, + }); + + const offsetBadge = filledPaths.find((p) => p.fillStyle === "rgb(255,71,71)"); + const duplicateBadge = arcs[0]; + expect(offsetBadge?.firstMoveTo).toBeDefined(); + expect(duplicateBadge).toBeDefined(); + expect(duplicateBadge.x + duplicateBadge.radius).toBeLessThan(offsetBadge!.firstMoveTo![0]); + }); + + it("skips the duplicate ghost badge when the offset badge leaves no room", () => { + const barelyOffsetEligible = { x: 0, y: 0, width: 31, height: 40 }; + const { ctx, filledPaths, arcs } = makeCtx(); + drawClip(ctx, testClip, barelyOffsetEligible, { + isSelected: false, + fps: 30, + ghost: true, + isDuplicate: true, + linkOffset: 5, + }); + + expect(filledPaths.some((p) => p.fillStyle === "rgb(255,71,71)")).toBe(true); + expect(arcs).toHaveLength(0); + }); +}); + +describe("drawClip fade knees", () => { + const rect = { x: 0, y: 0, width: 200, height: 60 }; + const visualClip = { + ...testClip, + mediaType: "video", + sourceClipType: "video", + } as Clip; + + it("draws both selected knee handles even when fade lengths are zero", () => { + const { ctx, fillRects } = makeCtx(); + drawClip(ctx, visualClip, rect, { isSelected: true, fps: 30 }); + + const kneeRects = fillRects.filter( + (r) => r.fillStyle === "rgba(255,255,255,0.95)" && r.width === 7 && r.height === 7, + ); + expect(kneeRects).toHaveLength(2); + expect(kneeRects[0].x).toBeCloseTo(2.5); + expect(kneeRects[1].x).toBeCloseTo(190.5); + }); + + it("draws full-length fade handles at upstream edge-specific centers", () => { + const { ctx: inCtx, fillRects: inRects } = makeCtx(); + drawClip( + inCtx, + { ...visualClip, fadeInFrames: 100 }, + rect, + { isSelected: true, fps: 30 }, + ); + const fullInKnees = inRects.filter( + (r) => r.fillStyle === "rgba(255,255,255,0.95)" && r.width === 7 && r.height === 7, + ); + expect(fullInKnees[0].x).toBeCloseTo(196.5); + + const { ctx: outCtx, fillRects: outRects } = makeCtx(); + drawClip( + outCtx, + { ...visualClip, fadeOutFrames: 100 }, + rect, + { isSelected: true, fps: 30 }, + ); + const fullOutKnees = outRects.filter( + (r) => r.fillStyle === "rgba(255,255,255,0.95)" && r.width === 7 && r.height === 7, + ); + expect(fullOutKnees[1].x).toBeCloseTo(-3.5); + }); +}); + describe("dbFromLinear", () => { it("maps unity to 0 dB", () => { expect(dbFromLinear(1)).toBeCloseTo(0); diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index 200070d..337d78c 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -6,11 +6,15 @@ * here (Rust media cache, SPEC §11.3) — drawn as a tinted band + type hint. */ -import { ACCENT, CLIP, TEXT, TRIM, BORDER } from "../../lib/theme"; +import { ACCENT, CLIP, FADE, TEXT, TRIM, BORDER } from "../../lib/theme"; import { trackColor, clipLabel, isLinked } from "../../lib/clip"; import type { ClipRect } from "../../lib/geometry"; import type { Clip } from "../../lib/types"; +/** Selection outline colour: a vivid blue that stays obvious on any clip body + * (the previous near-white border read as grey and was easy to miss). */ +const SELECTION_BLUE = "rgba(56,139,253,1)"; + interface DrawOpts { isSelected: boolean; fps: number; @@ -23,8 +27,23 @@ 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-right. */ + 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 }; + /** 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`. + * 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 { @@ -148,11 +167,12 @@ export function drawClip( ctx.fillRect(x, y, CLIP.stripWidth, height); ctx.restore(); - // 5. Border (ClipRenderer:121-132). Selected = a clear blue 2px outline (the - // old near-white border read as grey on the clip body and was easy to miss). + // 5. Border (ClipRenderer:121-132). Selected clips get a clear blue 2px outline + // (the old white border read as grey on the clip body and was easy to miss); + // blue is the unambiguous "this is selected" cue. roundRectPath(ctx, x, y, width, height, r); if (opts.isSelected) { - ctx.strokeStyle = "rgba(56,139,253,1)"; + ctx.strokeStyle = SELECTION_BLUE; ctx.lineWidth = 2; } else { ctx.strokeStyle = BORDER.primary; @@ -200,6 +220,20 @@ 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-right when this clip is out + // of step with its link-group lead (SPEC §5.4 linked-offset indicator). + let offsetBadgeRect: ClipRect | null = null; + if (opts.linkOffset != null && opts.linkOffset !== 0) { + offsetBadgeRect = drawOffsetBadge(ctx, opts.linkOffset, rect); + } + // 9. Keyframe diamonds along the bottom (ClipRenderer:163-191), y = maxY-5. drawKeyframeMarkers(ctx, clip, rect); @@ -208,6 +242,46 @@ 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) { + const rightLimit = offsetBadgeRect ? offsetBadgeRect.x - 2 : x + width - 2; + drawDuplicateBadge(ctx, x, y, width, rightLimit); + } + + 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, + rightLimit = x + width - 2, +) { + const radius = 7; + const minCx = x + radius + 2; + const maxCx = rightLimit - radius; + if (maxCx < minCx) return; + const cx = maxCx; + 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(); } @@ -260,10 +334,6 @@ function drawWaveform( } } -const FADE_KNEE_TOP_INSET = 4; -const FADE_EDGE_INSET = 6; -const FADE_KNEE_SIZE = 7; - /** Standard smoothstep (matches the shader + upstream `smoothstep`). */ function smoothstep(t: number): number { return t * t * (3 - 2 * t); @@ -329,19 +399,23 @@ function drawFades(ctx: CanvasRenderingContext2D, clip: Clip, rect: ClipRect, is const ppf = width / clip.durationFrames; const bodyMinY = y + CLIP.labelBarHeight; const bodyMaxY = y + height - 1; - const kneeY = bodyMinY + FADE_KNEE_TOP_INSET; + const kneeY = bodyMinY + FADE.kneeTopInset; const alpha = isSelected ? 0.95 : 0.75; const fadeColor = `rgba(255,255,255,${alpha * 0.7})`; - const kneeX = (offsetFrames: number) => - Math.max(x + FADE_EDGE_INSET, Math.min(x + width - FADE_EDGE_INSET, x + offsetFrames * ppf)); + const kneeX = (offsetFrames: number, edge: "left" | "right") => { + const actual = x + offsetFrames * ppf; + return edge === "left" + ? Math.max(x + FADE.edgeInset, actual) + : Math.min(x + width - FADE.edgeInset, actual); + }; ctx.save(); if (clip.fadeInFrames > 0) { - const lx = kneeX(Math.min(clip.fadeInFrames, clip.durationFrames)); + const lx = kneeX(Math.min(clip.fadeInFrames, clip.durationFrames), "left"); drawFadeWedge(ctx, [x, bodyMaxY], [lx, kneeY], clip.fadeInInterpolation, bodyMinY, 0.6, fadeColor); } if (clip.fadeOutFrames > 0) { - const rx = kneeX(Math.max(0, clip.durationFrames - clip.fadeOutFrames)); + const rx = kneeX(Math.max(0, clip.durationFrames - clip.fadeOutFrames), "right"); drawFadeWedge(ctx, [x + width, bodyMaxY], [rx, kneeY], clip.fadeOutInterpolation, bodyMinY, 0.6, fadeColor); } // Draggable knee handles (visual indicators) when selected. @@ -349,17 +423,13 @@ function drawFades(ctx: CanvasRenderingContext2D, clip: Clip, rect: ClipRect, is ctx.fillStyle = `rgba(255,255,255,${alpha})`; ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 0.5; - const half = FADE_KNEE_SIZE / 2; - if (clip.fadeInFrames > 0) { - const lx = kneeX(Math.min(clip.fadeInFrames, clip.durationFrames)); - ctx.fillRect(lx - half, kneeY - half, FADE_KNEE_SIZE, FADE_KNEE_SIZE); - ctx.strokeRect(lx - half, kneeY - half, FADE_KNEE_SIZE, FADE_KNEE_SIZE); - } - if (clip.fadeOutFrames > 0) { - const rx = kneeX(Math.max(0, clip.durationFrames - clip.fadeOutFrames)); - ctx.fillRect(rx - half, kneeY - half, FADE_KNEE_SIZE, FADE_KNEE_SIZE); - ctx.strokeRect(rx - half, kneeY - half, FADE_KNEE_SIZE, FADE_KNEE_SIZE); - } + const half = FADE.kneeSize / 2; + const lx = kneeX(Math.min(clip.fadeInFrames, clip.durationFrames), "left"); + const rx = kneeX(Math.max(0, clip.durationFrames - clip.fadeOutFrames), "right"); + ctx.fillRect(lx - half, kneeY - half, FADE.kneeSize, FADE.kneeSize); + ctx.strokeRect(lx - half, kneeY - half, FADE.kneeSize, FADE.kneeSize); + ctx.fillRect(rx - half, kneeY - half, FADE.kneeSize, FADE.kneeSize); + ctx.strokeRect(rx - half, kneeY - half, FADE.kneeSize, FADE.kneeSize); } ctx.restore(); } @@ -408,3 +478,121 @@ 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-right showing the + * frame offset vs the link-group lead ("+N" when this clip trails, "-N" when it + * leads in time beyond the lead start). Matches upstream's right-edge placement + * before the trim handle (SPEC §5.4 linked-offset indicator). + */ +function drawOffsetBadge(ctx: CanvasRenderingContext2D, offsetFrames: number, rect: ClipRect): ClipRect | null { + 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. + if (rect.width <= badgeW + TRIM.handleWidth * 2 + 4 || rect.height < badgeH + 4) { + ctx.restore(); + return null; + } + const bx = rect.x + rect.width - TRIM.handleWidth - badgeW - 2; + const by = rect.y + 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(); + return { x: bx, y: by, width: badgeW, height: badgeH }; +} diff --git a/web/src/components/timeline/hitTest.test.ts b/web/src/components/timeline/hitTest.test.ts new file mode 100644 index 0000000..ca07b0e --- /dev/null +++ b/web/src/components/timeline/hitTest.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { trackY } from "../../lib/geometry"; +import type { Clip, Timeline, Track } from "../../lib/types"; +import { audioVolumeKfHit, clipsInRect, fadeFramesForDrag, fadeKneeHit, hitTestClip } from "./hitTest"; + +function clip(id: string, overrides: Partial = {}): Clip { + return { + id, + mediaRef: `${id}-media`, + mediaType: "video", + sourceClipType: "video", + startFrame: 10, + durationFrames: 40, + trimStartFrame: 0, + trimEndFrame: 0, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + ...overrides, + }; +} + +function track(id: string, hidden: boolean, clips: Clip[]): Track { + return { + id, + type: clips[0]?.mediaType ?? "video", + muted: false, + hidden, + syncLocked: true, + clips, + }; +} + +function timeline(tracks: Track[]): Timeline { + return { + fps: 30, + width: 1920, + height: 1080, + settingsConfigured: true, + tracks, + }; +} + +describe("timeline hit testing", () => { + const heights = {}; + const pixelsPerFrame = 2; + + it("does not return clips on hidden tracks", () => { + const tl = timeline([track("v1", true, [clip("hidden")])]); + const y = trackY(tl, 0, heights) + 10; + + expect(hitTestClip(tl, 25, y, pixelsPerFrame, heights)).toBeNull(); + }); + + it("returns a visible clip after skipping a hidden track", () => { + const tl = timeline([ + track("v1", true, [clip("hidden")]), + track("v2", false, [clip("visible")]), + ]); + const y = trackY(tl, 1, heights) + 10; + + expect(hitTestClip(tl, 25, y, pixelsPerFrame, heights)?.clip.id).toBe("visible"); + }); + + it("does not marquee-select clips on hidden tracks", () => { + const tl = timeline([ + track("v1", true, [clip("hidden")]), + track("v2", false, [clip("visible")]), + ]); + const y0 = trackY(tl, 0, heights); + const y1 = trackY(tl, 2, heights); + + expect(clipsInRect(tl, 0, y0, 200, y1, pixelsPerFrame, heights)).toEqual( + new Set(["visible"]), + ); + }); + + it("does not return volume keyframes on hidden audio tracks", () => { + const audioClip = clip("audio", { + mediaType: "audio", + sourceClipType: "audio", + volumeTrack: { keyframes: [{ frame: 10, value: 0.5, interpolationOut: "linear" }] }, + }); + const tl = timeline([track("a1", true, [audioClip])]); + const y = trackY(tl, 0, heights) + 35; + + expect(audioVolumeKfHit(tl, 40, y, pixelsPerFrame, heights)).toBeNull(); + }); + + it("returns volume keyframes on visible audio tracks after a hidden track", () => { + const audioClip = clip("audio", { + mediaType: "audio", + sourceClipType: "audio", + volumeTrack: { keyframes: [{ frame: 10, value: 0.5, interpolationOut: "linear" }] }, + }); + const tl = timeline([ + track("v1", true, [clip("hidden")]), + track("a1", false, [audioClip]), + ]); + const y = trackY(tl, 1, heights) + 33; + + expect(audioVolumeKfHit(tl, 42, y, pixelsPerFrame, heights)).toEqual({ + clipId: "audio", + frame: 10, + }); + }); +}); + +describe("fade knee hit testing", () => { + const heights = {}; + const pixelsPerFrame = 2; + + it("hits zero-length fade knees at the edge inset", () => { + const tl = timeline([track("v1", false, [clip("c1")])]); + const y = trackY(tl, 0, heights) + 22; + + expect(fadeKneeHit(tl, 26, y, pixelsPerFrame, heights)).toEqual({ + clipId: "c1", + trackIndex: 0, + edge: "left", + currentFrames: 0, + }); + expect(fadeKneeHit(tl, 94, y, pixelsPerFrame, heights)).toEqual({ + clipId: "c1", + trackIndex: 0, + edge: "right", + currentFrames: 0, + }); + }); + + it("hits nonzero left and right knees", () => { + const tl = timeline([ + track("v1", false, [clip("c1", { fadeInFrames: 8, fadeOutFrames: 12 })]), + ]); + const y = trackY(tl, 0, heights) + 22; + + expect(fadeKneeHit(tl, 36, y, pixelsPerFrame, heights)?.edge).toBe("left"); + expect(fadeKneeHit(tl, 76, y, pixelsPerFrame, heights)?.edge).toBe("right"); + }); + + it("hits a full-length fade-in at the same edge-specific center as the renderer", () => { + const fullIn = timeline([ + track("v1", false, [clip("full-in", { fadeInFrames: 40, fadeOutFrames: 0 })]), + ]); + const y = trackY(fullIn, 0, heights) + 22; + + expect(fadeKneeHit(fullIn, 100, y, pixelsPerFrame, heights)?.edge).toBe("left"); + }); + + it("ignores hidden tracks", () => { + const tl = timeline([track("v1", true, [clip("hidden", { fadeInFrames: 8 })])]); + const y = trackY(tl, 0, heights) + 22; + + expect(fadeKneeHit(tl, 36, y, pixelsPerFrame, heights)).toBeNull(); + }); + + it("prefers the left knee when left and right hit boxes overlap", () => { + const tl = timeline([ + track("v1", false, [clip("short", { durationFrames: 6, fadeInFrames: 3, fadeOutFrames: 3 })]), + ]); + const y = trackY(tl, 0, heights) + 22; + + expect(fadeKneeHit(tl, 26, y, pixelsPerFrame, heights)?.edge).toBe("left"); + }); +}); + +describe("fade knee drag math", () => { + it("increases fade-in when dragging right and clamps against fade-out", () => { + const c = clip("c1", { durationFrames: 40, fadeInFrames: 8, fadeOutFrames: 10 }); + + expect(fadeFramesForDrag(c, "left", 8, 20, 25)).toBe(13); + expect(fadeFramesForDrag(c, "left", 8, 20, 100)).toBe(30); + }); + + it("increases fade-out when dragging left and clamps at zero", () => { + const c = clip("c1", { durationFrames: 40, fadeInFrames: 8, fadeOutFrames: 10 }); + + expect(fadeFramesForDrag(c, "right", 10, 60, 55)).toBe(15); + expect(fadeFramesForDrag(c, "right", 10, 60, 80)).toBe(0); + }); +}); diff --git a/web/src/components/timeline/hitTest.ts b/web/src/components/timeline/hitTest.ts index a5008d8..749bb4a 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, FADE } from "../../lib/theme"; import { clipRect } from "../../lib/geometry"; import type { Timeline, Clip } from "../../lib/types"; @@ -29,6 +29,7 @@ export function hitTestClip( ): ClipHit | null { for (let ti = 0; ti < timeline.tracks.length; ti++) { const track = timeline.tracks[ti]; + if (track.hidden) continue; for (let ci = 0; ci < track.clips.length; ci++) { const clip = track.clips[ci]; const rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); @@ -83,7 +84,9 @@ export function clipsInRect( const maxY = Math.max(y0, y1); const out = new Set(); for (let ti = 0; ti < timeline.tracks.length; ti++) { - for (const clip of timeline.tracks[ti].clips) { + const track = timeline.tracks[ti]; + if (track.hidden) continue; + for (const clip of track.clips) { const rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); const intersects = rect.x <= maxX && @@ -95,3 +98,143 @@ 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; +} + +export type FadeEdge = "left" | "right"; + +export interface FadeKneeHit { + clipId: string; + trackIndex: number; + edge: FadeEdge; + currentFrames: 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]; + if (track.hidden) continue; + 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; +} + +function fadeKneePoint( + clip: Clip, + rect: { x: number; y: number; width: number; height: number }, + edge: FadeEdge, +) { + const ppf = clip.durationFrames > 0 ? rect.width / clip.durationFrames : 0; + const offset = + edge === "left" + ? Math.min(clip.fadeInFrames, clip.durationFrames) + : Math.max(0, clip.durationFrames - clip.fadeOutFrames); + const actualX = rect.x + offset * ppf; + const x = + edge === "left" + ? Math.max(rect.x + FADE.edgeInset, actualX) + : Math.min(rect.x + rect.width - FADE.edgeInset, actualX); + const y = rect.y + CLIP.labelBarHeight + FADE.kneeTopInset; + return { x, y }; +} + +/** + * Hit-test the draggable opacity fade knees drawn by `drawFades`. The math mirrors + * upstream `TimelineGeometry.fadeKneeRect`: a 14px hit square centered on the + * fixed fade lane near the clip-body top. Audio fades are intentionally excluded + * until their handles are drawn locally; this avoids invisible hit zones over the + * audio volume rubber band. + */ +export function fadeKneeHit( + timeline: Timeline, + docX: number, + docY: number, + pixelsPerFrame: number, + trackHeights: Record, +): FadeKneeHit | null { + const half = FADE.hitSize / 2; + for (let ti = 0; ti < timeline.tracks.length; ti++) { + const track = timeline.tracks[ti]; + if (track.hidden) continue; + for (const clip of track.clips) { + if (clip.mediaType === "audio" || clip.durationFrames <= 0) continue; + const rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); + if (docX < rect.x || docX > rect.x + rect.width || docY < rect.y || docY > rect.y + rect.height) { + continue; + } + for (const edge of ["left", "right"] as const) { + const p = fadeKneePoint(clip, rect, edge); + if (docX >= p.x - half && docX <= p.x + half && docY >= p.y - half && docY <= p.y + half) { + return { + clipId: clip.id, + trackIndex: ti, + edge, + currentFrames: edge === "left" ? clip.fadeInFrames : clip.fadeOutFrames, + }; + } + } + } + } + return null; +} + +/** Port of upstream `applyFadeKneeDrag`: cursor movement changes the grabbed fade + * length and clamps against the opposite fade so the two ramps never overlap. */ +export function fadeFramesForDrag( + clip: Pick, + edge: FadeEdge, + originalFrames: number, + grabFrame: number, + cursorFrame: number, +): number { + const delta = cursorFrame - grabFrame; + const proposed = edge === "left" ? originalFrames + delta : originalFrames - delta; + const counterFade = edge === "left" ? clip.fadeOutFrames : clip.fadeInFrames; + const cap = Math.max(0, clip.durationFrames - counterFade); + return Math.max(0, Math.min(cap, proposed)); +} diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index be676b2..6da5ba8 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -5,10 +5,11 @@ * (SPEC §5.11), painted by the container. */ -import { BG, BORDER, TEXT } from "../../lib/theme"; +import { BG, BORDER, TEXT, LAYOUT, TRACK_SIZE } 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; @@ -42,8 +43,21 @@ 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: "move"; + ids: Set; + deltaFrames: number; + trackDelta: number; + /** Option/Alt-drag duplicate: ghost renders with a "+" badge. */ + isDuplicate?: boolean; + /** Dropping on an insert zone creates a new track of this type. */ + newTrackType?: ClipType; + /** Upstream `newTrackAt(index)` insertion index for the new-track drop. */ + newTrackIndex?: number; + } + | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number } + | { kind: "volumeKf"; clipId: string; fromFrame: number; ghostFrame: number } + | { kind: "fadeKnee"; clipId: string; edge: "left" | "right"; currentFrames: number }; export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { const { timeline, pixelsPerFrame, trackHeights, width, dpr, scrollLeft, scrollTop } = s; @@ -77,21 +91,54 @@ 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; + const insertionLineY = (index: number): number => { + if (timeline.tracks.length === 0) return LAYOUT.rulerHeight + LAYOUT.dropZoneHeight; + if (index <= 0) return trackY(timeline, 0, trackHeights); + if (index >= timeline.tracks.length) return trackY(timeline, timeline.tracks.length, trackHeights); + return trackY(timeline, index, trackHeights); + }; + // New-track drop indicator: dashed zone at the upstream insertion index. + if (drag?.kind === "move" && drag.newTrackType && timeline.tracks.length > 0) { + const newTrackY = insertionLineY(drag.newTrackIndex ?? timeline.tracks.length); + const newTrackH = trackDisplayHeight(timeline.tracks[0], trackHeights) || TRACK_SIZE.defaultHeight; + 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) { + const newTrackY = insertionLineY(drag.newTrackIndex ?? timeline.tracks.length); + const ghostH = (trackDisplayHeight(timeline.tracks[0], trackHeights) || TRACK_SIZE.defaultHeight) - 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 = @@ -101,7 +148,22 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { ghost = true; } if (rect.x + rect.width < scrollLeft || rect.x > visRight) continue; - drawClip(ctx, clip, rect, { + // 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; + const paintClip = + drag?.kind === "fadeKnee" && drag.clipId === clip.id + ? { + ...clip, + fadeInFrames: drag.edge === "left" ? drag.currentFrames : clip.fadeInFrames, + fadeOutFrames: drag.edge === "right" ? drag.currentFrames : clip.fadeOutFrames, + } + : clip; + drawClip(ctx, paintClip, rect, { isSelected: s.selectedClipIds.has(clip.id), fps: timeline.fps, waveform: clip.mediaType === "audio" ? s.waveforms.get(clip.mediaRef) : undefined, @@ -109,6 +171,9 @@ 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, + isDuplicate, }); } } diff --git a/web/src/components/ui/HoverButton.test.tsx b/web/src/components/ui/HoverButton.test.tsx new file mode 100644 index 0000000..80d696e --- /dev/null +++ b/web/src/components/ui/HoverButton.test.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { HoverButton } from "./HoverButton"; + +describe("HoverButton", () => { + it("does not opt icon buttons out of normal keyboard focus", () => { + const html = renderToStaticMarkup(P); + + expect(html).not.toContain('tabindex="-1"'); + }); +}); diff --git a/web/src/components/ui/HoverButton.tsx b/web/src/components/ui/HoverButton.tsx index e35de8a..3f39770 100644 --- a/web/src/components/ui/HoverButton.tsx +++ b/web/src/components/ui/HoverButton.tsx @@ -43,7 +43,7 @@ export function HoverButton({ flex: "0 0 auto", color: active ? "var(--text-primary)" : "var(--text-secondary)", opacity: disabled ? 0.35 : 1, - cursor: disabled ? "default" : "default", + cursor: disabled ? "default" : "pointer", ...style, }} > diff --git a/web/src/hooks/useKeyboardShortcuts.test.ts b/web/src/hooks/useKeyboardShortcuts.test.ts new file mode 100644 index 0000000..9605857 --- /dev/null +++ b/web/src/hooks/useKeyboardShortcuts.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + handleTransportSpaceKeyDown, + shouldHandleTransportSpaceKey, +} from "./useKeyboardShortcuts"; + +function event(overrides: Partial = {}): KeyboardEvent { + return { + code: "Space", + metaKey: false, + ctrlKey: false, + altKey: false, + target: null, + ...overrides, + } as KeyboardEvent; +} + +describe("keyboard transport Space shortcut", () => { + it("handles plain Space in the editor", () => { + expect(shouldHandleTransportSpaceKey(event(), "editor")).toBe(true); + }); + + it("does not suppress Space keyup outside the editor", () => { + expect(shouldHandleTransportSpaceKey(event(), "home")).toBe(false); + }); + + it("does not suppress modified Space keyup", () => { + expect(shouldHandleTransportSpaceKey(event({ metaKey: true }), "editor")).toBe(false); + }); + + it("toggles playback synchronously on Space keydown", () => { + let toggles = 0; + const e = event({ + preventDefault: () => {}, + stopPropagation: () => {}, + } as Partial); + + const handled = handleTransportSpaceKeyDown(e, { + view: "editor", + previewMediaId: null, + requestMediaPreviewToggle: () => {}, + togglePlay: () => { + toggles += 1; + }, + }); + + expect(handled).toBe(true); + expect(toggles).toBe(1); + }); + + it("does not toggle repeatedly while Space is held", () => { + let toggles = 0; + const e = event({ + repeat: true, + preventDefault: () => {}, + stopPropagation: () => {}, + } as Partial); + + const handled = handleTransportSpaceKeyDown(e, { + view: "editor", + previewMediaId: null, + requestMediaPreviewToggle: () => {}, + togglePlay: () => { + toggles += 1; + }, + }); + + expect(handled).toBe(true); + expect(toggles).toBe(0); + }); + + it("does not export stale focus-release or keyup-suppression helpers", async () => { + const shortcuts = await import("./useKeyboardShortcuts"); + + expect("releaseTransportSpaceFocus" in shortcuts).toBe(false); + expect("suppressTransportSpaceKeyUp" in shortcuts).toBe(false); + }); +}); diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 6a5b3b8..b975854 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -8,21 +8,63 @@ 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"; +import type { AppView } from "../store/uiStore"; /** Per-keypress zoom step for ⌘+ / ⌘- (剪映: Cmd + +/-). */ const ZOOM_KEY_STEP = 1.3; function isTextEntry(target: EventTarget | null): boolean { + if (typeof HTMLElement === "undefined") return false; if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable; } +export function shouldHandleTransportSpaceKey(e: KeyboardEvent, view: AppView): boolean { + return ( + view === "editor" && + e.code === "Space" && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !isTextEntry(e.target) + ); +} + +interface TransportSpaceUi { + view: AppView; + previewMediaId: string | null; + requestMediaPreviewToggle: () => void; + togglePlay: () => void; +} + +export function handleTransportSpaceKeyDown( + e: KeyboardEvent, + ui: TransportSpaceUi, +): boolean { + if (!shouldHandleTransportSpaceKey(e, ui.view)) return false; + e.preventDefault(); + e.stopPropagation(); + if (e.repeat) return true; + if (ui.previewMediaId) { + ui.requestMediaPreviewToggle(); + } else { + ui.togglePlay(); // rewinds from the parked end frame on replay + } + return true; +} + export function useKeyboardShortcuts() { useEffect(() => { + const handleSpaceKeyDown = (e: KeyboardEvent) => { + const ui = useEditorUiStore.getState(); + handleTransportSpaceKeyDown(e, ui); + }; const handler = (e: KeyboardEvent) => { if (isTextEntry(e.target)) return; const ui = useEditorUiStore.getState(); @@ -102,20 +144,28 @@ 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; } // Unmodified keys. switch (e.code) { - case "Space": - e.preventDefault(); - if (ui.previewMediaId) { - ui.requestMediaPreviewToggle(); - } else { - ui.togglePlay(); // rewinds from the parked end frame on replay - } - return; case "ArrowLeft": e.preventDefault(); ui.setCurrentFrame(Math.max(0, ui.activeFrame - (e.shiftKey ? 5 : 1))); @@ -175,7 +225,11 @@ export function useKeyboardShortcuts() { } }; + window.addEventListener("keydown", handleSpaceKeyDown, true); window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + return () => { + window.removeEventListener("keydown", handleSpaceKeyDown, true); + window.removeEventListener("keydown", handler); + }; }, []); } diff --git a/web/src/hooks/usePlaybackTicker.ts b/web/src/hooks/usePlaybackTicker.ts deleted file mode 100644 index 16608cf..0000000 --- a/web/src/hooks/usePlaybackTicker.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Local playback ticker. While `isPlaying`, advances the playhead at the - * timeline fps via requestAnimationFrame, stopping at the end. Real playback - * (audio sync + composite frames) is a Rust concern (SPEC §11); this provides a - * usable transport in v1. - */ - -import { useEffect, useRef } from "react"; -import { useEditorUiStore } from "../store/uiStore"; -import { useProjectStore } from "../store/projectStore"; -import { mediaClock } from "../components/preview/playbackClock"; - -export function usePlaybackTicker() { - const isPlaying = useEditorUiStore((s) => s.isPlaying); - const lastTsRef = useRef(null); - - useEffect(() => { - if (!isPlaying) { - lastTsRef.current = null; - return; - } - let raf = 0; - const tick = (ts: number) => { - // Yield while `` drives the playhead from real media - // elements (audio/video) — this fallback only advances through gaps or - // when the preview is unmounted. Keep looping so it resumes if released. - if (mediaClock.active) { - lastTsRef.current = null; - raf = requestAnimationFrame(tick); - return; - } - const ui = useEditorUiStore.getState(); - const tl = useProjectStore.getState().timeline; - const fps = tl.fps > 0 ? tl.fps : 30; - let total = 0; - for (const t of tl.tracks) - for (const c of t.clips) total = Math.max(total, c.startFrame + c.durationFrames); - - if (lastTsRef.current !== null) { - const dtSec = (ts - lastTsRef.current) / 1000; - const next = ui.activeFrame + dtSec * fps; - // Clips are half-open [start, end): the last DRAWABLE frame is total-1. - // Stopping at total over-shoots one frame past all content and the - // composite goes black, so clamp the end to total-1. - const last = Math.max(0, total - 1); - if (next >= last) { - ui.setCurrentFrame(last); - ui.setPlaying(false); - lastTsRef.current = null; - return; - } - ui.setActiveFrame(next); - } - lastTsRef.current = ts; - raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); - }, [isPlaying]); -} diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7aac2fa..4a3f3ea 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -75,6 +75,11 @@ const zh: Dict = { "media.importing": "正在导入…", "media.importFailed": "导入失败:{error}", "media.dropToAdd": "松开以添加到时间线", + "media.extractAudio": "提取音频", + "media.extractAudioHint": "提取音频轨到本地文件", + "media.extractAudioSuccess": "音频已保存:{path}", + "media.extractAudioFailed": "提取失败:{error}", + "media.extractAudioNoAudio": "此媒体没有音频轨", "media.offline": "媒体离线", "media.relink": "重新链接", @@ -91,6 +96,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 +109,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": "清除动画", @@ -116,6 +141,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)", @@ -134,6 +163,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": "时间线", @@ -174,6 +214,10 @@ const zh: Dict = { "settings.themeDesc": "应用配色方案。", "settings.theme.dark": "深色", "settings.theme.light": "浅色", + "settings.windowSize": "窗口大小", + "settings.windowSizeDesc": "应用窗口尺寸(缩小至 2/3 大小更适合小屏)。", + "settings.windowSize.standard": "标准 (1600x1000)", + "settings.windowSize.compact": "紧凑 (1066x666)", "settings.defaultImportFolder": "默认导入文件夹", "settings.defaultImportFolderDesc": "导入对话框默认打开的位置。", "settings.chooseFolder": "选择…", @@ -193,10 +237,63 @@ const zh: Dict = { "settings.aboutLicense": "许可", "settings.aboutDesc": "OpenTake 是 Palmier Pro 的开源跨平台分支。", + // Settings panes (sidebar labels) + "settings.section.mcp": "MCP 说明", + "settings.section.storage": "存储", + "settings.section.notifications": "通知", + + // MCP Instructions pane + "mcp.title": "MCP 服务器连接说明", + "mcp.overview": + "OpenTake 内置一个 MCP (Model Context Protocol) 服务器,将当前项目暴露给外部 AI 客户端。启用后,AI 助手可以直接读取时间线、添加片段、执行工作流。", + "mcp.serverUrl": "服务器地址", + "mcp.serverUrlDesc": "在支持的客户端中粘贴此地址,或使用下方一键命令。", + "mcp.copy": "复制", + "mcp.copied": "已复制", + "mcp.cursor": "Cursor", + "mcp.cursorInstall": "一键安装到 Cursor", + "mcp.cursorManual": "手动配置(settings.json 的 mcpServers 字段)", + "mcp.claudeCode": "Claude Code", + "mcp.claudeCodeCmd": "在终端运行", + "mcp.codex": "Codex", + "mcp.codexCmd": "在终端运行", + "mcp.claudeDesktop": "Claude Desktop", + "mcp.claudeDesktopManual": "手动配置(claude_desktop_config.json 的 mcpServers 字段)", + "mcp.note": "服务器仅在应用运行时可用,绑定 127.0.0.1,不接受外部连接。", + + // Storage pane + "storage.cache": "缓存", + "storage.cacheDesc": "导入与生成过程中产生的临时文件缓存。", + "storage.cachePath": "缓存位置", + "storage.clearCache": "清除缓存", + "storage.clearCacheConfirm": "确定清除缓存?这不会影响已导入的媒体。", + "storage.cacheCleared": "缓存已清除。", + "storage.searchIndex": "搜索索引", + "storage.searchIndexDesc": "媒体库的语义搜索索引(如启用)。", + "storage.placeholder": "暂未提供运行时统计,将在后续版本补齐。", + + // Notifications pane + "notifications.generation": "生成完成通知", + "notifications.generationDesc": "当 AI 生成任务完成时显示系统通知。", + "notifications.restartHint": "更改将在下次启动应用时生效。", + + // Home relative time + "home.relative.today": "今天", + "home.relative.yesterday": "昨天", + "home.relative.daysAgo": "{count} 天前", + "home.relative.weeksAgo": "{count} 周前", + "home.relative.monthsAgo": "{count} 个月前", + // Common "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": "素材库", @@ -275,9 +372,15 @@ const en: Dict = { "media.importing": "Importing…", "media.importFailed": "Import failed: {error}", "media.dropToAdd": "Release to add to the timeline", + "media.extractAudio": "Extract Audio", + "media.extractAudioHint": "Extract the audio track to a local file", + "media.extractAudioSuccess": "Audio saved: {path}", + "media.extractAudioFailed": "Extract failed: {error}", + "media.extractAudioNoAudio": "No audio track in this media", "media.offline": "Media Offline", "media.relink": "Relink", + // Inspector "inspector.title": "Inspector", "inspector.timeline": "Timeline", "inspector.selectedCount": "{count} selected", @@ -290,6 +393,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", @@ -299,6 +406,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", @@ -315,6 +438,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)", @@ -331,6 +458,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", @@ -367,6 +505,10 @@ const en: Dict = { "settings.themeDesc": "Application color scheme.", "settings.theme.dark": "Dark", "settings.theme.light": "Light", + "settings.windowSize": "Window Size", + "settings.windowSizeDesc": "Application window dimension (shrink to 2/3 for smaller screens).", + "settings.windowSize.standard": "Standard (1600x1000)", + "settings.windowSize.compact": "Compact (1066x666)", "settings.defaultImportFolder": "Default Import Folder", "settings.defaultImportFolderDesc": "Location the import dialog opens to by default.", "settings.chooseFolder": "Choose…", @@ -386,9 +528,62 @@ const en: Dict = { "settings.aboutLicense": "License", "settings.aboutDesc": "OpenTake is the open-source, cross-platform fork of Palmier Pro.", + // Settings panes (sidebar labels) + "settings.section.mcp": "MCP Instructions", + "settings.section.storage": "Storage", + "settings.section.notifications": "Notifications", + + // MCP Instructions pane + "mcp.title": "MCP Server Connection Guide", + "mcp.overview": + "OpenTake ships a built-in MCP (Model Context Protocol) server that exposes the current project to external AI clients. Once connected, an AI assistant can read the timeline, add clips, and run workflows.", + "mcp.serverUrl": "Server URL", + "mcp.serverUrlDesc": "Paste this URL into a supported client, or use one of the one-line commands below.", + "mcp.copy": "Copy", + "mcp.copied": "Copied", + "mcp.cursor": "Cursor", + "mcp.cursorInstall": "Install in Cursor", + "mcp.cursorManual": "Manual config (mcpServers field of settings.json)", + "mcp.claudeCode": "Claude Code", + "mcp.claudeCodeCmd": "Run in terminal", + "mcp.codex": "Codex", + "mcp.codexCmd": "Run in terminal", + "mcp.claudeDesktop": "Claude Desktop", + "mcp.claudeDesktopManual": "Manual config (mcpServers field of claude_desktop_config.json)", + "mcp.note": "The server is only available while the app is running, bound to 127.0.0.1, and does not accept external connections.", + + // Storage pane + "storage.cache": "Cache", + "storage.cacheDesc": "Temporary files produced during import and generation.", + "storage.cachePath": "Cache location", + "storage.clearCache": "Clear cache", + "storage.clearCacheConfirm": "Clear the cache? This does not affect imported media.", + "storage.cacheCleared": "Cache cleared.", + "storage.searchIndex": "Search index", + "storage.searchIndexDesc": "Semantic search index for the media library (if enabled).", + "storage.placeholder": "Runtime statistics are not yet available; they will arrive in a later release.", + + // Notifications pane + "notifications.generation": "Generation-complete notifications", + "notifications.generationDesc": "Show a system notification when an AI generation task finishes.", + "notifications.restartHint": "Changes take effect on the next app launch.", + + // Home relative time + "home.relative.today": "Today", + "home.relative.yesterday": "Yesterday", + "home.relative.daysAgo": "{count} days ago", + "home.relative.weeksAgo": "{count} weeks ago", + "home.relative.monthsAgo": "{count} months ago", + "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/lib/api.ts b/web/src/lib/api.ts index 1e05822..4c9a713 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -147,6 +147,19 @@ export async function getMedia(): Promise { return { items: [], folders: [] }; } +/** + * `extract_audio`: extract the audio track from a media asset into a + * self-contained audio file. `outPath`'s extension picks the codec + * (`.m4a` -> AAC, `.mp3` -> libmp3lame, `.wav` -> PCM s16le). Returns the + * output path on success. Outside Tauri there is no ffmpeg, so this rejects + * with a friendly error. + */ +export async function extractAudio(mediaId: string, outPath: string): Promise { + await ensureTauri(); + if (invokeImpl) return invokeImpl("extract_audio", { mediaId, outPath }); + throw new Error("audio extraction requires the desktop app (ffmpeg)"); +} + /** * Relink an offline asset to a newly chosen file, KEEPING its id so every clip * that references it recovers in place (the fix for "lost media stays red after diff --git a/web/src/lib/clip.test.ts b/web/src/lib/clip.test.ts index 18ae0e2..0e6b435 100644 --- a/web/src/lib/clip.test.ts +++ b/web/src/lib/clip.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { clampTrimDeltaFrames, trimSourceValues, trimToPlayheadEdits } from "./clip"; +import { + clampTrimDeltaFrames, + fitTransformForMedia, + mediaCanvasAspect, + resizeTransformKeepingSourceAspect, + trimSourceValues, + trimToPlayheadEdits, +} from "./clip"; import type { Clip, ClipType } from "./types"; function tc(over: Partial<{ durationFrames: number; speed: number; trimStartFrame: number; trimEndFrame: number; mediaType: ClipType }> = {}) { @@ -85,3 +92,76 @@ describe("trimToPlayheadEdits", () => { expect(trimToPlayheadEdits([c], 999, "right")).toEqual([]); // after the clip }); }); + +describe("media aspect transform helpers", () => { + it("fits 16:9 media to a 16:9 canvas without changing normalized size", () => { + expect(fitTransformForMedia(1920, 1080, 1920, 1080)).toMatchObject({ + width: 1, + height: 1, + }); + expect(mediaCanvasAspect(1920, 1080, 1920, 1080)).toBeCloseTo(1); + }); + + it("fits vertical media inside a horizontal canvas like upstream fitTransform", () => { + const fitted = fitTransformForMedia(1080, 1920, 1920, 1080); + + expect(fitted.width).toBeCloseTo(0.31640625); + expect(fitted.height).toBe(1); + expect(fitted.centerX).toBe(0.5); + expect(fitted.centerY).toBe(0.5); + expect(mediaCanvasAspect(1080, 1920, 1920, 1080)).toBeCloseTo(0.31640625); + }); + + it("uses upstream's loose aspect tolerance for nearly matching media", () => { + expect(fitTransformForMedia(1910, 1080, 1920, 1080)).toMatchObject({ + width: 1, + height: 1, + }); + }); + + it("resizes by width while preserving source aspect relative to the canvas", () => { + const resized = resizeTransformKeepingSourceAspect( + { + centerX: 0.5, + centerY: 0.5, + width: 0.31640625, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }, + 0.5, + mediaCanvasAspect(1080, 1920, 1920, 1080), + ); + + expect(resized.width).toBeCloseTo(0.5); + expect(resized.height).toBeCloseTo(1.5802469); + expect(resized.centerX).toBe(0.5); + expect(resized.centerY).toBe(0.5); + }); + + it("keeps current transform aspect when source dimensions are unavailable", () => { + const resized = resizeTransformKeepingSourceAspect( + { + centerX: 0.4, + centerY: 0.6, + width: 0.8, + height: 0.2, + rotation: 12, + flipHorizontal: true, + flipVertical: false, + }, + 0.5, + null, + ); + + expect(resized).toMatchObject({ + centerX: 0.4, + centerY: 0.6, + width: 0.5, + height: 0.125, + rotation: 12, + flipHorizontal: true, + }); + }); +}); diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 6947974..dbac19c 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -6,7 +6,16 @@ import { TRACK_COLOR } from "./theme"; import { formatClipDuration } from "./geometry"; -import type { Clip, ClipType, TrimEditReq } from "./types"; +import type { + AnimPair, + Clip, + ClipType, + Crop, + KeyframeTrack, + Timeline, + Transform, + TrimEditReq, +} from "./types"; export function trackColor(type: ClipType): string { return TRACK_COLOR[type] ?? TRACK_COLOR.video; @@ -31,6 +40,237 @@ export function isLinked(clip: Clip): boolean { return clip.linkGroupId != null; } +const ASPECT_TOLERANCE = 0.02; + +function positiveFinite(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +function defaultTransform(): Transform { + return { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }; +} + +/** Source aspect ratio relative to the timeline canvas, matching upstream + * `EditorViewModel.mediaCanvasAspect(for:)`. */ +export function mediaCanvasAspect( + sourceWidth: number | null | undefined, + sourceHeight: number | null | undefined, + canvasWidth: number, + canvasHeight: number, +): number | null { + if ( + !positiveFinite(sourceWidth ?? NaN) || + !positiveFinite(sourceHeight ?? NaN) || + !positiveFinite(canvasWidth) || + !positiveFinite(canvasHeight) + ) { + return null; + } + const canvasAspect = canvasWidth / canvasHeight; + return ((sourceWidth as number) / (sourceHeight as number)) / canvasAspect; +} + +/** Initial aspect-fit transform for a media asset on the canvas. This is a 1:1 + * port of upstream `fitTransform(for:canvasWidth:canvasHeight:)`. */ +export function fitTransformForMedia( + sourceWidth: number | null | undefined, + sourceHeight: number | null | undefined, + canvasWidth: number, + canvasHeight: number, +): Transform { + if ( + !positiveFinite(sourceWidth ?? NaN) || + !positiveFinite(sourceHeight ?? NaN) || + !positiveFinite(canvasWidth) || + !positiveFinite(canvasHeight) + ) { + return defaultTransform(); + } + const canvasAspect = canvasWidth / canvasHeight; + const sourceAspect = (sourceWidth as number) / (sourceHeight as number); + if (Math.abs(canvasAspect - sourceAspect) < ASPECT_TOLERANCE) return defaultTransform(); + if (sourceAspect > canvasAspect) { + return { ...defaultTransform(), width: 1, height: canvasAspect / sourceAspect }; + } + return { ...defaultTransform(), width: sourceAspect / canvasAspect, height: 1 }; +} + +/** Inspector scale edits use normalized canvas width as the displayed scale. + * Height is derived from the source/canvas aspect so resizing never changes + * the media's pixel aspect, matching upstream `writeScale(into:newScale:)`. + * If source metadata is unavailable, preserve the clip's current transform + * aspect instead of collapsing to a square. */ +export function resizeTransformKeepingSourceAspect( + transform: Transform, + width: number, + aspect: number | null, +): Transform { + const nextWidth = positiveFinite(width) ? width : transform.width; + const currentAspect = + positiveFinite(transform.width) && positiveFinite(transform.height) + ? transform.width / transform.height + : 1; + const effectiveAspect = aspect !== null && positiveFinite(aspect) ? aspect : currentAspect; + return { + ...transform, + width: nextWidth, + height: nextWidth / effectiveAspect, + }; +} + +export type TransformResizeCorner = "topLeft" | "topRight" | "bottomLeft" | "bottomRight"; + +function topLeftForTransform(transform: Transform): { x: number; y: number } { + return { + x: transform.centerX - transform.width / 2, + y: transform.centerY - transform.height / 2, + }; +} + +function transformFromTopLeft( + start: Transform, + left: number, + top: number, + width: number, + height: number, +): Transform { + return { + ...start, + centerX: left + width / 2, + centerY: top + height / 2, + width, + height, + }; +} + +function snapToBoundary(value: number, threshold: number): number { + if (Math.abs(value) < threshold) return 0; + if (Math.abs(value - 1) < threshold) return 1; + return value; +} + +/** Upstream TransformOverlayView resizedTransform port. Dragging a corner moves + * that corner, keeps the opposite corner anchored, clamps at 0.05 normalized + * size, preserves media aspect when known, and skips canvas-edge snap under + * rotation. */ +export function resizeTransformFromCorner( + start: Transform, + corner: TransformResizeCorner, + translationPx: { width: number; height: number }, + canvasPx: { width: number; height: number }, + mediaCanvasAspect: number | null, + rotated: boolean, + snapThresholdPx = 0, +): Transform { + if (!positiveFinite(canvasPx.width) || !positiveFinite(canvasPx.height)) return start; + const minSize = 0.05; + const dx = translationPx.width / canvasPx.width; + const dy = translationPx.height / canvasPx.height; + const tl = topLeftForTransform(start); + let left = tl.x; + let top = tl.y; + let right = left + start.width; + let bottom = top + start.height; + + switch (corner) { + case "topLeft": + left += dx; + top += dy; + break; + case "topRight": + right += dx; + top += dy; + break; + case "bottomLeft": + left += dx; + bottom += dy; + break; + case "bottomRight": + right += dx; + bottom += dy; + break; + } + + switch (corner) { + case "topLeft": + left = Math.min(left, right - minSize); + top = Math.min(top, bottom - minSize); + break; + case "topRight": + right = Math.max(right, left + minSize); + top = Math.min(top, bottom - minSize); + break; + case "bottomLeft": + left = Math.min(left, right - minSize); + bottom = Math.max(bottom, top + minSize); + break; + case "bottomRight": + right = Math.max(right, left + minSize); + bottom = Math.max(bottom, top + minSize); + break; + } + + const aspect = + mediaCanvasAspect !== null && positiveFinite(mediaCanvasAspect) ? mediaCanvasAspect : null; + if (aspect !== null) { + const w = right - left; + const h = bottom - top; + const widthFromHeight = h * aspect; + if (w >= widthFromHeight) { + const adjustedH = w / aspect; + if (corner === "topLeft" || corner === "topRight") top = bottom - adjustedH; + else bottom = top + adjustedH; + } else { + const adjustedW = h * aspect; + if (corner === "topLeft" || corner === "bottomLeft") left = right - adjustedW; + else right = left + adjustedW; + } + } + + if (!rotated && snapThresholdPx > 0) { + const snapH = snapThresholdPx / canvasPx.width; + const snapV = snapThresholdPx / canvasPx.height; + const movesLeft = corner === "topLeft" || corner === "bottomLeft"; + const movesTop = corner === "topLeft" || corner === "topRight"; + const hEdge = movesLeft ? left : right; + const vEdge = movesTop ? top : bottom; + const snappedH = snapToBoundary(hEdge, snapH); + const snappedV = snapToBoundary(vEdge, snapV); + + if (snappedH !== hEdge) { + if (movesLeft) left = snappedH; + else right = snappedH; + if (aspect !== null) { + if (movesTop) top = bottom - (right - left) / aspect; + else bottom = top + (right - left) / aspect; + } + } else if (snappedV !== vEdge) { + if (movesTop) top = snappedV; + else bottom = snappedV; + if (aspect !== null) { + if (movesLeft) left = right - (bottom - top) * aspect; + else right = left + (bottom - top) * aspect; + } + } + } + + return transformFromTopLeft( + start, + left, + top, + Math.max(minSize, right - left), + Math.max(minSize, bottom - top), + ); +} + /** Which edge a trim drag grabs. */ export type TrimEdge = "left" | "right"; @@ -112,3 +352,249 @@ 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) + ); +} + +/** + * 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/fallback.test.ts b/web/src/lib/fallback.test.ts new file mode 100644 index 0000000..5d89844 --- /dev/null +++ b/web/src/lib/fallback.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; +import { createFallbackStore } from "./fallback"; + +describe("browser fallback edit store", () => { + it("supports insertTrack and addClips for media drops", () => { + const fallback = createFallbackStore(); + fallback.reset(); + + const trackResult = fallback.editApply({ type: "insertTrack", kind: "video" }); + const addResult = fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "m1", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 12, + durationFrames: 30, + hasAudio: false, + addLinkedAudio: false, + }, + ], + }); + + const timeline = fallback.getTimeline().timeline; + + expect(trackResult.changed).toBe(true); + expect(addResult.changed).toBe(true); + expect(addResult.affectedClipIds).toHaveLength(1); + expect(timeline.tracks).toHaveLength(1); + expect(timeline.tracks[0].type).toBe("video"); + expect(timeline.tracks[0].clips[0]).toMatchObject({ + id: addResult.affectedClipIds[0], + mediaRef: "m1", + mediaType: "video", + sourceClipType: "video", + startFrame: 12, + durationFrames: 30, + }); + }); + + it("inserts tracks at a requested index", () => { + const fallback = createFallbackStore(); + fallback.reset(); + + fallback.editApply({ type: "insertTrack", kind: "video" }); + fallback.editApply({ type: "insertTrack", kind: "audio" }); + const result = fallback.editApply({ type: "insertTrack", kind: "video", at: 0 }); + + expect(result.affectedClipIds).toEqual(["t102"]); + expect(fallback.getTimeline().timeline.tracks.map((track) => track.id)).toEqual(["t102", "t100", "t101"]); + }); + + it("adds linked audio when dropping a video asset with audio", () => { + const fallback = createFallbackStore(); + fallback.reset(); + fallback.editApply({ type: "insertTrack", kind: "video" }); + + const addResult = fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "m1", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 12, + durationFrames: 30, + hasAudio: true, + addLinkedAudio: true, + }, + ], + }); + const timeline = fallback.getTimeline().timeline; + + expect(addResult.affectedClipIds).toHaveLength(2); + expect(timeline.tracks.map((track) => track.type)).toEqual(["video", "audio"]); + const video = timeline.tracks[0].clips[0]; + const audio = timeline.tracks[1].clips[0]; + expect(video.linkGroupId).toBeTruthy(); + expect(audio.linkGroupId).toBe(video.linkGroupId); + expect(audio).toMatchObject({ + id: addResult.affectedClipIds[1], + mediaRef: "m1", + mediaType: "audio", + sourceClipType: "video", + startFrame: 12, + durationFrames: 30, + }); + }); + + it("trims and splits overwritten regions instead of swallowing entire clips", () => { + const fallback = createFallbackStore(); + fallback.reset(); + fallback.editApply({ type: "insertTrack", kind: "video" }); + const first = fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "base", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 0, + durationFrames: 100, + hasAudio: false, + addLinkedAudio: false, + }, + ], + }); + + fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "overlay", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 40, + durationFrames: 20, + hasAudio: false, + addLinkedAudio: false, + }, + ], + }); + const clips = fallback.getTimeline().timeline.tracks[0].clips; + + expect(clips).toEqual([ + expect.objectContaining({ id: first.affectedClipIds[0], mediaRef: "base", startFrame: 0, durationFrames: 40 }), + expect.objectContaining({ mediaRef: "overlay", startFrame: 40, durationFrames: 20 }), + expect.objectContaining({ mediaRef: "base", startFrame: 60, durationFrames: 40 }), + ]); + }); + + it("supports duplicateClips for Option-drag duplicate previews", () => { + const fallback = createFallbackStore(); + fallback.reset(); + fallback.editApply({ type: "insertTrack", kind: "video" }); + fallback.editApply({ type: "insertTrack", kind: "video" }); + const addResult = fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "m1", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 10, + durationFrames: 20, + hasAudio: false, + addLinkedAudio: false, + }, + ], + }); + const sourceId = addResult.affectedClipIds[0]; + + const duplicateResult = fallback.editApply({ + type: "duplicateClips", + clipIds: [sourceId], + offsetFrames: 15, + targetTrackIndexes: [1], + }); + const timeline = fallback.getTimeline().timeline; + + expect(duplicateResult.changed).toBe(true); + expect(duplicateResult.affectedClipIds).toHaveLength(1); + expect(timeline.tracks[0].clips.map((clip) => clip.id)).toEqual([sourceId]); + expect(timeline.tracks[1].clips[0]).toMatchObject({ + id: duplicateResult.affectedClipIds[0], + mediaRef: "m1", + startFrame: 25, + durationFrames: 20, + }); + }); + + it("plans multi-clip duplicates before clearing destination ranges", () => { + const fallback = createFallbackStore(); + fallback.reset(); + fallback.editApply({ type: "insertTrack", kind: "video" }); + const addResult = fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "a", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 0, + durationFrames: 30, + hasAudio: false, + addLinkedAudio: false, + }, + { + mediaRef: "b", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 30, + durationFrames: 30, + hasAudio: false, + addLinkedAudio: false, + }, + ], + }); + + const duplicateResult = fallback.editApply({ + type: "duplicateClips", + clipIds: addResult.affectedClipIds, + offsetFrames: 15, + targetTrackIndexes: [0, 0], + }); + + expect(duplicateResult.affectedClipIds).toHaveLength(2); + expect(fallback.getTimeline().timeline.tracks[0].clips.map((clip) => clip.mediaRef)).toEqual([ + "a", + "a", + "b", + ]); + }); + + it("remaps linked duplicate groups to a fresh shared link", () => { + const fallback = createFallbackStore(); + fallback.reset(); + fallback.editApply({ type: "insertTrack", kind: "video" }); + const addResult = fallback.editApply({ + type: "addClips", + entries: [ + { + mediaRef: "linked-av", + mediaType: "video", + sourceClipType: "video", + trackIndex: 0, + startFrame: 0, + durationFrames: 30, + hasAudio: true, + addLinkedAudio: true, + }, + ], + }); + const originalVideoId = addResult.affectedClipIds[0]; + const originalAudioId = addResult.affectedClipIds[1]; + const originalClips = fallback.getTimeline().timeline.tracks.flatMap((track) => track.clips); + const originalVideo = originalClips.find((clip) => clip.id === originalVideoId); + const originalAudio = originalClips.find((clip) => clip.id === originalAudioId); + + const duplicateResult = fallback.editApply({ + type: "duplicateClips", + clipIds: [originalVideoId, originalAudioId], + offsetFrames: 200, + targetTrackIndexes: [0, 1], + }); + const clips = fallback.getTimeline().timeline.tracks.flatMap((track) => track.clips); + const videoCopy = clips.find((clip) => clip.id === duplicateResult.affectedClipIds[0]); + const audioCopy = clips.find((clip) => clip.id === duplicateResult.affectedClipIds[1]); + + expect(duplicateResult.affectedClipIds).toHaveLength(2); + expect(videoCopy?.linkGroupId).toBeTruthy(); + expect(audioCopy?.linkGroupId).toBe(videoCopy?.linkGroupId); + expect(videoCopy?.linkGroupId).not.toBe(originalVideo?.linkGroupId); + expect(originalVideo?.linkGroupId).toBe(originalAudio?.linkGroupId); + }); + + it("persists fade length and interpolation properties", () => { + const fallback = createFallbackStore(); + + const result = fallback.editApply({ + type: "setClipProperties", + clipIds: ["c1"], + properties: { + fadeInFrames: 7, + fadeOutFrames: 9, + fadeInInterpolation: "smooth", + fadeOutInterpolation: "smooth", + }, + }); + + const clip = fallback + .getTimeline() + .timeline.tracks.flatMap((track) => track.clips) + .find((candidate) => candidate.id === "c1"); + + expect(result.changed).toBe(true); + expect(clip?.fadeInFrames).toBe(7); + expect(clip?.fadeOutFrames).toBe(9); + expect(clip?.fadeInInterpolation).toBe("smooth"); + expect(clip?.fadeOutInterpolation).toBe("smooth"); + }); +}); diff --git a/web/src/lib/fallback.ts b/web/src/lib/fallback.ts index 7a9647b..3061e41 100644 --- a/web/src/lib/fallback.ts +++ b/web/src/lib/fallback.ts @@ -29,6 +29,34 @@ function defaultCrop() { return { left: 0, top: 0, right: 0, bottom: 0 }; } +function isVisual(type: Clip["mediaType"]): boolean { + return type !== "audio"; +} + +type AddClipEntry = Extract["entries"][number]; + +function newClipFromEntry(id: string, entry: AddClipEntry): Clip { + return { + id, + mediaRef: entry.mediaRef, + mediaType: entry.mediaType, + sourceClipType: entry.sourceClipType, + startFrame: Math.max(0, entry.startFrame), + durationFrames: Math.max(1, entry.durationFrames), + trimStartFrame: entry.trimStartFrame ?? 0, + trimEndFrame: entry.trimEndFrame ?? 0, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: entry.transform ?? defaultTransform(), + crop: defaultCrop(), + }; +} + function newClip( id: string, mediaRef: string, @@ -92,6 +120,7 @@ export function createFallbackStore() { let version = 0; let idSeq = 100; const nextId = () => `c${idSeq++}`; + const nextTrackId = () => `t${idSeq++}`; function snapshot(): TimelineSnapshot { return { timeline: structuredClone(timeline), version }; @@ -109,6 +138,66 @@ export function createFallbackStore() { return null; } + function insertionIndex(kind: Clip["mediaType"], requested = timeline.tracks.length): number { + const firstAudio = timeline.tracks.findIndex((track) => track.type === "audio"); + const firstAudioIndex = firstAudio >= 0 ? firstAudio : timeline.tracks.length; + const bounded = Math.max(0, Math.min(timeline.tracks.length, requested)); + if (kind === "audio") return Math.max(bounded, firstAudioIndex); + return Math.min(bounded, firstAudioIndex); + } + + function trackCompatible(track: Track, type: Clip["mediaType"]): boolean { + return type === "audio" ? track.type === "audio" : isVisual(track.type); + } + + function clearRegion(trackIndex: number, startFrame: number, durationFrames: number): void { + const endFrame = startFrame + durationFrames; + const next: Clip[] = []; + for (const clip of timeline.tracks[trackIndex].clips) { + const clipEnd = clip.startFrame + clip.durationFrames; + if (clipEnd <= startFrame || clip.startFrame >= endFrame) { + next.push(clip); + continue; + } + if (clip.startFrame < startFrame) { + const left = { ...structuredClone(clip), durationFrames: startFrame - clip.startFrame }; + left.trimEndFrame += Math.round((clipEnd - startFrame) * clip.speed); + next.push(left); + } + if (clipEnd > endFrame) { + const right = structuredClone(clip); + right.id = nextId(); + right.startFrame = endFrame; + right.durationFrames = clipEnd - endFrame; + right.trimStartFrame += Math.round((endFrame - clip.startFrame) * clip.speed); + next.push(right); + } + } + timeline.tracks[trackIndex].clips = next.sort((a, b) => a.startFrame - b.startFrame); + } + + function resolveOrCreateAudioTrack(startFrame: number, durationFrames: number): number { + const endFrame = startFrame + durationFrames; + for (let i = 0; i < timeline.tracks.length; i++) { + const track = timeline.tracks[i]; + if (track.type !== "audio") continue; + const overlaps = track.clips.some( + (clip) => clip.startFrame < endFrame && clip.startFrame + clip.durationFrames > startFrame, + ); + if (!overlaps) return i; + } + const index = insertionIndex("audio"); + timeline.tracks.splice(index, 0, { + id: nextTrackId(), + type: "audio", + muted: false, + hidden: false, + syncLocked: true, + clips: [], + }); + return index; + } + function result(changed: boolean, actionName: string, affected: string[]): EditResult { if (changed) bump(); return { @@ -129,6 +218,51 @@ export function createFallbackStore() { noop: (name: string): EditResult => result(false, name, []), editApply: (cmd: EditRequest): EditResult => { switch (cmd.type) { + case "insertTrack": { + const index = insertionIndex(cmd.kind, cmd.at); + const trackId = nextTrackId(); + timeline.tracks.splice(index, 0, { + id: trackId, + type: cmd.kind === "audio" ? "audio" : "video", + muted: false, + hidden: false, + syncLocked: true, + clips: [], + }); + return result(true, "Insert Track", [trackId]); + } + case "addClips": { + const affected: string[] = []; + for (const entry of cmd.entries) { + const track = timeline.tracks[entry.trackIndex]; + if (!track || !trackCompatible(track, entry.mediaType)) continue; + const id = nextId(); + const clip = newClipFromEntry(id, entry); + const shouldLink = + entry.addLinkedAudio === true && + entry.hasAudio === true && + track.type === "video" && + entry.sourceClipType === "video"; + const linkGroupId = shouldLink ? nextId() : undefined; + clip.linkGroupId = linkGroupId; + clearRegion(entry.trackIndex, clip.startFrame, clip.durationFrames); + track.clips.push(clip); + track.clips.sort((a, b) => a.startFrame - b.startFrame); + affected.push(id); + if (shouldLink && linkGroupId) { + const audioTrackIndex = resolveOrCreateAudioTrack(clip.startFrame, clip.durationFrames); + const audio: Clip = { + ...newClipFromEntry(nextId(), { ...entry, mediaType: "audio" }), + linkGroupId, + }; + clearRegion(audioTrackIndex, audio.startFrame, audio.durationFrames); + timeline.tracks[audioTrackIndex].clips.push(audio); + timeline.tracks[audioTrackIndex].clips.sort((a, b) => a.startFrame - b.startFrame); + affected.push(audio.id); + } + } + return result(affected.length > 0, affected.length === 1 ? "Add Clip" : "Add Clips", affected); + } case "removeClips": { let changed = false; for (const track of timeline.tracks) { @@ -155,6 +289,51 @@ export function createFallbackStore() { } return result(changed, "Move Clip", cmd.moves.map((m) => m.clipId)); } + case "duplicateClips": { + const plans: Array<{ copy: Clip; targetTrackIndex: number; startFrame: number }> = []; + for (let i = 0; i < cmd.clipIds.length; i++) { + const loc = findClip(cmd.clipIds[i]); + const targetTrackIndex = cmd.targetTrackIndexes[i]; + const target = timeline.tracks[targetTrackIndex]; + if (!loc || !target) continue; + const source = timeline.tracks[loc[0]].clips[loc[1]]; + if (!trackCompatible(target, source.mediaType)) continue; + plans.push({ + copy: structuredClone(source), + targetTrackIndex, + startFrame: Math.max(0, source.startFrame + cmd.offsetFrames), + }); + } + const affected: string[] = []; + for (const plan of plans) { + clearRegion(plan.targetTrackIndex, plan.startFrame, plan.copy.durationFrames); + } + const linkGroupCounts = new Map(); + for (const plan of plans) { + if (!plan.copy.linkGroupId) continue; + linkGroupCounts.set(plan.copy.linkGroupId, (linkGroupCounts.get(plan.copy.linkGroupId) ?? 0) + 1); + } + const linkGroupRemap = new Map(); + for (const [groupId, count] of linkGroupCounts) { + linkGroupRemap.set(groupId, count > 1 ? nextId() : undefined); + } + for (const plan of plans) { + const target = timeline.tracks[plan.targetTrackIndex]; + if (!target) continue; + const copy = plan.copy; + copy.id = nextId(); + copy.startFrame = plan.startFrame; + copy.linkGroupId = copy.linkGroupId ? linkGroupRemap.get(copy.linkGroupId) : undefined; + target.clips.push(copy); + target.clips.sort((a, b) => a.startFrame - b.startFrame); + affected.push(copy.id); + } + return result( + affected.length > 0, + affected.length === 1 ? "Duplicate Clip" : "Duplicate Clips", + affected, + ); + } case "splitClip": { const loc = findClip(cmd.clipId); if (!loc) return result(false, "Split Clip", []); @@ -179,6 +358,12 @@ export function createFallbackStore() { if (p.volume !== undefined) (c.volume = p.volume), (changed = true); if (p.speed !== undefined) (c.speed = p.speed), (changed = true); if (p.transform !== undefined) (c.transform = p.transform), (changed = true); + if (p.fadeInFrames !== undefined) (c.fadeInFrames = p.fadeInFrames), (changed = true); + if (p.fadeOutFrames !== undefined) (c.fadeOutFrames = p.fadeOutFrames), (changed = true); + if (p.fadeInInterpolation !== undefined) + (c.fadeInInterpolation = p.fadeInInterpolation), (changed = true); + if (p.fadeOutInterpolation !== undefined) + (c.fadeOutInterpolation = p.fadeOutInterpolation), (changed = true); } return result(changed, "Set Clip Property", cmd.clipIds); } diff --git a/web/src/lib/geometry.test.ts b/web/src/lib/geometry.test.ts new file mode 100644 index 0000000..250db20 --- /dev/null +++ b/web/src/lib/geometry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { dropTargetAt, trackY } from "./geometry"; +import type { ClipType, Timeline, Track } from "./types"; + +function track(id: string, type: ClipType): Track { + return { + id, + type, + muted: false, + hidden: false, + syncLocked: true, + clips: [], + }; +} + +function timeline(tracks: Track[]): Timeline { + return { + fps: 30, + width: 1920, + height: 1080, + settingsConfigured: true, + tracks, + }; +} + +describe("timeline geometry", () => { + it("matches upstream dropTargetAt boundary behavior", () => { + const tl = timeline([track("v1", "video"), track("v2", "video"), track("a1", "audio")]); + const heights = {}; + + expect(trackY(tl, 0, heights)).toBe(84); + expect(trackY(tl, 1, heights)).toBe(134); + expect(trackY(tl, 2, heights)).toBe(184); + + expect(dropTargetAt(tl, 50, heights)).toEqual({ kind: "newTrack", index: 0 }); + expect(dropTargetAt(tl, 100, heights)).toEqual({ kind: "existing", trackIndex: 0 }); + expect(dropTargetAt(tl, 130, heights)).toEqual({ kind: "newTrack", index: 1 }); + expect(dropTargetAt(tl, 134, heights)).toEqual({ kind: "newTrack", index: 1 }); + expect(dropTargetAt(tl, 200, heights)).toEqual({ kind: "existing", trackIndex: 2 }); + expect(dropTargetAt(tl, 250, heights)).toEqual({ kind: "newTrack", index: 3 }); + }); + + it("targets a new first track on an empty timeline", () => { + expect(dropTargetAt(timeline([]), 100, {})).toEqual({ kind: "newTrack", index: 0 }); + }); +}); diff --git a/web/src/lib/geometry.ts b/web/src/lib/geometry.ts index 53a0f13..2562e0d 100644 --- a/web/src/lib/geometry.ts +++ b/web/src/lib/geometry.ts @@ -15,6 +15,10 @@ export interface ClipRect { height: number; } +export type TrackDropTarget = + | { kind: "existing"; trackIndex: number } + | { kind: "newTrack"; index: number }; + /** displayHeight resolver: UI-only per-track height (default 50, clamped). */ export function trackDisplayHeight( track: Track, @@ -115,6 +119,40 @@ export function trackAt( return null; } +/** dropTargetAt(y): upstream insert-zone hit test for drag/drop. */ +export function dropTargetAt( + timeline: Timeline, + y: number, + heights: Record, +): TrackDropTarget { + const trackCount = timeline.tracks.length; + if (trackCount === 0) return { kind: "newTrack", index: 0 }; + + const firstTop = trackY(timeline, 0, heights); + if (y < firstTop) return { kind: "newTrack", index: 0 }; + + const threshold = LAYOUT.insertThreshold; + for (let i = 0; i < trackCount - 1; i++) { + const bottomOfTrack = trackY(timeline, i, heights) + trackDisplayHeight(timeline.tracks[i], heights); + const topOfNext = trackY(timeline, i + 1, heights); + if (y >= bottomOfTrack - threshold && y <= topOfNext + threshold) { + return { kind: "newTrack", index: i + 1 }; + } + } + + const lastTrackBottom = + trackY(timeline, trackCount - 1, heights) + + trackDisplayHeight(timeline.tracks[trackCount - 1], heights); + if (y >= lastTrackBottom) return { kind: "newTrack", index: trackCount }; + + for (let i = 0; i < trackCount; i++) { + if (y < trackY(timeline, i, heights) + trackDisplayHeight(timeline.tracks[i], heights)) { + return { kind: "existing", trackIndex: i }; + } + } + return { kind: "existing", trackIndex: Math.max(0, trackCount - 1) }; +} + /** end frame (exclusive). */ export function endFrame(clip: Clip): number { return clip.startFrame + clip.durationFrames; diff --git a/web/src/lib/snap.ts b/web/src/lib/snap.ts index 466c687..5edd256 100644 --- a/web/src/lib/snap.ts +++ b/web/src/lib/snap.ts @@ -22,11 +22,13 @@ export interface SnapResult { } /** Collect snap targets: every clip start/end (excluding dragged clips) plus an - * optional playhead (SnapEngine.swift:31-48). */ + * optional playhead (SnapEngine.swift:31-48). `includePlayhead` defaults false + * to match upstream: only callers that explicitly opt in get playhead snap. */ export function collectTargets( timeline: Timeline, excludeClipIds: Set, playheadFrame: number | null, + includePlayhead = false, ): SnapTarget[] { const targets: SnapTarget[] = []; for (const track of timeline.tracks) { @@ -36,7 +38,7 @@ export function collectTargets( targets.push({ frame: endFrame(clip), kind: "clipEdge" }); } } - if (playheadFrame !== null) { + if (playheadFrame !== null && includePlayhead) { targets.push({ frame: playheadFrame, kind: "playhead" }); } return targets; @@ -85,21 +87,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; diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index fbc6160..d8f9ca0 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -145,3 +145,11 @@ export const CLIP = { keyframeDiamondRadius: 3, minWidthForLabel: 20, } as const; + +/** §5.4 Fade-knee handle geometry (ClipRenderer.swift:7-14, TimelineGeometry.swift:156-166). */ +export const FADE = { + kneeTopInset: 4, + edgeInset: 6, + kneeSize: 7, + hitSize: 14, +} as const; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4c64386..ca4a276 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -100,6 +100,7 @@ export interface ClipEntryReq { trimEndFrame?: number; hasAudio?: boolean; addLinkedAudio?: boolean; + transform?: Transform; } export interface ClipMoveReq { @@ -123,6 +124,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`). */ @@ -152,6 +165,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[] } @@ -167,16 +186,25 @@ export type EditRequest = | { type: "link"; clipIds: string[] } | { type: "unlink"; clipIds: string[] } | { type: "removeTracks"; trackIndexes: number[] } - | { type: "insertTrack"; kind: ClipType } + | { type: "insertTrack"; kind: ClipType; at?: number } | { type: "setTrackProps"; trackIndex: number; muted?: boolean; hidden?: boolean; 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/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.browserFallback.test.ts b/web/src/store/editActions.browserFallback.test.ts new file mode 100644 index 0000000..ddcefd6 --- /dev/null +++ b/web/src/store/editActions.browserFallback.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { projectNew } from "../lib/api"; +import type { MediaItem } from "../lib/types"; +import { addMediaToTimelineAt, insertTrack } from "./editActions"; +import { useEditorUiStore } from "./uiStore"; +import { useProjectStore } from "./projectStore"; +import { forceRefresh } from "./sync"; + +function video(id: string): MediaItem { + return { + id, + name: id, + type: "video", + duration: 2, + width: 1920, + height: 1080, + hasAudio: false, + }; +} + +describe("browser fallback edit actions", () => { + beforeEach(async () => { + await projectNew(); + await forceRefresh(); + useEditorUiStore.setState({ activeFrame: 0, currentFrame: 0, selectedClipIds: new Set() }); + }); + + it("places media drops on the newly inserted zone-clamped track", async () => { + await insertTrack("video"); + await forceRefresh(); + await insertTrack("audio"); + await forceRefresh(); + + await addMediaToTimelineAt(video("drop"), 12, null, 2); + + const tracks = useProjectStore.getState().timeline.tracks; + expect(tracks.map((track) => track.type)).toEqual(["video", "video", "audio"]); + expect(tracks[0].clips).toHaveLength(0); + expect(tracks[1].clips.map((clip) => clip.mediaRef)).toEqual(["drop"]); + }); +}); diff --git a/web/src/store/editActions.test.ts b/web/src/store/editActions.test.ts index b10258a..aef8771 100644 --- a/web/src/store/editActions.test.ts +++ b/web/src/store/editActions.test.ts @@ -11,10 +11,20 @@ * exactly like Tauri where the mirror is only updated by the async event. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MediaItem, Timeline } from "../lib/types"; +import type { Clip, ClipType, MediaItem, Timeline, Transform } from "../lib/types"; const srv = vi.hoisted(() => { - type SClip = { startFrame: number; durationFrames: number }; + type SClip = { + id: string; + mediaRef: string; + mediaType: ClipType; + sourceClipType: ClipType; + startFrame: number; + durationFrames: number; + trimStartFrame: number; + trimEndFrame: number; + transform?: Transform; + }; type STrack = { id: string; type: string; clips: SClip[] }; const state: { tracks: STrack[]; version: number; seq: number } = { tracks: [], @@ -37,41 +47,69 @@ const srv = vi.hoisted(() => { apply(cmd: { type: string; kind?: string; - entries?: Array<{ trackIndex: number; startFrame: number; durationFrames: number }>; - }): boolean { + at?: number; + entries?: Array<{ + mediaRef: string; + mediaType: ClipType; + sourceClipType: ClipType; + trackIndex: number; + startFrame: number; + durationFrames: number; + trimStartFrame?: number; + trimEndFrame?: number; + transform?: Transform; + }>; + }): { changed: boolean; affectedClipIds: string[] } { if (cmd.type === "insertTrack") { - state.tracks.push({ + const at = Math.max(0, Math.min(state.tracks.length, cmd.at ?? state.tracks.length)); + state.tracks.splice(at, 0, { id: `t${++state.seq}`, type: cmd.kind === "audio" ? "audio" : "video", clips: [], }); state.version += 1; - return true; + return { changed: true, affectedClipIds: [] }; } if (cmd.type === "addClips" && cmd.entries) { + const affectedClipIds: string[] = []; for (const e of cmd.entries) { const track = state.tracks[e.trackIndex]; if (!track) continue; clearRegion(track, e.startFrame, e.startFrame + e.durationFrames); - track.clips.push({ startFrame: e.startFrame, durationFrames: e.durationFrames }); + const id = `c${++state.seq}`; + track.clips.push({ + id, + mediaRef: e.mediaRef, + mediaType: e.mediaType, + sourceClipType: e.sourceClipType, + startFrame: e.startFrame, + durationFrames: e.durationFrames, + trimStartFrame: e.trimStartFrame ?? 0, + trimEndFrame: e.trimEndFrame ?? 0, + transform: e.transform, + }); + affectedClipIds.push(id); } state.version += 1; - return true; + return { changed: true, affectedClipIds }; } - return false; + return { changed: false, affectedClipIds: [] }; }, }; }); vi.mock("../lib/api", () => ({ isTauri: true, - editApply: async (command: { type: string }) => ({ - changed: srv.apply(command as never), - actionName: command.type, - affectedClipIds: [], - timelineVersion: srv.state.version, - summary: "", - }), + editApply: async (command: { type: string }) => { + const res = srv.apply(command as never); + return { + changed: res.changed, + actionName: command.type, + affectedClipIds: res.affectedClipIds, + timelineVersion: srv.state.version, + summary: "", + }; + }, getTimeline: async () => ({ timeline: { fps: 30, @@ -84,10 +122,32 @@ vi.mock("../lib/api", () => ({ muted: false, hidden: false, syncLocked: true, - clips: t.clips.map((c, i) => ({ - id: `${t.id}-c${i}`, + clips: t.clips.map((c) => ({ + id: c.id, + mediaRef: c.mediaRef, + mediaType: c.mediaType, + sourceClipType: c.sourceClipType, startFrame: c.startFrame, durationFrames: c.durationFrames, + trimStartFrame: c.trimStartFrame, + trimEndFrame: c.trimEndFrame, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: c.transform ?? { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, })), })), }, @@ -98,7 +158,9 @@ vi.mock("../lib/api", () => ({ })); // Imported after the mock is registered (vitest hoists vi.mock above imports). -import { addMediaToTimeline } from "./editActions"; +import { addMediaToTimeline, addMediaToTimelineAt, insertTrack, pasteClipsAtPlayhead } from "./editActions"; +import { useClipboardStore } from "./clipboardStore"; +import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; const EMPTY: Timeline = { @@ -109,9 +171,9 @@ const EMPTY: Timeline = { tracks: [], }; -function video(name: string): MediaItem { +function video(name: string, width?: number, height?: number): MediaItem { // duration 2s * 30fps = 60 frames per clip. - return { id: name, name, type: "video", duration: 2, hasAudio: false }; + return { id: name, name, type: "video", duration: 2, width, height, hasAudio: false }; } function visualClipStarts(): number[] { @@ -120,10 +182,40 @@ function visualClipStarts(): number[] { return (track?.clips ?? []).map((c) => c.startFrame).sort((a, b) => a - b); } +function visualClipTransforms(): Transform[] { + const tl = useProjectStore.getState().timeline; + const track = tl.tracks.find((t) => t.type === "video"); + return (track?.clips ?? []).map((c) => c.transform); +} + +function clipboardClip(transform: Transform): Clip { + return { + id: "source-clip", + mediaRef: "vertical", + mediaType: "video", + sourceClipType: "video", + startFrame: 120, + durationFrames: 60, + trimStartFrame: 3, + trimEndFrame: 7, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + }; +} + describe("addMediaToTimeline", () => { beforeEach(() => { srv.reset(); useProjectStore.getState().setMirror(EMPTY, 0); + useClipboardStore.getState().clear(); + useEditorUiStore.setState({ activeFrame: 0, currentFrame: 0, selectedClipIds: new Set() }); }); it("appends a second item after the first when awaited sequentially", async () => { @@ -140,4 +232,51 @@ describe("addMediaToTimeline", () => { await Promise.all([p1, p2]); expect(visualClipStarts()).toEqual([0, 60]); }); + + it("drops overlapping media onto a new top overlay track instead of overwriting", async () => { + await addMediaToTimeline(video("base")); + await addMediaToTimelineAt(video("overlay"), 0, 0); + + const videoTracks = useProjectStore.getState().timeline.tracks.filter((t) => t.type === "video"); + expect(videoTracks).toHaveLength(2); + expect(videoTracks[0].clips.map((c) => [c.mediaRef, c.startFrame])).toEqual([["overlay", 0]]); + expect(videoTracks[1].clips.map((c) => [c.mediaRef, c.startFrame])).toEqual([["base", 0]]); + }); + + it("adds vertical media with the upstream aspect-fit transform", async () => { + await addMediaToTimeline(video("vertical", 1080, 1920)); + + const [transform] = visualClipTransforms(); + expect(transform.width).toBeCloseTo(0.31640625); + expect(transform.height).toBe(1); + }); + + it("pastes copied clips without resetting their transform", async () => { + await addMediaToTimeline(video("seed")); + const transform: Transform = { + centerX: 0.5, + centerY: 0.5, + width: 0.31640625, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }; + useClipboardStore.getState().set([{ clip: clipboardClip(transform), sourceTrackIndex: 0 }], 120); + useEditorUiStore.setState({ activeFrame: 240, currentFrame: 240 }); + + await pasteClipsAtPlayhead(); + + const transforms = visualClipTransforms(); + expect(transforms.at(-1)?.width).toBeCloseTo(0.31640625); + expect(transforms.at(-1)?.height).toBe(1); + }); + + it("forwards an explicit insertTrack index", async () => { + await insertTrack("video"); + await insertTrack("audio"); + await insertTrack("video", 0); + + expect(srv.state.tracks.map((track) => track.id)).toEqual(["t3", "t1", "t2"]); + }); }); diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7885792..93791ba 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -9,7 +9,8 @@ import { isTauri } from "../lib/api"; import { forceRefresh } from "./sync"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; -import { trimToPlayheadEdits } from "../lib/clip"; +import { fitTransformForMedia, 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[]) { @@ -45,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 }); @@ -71,8 +92,8 @@ export async function linkClips(clipIds: string[]) { /** Insert a new empty track of `kind` (clamped into its zone by the core). Used * by the drop flow to create a track on demand when the timeline has none * compatible. */ -export async function insertTrack(kind: ClipType) { - await applyAndRefresh({ type: "insertTrack", kind }); +export async function insertTrack(kind: ClipType, at?: number) { + return applyAndRefresh({ type: "insertTrack", kind, at }); } export async function unlinkClips(clipIds: string[]) { @@ -151,6 +172,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(); @@ -218,23 +265,52 @@ export async function trimEndToPlayhead() { await trimClips(trimToPlayheadEdits(clipsUnderPlayhead(), frame, "right")); } -/** Delete selected clips (⌫ / menu). */ +/** The subset of `selected` that still exists as a clip in the current timeline. + * A stale id (a clip already removed/replaced/split by a prior edit, left behind + * in the selection set) makes the core's RemoveClips/RippleDelete reject the + * WHOLE batch — so one orphan silently blocks deletion of everything. Filtering + * to live ids first is what makes ⌫ reliably delete. */ +function liveSelectedClipIds(): string[] { + const live = new Set(); + for (const track of useProjectStore.getState().timeline.tracks) { + for (const clip of track.clips) live.add(clip.id); + } + return [...useEditorUiStore.getState().selectedClipIds].filter((id) => live.has(id)); +} + +/** Delete selected clips (⌫ / menu). Wrapped in try/catch so that even if the + * backend RemoveClips rejects (IPC error, an edge the live-id filter missed), + * the selection is still cleared and the failure is surfaced as a toast instead + * of silently doing nothing (the reported "delete does nothing"). */ export async function deleteSelectedClips() { const ui = useEditorUiStore.getState(); - const ids = [...ui.selectedClipIds]; + const ids = liveSelectedClipIds(); if (ids.length > 0) { - await removeClips(ids); - ui.clearSelection(); + try { + await removeClips(ids); + // Tauri normally refreshes via the timeline_changed event; force it too so + // a missed/raced event can't leave the just-deleted clip painted on screen. + if (isTauri) await forceRefresh(); + } catch (err) { + ui.pushToast(`删除失败 / Delete failed: ${err instanceof Error ? err.message : String(err)}`); + } } + ui.clearSelection(); } /** Ripple-delete selected clips (⇧⌫): remove and close the gaps, shifting * sync-locked followers (the core refuses if a follower would collide). */ export async function rippleDeleteSelectedClips() { const ui = useEditorUiStore.getState(); - const ids = [...ui.selectedClipIds]; - if (ids.length === 0) return; - await applyAndRefresh({ type: "rippleDeleteClips", clipIds: ids }); + const ids = liveSelectedClipIds(); + if (ids.length > 0) { + try { + await applyAndRefresh({ type: "rippleDeleteClips", clipIds: ids }); + if (isTauri) await forceRefresh(); + } catch (err) { + ui.pushToast(`删除失败 / Delete failed: ${err instanceof Error ? err.message : String(err)}`); + } + } ui.clearSelection(); } @@ -263,6 +339,14 @@ function firstCompatibleTrackIndex(timeline: Timeline, type: MediaItem["type"]): return null; } +function trackIsCompatible(timeline: Timeline, trackIndex: number, type: MediaItem["type"]): boolean { + const track = timeline.tracks[trackIndex]; + if (!track) return false; + const wantAudio = type === "audio"; + const trackIsAudio = track.type === "audio"; + return wantAudio ? trackIsAudio : !trackIsAudio && isVisual(track.type); +} + /** Append position on a track: just past its last clip (clamped to >= 0). */ function appendStartFrame(timeline: Timeline, trackIndex: number): number { return timeline.tracks[trackIndex].clips.reduce( @@ -271,6 +355,31 @@ function appendStartFrame(timeline: Timeline, trackIndex: number): number { ); } +function trackOverlaps(timeline: Timeline, trackIndex: number, startFrame: number, durationFrames: number): boolean { + const endFrame = startFrame + durationFrames; + return timeline.tracks[trackIndex].clips.some((c) => c.startFrame < endFrame && c.startFrame + c.durationFrames > startFrame); +} + +function firstOpenCompatibleTrackIndex( + timeline: Timeline, + type: MediaItem["type"], + startFrame: number, + durationFrames: number, + preferredTrackIndex: number | null, +): number | null { + const candidates: number[] = []; + if (preferredTrackIndex !== null && trackIsCompatible(timeline, preferredTrackIndex, type)) { + candidates.push(preferredTrackIndex); + } + for (let i = 0; i < timeline.tracks.length; i++) { + if (i !== preferredTrackIndex && trackIsCompatible(timeline, i, type)) candidates.push(i); + } + for (const trackIndex of candidates) { + if (!trackOverlaps(timeline, trackIndex, startFrame, durationFrames)) return trackIndex; + } + return null; +} + /** Build the clip entry for a media item dropped on the timeline, or null when * no compatible track exists. */ function entryForMedia(timeline: Timeline, item: MediaItem): ClipEntryReq | null { @@ -287,6 +396,36 @@ function entryForMedia(timeline: Timeline, item: MediaItem): ClipEntryReq | null durationFrames, hasAudio: item.hasAudio, addLinkedAudio: item.type === "video" && item.hasAudio, + transform: fitTransformForMedia(item.width, item.height, timeline.width, timeline.height), + }; +} + +function entryForMediaAt( + timeline: Timeline, + item: MediaItem, + startFrame: number, + preferredTrackIndex: number | null, +): ClipEntryReq | null { + const seconds = item.duration > 0 ? item.duration : DEFAULT_IMAGE_SECONDS; + const durationFrames = Math.max(1, Math.round(seconds * timeline.fps)); + const trackIndex = firstOpenCompatibleTrackIndex( + timeline, + item.type, + startFrame, + durationFrames, + preferredTrackIndex, + ); + if (trackIndex === null) return null; + return { + mediaRef: item.id, + mediaType: item.type, + sourceClipType: item.type, + trackIndex, + startFrame: Math.max(0, startFrame), + durationFrames, + hasAudio: item.hasAudio, + addLinkedAudio: item.type === "video" && item.hasAudio, + transform: fitTransformForMedia(item.width, item.height, timeline.width, timeline.height), }; } @@ -295,6 +434,13 @@ function entryForMedia(timeline: Timeline, item: MediaItem): ClipEntryReq | null * racing on the shared mirror. See [`addMediaToTimeline`]. */ let mediaAddQueue: Promise = Promise.resolve(); +function enqueueMediaAdd(run: () => Promise): Promise { + const result = mediaAddQueue.then(run, run); + // Keep the queue alive even if an individual add rejects. + mediaAddQueue = result.catch(() => {}); + return result; +} + /** Add a media-library item to the timeline (drag-drop / double-click from the * media panel). Resolves the target track and append position from the current * mirror; if the timeline has no compatible track (e.g. a brand-new empty @@ -306,11 +452,16 @@ let mediaAddQueue: Promise = Promise.resolve(); * compute `startFrame` 0 again, and have the core's overwrite-on-place drop the * first clip. The queue makes each add observe the previous one's result. */ export function addMediaToTimeline(item: MediaItem): Promise { - const run = () => addMediaToTimelineInner(item); - const result = mediaAddQueue.then(run, run); - // Keep the queue alive even if an individual add rejects. - mediaAddQueue = result.catch(() => {}); - return result; + return enqueueMediaAdd(() => addMediaToTimelineInner(item)); +} + +export function addMediaToTimelineAt( + item: MediaItem, + startFrame: number, + preferredTrackIndex: number | null, + insertTrackAt?: number, +): Promise { + return enqueueMediaAdd(() => addMediaToTimelineAtInner(item, startFrame, preferredTrackIndex, insertTrackAt)); } async function addMediaToTimelineInner(item: MediaItem): Promise { @@ -332,6 +483,48 @@ async function addMediaToTimelineInner(item: MediaItem): Promise { if (isTauri) await forceRefresh(); } +async function addMediaToTimelineAtInner( + item: MediaItem, + startFrame: number, + preferredTrackIndex: number | null, + insertTrackAt?: number, +): Promise { + let timeline = useProjectStore.getState().timeline; + if (insertTrackAt !== undefined) { + const res = await insertTrack(item.type === "audio" ? "audio" : "video", insertTrackAt); + await forceRefresh(); + timeline = useProjectStore.getState().timeline; + const insertedTrackId = res?.affectedClipIds[0]; + const insertedIndex = insertedTrackId + ? timeline.tracks.findIndex((track) => track.id === insertedTrackId) + : -1; + if (insertedIndex >= 0) preferredTrackIndex = insertedIndex; + } + let entry = entryForMediaAt(timeline, item, Math.max(0, startFrame), preferredTrackIndex); + if (!entry) { + const fallbackInsertAt = preferredTrackIndex ?? undefined; + const res = await insertTrack(item.type === "audio" ? "audio" : "video", fallbackInsertAt); + await forceRefresh(); + timeline = useProjectStore.getState().timeline; + const insertedTrackId = res?.affectedClipIds[0]; + const insertedIndex = insertedTrackId + ? timeline.tracks.findIndex((track) => track.id === insertedTrackId) + : -1; + if (insertedIndex >= 0) { + preferredTrackIndex = insertedIndex; + } else if (fallbackInsertAt !== undefined) { + preferredTrackIndex = Math.max(0, Math.min(fallbackInsertAt, timeline.tracks.length - 1)); + } + entry = entryForMediaAt(timeline, item, Math.max(0, startFrame), preferredTrackIndex); + } + if (!entry) return; + const res = await addClips([entry]); + if (res && res.affectedClipIds.length > 0) { + useEditorUiStore.getState().selectClips(new Set(res.affectedClipIds)); + } + if (isTauri) await forceRefresh(); +} + // MARK: - Text tool (Toolbar "T" button, SPEC §4) /** Default text clip duration: 3 seconds at the timeline's fps. */ @@ -389,3 +582,112 @@ 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", + transform: e.clip.transform, + // 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)); + if (isTauri) await forceRefresh(); +} diff --git a/web/src/store/projectActions.test.ts b/web/src/store/projectActions.test.ts new file mode 100644 index 0000000..f2d78d1 --- /dev/null +++ b/web/src/store/projectActions.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MediaList, Timeline } from "../lib/types"; + +const srv = vi.hoisted(() => { + const timeline: Timeline = { + fps: 30, + width: 1920, + height: 1080, + settingsConfigured: true, + tracks: [], + }; + const media: MediaList = { + items: [ + { + id: "m1", + name: "clip", + type: "video", + duration: 10, + hasAudio: true, + path: "/tmp/clip.mov", + }, + ], + folders: [], + }; + return { timeline, media }; +}); + +vi.mock("../lib/api", () => ({ + projectOpen: async () => ({ timeline: srv.timeline, version: 7 }), + getMedia: async () => srv.media, +})); + +import { openProjectPath } from "./projectActions"; +import { useEditorUiStore } from "./uiStore"; +import { useMediaStore } from "./mediaStore"; +import { useProjectStore } from "./projectStore"; + +describe("openProjectPath", () => { + beforeEach(() => { + useMediaStore.getState().setItems([]); + useProjectStore.setState({ projectPath: null, timelineVersion: 0 }); + useEditorUiStore.setState({ view: "home" }); + }); + + it("refreshes the media mirror after opening a project", async () => { + await openProjectPath("/tmp/demo.opentake"); + + expect(useProjectStore.getState().projectPath).toBe("/tmp/demo.opentake"); + expect(useMediaStore.getState().items.map((item) => item.id)).toEqual(["m1"]); + expect(useEditorUiStore.getState().view).toBe("editor"); + }); +}); diff --git a/web/src/store/projectActions.ts b/web/src/store/projectActions.ts index 9b01905..ce53883 100644 --- a/web/src/store/projectActions.ts +++ b/web/src/store/projectActions.ts @@ -11,6 +11,7 @@ import { forceRefresh } from "./sync"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { useRecentStore } from "./recentStore"; +import { refreshMedia } from "./mediaStore"; import { openDialog, saveDialog } from "../lib/dialog"; import { t } from "../i18n"; @@ -89,6 +90,7 @@ export async function openProjectPath(path: string): Promise { useProjectStore.getState().setProjectPath(path); useProjectStore.getState().markSaved(); useRecentStore.getState().add(path); + await refreshMedia(); useEditorUiStore.getState().setView("editor"); } diff --git a/web/src/store/recentStore.ts b/web/src/store/recentStore.ts index e8ef17c..b1c1a67 100644 --- a/web/src/store/recentStore.ts +++ b/web/src/store/recentStore.ts @@ -6,6 +6,7 @@ */ import { create } from "zustand"; +import { useProjectStore } from "./projectStore"; const LS_RECENTS = "recentProjects"; const MAX_RECENTS = 12; @@ -49,6 +50,7 @@ interface RecentState { recents: RecentProject[]; add: (path: string) => void; remove: (path: string) => void; + validateRecents: () => Promise; } export const useRecentStore = create((set, get) => ({ @@ -67,5 +69,47 @@ export const useRecentStore = create((set, get) => ({ const next = get().recents.filter((r) => r.path !== path); persist(next); set({ recents: next }); + + // Clear active project from memory if it matches the removed path + const projStore = useProjectStore.getState(); + if (projStore.projectPath === path) { + projStore.setProjectPath(null); + projStore.setMirror({ + fps: 30, + width: 1920, + height: 1080, + settingsConfigured: false, + tracks: [], + }, 0); + } + }, + validateRecents: async () => { + const list = get().recents; + if (list.length === 0) return; + + const { isTauri } = await import("../lib/api"); + if (!isTauri) return; + + try { + const { invoke } = await import("@tauri-apps/api/core"); + const valid: RecentProject[] = []; + for (const entry of list) { + try { + const exists = await invoke("check_path_exists", { path: entry.path }); + if (exists) { + valid.push(entry); + } + } catch { + // If we fail to check, fallback to keeping the project + valid.push(entry); + } + } + if (valid.length !== list.length) { + persist(valid); + set({ recents: valid }); + } + } catch (e) { + console.error("Failed to validate recents:", e); + } }, })); diff --git a/web/src/store/settingsStore.ts b/web/src/store/settingsStore.ts index 7698137..a5f0c36 100644 --- a/web/src/store/settingsStore.ts +++ b/web/src/store/settingsStore.ts @@ -8,14 +8,17 @@ */ import { create } from "zustand"; +import { isTauri } from "../lib/api"; export type Theme = "dark" | "light"; export type ByokProvider = "anthropic" | "openai" | "google"; +export type WindowSizeOpt = "standard" | "compact"; const LS = { theme: "theme", defaultImportFolder: "defaultImportFolder", byokProvider: "byokProvider", + windowSize: "windowSize", } as const; function loadTheme(): Theme { @@ -30,6 +33,10 @@ function loadProvider(): ByokProvider { const v = loadString(LS.byokProvider); return v === "openai" || v === "google" ? v : "anthropic"; } +function loadWindowSize(): WindowSizeOpt { + if (typeof localStorage === "undefined") return "standard"; + return localStorage.getItem(LS.windowSize) === "compact" ? "compact" : "standard"; +} function persist(key: string, value: string | null) { if (typeof localStorage === "undefined") return; if (value === null) localStorage.removeItem(key); @@ -40,15 +47,18 @@ interface SettingsState { theme: Theme; defaultImportFolder: string | null; byokProvider: ByokProvider; + windowSize: WindowSizeOpt; setTheme: (theme: Theme) => void; setDefaultImportFolder: (path: string | null) => void; setByokProvider: (provider: ByokProvider) => void; + setWindowSize: (size: WindowSizeOpt) => void; } export const useSettingsStore = create((set) => ({ theme: loadTheme(), defaultImportFolder: loadString(LS.defaultImportFolder), byokProvider: loadProvider(), + windowSize: loadWindowSize(), setTheme: (theme) => { persist(LS.theme, theme); applyTheme(theme); @@ -62,6 +72,11 @@ export const useSettingsStore = create((set) => ({ persist(LS.byokProvider, byokProvider); set({ byokProvider }); }, + setWindowSize: (windowSize) => { + persist(LS.windowSize, windowSize); + void applyWindowSize(windowSize); + set({ windowSize }); + }, })); /** Reflect the theme onto the document root so tokens can switch on it. */ @@ -71,7 +86,42 @@ export function applyTheme(theme: Theme): void { } } -/** Apply the persisted theme at startup. */ +/** Apply the window size (width: 1600x1000 or 1066x666 centered) dynamically in Tauri. */ +export async function applyWindowSize(size: WindowSizeOpt): Promise { + if (!isTauri) return; + try { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const { LogicalSize, LogicalPosition } = await import("@tauri-apps/api/dpi"); + const win = getCurrentWindow(); + const factor = await win.scaleFactor(); + + const targetWidth = size === "compact" ? 1066 : 1600; + const targetHeight = size === "compact" ? 666 : 1000; + + const physicalSize = await win.innerSize(); + const logicalSize = physicalSize.toLogical(factor); + + const physicalPos = await win.outerPosition(); + const logicalPos = physicalPos.toLogical(factor); + + const dw = logicalSize.width - targetWidth; + const dh = logicalSize.height - targetHeight; + + const newX = logicalPos.x + dw / 2; + const newY = logicalPos.y + dh / 2; + + await win.setPosition(new LogicalPosition(newX, newY)); + await win.setSize(new LogicalSize(targetWidth, targetHeight)); + } catch (e) { + console.error("Failed to apply window size:", e); + } +} + +/** Apply the persisted theme and window size at startup. */ export function initTheme(): void { applyTheme(useSettingsStore.getState().theme); } + +export function initWindowSize(): void { + void applyWindowSize(useSettingsStore.getState().windowSize); +} diff --git a/web/src/store/uiStore.test.ts b/web/src/store/uiStore.test.ts new file mode 100644 index 0000000..7c6766c --- /dev/null +++ b/web/src/store/uiStore.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { Timeline } from "../lib/types"; +import { useProjectStore } from "./projectStore"; +import { useEditorUiStore } from "./uiStore"; + +const timeline: Timeline = { + fps: 30, + width: 1920, + height: 1080, + settingsConfigured: true, + tracks: [ + { + id: "v1", + type: "video", + muted: false, + hidden: false, + syncLocked: true, + clips: [ + { + id: "c1", + mediaRef: "m1", + mediaType: "video", + sourceClipType: "video", + startFrame: 0, + durationFrames: 300, + trimStartFrame: 0, + trimEndFrame: 300, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "smooth", + fadeOutInterpolation: "smooth", + opacity: 1, + transform: { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + }, + ], + }, + ], +}; + +describe("timeline playback state", () => { + beforeEach(() => { + useProjectStore.setState({ timeline, timelineVersion: 1 }); + useEditorUiStore.setState({ + currentFrame: 0, + activeFrame: 0, + isPlaying: false, + isScrubbing: false, + previewMediaId: null, + }); + }); + + it("commits the active playhead frame immediately when pausing", () => { + useEditorUiStore.setState({ currentFrame: 0, activeFrame: 42, isPlaying: true }); + + useEditorUiStore.getState().togglePlay(); + + const state = useEditorUiStore.getState(); + expect(state.isPlaying).toBe(false); + expect(state.activeFrame).toBe(42); + expect(state.currentFrame).toBe(42); + }); + + it("clears a stale scrub gesture when starting playback", () => { + useEditorUiStore.setState({ activeFrame: 42, isScrubbing: true }); + + useEditorUiStore.getState().togglePlay(); + + const state = useEditorUiStore.getState(); + expect(state.isPlaying).toBe(true); + expect(state.isScrubbing).toBe(false); + }); + + it("clears a stale scrub gesture when pausing playback", () => { + useEditorUiStore.setState({ activeFrame: 42, isPlaying: true, isScrubbing: true }); + + useEditorUiStore.getState().togglePlay(); + + const state = useEditorUiStore.getState(); + expect(state.isPlaying).toBe(false); + expect(state.isScrubbing).toBe(false); + expect(state.activeFrame).toBe(42); + expect(state.currentFrame).toBe(42); + }); +}); diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 768bfb5..21bdafe 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -52,10 +52,16 @@ function persist(key: string, value: string) { if (typeof localStorage !== "undefined") localStorage.setItem(key, value); } +function settledFrame(frame: number): number { + return Math.max(0, Math.round(frame)); +} + interface UiState { // Top-level navigation view: AppView; setView: (view: AppView) => void; + settingsOpen: boolean; + setSettingsOpen: (open: boolean) => void; // Playback / playhead currentFrame: number; @@ -103,6 +109,14 @@ interface UiState { // Media panel navigation mediaPanelCurrentFolderId: string | null; + setMediaPanelCurrentFolderId: (id: string | null) => void; + + /** 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; @@ -143,11 +157,18 @@ 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) => ({ view: "home", setView: (view) => set({ view }), + settingsOpen: false, + setSettingsOpen: (settingsOpen) => set({ settingsOpen }), currentFrame: 0, activeFrame: 0, @@ -185,14 +206,26 @@ export const useEditorUiStore = create((set, get) => ({ previewActiveTabId: "timeline", mediaPanelCurrentFolderId: null, + setMediaPanelCurrentFolderId: (mediaPanelCurrentFolderId) => set({ mediaPanelCurrentFolderId }), + + pendingSwapClipId: null, + setPendingSwapClipId: (pendingSwapClipId) => set({ pendingSwapClipId }), setActiveFrame: (activeFrame) => set({ activeFrame }), setCurrentFrame: (currentFrame) => set({ currentFrame, activeFrame: currentFrame }), - setPlaying: (isPlaying) => set({ isPlaying }), + setPlaying: (isPlaying) => { + if (isPlaying) { + set({ isPlaying: true, isScrubbing: false }); + return; + } + const frame = settledFrame(get().activeFrame); + set({ currentFrame: frame, activeFrame: frame, isPlaying: false, isScrubbing: false }); + }, togglePlay: () => { const { isPlaying, activeFrame } = get(); if (isPlaying) { - set({ isPlaying: false }); + const frame = settledFrame(activeFrame); + set({ currentFrame: frame, activeFrame: frame, isPlaying: false, isScrubbing: false }); return; } // Starting playback: if parked at/after the last drawable frame (where the @@ -200,9 +233,9 @@ export const useEditorUiStore = create((set, get) => ({ // very first tick and stall play. Without media there's nothing to rewind. const last = Math.max(0, totalFrames(useProjectStore.getState().timeline) - 1); if (activeFrame >= last) { - set({ currentFrame: 0, activeFrame: 0, isPlaying: true }); + set({ currentFrame: 0, activeFrame: 0, isPlaying: true, isScrubbing: false }); } else { - set({ isPlaying: true }); + set({ isPlaying: true, isScrubbing: false }); } }, setScrubbing: (isScrubbing) => set({ isScrubbing }), @@ -272,4 +305,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 }), }));