Includes: - Extended caption styling (font, shadow, dimmed color, bg toggle) - Media server, subtitle downloader, VTT parser, processing modal - Waveform tiers, thumbnail/timeline improvements, transport controls - Hybrid download model, dependency management, clip export enhancements - 21 chat summaries, 2 implementation plans, 2 design specs Co-authored-by: Cursor <cursoragent@cursor.com>
859 lines
28 KiB
Markdown
859 lines
28 KiB
Markdown
# 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<Vec<String>, String>` (Rust), `listSystemFonts(): Promise<string[]>` (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<Vec<String>, 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<String> = 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<string[]> {
|
|
return invoke<string[]>('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 `<script>` block (after imports):
|
|
|
|
```typescript
|
|
function hexToRgb(hex: string): string {
|
|
const h = hex.replace('#', '');
|
|
const r = parseInt(h.substring(0, 2), 16);
|
|
const g = parseInt(h.substring(2, 4), 16);
|
|
const b = parseInt(h.substring(4, 6), 16);
|
|
return `${r}, ${g}, ${b}`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update the dimmed word color in the template**
|
|
|
|
In the caption rendering section (around line 184-189), update the unspoken word styling:
|
|
|
|
Replace:
|
|
```svelte
|
|
style="color: {word.spoken ? captionStyle.color : captionStyle.color}; opacity: {word.spoken ? 1 : 0.4};"
|
|
```
|
|
|
|
With:
|
|
```svelte
|
|
style="color: {word.spoken
|
|
? captionStyle.color
|
|
: preferences.captionSettings.dimmedColorMode === 'custom'
|
|
? preferences.captionSettings.dimmedColor
|
|
: captionStyle.color}; opacity: {word.spoken ? 1 : (preferences.captionSettings.dimmedColorMode === 'custom' ? 1 : preferences.captionSettings.dimmedOpacity)};"
|
|
```
|
|
|
|
- [ ] **Step 4: Update template references to use new properties**
|
|
|
|
`$derived.by` still produces a reactive value, so all template references remain `captionStyle.X` (unchanged from before). Add `font-family` and `font-weight` to the inline styles on `.caption-text`:
|
|
|
|
Replace the `style` attribute on `.caption-text`:
|
|
```svelte
|
|
style="font-family: {captionStyle.fontFamily}; font-size: {captionStyle.fontSize}; font-weight: {captionStyle.fontWeight}; background: {captionStyle.background}; text-shadow: {captionStyle.textShadow};"
|
|
```
|
|
|
|
And the non-karaoke span:
|
|
```svelte
|
|
<span style="color: {captionStyle.color};">{cue.text}</span>
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/components/VideoPlayer.svelte
|
|
git commit -m "feat(captions): use font, shadow, dimmed color, bg toggle in preview captions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Settings panel UI — add all new controls
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/components/CaptionSettingsPanel.svelte`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CaptionSettings` from Task 1, `listSystemFonts()` from Task 2
|
|
- Produces: Full settings panel with all controls
|
|
|
|
- [ ] **Step 1: Rewrite the panel component**
|
|
|
|
Replace the entire contents of `src/lib/components/CaptionSettingsPanel.svelte` with the expanded panel. The component should:
|
|
|
|
1. Import `listSystemFonts` from `$lib/bindings/mediaAnalysis`
|
|
2. Call `listSystemFonts()` on mount, store result in `let systemFonts = $state<string[]>(['Arial'])`
|
|
3. Use `onMount` to trigger the font loading
|
|
4. Layout the controls in this order:
|
|
- **Font**: `<select>` dropdown with system fonts, each `<option>` styled with `font-family: {font}`. Font size slider. Bold checkbox.
|
|
- **Text Color**: Color picker
|
|
- **Dimmed Text**: Auto/Custom toggle (radio). When auto: opacity slider (0.1-0.9). When custom: color picker.
|
|
- **Outline**: Checkbox + color picker (picker disabled when unchecked)
|
|
- **Background**: Checkbox + color picker + opacity slider. When enabled, sets `shadowEnabled: false`.
|
|
- **Shadow**: Checkbox + depth slider (1-5) + color picker. When enabled, sets `backgroundEnabled: false`.
|
|
- **Position**: Bottom/Top radio
|
|
- **Word Highlight**: Checkbox
|
|
- **Reset to defaults** button
|
|
|
|
The panel width stays at 320px. Max-height with overflow-y scroll for smaller screens.
|
|
|
|
Full component code:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import {
|
|
preferences,
|
|
setCaptionSettings,
|
|
DEFAULT_CAPTION_SETTINGS,
|
|
type CaptionSettings,
|
|
} from '$lib/stores/preferences.svelte';
|
|
import { listSystemFonts } from '$lib/bindings/mediaAnalysis';
|
|
|
|
let { onClose }: { onClose: () => void } = $props();
|
|
|
|
let settings = $derived(preferences.captionSettings);
|
|
let systemFonts = $state<string[]>(['Arial']);
|
|
|
|
onMount(async () => {
|
|
try {
|
|
systemFonts = await listSystemFonts();
|
|
} catch {
|
|
systemFonts = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'Courier New', 'Impact'];
|
|
}
|
|
});
|
|
|
|
function update(partial: Partial<CaptionSettings>) {
|
|
// Enforce mutual exclusivity
|
|
if (partial.backgroundEnabled) {
|
|
partial.shadowEnabled = false;
|
|
} else if (partial.shadowEnabled) {
|
|
partial.backgroundEnabled = false;
|
|
}
|
|
setCaptionSettings(partial);
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
setCaptionSettings({ ...DEFAULT_CAPTION_SETTINGS });
|
|
}
|
|
</script>
|
|
|
|
<div class="panel">
|
|
<div class="panel-header">
|
|
<h3>Caption Appearance</h3>
|
|
<button class="close-btn" onclick={onClose} title="Close">x</button>
|
|
</div>
|
|
|
|
<div class="scroll-area">
|
|
<!-- Font Family -->
|
|
<div class="control">
|
|
<label>Font</label>
|
|
<select
|
|
value={settings.fontFamily}
|
|
onchange={(e) => update({ fontFamily: (e.target as HTMLSelectElement).value })}
|
|
>
|
|
{#each systemFonts as font}
|
|
<option value={font} style="font-family: {font}">{font}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Font Size -->
|
|
<div class="control">
|
|
<label>
|
|
Font Size
|
|
<span class="value">{settings.fontSize}px</span>
|
|
</label>
|
|
<input
|
|
type="range" min="12" max="36" step="1"
|
|
value={settings.fontSize}
|
|
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Text Color + Bold -->
|
|
<div class="control row">
|
|
<label>Text Color</label>
|
|
<div class="row-controls">
|
|
<input
|
|
type="color" value={settings.textColor}
|
|
oninput={(e) => update({ textColor: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
<label class="inline-check">
|
|
<input type="checkbox" checked={settings.bold}
|
|
onchange={(e) => update({ bold: (e.target as HTMLInputElement).checked })} />
|
|
Bold
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dimmed Text Color -->
|
|
<div class="control">
|
|
<label>Dimmed Text</label>
|
|
<div class="radio-row">
|
|
<label>
|
|
<input type="radio" name="dimmed-mode" value="auto"
|
|
checked={settings.dimmedColorMode === 'auto'}
|
|
onchange={() => update({ dimmedColorMode: 'auto' })} />
|
|
Auto
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="dimmed-mode" value="custom"
|
|
checked={settings.dimmedColorMode === 'custom'}
|
|
onchange={() => update({ dimmedColorMode: 'custom' })} />
|
|
Custom
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{#if settings.dimmedColorMode === 'auto'}
|
|
<div class="control">
|
|
<label>
|
|
Dim Opacity
|
|
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
|
|
</label>
|
|
<input
|
|
type="range" min="10" max="90" step="5"
|
|
value={Math.round(settings.dimmedOpacity * 100)}
|
|
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="control row">
|
|
<label>Dimmed Color</label>
|
|
<div class="row-controls">
|
|
<input
|
|
type="color" value={settings.dimmedColor}
|
|
oninput={(e) => update({ dimmedColor: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Outline -->
|
|
<div class="control row">
|
|
<label>
|
|
<input type="checkbox" checked={settings.textOutline}
|
|
onchange={(e) => update({ textOutline: (e.target as HTMLInputElement).checked })} />
|
|
Outline
|
|
</label>
|
|
<div class="row-controls">
|
|
<input
|
|
type="color" value={settings.outlineColor}
|
|
disabled={!settings.textOutline}
|
|
class:disabled={!settings.textOutline}
|
|
oninput={(e) => update({ outlineColor: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Background (mutually exclusive with Shadow) -->
|
|
<div class="control row">
|
|
<label>
|
|
<input type="checkbox" checked={settings.backgroundEnabled}
|
|
onchange={(e) => update({ backgroundEnabled: (e.target as HTMLInputElement).checked })} />
|
|
Background
|
|
</label>
|
|
<div class="row-controls">
|
|
<input
|
|
type="color" value={settings.backgroundColor}
|
|
disabled={!settings.backgroundEnabled}
|
|
class:disabled={!settings.backgroundEnabled}
|
|
oninput={(e) => update({ backgroundColor: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{#if settings.backgroundEnabled}
|
|
<div class="control">
|
|
<label>
|
|
BG Opacity
|
|
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
|
|
</label>
|
|
<input
|
|
type="range" min="0" max="100" step="5"
|
|
value={Math.round(settings.backgroundOpacity * 100)}
|
|
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Shadow (mutually exclusive with Background) -->
|
|
<div class="control row">
|
|
<label>
|
|
<input type="checkbox" checked={settings.shadowEnabled}
|
|
onchange={(e) => update({ shadowEnabled: (e.target as HTMLInputElement).checked })} />
|
|
Drop Shadow
|
|
</label>
|
|
<div class="row-controls">
|
|
<input
|
|
type="color" value={settings.shadowColor}
|
|
disabled={!settings.shadowEnabled}
|
|
class:disabled={!settings.shadowEnabled}
|
|
oninput={(e) => update({ shadowColor: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{#if settings.shadowEnabled}
|
|
<div class="control">
|
|
<label>
|
|
Shadow Depth
|
|
<span class="value">{settings.shadowDepth}px</span>
|
|
</label>
|
|
<input
|
|
type="range" min="1" max="5" step="1"
|
|
value={settings.shadowDepth}
|
|
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Position -->
|
|
<div class="control">
|
|
<label>Position</label>
|
|
<div class="radio-row">
|
|
<label>
|
|
<input type="radio" name="caption-position" value="bottom"
|
|
checked={settings.position === 'bottom'}
|
|
onchange={() => update({ position: 'bottom' })} />
|
|
Bottom
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="caption-position" value="top"
|
|
checked={settings.position === 'top'}
|
|
onchange={() => update({ position: 'top' })} />
|
|
Top
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Word Highlight -->
|
|
<div class="control">
|
|
<label>
|
|
<input type="checkbox" checked={settings.wordHighlight}
|
|
onchange={(e) => update({ wordHighlight: (e.target as HTMLInputElement).checked })} />
|
|
Word-by-word highlight
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-footer">
|
|
<button class="reset-btn" onclick={resetToDefaults}>Reset to defaults</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.panel {
|
|
position: absolute;
|
|
bottom: 44px;
|
|
right: 12px;
|
|
background: var(--bg-primary, #1e1e2e);
|
|
border: 1px solid var(--border, #353550);
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
width: 320px;
|
|
max-height: 480px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 20;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
h3 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text-primary, #cdd6f4); }
|
|
|
|
.close-btn {
|
|
background: none; border: none; color: var(--text-secondary, #6c7086);
|
|
cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 3px;
|
|
}
|
|
.close-btn:hover { background: var(--bg-tertiary, #2a2a3e); color: var(--text-primary, #cdd6f4); }
|
|
|
|
.scroll-area {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.control { margin-bottom: 10px; }
|
|
.control label {
|
|
display: flex; align-items: center; gap: 6px;
|
|
font-size: 12px; color: var(--text-secondary, #6c7086); margin-bottom: 4px;
|
|
}
|
|
.control.row { display: flex; align-items: center; justify-content: space-between; }
|
|
.control.row > label { margin-bottom: 0; }
|
|
|
|
.row-controls { display: flex; align-items: center; gap: 8px; }
|
|
.inline-check {
|
|
display: flex; align-items: center; gap: 4px;
|
|
font-size: 12px; color: var(--text-secondary, #6c7086);
|
|
margin-bottom: 0 !important; cursor: pointer;
|
|
}
|
|
|
|
.value {
|
|
margin-left: auto; font-size: 11px;
|
|
color: var(--text-primary, #cdd6f4); font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
select {
|
|
width: 100%; padding: 4px 6px; font-size: 12px;
|
|
background: var(--bg-secondary, #252535); color: var(--text-primary, #cdd6f4);
|
|
border: 1px solid var(--border, #353550); border-radius: 4px;
|
|
}
|
|
|
|
input[type='range'] { width: 100%; accent-color: #89b4fa; }
|
|
input[type='color'] {
|
|
width: 32px; height: 24px; padding: 0;
|
|
border: 1px solid var(--border, #353550); border-radius: 3px;
|
|
cursor: pointer; background: none;
|
|
}
|
|
input[type='color'].disabled { opacity: 0.3; cursor: not-allowed; }
|
|
input[type='checkbox'] { accent-color: #89b4fa; }
|
|
input[type='radio'] { accent-color: #89b4fa; }
|
|
|
|
.radio-row { display: flex; gap: 16px; }
|
|
.radio-row label { font-size: 12px; color: var(--text-secondary, #6c7086); cursor: pointer; }
|
|
|
|
.panel-footer {
|
|
margin-top: 8px; padding-top: 8px; flex-shrink: 0;
|
|
border-top: 1px solid var(--border, #353550);
|
|
}
|
|
.reset-btn {
|
|
font-size: 11px; color: var(--text-secondary, #6c7086);
|
|
background: none; border: none; cursor: pointer; padding: 2px 0;
|
|
}
|
|
.reset-btn:hover { color: var(--text-primary, #cdd6f4); }
|
|
</style>
|
|
```
|
|
|
|
- [ ] **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
|