# Extended Caption Styling Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add system font selection, drop shadow controls, configurable dimmed text color, and background toggle to the shared caption settings, wired through to both preview and burn-in export. **Architecture:** Extend `CaptionSettings` (frontend) and `CaptionStyle` (backend) with new fields. Add a `list_system_fonts` Tauri command using `fc-list`. Update the settings panel UI, the ASS generation code, and the preview caption rendering. **Tech Stack:** Svelte 5, Rust/Tauri v2, ASS subtitle format, fontconfig (`fc-list`) ## Global Constraints - Background and shadow are mutually exclusive: enabling one disables the other - `fc-list` fallback: if unavailable, use preset list: Arial, Helvetica, Verdana, Georgia, Times New Roman, Courier New, Impact - Dimmed color auto mode derives from `textColor` + `dimmedOpacity`; custom mode uses `dimmedColor` directly - ASS `BackColour` serves as background color (when background ON) or shadow color (when shadow ON) - All new fields must have backwards-compatible defaults (existing saved preferences without new fields should merge cleanly via `{ ...DEFAULT_CAPTION_SETTINGS, ...savedCaptions }`) - No new crate dependencies --- ### Task 1: Data model — add new fields to frontend and backend **Files:** - Modify: `src/lib/stores/preferences.svelte.ts:6-27` - Modify: `src-tauri/src/models.rs:91-108` - Modify: `src/lib/bindings/export.ts:3-10` - Modify: `src/lib/components/ExportDialog.svelte:86-94` **Interfaces:** - Consumes: Existing `CaptionSettings`, `CaptionStyle`, `CaptionStyle` (TS binding) - Produces: Extended versions of all three with new fields. All later tasks depend on these types. - [ ] **Step 1: Update `CaptionSettings` interface and defaults** In `src/lib/stores/preferences.svelte.ts`, replace the interface and defaults: ```typescript export interface CaptionSettings { fontFamily: string; fontSize: number; textColor: string; bold: boolean; outlineColor: string; backgroundEnabled: boolean; backgroundOpacity: number; backgroundColor: string; textOutline: boolean; shadowEnabled: boolean; shadowDepth: number; shadowColor: string; dimmedColorMode: 'auto' | 'custom'; dimmedOpacity: number; dimmedColor: string; position: 'bottom' | 'top'; wordHighlight: boolean; } export const DEFAULT_CAPTION_SETTINGS: CaptionSettings = { fontFamily: 'Arial', fontSize: 18, textColor: '#ffffff', bold: true, outlineColor: '#000000', backgroundEnabled: true, backgroundOpacity: 0.75, backgroundColor: '#000000', textOutline: true, shadowEnabled: false, shadowDepth: 2, shadowColor: '#000000', dimmedColorMode: 'auto', dimmedOpacity: 0.4, dimmedColor: '#999999', position: 'bottom', wordHighlight: true, }; ``` - [ ] **Step 2: Update `CaptionStyle` struct in Rust** In `src-tauri/src/models.rs`, replace the struct: ```rust #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CaptionStyle { pub font_family: String, pub font_size: u32, pub text_color: String, pub bold: bool, pub outline_color: String, pub background_enabled: bool, pub background_opacity: f64, pub background_color: String, pub text_outline: bool, pub shadow_enabled: bool, pub shadow_depth: u32, pub shadow_color: String, pub dimmed_color_mode: String, pub dimmed_opacity: f64, pub dimmed_color: String, pub position: String, pub word_highlight: bool, } ``` - [ ] **Step 3: Update TypeScript `CaptionStyle` binding** In `src/lib/bindings/export.ts`, replace the interface: ```typescript export interface CaptionStyle { fontFamily: string; fontSize: number; textColor: string; bold: boolean; outlineColor: string; backgroundEnabled: boolean; backgroundOpacity: number; backgroundColor: string; textOutline: boolean; shadowEnabled: boolean; shadowDepth: number; shadowColor: string; dimmedColorMode: string; dimmedOpacity: number; dimmedColor: string; position: string; wordHighlight: boolean; } ``` - [ ] **Step 4: Update ExportDialog passthrough** In `src/lib/components/ExportDialog.svelte`, replace the `captionStyle` construction: ```typescript captionStyle: (burnInCaptions && includeCaptions && hasCaptions) ? { fontFamily: preferences.captionSettings.fontFamily, fontSize: preferences.captionSettings.fontSize, textColor: preferences.captionSettings.textColor, bold: preferences.captionSettings.bold, outlineColor: preferences.captionSettings.outlineColor, backgroundEnabled: preferences.captionSettings.backgroundEnabled, backgroundOpacity: preferences.captionSettings.backgroundOpacity, backgroundColor: preferences.captionSettings.backgroundColor, textOutline: preferences.captionSettings.textOutline, shadowEnabled: preferences.captionSettings.shadowEnabled, shadowDepth: preferences.captionSettings.shadowDepth, shadowColor: preferences.captionSettings.shadowColor, dimmedColorMode: preferences.captionSettings.dimmedColorMode, dimmedOpacity: preferences.captionSettings.dimmedOpacity, dimmedColor: preferences.captionSettings.dimmedColor, position: preferences.captionSettings.position, wordHighlight: preferences.captionSettings.wordHighlight, } : null, ``` - [ ] **Step 5: Build and test** Run: `cd src-tauri && cargo build 2>&1 | tail -5` Expected: Clean build (warnings OK if pre-existing). Run: `cd src-tauri && cargo test --lib services::clip_exporter::tests -- --nocapture 2>&1 | tail -5` Expected: All 23 tests pass (they don't construct CaptionStyle directly). - [ ] **Step 6: Commit** ```bash git add src/lib/stores/preferences.svelte.ts src-tauri/src/models.rs src/lib/bindings/export.ts src/lib/components/ExportDialog.svelte git commit -m "feat(captions): extend data model with font, shadow, dimmed color, bg toggle fields" ``` --- ### Task 2: System font detection — add `list_system_fonts` command **Files:** - Modify: `src-tauri/src/commands/media_analysis.rs` (add new command) - Modify: `src-tauri/src/lib.rs:31-46` (register command) - Modify: `src/lib/bindings/mediaAnalysis.ts` (add TS binding) **Interfaces:** - Consumes: `fc-list` CLI tool (from fontconfig) - Produces: `list_system_fonts() -> Result, String>` (Rust), `listSystemFonts(): Promise` (TS) - [ ] **Step 1: Add the Rust command** Append to `src-tauri/src/commands/media_analysis.rs`: ```rust #[tauri::command] pub async fn list_system_fonts() -> Result, String> { tokio::task::spawn_blocking(|| { // Try fc-list first (available if fontconfig is installed, which ffmpeg depends on) let output = std::process::Command::new("fc-list") .args([":lang=en", "family"]) .output(); match output { Ok(out) if out.status.success() => { let text = String::from_utf8_lossy(&out.stdout); let mut families: Vec = text .lines() .flat_map(|line| line.split(',')) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); families.sort_unstable(); families.dedup(); Ok(families) } _ => { // Fallback: common cross-platform fonts Ok(vec![ "Arial".to_string(), "Comic Sans MS".to_string(), "Courier New".to_string(), "Georgia".to_string(), "Helvetica".to_string(), "Impact".to_string(), "Times New Roman".to_string(), "Verdana".to_string(), ]) } } }) .await .map_err(|e| format!("Font detection failed: {e}"))? } ``` - [ ] **Step 2: Register the command in `lib.rs`** In `src-tauri/src/lib.rs`, add `media_analysis::list_system_fonts` to the `generate_handler!` macro, after `media_analysis::check_embedded_subtitles`: ```rust media_analysis::check_embedded_subtitles, media_analysis::list_system_fonts, ``` - [ ] **Step 3: Add the TS binding** Append to `src/lib/bindings/mediaAnalysis.ts`: ```typescript export async function listSystemFonts(): Promise { return invoke('list_system_fonts'); } ``` - [ ] **Step 4: Build and test** Run: `cd src-tauri && cargo build 2>&1 | tail -5` Expected: Clean build. - [ ] **Step 5: Commit** ```bash git add src-tauri/src/commands/media_analysis.rs src-tauri/src/lib.rs src/lib/bindings/mediaAnalysis.ts git commit -m "feat(captions): add list_system_fonts command with fc-list + fallback" ``` --- ### Task 3: ASS generation — wire all new fields into burn-in subtitle output **Files:** - Modify: `src-tauri/src/services/clip_exporter.rs:549-584` **Interfaces:** - Consumes: `CaptionStyle` struct from Task 1 (with all new fields) - Produces: Updated `vtt_to_ass_with_karaoke()` that uses font family, background/shadow mutual exclusivity, and dimmed color settings - [ ] **Step 1: Replace the style extraction block in `vtt_to_ass_with_karaoke`** In `src-tauri/src/services/clip_exporter.rs`, replace lines 550-566 (the style extraction block) with: ```rust // Font let font_name = style.map_or("Arial".to_string(), |s| s.font_family.clone()); let font_size = style.map_or(50, |s| (s.font_size as f64 * 2.75).round() as u32); let bold_flag: i32 = if style.map_or(true, |s| s.bold) { -1 } else { 0 }; // Colors let primary_colour = style.map_or("&H00FFFFFF".to_string(), |s| hex_to_ass_color(&s.text_color)); let outline_colour = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.outline_color)); // Dimmed color (SecondaryColour) — auto derives from text color, custom uses direct color let secondary_colour = style.map_or("&H73CCCCCC".to_string(), |s| { if s.dimmed_color_mode == "custom" { hex_to_ass_color(&s.dimmed_color) } else { let alpha = ((1.0 - s.dimmed_opacity.clamp(0.0, 1.0)) * 255.0).round() as u8; hex_to_ass_color_with_alpha(&s.text_color, alpha) } }); // Background/Shadow mutual exclusivity let bg_enabled = style.map_or(true, |s| s.background_enabled); let shadow_enabled = style.map_or(false, |s| s.shadow_enabled); let text_outline = style.map_or(true, |s| s.text_outline); let (border_style, outline_val, shadow_val, back_colour) = if bg_enabled { // Background mode: box + optional outline, no shadow let bc = style.map_or("&H80000000".to_string(), |s| { let alpha = ((1.0 - s.background_opacity.clamp(0.0, 1.0)) * 255.0).round() as u8; hex_to_ass_color_with_alpha(&s.background_color, alpha) }); let bs = if text_outline { 4 } else { 3 }; let ol = if text_outline { 2 } else { 0 }; (bs, ol, 0, bc) } else if shadow_enabled { // Shadow mode: outline + shadow, no box let sc = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.shadow_color)); let sd = style.map_or(2, |s| s.shadow_depth) as i32; let ol = if text_outline { 2 } else { 0 }; (1, ol, sd, sc) } else { // Neither: just outline if enabled let ol = if text_outline { 2 } else { 0 }; (1, ol, 0, "&HFF000000".to_string()) // fully transparent back }; let word_highlight = style.map_or(true, |s| s.word_highlight); let margin_v: i32 = 40; ``` - [ ] **Step 2: Update the Style format string to use `font_name`** Replace the Style line (currently around line 580-584): ```rust output.push_str(&format!( "Style: Default,{},{},{},{},{},{},{},0,0,0,100,100,0,0,{},{},{},2,20,20,{},1\n", font_name, font_size, primary_colour, secondary_colour, outline_colour, back_colour, bold_flag, border_style, outline_val, shadow_val, margin_v )); ``` - [ ] **Step 3: Run tests** Run: `cd src-tauri && cargo test --lib services::clip_exporter::tests -- --nocapture 2>&1 | tail -10` Expected: All 23 tests pass. Run: `cd src-tauri && cargo build 2>&1 | tail -5` Expected: Clean build. - [ ] **Step 4: Commit** ```bash git add src-tauri/src/services/clip_exporter.rs git commit -m "feat(captions): wire font, shadow, dimmed color, bg toggle into ASS generation" ``` --- ### Task 4: Preview captions — use new settings in VideoPlayer **Files:** - Modify: `src/lib/components/VideoPlayer.svelte:29-40, 184-189` **Interfaces:** - Consumes: `CaptionSettings` from Task 1 (via `preferences.captionSettings`) - Produces: Updated preview caption rendering using font, background toggle, shadow, and dimmed color settings - [ ] **Step 1: Update the `captionStyle` derived** In `src/lib/components/VideoPlayer.svelte`, replace the `captionStyle` derived block (lines 29-36): ```typescript let captionStyle = $derived.by(() => { const s = preferences.captionSettings; const bg = s.backgroundEnabled ? `rgba(${hexToRgb(s.backgroundColor)}, ${s.backgroundOpacity})` : 'transparent'; const shadow = s.shadowEnabled ? `${s.shadowDepth}px ${s.shadowDepth}px ${s.shadowDepth * 2}px ${s.shadowColor}` : s.textOutline ? '1px 1px 2px rgba(0,0,0,0.9), -1px -1px 2px rgba(0,0,0,0.9)' : 'none'; return { fontFamily: s.fontFamily, fontSize: `${s.fontSize}px`, fontWeight: s.bold ? 'bold' : 'normal', color: s.textColor, background: bg, textShadow: shadow, }; }); ``` - [ ] **Step 2: Add the `hexToRgb` helper** Add this helper function at the top of the `

