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:
co-authored by
Cursor Agent
Palash Debnath
parent
0ed7d2ec22
commit
65236774aa
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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": "طي إعدادات الترجمة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "अनुवाद सेटिंग संक्षिप्त करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "翻訳設定を折りたたむ",
|
||||
|
||||
@@ -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": "번역 설정 접기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Свернуть настройки перевода",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ยุบการตั้งค่าการแปล",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Згорнути налаштування перекладу",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "收起翻译设置",
|
||||
|
||||
@@ -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": "折疊翻譯設置",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user