Skip to content

Commit 5548cae

Browse files
fralapoclaude
andcommitted
feat(subtitles): per-preset text/stroke colour + left/center horizontal align
Karaoke (animated) captions now expose Text color + Stroke color pickers in both the Create pre-selection panel and the per-clip Edit modal. The stroke defaults to black and the highlight accent stays the preset's; both the base text and stroke are recolourable per preset. generate_ass_karaoke gains a validated outline_color override. Subtitles can also be horizontally aligned left (ragged / "a bandiera") or centered — never right, since the social UI (like/comment/share) lives down the right edge. Left keeps a small margin from the edge and widens the right margin so the text column clears the social buttons. The pure, host-tested subtitles.ass_alignment_and_margins() maps the vertical anchor's ASS \an code to its left variant (+margins); the classic SRT path does the same on the legacy SSA codes via burn_subtitles(h_align=). normalize_h_align() rejects 'right'. Wired through seedSubtitleParams / optsToPreselections / compose._apply_subtitles. Host-tested (tests/domain/test_subtitle_align.py); ffmpeg burn paths verified by the Docker integration suite (30 passed). Frontend lint+build+tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kggb11c98UYEA648Q2Y629
1 parent 11969a7 commit 5548cae

8 files changed

Lines changed: 246 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ When using ASS karaoke, the `ass` FFmpeg filter is used with `fontsdir` pointing
250250

