feat(dub): project-level drag casting board (#1767)

* feat(dub): project-level drag casting board

Adds an expandable Casting Board to the dub editor's CAST strip: speaker
rows (with the auto-clone chip when the extractor found a usable passage)
and draggable voice chips — Default, clone profiles, design presets.
Dropping a chip on a speaker writes the exact fields the CAST <select>
always has (profile_id + merge_parts/merge_parts_original attribution),
via a shared assignSpeakerProfile helper both views now call, so the
dropdowns stay in sync and job persistence is unchanged. Keyboard path:
focus a speaker row, pick from a listbox (arrows/Enter/Escape).

The pre-existing CAST dropdown strip moves verbatim into the new
CastingBoard.jsx (DubLeftColumn shrinks below 800 lines; the new file
holds the 300-line soft cap). Styles extend the .dub-cast-* cluster in
index.css. Six new i18n keys translated in all 21 locales.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* docs: changelog + roadmap entries for the casting board (#1767)

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* fix(casting): validate and preserve speaker assignments

* fix(casting): recover cleared merged assignments

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Palash Debnath <4178343+debpalash@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-09-02 15:37:57 +05:30
committed by GitHub
co-authored by Cursor Agent Palash Debnath
parent 0ed7d2ec22
commit 65236774aa
28 changed files with 797 additions and 107 deletions
+2
View File
@@ -28,11 +28,13 @@ the frozen-backend fallback mirror it for their toolchains.
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
### Changed
### Added
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
+3 -3
View File
@@ -18,7 +18,7 @@ Phase 5 · Productisation ░░░░░░░░░░ 0 / 5
Design track ▓▓▓▓▓▓▓▓▓░ ongoing · 14 primitives + ~67 migrated inline styles · DubTab/Header/Sidebar/CloneDesignTab drained
Performance track ▓▓▓░░░░░░░ underway · profiling, preload, isolated engines + cache-remix I/O
Feature-magic track ░░░░░░░░░░ not started
Feature-magic track ▓▓░░░░░░░░ underway · project-level casting board shipped
Quality track ▓▓░░░░░░░░ 12 smoke tests, 10 error messages rewritten
```
@@ -205,11 +205,11 @@ None on the critical path to world-class. All are answers to real demand.
| Interaction budgets (<50 ms UI, <200 ms preview, <4 s first seg) | 🟡 | `/ws/tts` reports real TTFA, total generation time and RTF; frontend responsiveness instrumentation exists, but no cross-surface budget gate yet. |
| Dedicated dev-week per quarter | ⏳ | Cadence not yet booked. |
### ✨ Feature-magic track _(⏳ not started)_
### ✨ Feature-magic track _(🟡 underway)_
| Feature | Status | Phase gate |
|------|:---:|------|
| Project-level casting view (drag voices to speakers) | | After Phase 3 |
| Project-level casting view (drag voices to speakers) | | Shipped (#1767): the dub CAST strip expands into a casting board — drag voice chips onto speaker rows, keyboard listbox included, same fields as the dropdowns. |
| Voice memory across projects | ⏳ | After Phase 4 |
| Context-aware pipeline (video frames → pipeline decisions) | ⏳ | After Phase 4 |
| On-device learning from corrections (user edits → LoRA) | ⏳ | Research only; possibly Phase 5+ |
@@ -0,0 +1,305 @@
import { useEffect, useRef, useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import toast from 'react-hot-toast';
import { PRESETS } from '../../utils/constants';
import { autoProfileId, assignSpeakerProfile, castParts, castSpeakers } from '../../utils/segments';
// The Default voice IS the empty profile_id, but a dataTransfer payload can't
// carry '' distinguishably from "no data" — so it travels as this sentinel.
const DEFAULT_VOICE = '__default__';
/**
* CAST — per-speaker voice assignment: the compact <select> strip
* (pre-existing, unchanged markup) plus an expandable casting board —
* draggable voice chips dropped onto speaker rows, with a keyboard path
* (focus a speaker, pick from a listbox). Both views write through
* `assignSpeakerProfile`, so a drop is byte-identical to picking the same
* option in the dropdown — no new persistence, the job save path already carries these fields.
*/
export default function CastingBoard({ t, dubSegments, setDubSegments, speakerClones, profiles }) {
const [boardOpen, setBoardOpen] = useState(false);
const [dropTarget, setDropTarget] = useState(null); // speaker id under a drag
const [pickerFor, setPickerFor] = useState(null); // speaker id with the listbox open
const [activeIdx, setActiveIdx] = useState(0);
const optionRefs = useRef([]);
const rowBtnRefs = useRef({});
// Roving focus: while a listbox is open, the active option owns focus.
useEffect(() => {
if (pickerFor !== null) optionRefs.current[activeIdx]?.focus();
}, [pickerFor, activeIdx]);
const speakers = castSpeakers(dubSegments);
if (!speakers.length) return null;
const currentVoice = (spk) =>
dubSegments.find((s) => s.speaker_id === spk)?.profile_id ||
dubSegments.flatMap(castParts).find((part) => part.speaker_id === spk)?.profile_id ||
'';
const voiceLabel = (val, spk) => {
if (!val) return t('dub.default');
const clone = speakerClones[spk];
if (clone && val === autoProfileId(spk)) {
return t('dub.from_video', { duration: clone.duration.toFixed(1) });
}
if (val.startsWith('preset:')) {
return PRESETS.find((p) => p.id === val.replace('preset:', ''))?.name || val;
}
return profiles.find((p) => p.id === val)?.name || val;
};
// Per-speaker option list — the exact choices the <select> offers, in the
// same order (auto-clone first when available, then Default, then saved
// clone profiles, then design presets).
const voiceOptions = (spk) => {
const clone = speakerClones[spk];
const auto = clone
? [
{
value: autoProfileId(spk),
label: t('dub.from_video', { duration: clone.duration.toFixed(1) }),
},
]
: [];
return [
...auto,
{ value: '', label: t('dub.default') },
...profiles.map((p) => ({ value: p.id, label: p.name })),
...PRESETS.map((p) => ({ value: `preset:${p.id}`, label: p.name })),
];
};
const assign = (spk, val) => {
setDubSegments(assignSpeakerProfile(dubSegments, spk, val));
toast.success(t('dub.casting_assigned', { voice: voiceLabel(val, spk), speaker: spk }));
};
const closePicker = (refocus = true) => {
const spk = pickerFor;
setPickerFor(null);
setActiveIdx(0);
if (refocus && spk !== null) rowBtnRefs.current[spk]?.focus();
};
const onListboxKeyDown = (e, spk, options) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIdx((i) => (i + 1) % options.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx((i) => (i - 1 + options.length) % options.length);
} else if (e.key === 'Home') {
e.preventDefault();
setActiveIdx(0);
} else if (e.key === 'End') {
e.preventDefault();
setActiveIdx(options.length - 1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
assign(spk, options[activeIdx].value);
closePicker();
} else if (e.key === 'Escape') {
e.preventDefault();
closePicker();
} else if (e.key === 'Tab') {
// Let the browser move focus forward/backward. Refocusing the trigger
// here traps keyboard users inside the picker.
closePicker(false);
}
};
const onDrop = (e, spk) => {
e.preventDefault();
setDropTarget(null);
const raw = e.dataTransfer?.getData('text/plain');
if (!raw) return;
const value = raw === DEFAULT_VOICE ? '' : raw;
if (!voiceOptions(spk).some((option) => option.value === value)) return;
assign(spk, value);
};
// Speaker-independent palette (the auto-clone chip lives on its row).
const paletteChips = [
{ value: '', label: t('dub.default'), group: '' },
...profiles.map((p) => ({ value: p.id, label: p.name, group: t('dub.clone_profiles') })),
...PRESETS.map((p) => ({
value: `preset:${p.id}`,
label: p.name,
group: t('dub.design_presets'),
})),
];
return (
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
<span
className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold"
title={t('dub.cast_title')}
>
{t('dub.cast')}
</span>
{speakers.map((spk) => {
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]">
{spk}:
</span>
<select
className="input-base dub-cast__select"
value={currentVoice(spk)}
onChange={(e) =>
setDubSegments(assignSpeakerProfile(dubSegments, spk, e.target.value))
}
>
{clone && (
<option value={autoProfileId(spk)}>
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
</option>
)}
<option value="">{t('dub.default')}</option>
{profiles.length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('dub.design_presets')}>
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
</div>
);
})}
<button
type="button"
className={`dub-cast__board-toggle ${boardOpen ? 'is-open' : ''}`}
aria-expanded={boardOpen}
onClick={() => setBoardOpen((o) => !o)}
title={t('dub.casting_board_hint')}
>
<LayoutGrid size={10} /> {t('dub.casting_board')}
</button>
</div>
{boardOpen && (
<div className="dub-cast__board" data-testid="casting-board">
<div className="dub-cast__palette" role="list" aria-label={t('dub.casting_voices')}>
{paletteChips.map((chip) => (
<span
key={chip.value || DEFAULT_VOICE}
role="listitem"
className="dub-cast__chip"
draggable
data-profile={chip.value}
title={chip.group ? `${chip.group} · ${chip.label}` : chip.label}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', chip.value || DEFAULT_VOICE);
e.dataTransfer.effectAllowed = 'copy';
}}
>
{chip.label}
</span>
))}
</div>
<p className="dub-cast__hint">{t('dub.casting_drag_hint')}</p>
<div className="dub-cast__rows">
{speakers.map((spk) => {
const clone = speakerClones[spk];
const options = voiceOptions(spk);
const current = currentVoice(spk);
return (
<div
key={spk}
className={`dub-cast__row ${dropTarget === spk ? 'is-drop' : ''}`}
data-speaker={spk}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}}
onDragEnter={() => setDropTarget(spk)}
onDragLeave={(e) => {
if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget)) {
setDropTarget((cur) => (cur === spk ? null : cur));
}
}}
onDrop={(e) => onDrop(e, spk)}
>
<button
type="button"
ref={(el) => {
rowBtnRefs.current[spk] = el;
}}
className="dub-cast__row-btn"
aria-haspopup="listbox"
aria-expanded={pickerFor === spk}
aria-label={t('dub.casting_assign_to', { speaker: spk })}
title={t('dub.casting_assign_to', { speaker: spk })}
onClick={() => {
if (pickerFor === spk) {
closePicker();
return;
}
optionRefs.current = [];
const idx = options.findIndex((o) => o.value === current);
setActiveIdx(idx >= 0 ? idx : 0);
setPickerFor(spk);
}}
>
<span className="dub-cast__row-speaker">{spk}</span>
<span className="dub-cast__row-voice">{voiceLabel(current, spk)}</span>
</button>
{clone && (
<span
className="dub-cast__auto-chip"
title={t('dub.from_video', { duration: clone.duration.toFixed(1) })}
>
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
</span>
)}
{pickerFor === spk && (
<div
className="dub-cast__listbox"
role="listbox"
aria-label={t('dub.casting_assign_to', { speaker: spk })}
onKeyDown={(e) => onListboxKeyDown(e, spk, options)}
>
{options.map((opt, i) => (
<button
type="button"
key={opt.value || DEFAULT_VOICE}
ref={(el) => {
optionRefs.current[i] = el;
}}
role="option"
aria-selected={opt.value === current}
tabIndex={i === activeIdx ? 0 : -1}
className={`dub-cast__option ${opt.value === current ? 'is-current' : ''}`}
onClick={() => {
assign(spk, opt.value);
closePicker();
}}
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,237 @@
import { useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import i18n from '../../i18n';
import CastingBoard from './CastingBoard';
vi.mock('react-hot-toast', () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
const t = i18n.t.bind(i18n);
const profiles = [
{ id: 'voice-a', name: 'Anna' },
{ id: 'voice-b', name: 'Ben' },
];
// SPEAKER_2 speaks segment 2 directly AND the second half of a merged row —
// the same shape the CAST <select> tests pin down: a drop must set the direct
// segment's profile_id and the merged row's part attribution (both mirrors)
// while leaving the merged row's own top-level voice alone.
const makeSegments = () => [
{ id: '1', speaker_id: 'SPEAKER_1', profile_id: '', text: 'one' },
{ id: '2', speaker_id: 'SPEAKER_2', profile_id: '', text: 'two' },
{
id: 'merged',
speaker_id: 'SPEAKER_1',
profile_id: 'voice-a',
text: 'three four',
merge_parts: [
{ textStart: 0, textEnd: 5, speaker_id: 'SPEAKER_1', profile_id: 'voice-a' },
{ textStart: 6, textEnd: 10, speaker_id: 'SPEAKER_2', profile_id: '' },
],
merge_parts_original: [
{ textStart: 0, textEnd: 5, speaker_id: 'SPEAKER_1', profile_id: 'voice-a' },
{ textStart: 6, textEnd: 10, speaker_id: 'SPEAKER_2', profile_id: '' },
],
},
];
function renderBoard(over = {}) {
const props = {
t,
dubSegments: makeSegments(),
setDubSegments: vi.fn(),
speakerClones: {},
profiles,
...over,
};
const utils = render(<CastingBoard {...props} />);
return { ...utils, props };
}
const openBoard = () => fireEvent.click(screen.getByRole('button', { name: /casting board/i }));
describe('CastingBoard drag & drop', () => {
it('dropping a voice chip on a speaker writes the same fields as the CAST select', () => {
const { props } = renderBoard();
openBoard();
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_2"]');
fireEvent.drop(row, { dataTransfer: { getData: () => 'voice-b' } });
const updated = props.setDubSegments.mock.calls[0][0];
expect(updated[0].profile_id).toBe(''); // other speaker untouched
expect(updated[1].profile_id).toBe('voice-b'); // direct match
expect(updated[2].profile_id).toBe('voice-a'); // merged row keeps its top voice
expect(updated[2].merge_parts[1].profile_id).toBe('voice-b');
expect(updated[2].merge_parts_original[1].profile_id).toBe('voice-b');
});
it('a dragged chip carries its profile id, and the Default chip a sentinel that maps back to ""', () => {
const { props } = renderBoard();
openBoard();
const board = screen.getByTestId('casting-board');
const carried = {};
fireEvent.dragStart(board.querySelector('[data-profile="voice-b"]'), {
dataTransfer: { setData: (k, v) => (carried[k] = v), effectAllowed: '' },
});
expect(carried['text/plain']).toBe('voice-b');
const defaults = {};
fireEvent.dragStart(board.querySelector('[data-profile=""]'), {
dataTransfer: { setData: (k, v) => (defaults[k] = v), effectAllowed: '' },
});
expect(defaults['text/plain']).toBe('__default__');
const row = board.querySelector('[data-speaker="SPEAKER_2"]');
fireEvent.drop(row, { dataTransfer: { getData: () => defaults['text/plain'] } });
expect(props.setDubSegments.mock.calls[0][0][1].profile_id).toBe('');
});
it('a drop with no payload is a no-op', () => {
const { props } = renderBoard();
openBoard();
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_1"]');
fireEvent.drop(row, { dataTransfer: { getData: () => '' } });
expect(props.setDubSegments).not.toHaveBeenCalled();
});
it('ignores text/plain drops that are not one of the available voices', () => {
const { props } = renderBoard();
openBoard();
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_1"]');
fireEvent.drop(row, { dataTransfer: { getData: () => 'selected text from another app' } });
expect(props.setDubSegments).not.toHaveBeenCalled();
});
});
describe('CastingBoard keyboard path', () => {
it('Enter on a speaker opens a listbox; arrows + Enter assign the picked voice', () => {
const { props } = renderBoard();
openBoard();
// The row button is the keyboard entry point (native Enter/Space → click).
fireEvent.click(screen.getByRole('button', { name: /assign a voice to SPEAKER_2/i }));
const listbox = screen.getByRole('listbox');
const options = within(listbox).getAllByRole('option');
expect(options[0]).toHaveTextContent('Default');
expect(options[1]).toHaveTextContent('Anna');
expect(options[2]).toHaveTextContent('Ben');
fireEvent.keyDown(listbox, { key: 'ArrowDown' });
fireEvent.keyDown(listbox, { key: 'ArrowDown' });
fireEvent.keyDown(listbox, { key: 'Enter' });
expect(screen.queryByRole('listbox')).toBeNull();
const updated = props.setDubSegments.mock.calls[0][0];
expect(updated[1].profile_id).toBe('voice-b');
expect(updated[2].merge_parts[1].profile_id).toBe('voice-b');
});
it('Escape closes the listbox without assigning', () => {
const { props } = renderBoard();
openBoard();
fireEvent.click(screen.getByRole('button', { name: /assign a voice to SPEAKER_1/i }));
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
expect(props.setDubSegments).not.toHaveBeenCalled();
});
it.each([
['Tab', false],
['Shift+Tab', true],
])('%s closes the listbox without trapping focus', (_label, shiftKey) => {
renderBoard();
openBoard();
const trigger = screen.getByRole('button', { name: /assign a voice to SPEAKER_1/i });
fireEvent.click(trigger);
const listbox = screen.getByRole('listbox');
expect(fireEvent.keyDown(listbox, { key: 'Tab', shiftKey })).toBe(true);
expect(screen.queryByRole('listbox')).toBeNull();
expect(trigger).not.toHaveFocus();
});
});
describe('CastingBoard ↔ dropdown sync', () => {
function Harness() {
const [segments, setSegments] = useState(makeSegments());
return (
<CastingBoard
t={t}
dubSegments={segments}
setDubSegments={setSegments}
speakerClones={{}}
profiles={profiles}
/>
);
}
it('a drop updates the pre-existing CAST dropdown for that speaker', () => {
render(<Harness />);
openBoard();
const selects = document.querySelectorAll('.dub-cast__select');
expect(selects[1].value).toBe(''); // SPEAKER_2 starts on Default
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_2"]');
fireEvent.drop(row, { dataTransfer: { getData: () => 'voice-b' } });
expect(document.querySelectorAll('.dub-cast__select')[1].value).toBe('voice-b');
expect(within(row).getByRole('button')).toHaveTextContent('Ben');
});
});
describe('CastingBoard auto-clone chip', () => {
it('shows the from-video chip and lists the auto clone first for cloned speakers', () => {
renderBoard({ speakerClones: { SPEAKER_1: { duration: 6.24 } } });
openBoard();
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_1"]');
expect(within(row).getByTitle(/from video · 6\.2s/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /assign a voice to SPEAKER_1/i }));
const options = within(screen.getByRole('listbox')).getAllByRole('option');
expect(options[0]).toHaveTextContent(/from video · 6\.2s/i);
expect(options[1]).toHaveTextContent('Default');
});
it('keeps a speaker addressable when only merge_parts_original remains', () => {
const segments = [
{
id: 'merged',
speaker_id: 'SPEAKER_1',
profile_id: 'voice-a',
text: 'one two',
merge_parts: [],
merge_parts_original: [
{ speaker_id: 'SPEAKER_1', profile_id: 'voice-a' },
{ speaker_id: 'SPEAKER_2', profile_id: '' },
],
},
];
const { props } = renderBoard({ dubSegments: segments });
openBoard();
expect(
screen.getByRole('button', { name: /assign a voice to SPEAKER_2/i }),
).toBeInTheDocument();
const selects = document.querySelectorAll('.dub-cast__select');
fireEvent.change(selects[1], { target: { value: 'voice-b' } });
const updated = props.setDubSegments.mock.calls[0][0];
expect(updated[0].profile_id).toBe('voice-a');
expect(updated[0].merge_parts).toEqual([]);
expect(updated[0].merge_parts_original[1].profile_id).toBe('voice-b');
});
it('renders nothing when no segment carries a speaker', () => {
const { container } = renderBoard({
dubSegments: [{ id: '1', text: 'no speakers here' }],
});
expect(container).toBeEmptyDOMElement();
});
});
+15 -104
View File
@@ -21,13 +21,13 @@ import { API } from '../../api/client';
import { dubListTracks } from '../../api/dub';
import { LANG_CODES } from '../../utils/languages';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS, PRESETS } from '../../utils/constants';
import { POPULAR_LANGS } from '../../utils/constants';
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
import { dubSegmentsText } from '../../api/dub';
import { copyText } from '../../utils/copyText';
import { openExternal } from '../../api/external';
import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap';
import { autoProfileId } from '../../utils/segments';
import CastingBoard from './CastingBoard';
import toast from 'react-hot-toast';
// ── Translation-settings bar utility class clusters ──────────────────────
@@ -355,108 +355,19 @@ export default function DubLeftColumn({
}
/>
{/* Cast — per-speaker voice assignment. When the auto-clone
extractor found a usable passage per speaker (≥5s from the
isolated vocals), that option becomes first-class in the
dropdown. It's also pre-selected on the segments so "new
language = same speaker's voice" works by default. */}
{dubSegments.some((s) => s.speaker_id || s.merge_parts?.some((part) => part.speaker_id)) && (
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
<span
className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold"
title={t('dub.cast_title')}
>
{t('dub.cast')}
</span>
{[
...new Set(
dubSegments
.flatMap((s) => [
s.speaker_id,
...(s.merge_parts || []).map((part) => part.speaker_id),
])
.filter(Boolean),
),
].map((spk) => {
const autoId = autoProfileId(spk);
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]">
{spk}:
</span>
<select
className="input-base dub-cast__select"
value={
dubSegments.find((s) => s.speaker_id === spk)?.profile_id ||
dubSegments
.flatMap((s) => s.merge_parts || [])
.find((part) => part.speaker_id === spk)?.profile_id ||
''
}
onChange={(e) => {
const val = e.target.value;
setDubSegments(
dubSegments.map((s) => {
const directMatch = s.speaker_id === spk;
const nestedMatch = s.merge_parts?.some(
(part) => part.speaker_id === spk,
);
if (!directMatch && !nestedMatch) return s;
return {
...s,
...(directMatch ? { profile_id: val } : {}),
...(s.merge_parts
? {
merge_parts: s.merge_parts.map((part) =>
part.speaker_id === spk ? { ...part, profile_id: val } : part,
),
}
: {}),
...(s.merge_parts_original
? {
merge_parts_original: s.merge_parts_original.map((part) =>
part.speaker_id === spk ? { ...part, profile_id: val } : part,
),
}
: {}),
};
}),
);
}}
>
{clone && (
<option value={autoId}>
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
</option>
)}
<option value="">{t('dub.default')}</option>
{profiles.length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('dub.design_presets')}>
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
</div>
);
})}
</div>
</div>
)}
{/* Cast — per-speaker voice assignment: the compact dropdown strip plus
the drag-and-drop casting board. Renders nothing when no segment
carries a speaker. When the auto-clone extractor found a usable
passage per speaker (≥5s from the isolated vocals), that option is
first-class in both views and pre-selected on the segments so "new
language = same speaker's voice" works by default. */}
<CastingBoard
t={t}
dubSegments={dubSegments}
setDubSegments={setDubSegments}
speakerClones={speakerClones}
profiles={profiles}
/>
{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 من الفيديو · {{duration}}s",
"clone_profiles": "ملفات تعريف الاستنساخ",
"design_presets": "إعدادات التصميم المسبقة",
"casting_board": "لوحة توزيع الأدوار",
"casting_board_hint": "افتح لوحة توزيع الأدوار — اسحب صوتًا إلى متحدث",
"casting_voices": "الأصوات",
"casting_drag_hint": "اسحب شريحة صوت إلى متحدث، أو اضغط Enter على متحدث للاختيار من قائمة.",
"casting_assign_to": "تعيين صوت إلى {{speaker}}",
"casting_assigned": "تم إسناد {{voice}} إلى {{speaker}}",
"edit_settings": "تحرير إعدادات الترجمة",
"style_label_prefix": "أسلوب:",
"collapse_settings": "طي إعدادات الترجمة",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Aus Video · {{duration}}s",
"clone_profiles": "Profile klonen",
"design_presets": "Design-Voreinstellungen",
"casting_board": "Casting-Board",
"casting_board_hint": "Casting-Board öffnen — eine Stimme auf einen Sprecher ziehen",
"casting_voices": "Stimmen",
"casting_drag_hint": "Ziehe einen Stimmen-Chip auf einen Sprecher oder drücke Enter auf einem Sprecher, um aus einer Liste zu wählen.",
"casting_assign_to": "{{speaker}} eine Stimme zuweisen",
"casting_assigned": "{{voice}} als {{speaker}} besetzt",
"edit_settings": "Übersetzungseinstellungen bearbeiten",
"style_label_prefix": "Stil:",
"collapse_settings": "Übersetzungseinstellungen ausblenden",
+6
View File
@@ -1266,6 +1266,12 @@
"from_video": "🎤 From video · {{duration}}s",
"clone_profiles": "Clone Profiles",
"design_presets": "Design Presets",
"casting_board": "Casting board",
"casting_board_hint": "Open the casting board — drag a voice onto a speaker",
"casting_voices": "Voices",
"casting_drag_hint": "Drag a voice chip onto a speaker, or press Enter on a speaker to pick from a list.",
"casting_assign_to": "Assign a voice to {{speaker}}",
"casting_assigned": "Cast {{voice}} as {{speaker}}",
"edit_settings": "Edit translation settings",
"style_label_prefix": "style: ",
"collapse_settings": "Collapse translation settings",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Del vídeo · {{duration}}s",
"clone_profiles": "Clonar perfiles",
"design_presets": "Ajustes preestablecidos de diseño",
"casting_board": "Tablero de reparto",
"casting_board_hint": "Abrir el tablero de reparto: arrastra una voz a un hablante",
"casting_voices": "Voces",
"casting_drag_hint": "Arrastra una ficha de voz a un hablante, o pulsa Enter sobre un hablante para elegir de una lista.",
"casting_assign_to": "Asignar una voz a {{speaker}}",
"casting_assigned": "{{voice}} asignada a {{speaker}}",
"edit_settings": "Editar configuración de traducción",
"style_label_prefix": "estilo:",
"collapse_settings": "Contraer configuración de traducción",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 À partir de la vidéo · {{duration}}s",
"clone_profiles": "Cloner des profils",
"design_presets": "Préréglages de conception",
"casting_board": "Tableau de casting",
"casting_board_hint": "Ouvrir le tableau de casting — glissez une voix sur un locuteur",
"casting_voices": "Voix",
"casting_drag_hint": "Glissez une pastille de voix sur un locuteur, ou appuyez sur Entrée sur un locuteur pour choisir dans une liste.",
"casting_assign_to": "Attribuer une voix à {{speaker}}",
"casting_assigned": "{{voice}} attribuée à {{speaker}}",
"edit_settings": "Modifier les paramètres de traduction",
"style_label_prefix": "style:",
"collapse_settings": "Réduire les paramètres de traduction",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤वीडियो से · {{duration}}s",
"clone_profiles": "क्लोन प्रोफाइल",
"design_presets": "डिज़ाइन प्रीसेट",
"casting_board": "कास्टिंग बोर्ड",
"casting_board_hint": "कास्टिंग बोर्ड खोलें — किसी वक्ता पर आवाज़ खींचकर छोड़ें",
"casting_voices": "आवाज़ें",
"casting_drag_hint": "किसी वक्ता पर आवाज़ चिप खींचें, या सूची से चुनने के लिए वक्ता पर Enter दबाएँ।",
"casting_assign_to": "{{speaker}} को आवाज़ सौंपें",
"casting_assigned": "{{voice}} को {{speaker}} के रूप में कास्ट किया गया",
"edit_settings": "अनुवाद सेटिंग संपादित करें",
"style_label_prefix": "शैली:",
"collapse_settings": "अनुवाद सेटिंग संक्षिप्त करें",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Dari video · {{duration}}s",
"clone_profiles": "Profil Klon",
"design_presets": "Preset Desain",
"casting_board": "Papan casting",
"casting_board_hint": "Buka papan casting — seret suara ke pembicara",
"casting_voices": "Suara",
"casting_drag_hint": "Seret chip suara ke pembicara, atau tekan Enter pada pembicara untuk memilih dari daftar.",
"casting_assign_to": "Tetapkan suara untuk {{speaker}}",
"casting_assigned": "{{voice}} ditetapkan sebagai {{speaker}}",
"edit_settings": "Edit pengaturan terjemahan",
"style_label_prefix": "gaya:",
"collapse_settings": "Ciutkan pengaturan terjemahan",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Dal video · {{duration}}s",
"clone_profiles": "Clona profili",
"design_presets": "Preimpostazioni di progettazione",
"casting_board": "Tabellone del cast",
"casting_board_hint": "Apri il tabellone del cast — trascina una voce su uno speaker",
"casting_voices": "Voci",
"casting_drag_hint": "Trascina una chip vocale su uno speaker, oppure premi Invio su uno speaker per scegliere da un elenco.",
"casting_assign_to": "Assegna una voce a {{speaker}}",
"casting_assigned": "{{voice}} assegnata a {{speaker}}",
"edit_settings": "Modifica le impostazioni di traduzione",
"style_label_prefix": "stile:",
"collapse_settings": "Comprimi le impostazioni di traduzione",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 ビデオより · {{duration}}s",
"clone_profiles": "プロファイルのクローンを作成する",
"design_presets": "デザインプリセット",
"casting_board": "キャスティングボード",
"casting_board_hint": "キャスティングボードを開く — 話者にボイスをドラッグ",
"casting_voices": "ボイス",
"casting_drag_hint": "ボイスチップを話者にドラッグするか、話者で Enter を押して一覧から選択します。",
"casting_assign_to": "{{speaker}} にボイスを割り当てる",
"casting_assigned": "{{voice}} を {{speaker}} に割り当てました",
"edit_settings": "翻訳設定を編集する",
"style_label_prefix": "スタイル:",
"collapse_settings": "翻訳設定を折りたたむ",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 영상에서 · {{duration}}s",
"clone_profiles": "프로필 복제",
"design_presets": "디자인 사전 설정",
"casting_board": "캐스팅 보드",
"casting_board_hint": "캐스팅 보드 열기 — 화자 위로 음성을 끌어다 놓으세요",
"casting_voices": "음성",
"casting_drag_hint": "음성 칩을 화자 위로 끌어다 놓거나, 화자에서 Enter 키를 눌러 목록에서 선택하세요.",
"casting_assign_to": "{{speaker}}에게 음성 지정",
"casting_assigned": "{{voice}}을(를) {{speaker}}에 지정했습니다",
"edit_settings": "번역 설정 수정",
"style_label_prefix": "스타일:",
"collapse_settings": "번역 설정 접기",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Uit video · {{duration}}s",
"clone_profiles": "Kloon profielen",
"design_presets": "Ontwerpvoorinstellingen",
"casting_board": "Castingbord",
"casting_board_hint": "Castingbord openen — sleep een stem naar een spreker",
"casting_voices": "Stemmen",
"casting_drag_hint": "Sleep een stemchip naar een spreker, of druk op Enter bij een spreker om uit een lijst te kiezen.",
"casting_assign_to": "Een stem toewijzen aan {{speaker}}",
"casting_assigned": "{{voice}} toegewezen aan {{speaker}}",
"edit_settings": "Vertaalinstellingen bewerken",
"style_label_prefix": "stijl:",
"collapse_settings": "Vertaalinstellingen samenvouwen",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Z filmu · {{duration}}s",
"clone_profiles": "Profile klonowania",
"design_presets": "Ustawienia wstępne projektu",
"casting_board": "Tablica castingowa",
"casting_board_hint": "Otwórz tablicę castingową — przeciągnij głos na mówcę",
"casting_voices": "Głosy",
"casting_drag_hint": "Przeciągnij żeton głosu na mówcę lub naciśnij Enter na mówcy, aby wybrać z listy.",
"casting_assign_to": "Przypisz głos do {{speaker}}",
"casting_assigned": "Przypisano {{voice}} do {{speaker}}",
"edit_settings": "Edytuj ustawienia tłumaczenia",
"style_label_prefix": "styl:",
"collapse_settings": "Zwiń ustawienia tłumaczenia",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Do vídeo · {{duration}}s",
"clone_profiles": "Clonar perfis",
"design_presets": "Predefinições de projeto",
"casting_board": "Quadro de elenco",
"casting_board_hint": "Abrir o quadro de elenco — arraste uma voz para um falante",
"casting_voices": "Vozes",
"casting_drag_hint": "Arraste um chip de voz para um falante, ou pressione Enter num falante para escolher de uma lista.",
"casting_assign_to": "Atribuir uma voz a {{speaker}}",
"casting_assigned": "{{voice}} atribuída a {{speaker}}",
"edit_settings": "Editar configurações de tradução",
"style_label_prefix": "estilo:",
"collapse_settings": "Recolher configurações de tradução",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Из видео · {{duration}}s",
"clone_profiles": "Клонировать профили",
"design_presets": "Пресеты дизайна",
"casting_board": "Кастинг-доска",
"casting_board_hint": "Открыть кастинг-доску — перетащите голос на говорящего",
"casting_voices": "Голоса",
"casting_drag_hint": "Перетащите плашку голоса на говорящего или нажмите Enter на говорящем, чтобы выбрать из списка.",
"casting_assign_to": "Назначить голос для {{speaker}}",
"casting_assigned": "{{voice}} назначен для {{speaker}}",
"edit_settings": "Изменить настройки перевода",
"style_label_prefix": "стиль:",
"collapse_settings": "Свернуть настройки перевода",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Från video · {{duration}}s",
"clone_profiles": "Klona profiler",
"design_presets": "Designförinställningar",
"casting_board": "Rollbesättningstavla",
"casting_board_hint": "Öppna rollbesättningstavlan — dra en röst till en talare",
"casting_voices": "Röster",
"casting_drag_hint": "Dra en röstbricka till en talare, eller tryck på Enter på en talare för att välja från en lista.",
"casting_assign_to": "Tilldela en röst till {{speaker}}",
"casting_assigned": "{{voice}} tilldelad {{speaker}}",
"edit_settings": "Redigera översättningsinställningar",
"style_label_prefix": "stil:",
"collapse_settings": "Komprimera översättningsinställningar",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎶 จากวิดีโอ · {{duration}}s",
"clone_profiles": "โปรไฟล์โคลน",
"design_presets": "การออกแบบที่ตั้งไว้ล่วงหน้า",
"casting_board": "บอร์ดคัดเลือกเสียง",
"casting_board_hint": "เปิดบอร์ดคัดเลือกเสียง — ลากเสียงไปวางบนผู้พูด",
"casting_voices": "เสียง",
"casting_drag_hint": "ลากชิปเสียงไปวางบนผู้พูด หรือกด Enter ที่ผู้พูดเพื่อเลือกจากรายการ",
"casting_assign_to": "กำหนดเสียงให้ {{speaker}}",
"casting_assigned": "กำหนด {{voice}} ให้ {{speaker}} แล้ว",
"edit_settings": "แก้ไขการตั้งค่าการแปล",
"style_label_prefix": "สไตล์:",
"collapse_settings": "ยุบการตั้งค่าการแปล",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Videodan · {{duration}}s",
"clone_profiles": "Profilleri Klonla",
"design_presets": "Tasarım Ön Ayarları",
"casting_board": "Seslendirme panosu",
"casting_board_hint": "Seslendirme panosunu aç — bir sesi konuşmacının üzerine sürükleyin",
"casting_voices": "Sesler",
"casting_drag_hint": "Bir ses çipini konuşmacının üzerine sürükleyin veya listeden seçmek için konuşmacıda Enter'a basın.",
"casting_assign_to": "{{speaker}} için ses ata",
"casting_assigned": "{{voice}}, {{speaker}} olarak atandı",
"edit_settings": "Çeviri ayarlarını düzenleyin",
"style_label_prefix": "stil:",
"collapse_settings": "Çeviri ayarlarını daralt",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 З відео · {{duration}}s",
"clone_profiles": "Профілі клонів",
"design_presets": "Попередні налаштування дизайну",
"casting_board": "Кастинг-дошка",
"casting_board_hint": "Відкрити кастинг-дошку — перетягніть голос на мовця",
"casting_voices": "Голоси",
"casting_drag_hint": "Перетягніть плашку голосу на мовця або натисніть Enter на мовці, щоб вибрати зі списку.",
"casting_assign_to": "Призначити голос для {{speaker}}",
"casting_assigned": "{{voice}} призначено для {{speaker}}",
"edit_settings": "Редагувати налаштування перекладу",
"style_label_prefix": "стиль:",
"collapse_settings": "Згорнути налаштування перекладу",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 Từ video · {{duration}}s",
"clone_profiles": "Hồ sơ nhân bản",
"design_presets": "Cài đặt trước thiết kế",
"casting_board": "Bảng phân vai",
"casting_board_hint": "Mở bảng phân vai — kéo một giọng nói thả vào người nói",
"casting_voices": "Giọng nói",
"casting_drag_hint": "Kéo thẻ giọng nói thả vào người nói, hoặc nhấn Enter trên người nói để chọn từ danh sách.",
"casting_assign_to": "Gán giọng nói cho {{speaker}}",
"casting_assigned": "Đã gán {{voice}} cho {{speaker}}",
"edit_settings": "Chỉnh sửa cài đặt dịch",
"style_label_prefix": "phong cách:",
"collapse_settings": "Thu gọn cài đặt dịch",
+6
View File
@@ -977,6 +977,12 @@
"from_video": "🎤 来自视频 · {{duration}}s",
"clone_profiles": "克隆配置",
"design_presets": "设计预设",
"casting_board": "选角面板",
"casting_board_hint": "打开选角面板——将声音拖到说话人上",
"casting_voices": "声音",
"casting_drag_hint": "将声音标签拖到说话人上,或在说话人上按 Enter 从列表中选择。",
"casting_assign_to": "为 {{speaker}} 指派声音",
"casting_assigned": "已将 {{voice}} 指派给 {{speaker}}",
"edit_settings": "编辑翻译设置",
"style_label_prefix": "风格:",
"collapse_settings": "收起翻译设置",
+6
View File
@@ -1018,6 +1018,12 @@
"from_video": "🎤 來自影片·{{duration}}s",
"clone_profiles": "克隆設定檔",
"design_presets": "設計預設",
"casting_board": "選角面板",
"casting_board_hint": "開啟選角面板——將聲音拖曳到說話者上",
"casting_voices": "聲音",
"casting_drag_hint": "將聲音標籤拖曳到說話者上,或在說話者上按 Enter 從清單中選擇。",
"casting_assign_to": "為 {{speaker}} 指派聲音",
"casting_assigned": "已將 {{voice}} 指派給 {{speaker}}",
"edit_settings": "編輯翻譯設定",
"style_label_prefix": "風格:",
"collapse_settings": "折疊翻譯設置",
+53
View File
@@ -4641,6 +4641,59 @@ button.dub-stepper__action:focus-visible {
.dub-cast--muted .dub-cast__label,
.dub-cast--muted .dub-cast__kicker { color: var(--chrome-fg-dim); }
/* ── Casting board — drag voice chips onto speaker rows ──────────────────── */
.dub-cast__board-toggle {
display: inline-flex; align-items: center; gap: 3px; margin-left: auto;
padding: 1px 7px; font-size: 0.58rem; font-weight: var(--weight-semibold);
color: var(--chrome-fg-muted); background: transparent;
border: 1px solid var(--chrome-border); border-radius: var(--radius-pill);
cursor: pointer; transition: color 0.15s ease, border-color 0.15s ease;
}
.dub-cast__board-toggle:hover,
.dub-cast__board-toggle.is-open { color: var(--color-brand); border-color: var(--color-brand); }
.dub-cast__board { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; padding: 6px; border: 1px dashed var(--chrome-border); border-radius: var(--radius-sm); }
.dub-cast__palette { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }
.dub-cast__chip {
padding: 1px 8px; font-size: 0.6rem; color: var(--chrome-fg);
background: color-mix(in srgb, var(--color-brand) 10%, transparent);
border: 1px solid var(--chrome-border); border-radius: var(--radius-pill);
cursor: grab; user-select: none; white-space: nowrap;
}
.dub-cast__chip:active { cursor: grabbing; }
.dub-cast__hint { margin: 0; font-size: 0.55rem; color: var(--chrome-fg-dim); }
.dub-cast__rows { display: flex; flex-direction: column; gap: 2px; }
.dub-cast__row {
position: relative; display: flex; align-items: center; gap: 6px;
padding: 2px 6px; border: 1px solid transparent; border-radius: var(--radius-sm);
transition: border-color 0.15s ease, background 0.15s ease;
}
.dub-cast__row.is-drop { border-color: var(--color-brand); background: color-mix(in srgb, var(--color-brand) 12%, transparent); }
.dub-cast__row-btn {
display: inline-flex; align-items: baseline; gap: 6px; min-width: 0;
padding: 1px 2px; background: transparent; border: none; cursor: pointer;
font: inherit; text-align: left;
}
.dub-cast__row-speaker { font-family: var(--chrome-font-mono); font-size: 0.62rem; color: var(--chrome-fg); }
.dub-cast__row-voice { font-size: 0.6rem; color: var(--color-brand); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dub-cast__auto-chip {
padding: 0 6px; font-size: 0.55rem; color: var(--chrome-fg-muted);
border: 1px solid var(--chrome-border); border-radius: var(--radius-pill); white-space: nowrap;
}
.dub-cast__listbox {
position: absolute; z-index: 20; top: calc(100% + 2px); left: 0;
display: flex; flex-direction: column; min-width: 150px; max-height: 180px;
overflow-y: auto; padding: 3px; background: var(--chrome-bg, #282828);
border: 1px solid var(--chrome-border); border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.dub-cast__option {
padding: 2px 7px; font-size: 0.62rem; text-align: left; color: var(--chrome-fg);
background: transparent; border: none; border-radius: var(--radius-sm); cursor: pointer;
}
.dub-cast__option:hover,
.dub-cast__option:focus-visible { background: color-mix(in srgb, var(--color-brand) 16%, transparent); outline: none; }
.dub-cast__option.is-current { color: var(--color-brand); font-weight: var(--weight-semibold); }
/* ── Multi-language preview switcher ──────────────────────────────────── */
.dub-lang-switch {
display: flex;
+56
View File
@@ -46,6 +46,62 @@ export function autoProfileId(speakerId) {
return `auto:${cleaned.join('') || 'speaker'}`;
}
/** Active merged parts for cast lookup. An empty `merge_parts` means the
* editable projection was cleared; the original attribution remains the
* canonical source for speakers and their assigned voices. */
export function castParts(segment) {
if (Array.isArray(segment?.merge_parts) && segment.merge_parts.length) {
return segment.merge_parts;
}
return Array.isArray(segment?.merge_parts_original) ? segment.merge_parts_original : [];
}
/**
* The one per-speaker cast mutation: give every segment (and merged part)
* spoken by `speakerId` the voice `profileId`. Shared by the CAST dropdowns
* and the casting board so a drag-and-drop writes exactly the fields the
* <select> always has `profile_id` on directly-matching segments, plus
* `merge_parts` / `merge_parts_original` attribution on merged rows (a merged
* row keeps its own top-level voice when only a nested part's speaker moves).
*/
export function assignSpeakerProfile(segments, speakerId, profileId) {
return (segments || []).map((s) => {
const directMatch = s.speaker_id === speakerId;
const nestedMatch = castParts(s).some((part) => part.speaker_id === speakerId);
if (!directMatch && !nestedMatch) return s;
return {
...s,
...(directMatch ? { profile_id: profileId } : {}),
...(s.merge_parts
? {
merge_parts: s.merge_parts.map((part) =>
part.speaker_id === speakerId ? { ...part, profile_id: profileId } : part,
),
}
: {}),
...(s.merge_parts_original
? {
merge_parts_original: s.merge_parts_original.map((part) =>
part.speaker_id === speakerId ? { ...part, profile_id: profileId } : part,
),
}
: {}),
};
});
}
/** Every distinct diarized speaker in cast order top-level ids first-seen,
* including speakers that only survive inside merged rows' `merge_parts`. */
export function castSpeakers(segments) {
return [
...new Set(
(segments || [])
.flatMap((s) => [s.speaker_id, ...castParts(s).map((part) => part.speaker_id)])
.filter(Boolean),
),
];
}
/** Recover path-free cast metadata from current or legacy job payloads. */
export function castSourcesFromJob(job) {
if (!job || typeof job !== 'object') return {};