diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 28e854d..94b5343 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -721,7 +721,7 @@ impl Dispatcher { let Some(transform_patch) = a.transform else { let res = self.apply(EditCommand::SetClipProperties { clip_ids, - properties, + properties: Box::new(properties), })?; return Ok(ToolResult::ok(res.summary)); }; @@ -746,7 +746,7 @@ impl Dispatcher { for (clip_id, clip_properties) in per_clip { let res = self.apply(EditCommand::SetClipProperties { clip_ids: vec![clip_id], - properties: clip_properties, + properties: Box::new(clip_properties), })?; summaries.push(res.summary); } diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index d13cd7f..60265f9 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -134,6 +134,9 @@ pub struct ClipProperties { pub opacity: Option, pub transform: Option, pub text_content: Option, + /// Text style for a text clip (font / size / color / alignment / shadow / + /// background / border). Replaces the clip's whole `text_style`. + pub text_style: Option, /// Per-clip crop insets (normalized 0–1). Setting this clears `crop_track`. pub crop: Option, /// Fade-in length in frames. Setting this clamps to the clip duration. @@ -204,9 +207,12 @@ pub enum EditCommand { /// Overwrite-style trim: resize clips in place from new source-frame trims. TrimClips { edits: Vec }, /// Assign clip properties (timing changes propagate to linked partners). + /// `properties` is boxed: it carries a full `TextStyle`, which would + /// otherwise make this the dominant `EditCommand` variant (the enum is + /// `Clone`d on every undo snapshot path). SetClipProperties { clip_ids: Vec, - properties: ClipProperties, + properties: Box, }, /// Replace (or clear) a clip's keyframe track for one property. SetKeyframes { @@ -394,7 +400,7 @@ pub fn apply( EditCommand::SetClipProperties { clip_ids, properties, - } => set_clip_properties(state, clip_ids, properties), + } => set_clip_properties(state, clip_ids, *properties), EditCommand::SetKeyframes { clip_id, property, @@ -1056,6 +1062,9 @@ fn apply_property_changes( if let Some(c) = &props.text_content { clip.text_content = Some(c.clone()); } + if let Some(s) = &props.text_style { + clip.text_style = Some(s.clone()); + } } fn set_keyframes( @@ -2842,3 +2851,95 @@ mod duplicate_clips_tests { assert_eq!(state.version(), version_before + 2); // commit + undo } } + +#[cfg(test)] +mod text_style_property_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{Clip, ClipType, TextAlignment, TextStyle, Track}; + + fn state_with_text_clip() -> EditorState { + let mut tl = Timeline::new(); + let mut t = Track::new("v1", ClipType::Video); + let mut clip = Clip::new("c1", "", 0, 30); + clip.media_type = ClipType::Text; + clip.source_clip_type = ClipType::Text; + clip.text_content = Some("Hi".into()); + clip.text_style = Some(TextStyle::default()); + t.clips.push(clip); + tl.tracks.push(t); + EditorState::from_timeline(tl) + } + + #[test] + fn set_text_style_replaces_clip_style_and_is_undoable() { + let mut state = state_with_text_clip(); + let ids = SeqIdGen::default(); + let version_before = state.version(); + + let style = TextStyle { + font_name: "Times-Bold".into(), + font_size: 48.0, + alignment: TextAlignment::Left, + ..Default::default() + }; + let res = apply( + &mut state, + EditCommand::SetClipProperties { + clip_ids: vec!["c1".into()], + properties: Box::new(ClipProperties { + text_style: Some(style.clone()), + ..Default::default() + }), + }, + &ids, + ) + .unwrap(); + + assert!(res.changed); + let applied = state.timeline.tracks[0].clips[0] + .text_style + .as_ref() + .expect("text_style present"); + assert_eq!(applied.font_name, "Times-Bold"); + assert_eq!(applied.font_size, 48.0); + assert_eq!(applied.alignment, TextAlignment::Left); + + // Undo restores the original default style. + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + let restored = state.timeline.tracks[0].clips[0] + .text_style + .as_ref() + .expect("text_style present"); + assert_eq!(restored.font_name, "Helvetica-Bold"); + assert_eq!(state.version(), version_before + 2); // commit + undo + } + + #[test] + fn set_text_style_alongside_text_content() { + let mut state = state_with_text_clip(); + let ids = SeqIdGen::default(); + + let style = TextStyle { + font_size: 120.0, + ..Default::default() + }; + apply( + &mut state, + EditCommand::SetClipProperties { + clip_ids: vec!["c1".into()], + properties: Box::new(ClipProperties { + text_content: Some("Updated".into()), + text_style: Some(style), + ..Default::default() + }), + }, + &ids, + ) + .unwrap(); + + let clip = &state.timeline.tracks[0].clips[0]; + assert_eq!(clip.text_content.as_deref(), Some("Updated")); + assert_eq!(clip.text_style.as_ref().unwrap().font_size, 120.0); + } +} diff --git a/crates/opentake-ops/src/intent.rs b/crates/opentake-ops/src/intent.rs index 77a1277..b88d426 100644 --- a/crates/opentake-ops/src/intent.rs +++ b/crates/opentake-ops/src/intent.rs @@ -230,11 +230,11 @@ pub fn plan_smart_reframe( "smart_reframe", vec![EditCommand::SetClipProperties { clip_ids: clip_ids.to_vec(), - properties: ClipProperties { + properties: Box::new(ClipProperties { crop: Some(crop), transform, ..Default::default() - }, + }), }], )) } diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index a49281b..f84f809 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -562,10 +562,10 @@ fn set_clip_properties_propagates_timing_to_linked_partner() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["v1".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { duration_frames: Some(40), ..Default::default() - }, + }), }, &g, ) @@ -593,10 +593,10 @@ fn set_clip_properties_scalar_clears_keyframe_track() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["c".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { opacity: Some(0.5), ..Default::default() - }, + }), }, &g, ) @@ -627,7 +627,7 @@ fn set_clip_properties_crop_sets_and_clears_track() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["c".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { crop: Some(opentake_domain::Crop { left: 0.2, top: 0.1, @@ -635,7 +635,7 @@ fn set_clip_properties_crop_sets_and_clears_track() { bottom: 0.0, }), ..Default::default() - }, + }), }, &g, ) @@ -655,13 +655,13 @@ fn set_clip_properties_fade_sets_frames_and_interpolation() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["c".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { fade_in_frames: Some(10), fade_out_frames: Some(15), fade_in_interpolation: Some(Interpolation::Smooth), fade_out_interpolation: Some(Interpolation::Hold), ..Default::default() - }, + }), }, &g, ) @@ -683,10 +683,10 @@ fn set_clip_properties_fade_clamps_to_duration() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["c".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { fade_in_frames: Some(100), ..Default::default() - }, + }), }, &g, ) @@ -704,11 +704,11 @@ fn set_clip_properties_flip_writes_to_transform() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["c".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { flip_horizontal: Some(true), flip_vertical: Some(true), ..Default::default() - }, + }), }, &g, ) @@ -726,7 +726,7 @@ fn set_clip_properties_multiple_fields_at_once() { &mut st, EditCommand::SetClipProperties { clip_ids: vec!["c".into()], - properties: ClipProperties { + properties: Box::new(ClipProperties { crop: Some(opentake_domain::Crop { left: 0.1, top: 0.2, @@ -738,7 +738,7 @@ fn set_clip_properties_multiple_fields_at_once() { flip_horizontal: Some(true), opacity: Some(0.8), ..Default::default() - }, + }), }, &g, ) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e4c50a9..075895b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -163,7 +163,9 @@ pub enum EditRequest { #[serde(rename_all = "camelCase")] SetClipProperties { clip_ids: Vec, - properties: ClipPropertiesDto, + // Boxed to keep `EditRequest` small: `ClipPropertiesDto` carries a full + // `TextStyle`, which would otherwise dominate the enum size. + properties: Box, }, #[serde(rename_all = "camelCase")] SetKeyframes { @@ -304,7 +306,7 @@ impl EditRequest { properties, } => EditCommand::SetClipProperties { clip_ids, - properties: properties.into_properties(), + properties: Box::new((*properties).into_properties()), }, EditRequest::SetKeyframes { clip_id, @@ -540,6 +542,8 @@ pub struct ClipPropertiesDto { #[serde(default)] pub text_content: Option, #[serde(default)] + pub text_style: Option, + #[serde(default)] pub crop: Option, #[serde(default)] pub fade_in_frames: Option, @@ -566,6 +570,7 @@ impl ClipPropertiesDto { opacity: self.opacity, transform: self.transform, text_content: self.text_content, + text_style: self.text_style, crop: self.crop, fade_in_frames: self.fade_in_frames, fade_out_frames: self.fade_out_frames, @@ -739,6 +744,30 @@ mod edit_request_serde_tests { .expect("rippleDeleteClips camelCase"); } + #[test] + fn deserializes_set_clip_properties_with_text_style() { + // The Inspector sends camelCase `textStyle` with nested camelCase fields + // (fontName/fontSize/…). It must deserialize and map onto the command's + // ClipProperties.text_style. + let request = serde_json::from_str::( + r#"{"type":"setClipProperties","clipIds":["c1"],"properties":{"textStyle":{"fontName":"Times-Bold","fontSize":48,"alignment":"left"}}}"#, + ) + .expect("setClipProperties with textStyle camelCase"); + + match request.into_command().expect("setClipProperties command") { + EditCommand::SetClipProperties { + clip_ids, + properties, + } => { + assert_eq!(clip_ids, vec!["c1"]); + let style = properties.text_style.expect("text_style present"); + assert_eq!(style.font_name, "Times-Bold"); + assert_eq!(style.font_size, 48.0); + } + other => panic!("expected SetClipProperties, got {other:?}"), + } + } + #[test] fn deserializes_swap_media_and_maps_to_command() { let request = serde_json::from_str::( diff --git a/web/src/components/inspector/TextTab.tsx b/web/src/components/inspector/TextTab.tsx index edd1c43..87f1253 100644 --- a/web/src/components/inspector/TextTab.tsx +++ b/web/src/components/inspector/TextTab.tsx @@ -1,53 +1,382 @@ /** - * TextTab (SPEC §6.3). Inspector tab for text clips. MVP: edits `textContent` - * only — `textStyle` (fontSize/color/align) requires a backend extension to - * `ClipPropertiesReq` and is left for a follow-up. Commits on blur via - * SetClipProperties. + * TextTab (SPEC §6.3). Inspector tab for text clips. Edits `textContent` (text + * box) plus the full `textStyle` (font / size / color / alignment / background / + * border / shadow). Text commits on blur; style controls commit immediately via + * SetClipProperties (the backend writes `clip.text_style`, the render layer + * re-rasterizes the text box on the next `timeline_changed`). */ import { useEffect, useState } from "react"; +import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react"; import * as edit from "../../store/editActions"; +import { Icon } from "../ui/Icon"; +import { ScrubbableNumberField } from "./ScrubbableNumberField"; +import { RADIUS, SPACE } from "../../lib/theme"; import type { TFunction } from "../../i18n"; -import type { Clip } from "../../lib/types"; +import type { Clip, Rgba, TextAlignment, TextStyle } from "../../lib/types"; + +const COLOR_SWATCH_SIZE = SPACE.lgXl; + +/** Same default as `DEFAULT_TEXT_STYLE` in editActions / domain `TextStyle`. */ +function completeTextStyle(style: TextStyle | undefined): TextStyle { + return { + fontName: style?.fontName ?? "Helvetica-Bold", + fontSize: style?.fontSize ?? 96, + fontScale: style?.fontScale ?? 1, + color: { r: 1, g: 1, b: 1, a: 1, ...style?.color }, + alignment: style?.alignment ?? "center", + shadow: { + enabled: style?.shadow?.enabled ?? true, + color: { r: 0, g: 0, b: 0, a: 0.6, ...style?.shadow?.color }, + offsetX: style?.shadow?.offsetX ?? 0, + offsetY: style?.shadow?.offsetY ?? -2, + blur: style?.shadow?.blur ?? 6, + }, + background: { + enabled: style?.background?.enabled ?? false, + color: { r: 0, g: 0, b: 0, a: 0.6, ...style?.background?.color }, + }, + border: { + enabled: style?.border?.enabled ?? false, + color: { r: 0, g: 0, b: 0, a: 1, ...style?.border?.color }, + }, + }; +} + +/** A short, opinionated list of common font families. Free-text is also allowed + * so any installed system font name works (the rasterizer resolves it). */ +const FONT_OPTIONS = [ + "Helvetica-Bold", + "Helvetica", + "Arial-BoldMT", + "ArialMT", + "TimesNewRomanPS-BoldMT", + "Georgia", + "Courier-Bold", + "Verdana", +]; + +const ALIGN_ICON: Record = { + left: AlignLeft, + center: AlignCenter, + right: AlignRight, +}; export function TextTab({ clip, t }: { clip: Clip; t: TFunction }) { const [value, setValue] = useState(clip.textContent ?? ""); + const [style, setStyle] = useState(() => completeTextStyle(clip.textStyle)); - // Reset local state when the selected clip changes. + // Reset local state when the selected clip (or its persisted style) changes. useEffect(() => { setValue(clip.textContent ?? ""); }, [clip.id, clip.textContent]); + useEffect(() => { + setStyle(completeTextStyle(clip.textStyle)); + }, [clip.id, clip.textStyle]); - const commit = () => { + const commitText = () => { if (value === (clip.textContent ?? "")) return; void edit.setClipProperties([clip.id], { textContent: value }); }; + // Commit a whole new style (style edits are immediate, like the grade panel). + const commitStyle = (next: TextStyle) => { + setStyle(next); + void edit.setClipProperties([clip.id], { textStyle: next }); + }; + return ( -
-
- {t("inspector.section.text")} +
+
+ +