251251
The live subtitle controls live in **two** places, kept symmetric: the Create-tab pre-selection panel `create.jsx:SubConfig` (writes flat `opts.sub*` keys → `realApi.js:optsToPreselections``preselections.subtitles.*`) and the per-clip `captions.jsx:EditClipModal` (seeds from `preselections.subtitles` + the clip's saved `subtitleParams`). Both emit the **same `subtitle_params` shape** via `lib/seedClipParams.js:seedSubtitleParams`, consumed by `compose.py:_apply_subtitles``subtitles.py`.
252252

253-
**Controls (both panels):** mode (karaoke/classic), position (top/center/bottom segmented), and a **Vertical nudge** slider (`offset_y`, −50…+50). Karaoke adds a **Style preset** grid + a **Font size** slider (`font_size`, 0 = Auto → preset size). Classic adds font, colour swatch, **Outline width** (`border_width`, 0–6) and a **Background box** toggle (`bg_opacity` 0/0.6 + black `bg_color`).
253+
**Controls (both panels):** mode (karaoke/classic), position (top/center/bottom segmented), an **Alignment** segmented (`align`**left** = ragged "a bandiera" / **center**; **no right** because the social UI like/comment/share buttons sit on the right edge), and a **Vertical nudge** slider (`offset_y`, −50…+50). Karaoke adds a **Style preset** grid, a **Font size** slider (`font_size`, 0 = Auto → preset size), and **Text color** + **Stroke color** pickers (`font_color` / `outline_color` — stroke defaults black, both recolourable per preset; highlight accent stays the preset's). Classic adds font, colour swatch, **Outline width** (`border_width`, 0–6) and a **Background box** toggle (`bg_opacity` 0/0.6 + black `bg_color`).
254+
255+
- **Horizontal alignment (`align`)** is left/center only, honoured in both modes. Karaoke: `subtitles.py:ass_alignment_and_margins(vpos, align)` (pure, host-tested) maps the vertical anchor's centred ASS \an code (top 8 / center 5 / bottom 2) to its left variant (7/4/1) and widens the right margin (`_SUB_MARGIN_LEFT_RIGHT=220` vs left `_SUB_MARGIN_EDGE=110`) so ragged-left text keeps a small edge margin but clears the right-edge social buttons. Classic SRT (`burn_subtitles(..., h_align=)`) does the same on the legacy SSA codes (centre 6/10/2 → left 5/9/1) + `MarginL`/`MarginR`. `normalize_h_align` collapses anything that isn't an explicit left request (incl. `right`) to `center`. Karaoke text/stroke colour overrides ride `font_color`/`outline_color` through `compose._apply_subtitles``generate_ass_karaoke`. Host-tested in `tests/domain/test_subtitle_align.py`.
254256

255257
**Param-flow invariants (don't regress):**
256258
- **`offset_y` sign is unified** — positive moves the caption DOWN in *both* the karaoke ASS and classic SRT paths via the pure `subtitles.py:_offset_margin(position_norm, base, offset_y)` helper (top-anchor adds, bottom/center subtract; clamped ≥0). Host-tested in `tests/domain/test_subtitle_style.py`.

dashboard/src/lib/seedClipParams.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export function seedSubtitleParams(preselections) {
6161
offset_y: subs?.offset_y ?? 0,
6262
font_color: subs?.font_color || '#FFFFFF',
6363
position: subs?.position || 'bottom',
64+
// Horizontal alignment: 'center' (default) or 'left' (a bandiera). No
65+
// 'right' — the social UI lives down the right edge.
66+
align: subs?.align || 'center',
67+
// Karaoke stroke (outline) colour — defaults black; recolourable.
68+
outline_color: subs?.outline_color || '#000000',
6469
// Classic-mode stroke + background (passed through to burn_subtitles).
6570
border_color: subs?.border_color || '#000000',
6671
border_width: subs?.border_width ?? 2,

dashboard/src/redesign/captions.jsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ export function EditClipModal({ clip, idx, jobId, initial, appliedMode, preselec
8585
const [position, setPosition] = useState(sp.position || preSubs.position || 'bottom');
8686
const [subFont, setSubFont] = useState(sp.font || preSubs.font || 'Montserrat-Black');
8787
const [subColor, setSubColor] = useState(sp.font_color || preSubs.font_color || '#FFFFFF');
88+
// Karaoke stroke (outline) colour — defaults to black; the user can recolour
89+
// it per preset, but the default stays black.
90+
const [subStroke, setSubStroke] = useState(sp.outline_color || preSubs.outline_color || '#000000');
91+
// Horizontal alignment: 'center' or 'left' (a bandiera). No 'right' — the
92+
// social UI (like/comment/share) lives down the right edge.
93+
const [align, setAlign] = useState(sp.align || preSubs.align || 'center');
8894
const [offsetY, setOffsetY] = useState(Number(sp.offset_y ?? preSubs.offset_y ?? 0));
8995
const [kSize, setKSize] = useState(Number(sp.font_size ?? preSubs.font_size ?? 0));
9096
const [cOutline, setCOutline] = useState(Number(sp.border_width ?? preSubs.border_width ?? 2));
@@ -184,10 +190,10 @@ export function EditClipModal({ clip, idx, jobId, initial, appliedMode, preselec
184190
const apply = () => {
185191
// Build from the clean seed + current UI state only (no raw `...sp` spread,
186192
// which would leak stale style keys into a karaoke re-compose).
187-
const subtitleParams = { ...seedSubtitleParams(preselections), mode, preset, position,
193+
const subtitleParams = { ...seedSubtitleParams(preselections), mode, preset, position, align,
188194
offset_y: offsetY,
189195
...(mode === 'karaoke'
190-
? { font_size: kSize > 0 ? kSize : undefined }
196+
? { font_size: kSize > 0 ? kSize : undefined, font_color: subColor, outline_color: subStroke }
191197
: { font: subFont, font_color: subColor, border_width: cOutline,
192198
bg_opacity: cBg ? 0.6 : 0, bg_color: '#000000' }) };
193199
const hookParams = { ...seedHookParams(clip, preselections), ...(initial?.hookParams || {}), ...hookStyle, text: hookText };
@@ -334,6 +340,26 @@ export function EditClipModal({ clip, idx, jobId, initial, appliedMode, preselec
334340
<input type="range" min="0" max="80" step="1" value={kSize} aria-label="Subtitle font size"
335341
onChange={(e) => setKSize(Number(e.target.value))} style={{ width: '100%' }} />
336342
</div>
343+
<div className="cf-row" style={{ display: 'flex', gap: 12 }}>
344+
<label style={{ flex: 1 }}>
345+
<span className="field-label" style={{ marginBottom: 9, display: 'flex' }}>Text color</span>
346+
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
347+
<input type="color" aria-label="Subtitle text color" value={subColor}
348+
onChange={(e) => setSubColor(e.target.value)}
349+
style={{ width: 40, height: 30, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }} />
350+
<span className="eo-d">{subColor.toUpperCase()}</span>
351+
</span>
352+
</label>
353+
<label style={{ flex: 1 }}>
354+
<span className="field-label" style={{ marginBottom: 9, display: 'flex' }}>Stroke color</span>
355+
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
356+
<input type="color" aria-label="Subtitle stroke color" value={subStroke}
357+
onChange={(e) => setSubStroke(e.target.value)}
358+
style={{ width: 40, height: 30, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }} />
359+
<span className="eo-d">{subStroke.toUpperCase()}</span>
360+
</span>
361+
</label>
362+
</div>
337363
</>
338364
)}
339365
{mode === 'classic' && (
@@ -373,6 +399,12 @@ export function EditClipModal({ clip, idx, jobId, initial, appliedMode, preselec
373399
<Segmented full value={position} onChange={setPosition}
374400
options={[{ id: 'top', label: 'Top' }, { id: 'center', label: 'Center' }, { id: 'bottom', label: 'Bottom' }]} />
375401
</div>
402+
<div className="cf-row">
403+
<span className="field-label" style={{ marginBottom: 9, display: 'flex' }}>Alignment</span>
404+
<Segmented full value={align} onChange={setAlign}
405+
options={[{ id: 'left', label: 'Left' }, { id: 'center', label: 'Center' }]} />
406+
<div className="eo-d" style={{ marginTop: 6 }}>Left = ragged (a bandiera) with a margin from the edge · no right (social buttons there)</div>
407+
</div>
376408
<div className="cf-row">
377409
<span className="field-label" style={{ marginBottom: 9, display: 'flex', justifyContent: 'space-between' }}>
378410
<span>Vertical nudge</span><span className="eo-d">{offsetY > 0 ? `+${offsetY}` : offsetY}</span>

dashboard/src/redesign/create.jsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,26 @@ function SubConfig({ opts, set }) {
194194
<input type="range" min="0" max="80" step="1" value={opts.subFontSize || 0} aria-label="Subtitle font size"
195195
onChange={(e) => set({ subFontSize: Number(e.target.value) })} style={{ width: '100%' }} />
196196
</div>
197+
<div className="cf-row" style={{ display: 'flex', gap: 12 }}>
198+
<label style={{ flex: 1 }}>
199+
<span className="field-label" style={{ marginBottom: 9, display: 'flex' }}>Text color</span>
200+
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
201+
<input type="color" aria-label="Subtitle text color" value={opts.subColor || '#FFFFFF'}
202+
onChange={(e) => set({ subColor: e.target.value })}
203+
style={{ width: 40, height: 30, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }} />
204+
<span className="od">{(opts.subColor || '#FFFFFF').toUpperCase()}</span>
205+
</span>
206+
</label>
207+
<label style={{ flex: 1 }}>
208+
<span className="field-label" style={{ marginBottom: 9, display: 'flex' }}>Stroke color</span>
209+
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
210+
<input type="color" aria-label="Subtitle stroke color" value={opts.subStroke || '#000000'}
211+
onChange={(e) => set({ subStroke: e.target.value })}
212+
style={{ width: 40, height: 30, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }} />
213+
<span className="od">{(opts.subStroke || '#000000').toUpperCase()}</span>
214+
</span>
215+
</label>
216+
</div>
197217
</>
198218
)}
199219
{opts.subMode === 'classic' && (
@@ -234,6 +254,12 @@ function SubConfig({ opts, set }) {
234254
<Segmented full value={opts.subPosition || 'bottom'} onChange={(id) => set({ subPosition: id })}
235255
options={[{ id: 'top', label: 'Top' }, { id: 'center', label: 'Center' }, { id: 'bottom', label: 'Bottom' }]} />
236256
</div>
257+
<div className="cf-row">
258+
<span className="field-label" style={{ marginBottom: 9, display: 'flex' }}>Alignment</span>
259+
<Segmented full value={opts.subAlign || 'center'} onChange={(id) => set({ subAlign: id })}
260+
options={[{ id: 'left', label: 'Left' }, { id: 'center', label: 'Center' }]} />
261+
<div className="od" style={{ marginTop: 6 }}>Left = ragged (a bandiera), margin from edge · no right (social buttons)</div>
262+
</div>
237263
<div className="cf-row">
238264
<span className="field-label" style={{ marginBottom: 9, display: 'flex', justifyContent: 'space-between' }}>
239265
<span>Vertical nudge</span><span className="od">{(opts.subOffsetY || 0) > 0 ? `+${opts.subOffsetY}` : (opts.subOffsetY || 0)}</span>

dashboard/src/redesign/realApi.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,19 @@ export function optsToPreselections(opts) {
317317
subtitles: opts.subtitles
318318
? {
319319
mode: opts.subMode, preset: opts.subPreset, position: opts.subPosition || 'bottom',
320+
// Horizontal alignment applies to both modes ('center' | 'left').
321+
align: opts.subAlign || 'center',
320322
// Vertical nudge applies to both modes.
321323
offset_y: opts.subOffsetY || 0,
322324
// Karaoke font-size override (0 = Auto → use the preset size; omitted
323-
// so seedSubtitleParams doesn't force a value).
324-
...(opts.subMode === 'karaoke' && opts.subFontSize > 0
325-
? { font_size: opts.subFontSize }
325+
// so seedSubtitleParams doesn't force a value) + text/stroke colours
326+
// (stroke defaults black; both recolourable per preset).
327+
...(opts.subMode === 'karaoke'
328+
? {
329+
font_color: opts.subColor || '#FFFFFF',
330+
outline_color: opts.subStroke || '#000000',
331+
...(opts.subFontSize > 0 ? { font_size: opts.subFontSize } : {}),
332+
}
326333
: {}),
327334
// Classic-mode typography (karaoke draws style from the preset, so
328335
// these are only meaningful for classic).

src/clippyme/domain/compose.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ async def _apply_subtitles(
237237
font_size=subtitle_params.get("font_size"),
238238
position=subtitle_params.get("position", "bottom"),
239239
offset_y=sub_offset_y,
240+
outline_color=subtitle_params.get("outline_color"),
241+
align=subtitle_params.get("align", "center"),
240242
),
241243
)
242244
if not success:
@@ -278,6 +280,7 @@ async def _apply_subtitles(
278280
bg_color=subtitle_params.get("bg_color", "#000000"),
279281
bg_opacity=subtitle_params.get("bg_opacity", 0.0),
280282
offset_y=sub_offset_y,
283+
h_align=subtitle_params.get("align", "center"),
281284
),
282285
)
283286
return sub_output

0 commit comments

Comments
 (0)