Caption Appearance

update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })} />
update({ textColor: (e.target as HTMLInputElement).value })} />
{#if settings.dimmedColorMode === 'auto'}
update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })} />
{:else}
update({ dimmedColor: (e.target as HTMLInputElement).value })} />
{/if}
update({ outlineColor: (e.target as HTMLInputElement).value })} />
update({ backgroundColor: (e.target as HTMLInputElement).value })} />
{#if settings.backgroundEnabled}
update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })} />
{/if}
update({ shadowColor: (e.target as HTMLInputElement).value })} />
{#if settings.shadowEnabled}
update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })} />
{/if}
``` - [ ] **Step 2: Build check** Run: `npm run check 2>&1 | grep "Error:" | head -10` Expected: Only pre-existing errors (node:path, node:process, node:url, overload). No new errors. - [ ] **Step 3: Commit** ```bash git add src/lib/components/CaptionSettingsPanel.svelte git commit -m "feat(captions): expand settings panel with font, shadow, dimmed color, bg toggle controls" ``` --- ### Task 6: Build, test, and verify **Files:** - No new changes (verification only) **Interfaces:** - Consumes: All changes from Tasks 1-5 - [ ] **Step 1: Full Rust build and test** Run: `cd src-tauri && cargo build 2>&1 | tail -5` Expected: Clean build. Run: `cd src-tauri && cargo test --lib services::clip_exporter::tests -- --nocapture 2>&1 | tail -10` Expected: All tests pass. - [ ] **Step 2: Frontend type check** Run: `npm run check 2>&1 | grep "Error:" | head -10` Expected: Only pre-existing errors. No new errors from caption changes. - [ ] **Step 3: Manual smoke test** Run: `cargo tauri dev` Verify: 1. Caption settings panel opens, shows font dropdown (populated with system fonts) 2. Selecting a different font updates the preview captions 3. Enabling shadow disables background, and vice versa 4. Dimmed color auto mode shows opacity slider; custom mode shows color picker 5. Export a short clip with burn-in and verify the ASS uses the selected font and style settings