feat(ui): organize dubbing controls and export drawer

This commit is contained in:
Palash Debnath
2026-09-05 23:17:56 +05:30
parent 81af253c7e
commit 0af98edb3e
17 changed files with 447 additions and 312 deletions
+4
View File
@@ -15,6 +15,10 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
+1 -1
View File
@@ -131,7 +131,7 @@ The desktop launcher configures Python dependencies on first run via `uv` automa
|---|---|
| **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video ([export guide](docs/dubbing/export.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video; grouped casting and translation controls include searchable language and engine menus ([export guide](docs/dubbing/export.md)) |
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
| **Vocal Isolation** | Demucs speech/background separation |
+21 -22
View File
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useTranslation } from 'react-i18next';
import { List } from 'react-window';
import DubSegmentRow from './DubSegmentRow';
import { Table, Select } from '../ui';
import { Table } from '../ui';
import { Headphones } from 'lucide-react';
import SearchableSelect from './SearchableSelect';
import DubToggle from './dub/DubToggle';
import { useAppStore } from '../store';
import { visibleMergeAvailability } from '../utils/segmentParts';
import useDubLivePreview from '../hooks/useDubLivePreview';
@@ -315,29 +318,25 @@ export default function DubSegmentTable({
searchPlaceholder={t('segment.search_placeholder')}
meta={meta}
>
<label className="dub-live-toggle" title={t('dub.live_preview_title')}>
<input
type="checkbox"
className="accent-[var(--color-brand)]"
checked={!!livePreviewOn}
onChange={(e) => setDubLivePreview(e.target.checked)}
/>
{t('dub.live_preview')}
</label>
<DubToggle
label={t('dub.live_preview')}
title={t('dub.live_preview_title')}
Icon={Headphones}
checked={livePreviewOn}
onChange={setDubLivePreview}
/>
{speakers.length > 1 && (
<Select
size="sm"
<SearchableSelect
menuPortal
ariaLabel={t('segment.all_speakers')}
value={speakerFilter}
onChange={(e) => setSpeakerFilter(e.target.value)}
className="dub-segment-table__spk-filter"
>
<option value="">{t('segment.all_speakers')}</option>
{speakers.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</Select>
onChange={setSpeakerFilter}
buttonClassName="min-h-10 rounded-lg border-0 px-3 text-sm bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]"
options={[
{ value: '', label: t('segment.all_speakers') },
...speakers.map((s) => ({ value: s, label: s })),
]}
/>
)}
</Table.Toolbar>
+18
View File
@@ -0,0 +1,18 @@
.export-drawer > header { padding: 18px 20px; border: 0; }
.export-drawer > header > span:not([aria-hidden]) { min-width: 0; }
.export-drawer > header > button { min-width: 36px; min-height: 36px; flex-shrink: 0; }
.export-tabs button { justify-content: center; min-height: 42px; border: 0; border-radius: 6px; }
.export-tabs button svg { width: 16px; height: 16px; }
.export-tabs .export-tab-active { background: var(--chrome-accent-bg); }
.export-fields > .grid { grid-template-columns: repeat(auto-fit,minmax(min(100%,260px),1fr)); gap: 12px; }
.export-field .ui-seg { flex-wrap: wrap; align-self: flex-start; border-radius: 8px; }
.export-field .ui-seg button { min-height: 34px; font-size: 12px; }
.export-field .dub-setting-toggle { text-align: left; line-height: 1.5; padding-inline: 0; }
.export-summary > div:first-child { flex: 1; flex-direction: column; align-items: flex-start; }
.export-summary code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
.export-summary button { min-height: 40px; }
@media (max-width: 520px) {
.export-summary { flex-direction: column; align-items: stretch; }
.export-summary > div:last-child { justify-content: flex-end; }
.export-tabs button { padding-inline: 4px; font-size: 12px; gap: 4px; }
}
+92 -106
View File
@@ -16,17 +16,16 @@ import {
} from 'lucide-react';
import { Button, Segmented } from '../ui';
import TrackManager from './dub/TrackManager';
import DubToggle from './dub/DubToggle';
import SearchableSelect from './SearchableSelect';
import './ExportModal.css';
const TAB_BASE =
'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]';
const tabCls = (active) =>
active
? `${TAB_BASE} border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
? `${TAB_BASE} export-tab-active border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
: `${TAB_BASE} border-b-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]`;
const TOGGLE_CLS =
'inline-flex items-center gap-[6px] text-[length:var(--text-sm)] text-[var(--chrome-fg)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
const TOGGLE_INDENT_CLS =
'inline-flex items-center gap-[6px] ml-[var(--space-4)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
/**
* ExportModal — comprehensive export panel for the dubbing studio.
@@ -278,7 +277,7 @@ export default function ExportModal({
aria-label={t('exportModal.export_options')}
>
<div
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-transparent bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
className="export-drawer pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[85vh] flex-col overflow-hidden rounded-t-xl border-0 bg-[var(--chrome-bg)] shadow-xl animate-in fade-in duration-200 motion-reduce:animate-none"
ref={drawerRef}
>
<header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]">
@@ -303,7 +302,7 @@ export default function ExportModal({
<X size={13} />
</button>
</header>
<div className="flex flex-col gap-[var(--space-4)] overflow-y-auto p-[var(--space-3)_var(--space-4)_var(--space-4)]">
<div className="export-body flex min-h-0 flex-col gap-4 overflow-y-auto p-4">
{/* Preset chips */}
<div className="flex flex-wrap items-center gap-[var(--space-2)] pb-[var(--space-3)] [border-bottom:1px_solid_var(--chrome-border)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
@@ -332,7 +331,7 @@ export default function ExportModal({
/>
{/* Tabs */}
<div className="flex gap-[var(--space-1)] [border-bottom:1px_solid_var(--chrome-border)]">
<div className="export-tabs grid grid-cols-4 gap-1 rounded-lg bg-[var(--chrome-hover-bg)] p-1">
<button
type="button"
className={tabCls(tab === 'video')}
@@ -356,7 +355,7 @@ export default function ExportModal({
</div>
{/* Tab body */}
<div className="min-h-[160px]">
<div className="export-fields">
{tab === 'video' && (
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-[var(--space-4)]">
<Field label={t('exportModal.container')}>
@@ -371,42 +370,38 @@ export default function ExportModal({
label={t('exportModal.default_audio_track')}
hint={t('exportModal.default_audio_hint')}
>
<select
className="input-base input-base--xs"
<SearchableSelect
menuPortal
ariaLabel={t('exportModal.default_audio_track')}
value={defaultTrack}
onChange={(e) => setDefaultTrack(e.target.value)}
>
{exportTracks['original'] !== false && (
<option value="original">{t('exportModal.original')}</option>
)}
{(dubTracks || [])
.filter((code) => exportTracks[code] !== false)
.map((code) => (
<option key={code} value={code}>
{code.toUpperCase()} {t('exportModal.dub_suffix')}
</option>
))}
</select>
onChange={setDefaultTrack}
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
options={[
...(exportTracks.original !== false
? [{ value: 'original', label: t('exportModal.original') }]
: []),
...(dubTracks || [])
.filter((code) => exportTracks[code] !== false)
.map((code) => ({
value: code,
label: code.toUpperCase() + ' ' + t('exportModal.dub_suffix'),
})),
]}
/>
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className={TOGGLE_CLS}>
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
{t('exportModal.mix_bg_video')}
</label>
<DubToggle
label={t('exportModal.mix_bg_video')}
checked={preserveBg}
onChange={setPreserveBg}
/>
</Field>
<Field label={t('exportModal.subs_in_video')}>
<label className={TOGGLE_CLS}>
<input
type="checkbox"
checked={burnSubs}
onChange={(e) => setBurnSubs(e.target.checked)}
/>
{t('exportModal.hardsub')}
</label>
<DubToggle
label={t('exportModal.hardsub')}
checked={burnSubs}
onChange={setBurnSubs}
/>
{burnSubs && (
<Segmented
size="sm"
@@ -425,14 +420,11 @@ export default function ExportModal({
/>
)}
{burnSubs && (
<label className={TOGGLE_INDENT_CLS}>
<input
type="checkbox"
checked={!!dualSubs}
onChange={(e) => setDualSubs(e.target.checked)}
/>
{t('exportModal.dual_subs_video')}
</label>
<DubToggle
label={t('exportModal.dual_subs_video')}
checked={!!dualSubs}
onChange={setDualSubs}
/>
)}
</Field>
{(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && (
@@ -482,28 +474,25 @@ export default function ExportModal({
]}
/>
{audioBatch === 'primary' && (
<select
className="input-base input-base--xs mt-[6px]"
<SearchableSelect
menuPortal
ariaLabel={t('exportModal.languages')}
value={audioPrimaryLang}
onChange={(e) => setAudioPrimaryLang(e.target.value)}
>
{(dubTracks || []).map((code) => (
<option key={code} value={code}>
{code.toUpperCase()}
</option>
))}
</select>
onChange={setAudioPrimaryLang}
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
options={(dubTracks || []).map((code) => ({
value: code,
label: code.toUpperCase(),
}))}
/>
)}
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className={TOGGLE_CLS}>
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
{t('exportModal.mix_bg_audio')}
</label>
<DubToggle
label={t('exportModal.mix_bg_audio')}
checked={preserveBg}
onChange={setPreserveBg}
/>
</Field>
</div>
)}
@@ -602,44 +591,43 @@ export default function ExportModal({
.
</span>
</div>
{/* Summary footer */}
<div className="flex items-center justify-between gap-[var(--space-3)] pt-[var(--space-3)] [border-top:1px_solid_var(--chrome-border)]">
<div className="flex min-w-0 items-center gap-[var(--space-2)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{t('exportModal.output')}
</span>
<code
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
title={filenamePreview}
>
{filenamePreview}
</code>
</div>
<div className="inline-flex gap-[var(--space-2)]">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={active.fn}
disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : t('exportModal.nothing_selected')}
>
{active.label}
</Button>
</>
)}
{tab === 'pkg' && (
</div>
{/* Summary footer stays visible while format settings scroll. */}
<div className="export-summary flex shrink-0 items-center justify-between gap-3 p-4 bg-[var(--chrome-hover-bg)]">
<div className="flex min-w-0 items-center gap-[var(--space-2)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{t('exportModal.output')}
</span>
<code
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
title={filenamePreview}
>
{filenamePreview}
</code>
</div>
<div className="inline-flex gap-[var(--space-2)]">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.close')}
{t('common.cancel')}
</Button>
)}
</div>
<Button
variant="primary"
size="sm"
onClick={active.fn}
disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : t('exportModal.nothing_selected')}
>
{active.label}
</Button>
</>
)}
{tab === 'pkg' && (
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.close')}
</Button>
)}
</div>
</div>
</div>
@@ -650,11 +638,9 @@ export default function ExportModal({
function Field({ label, hint, children }) {
return (
<div className="flex flex-col gap-[6px]">
<div className="export-field flex min-w-0 flex-col gap-3 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
<div className="flex flex-col gap-[2px]">
<span className="uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{label}
</span>
<span className="text-sm font-medium text-[var(--chrome-fg)]">{label}</span>
{hint && (
<span className="text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)] leading-[1.4]">
{hint}
+13 -2
View File
@@ -3,8 +3,8 @@
// inside `.map(t => …)` callbacks where `t` was the loop variable, shadowing the
// useTranslation `t`. Rendering with a dub track that equals the primary
// dubLangCode exercises the exact crashing branch.
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '../i18n';
import ExportModal from './ExportModal';
@@ -41,6 +41,17 @@ function renderModal(extra = {}) {
}
describe('ExportModal (regression #183)', () => {
it('keeps export actions outside the scrolling settings and uses themed track choices', () => {
const setDefaultTrack = vi.fn();
renderModal({ setDefaultTrack });
expect(document.querySelector('.export-body')).not.toContainElement(
document.querySelector('.export-summary'),
);
fireEvent.click(screen.getByRole('button', { name: /default audio track/i }));
fireEvent.mouseDown(screen.getByRole('option', { name: /^ES/ }));
expect(setDefaultTrack).toHaveBeenCalledWith('es');
expect(screen.getAllByRole('switch')).toHaveLength(2);
});
it('renders with dub tracks incl. the primary dub without throwing', () => {
expect(() => renderModal()).not.toThrow();
});
+53 -34
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import { LayoutGrid, Users, UserRound, Fingerprint } from 'lucide-react';
import SearchableSelect from '../SearchableSelect';
import { PRESET_ICONS, FALLBACK_VOICE_ICON, stripVoiceEmoji } from '../../utils/voiceIcons';
import toast from 'react-hot-toast';
import { PRESETS } from '../../utils/constants';
import { autoProfileId, assignSpeakerProfile, castParts, castSpeakers } from '../../utils/segments';
@@ -130,12 +132,13 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
];
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="dub-casting-controls my-3 p-4 bg-[var(--chrome-hover-bg)] rounded-lg border-0">
<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')}
>
<Users size={16} aria-hidden="true" className="inline-block mr-2 align-text-bottom" />
{t('dub.cast')}
</span>
{speakers.map((spk) => {
@@ -143,40 +146,56 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
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}:
<UserRound
size={14}
aria-hidden="true"
className="inline-block mr-1 align-text-bottom"
/>
{spk}
</span>
<select
className="input-base dub-cast__select"
<SearchableSelect
ariaLabel={spk}
menuPortal
renderGroupHeaders
buttonClassName="dub-cast__select min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 py-2 text-sm text-[var(--chrome-fg)] hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
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>
onChange={(value) => setDubSegments(assignSpeakerProfile(dubSegments, spk, value))}
options={[
...(clone
? [
{
value: autoProfileId(spk),
label: t('dub.from_video', { duration: clone.duration.toFixed(1) }),
Icon: UserRound,
},
]
: []),
{ value: '', label: t('dub.default'), Icon: UserRound },
...profiles.map((p) => ({
value: p.id,
label: p.name,
group: 'clones',
groupLabel: t('dub.clone_profiles'),
Icon: Fingerprint,
})),
...PRESETS.map((p) => ({
value: 'preset:' + p.id,
label: stripVoiceEmoji(p.name),
group: 'presets',
groupLabel: t('dub.design_presets'),
Icon: PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON,
})),
]}
renderOption={(option) => {
const Icon = option.Icon;
return (
<span className="inline-flex items-center gap-2">
<Icon size={16} aria-hidden="true" className="shrink-0 opacity-75" />
<span>{option.label}</span>
</span>
);
}}
/>
</div>
);
})}
@@ -175,12 +175,12 @@ describe('CastingBoard ↔ dropdown sync', () => {
openBoard();
const selects = document.querySelectorAll('.dub-cast__select');
expect(selects[1].value).toBe(''); // SPEAKER_2 starts on Default
expect(selects[1]).toHaveTextContent(t('dub.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(document.querySelectorAll('.dub-cast__select')[1]).toHaveTextContent('Ben');
expect(within(row).getByRole('button')).toHaveTextContent('Ben');
});
});
@@ -220,7 +220,8 @@ describe('CastingBoard auto-clone chip', () => {
).toBeInTheDocument();
const selects = document.querySelectorAll('.dub-cast__select');
fireEvent.change(selects[1], { target: { value: 'voice-b' } });
fireEvent.click(selects[1]);
fireEvent.mouseDown(screen.getByRole('option', { name: 'Ben', exact: true }));
const updated = props.setDubSegments.mock.calls[0][0];
expect(updated[0].profile_id).toBe('voice-a');
@@ -0,0 +1,22 @@
.dub-panel-left .wfm-layout { flex: 0 0 auto; }
.dub-panel-left .wfm-stack { flex: 0 0 auto; }
.dub-panel-left .wfm-controls { padding: 10px 4px; gap: 8px; flex-wrap: wrap; }
.dub-panel-left .wfm-controls button { min-width: 32px; min-height: 32px; border: 0; border-radius: 6px; }
.dub-translation-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; align-items: start; }
.dub-translation-fields > div { min-width: 0; }
.dub-translation-settings label { font-size: 12px; line-height: 1.5; gap: 8px; }
.dub-translation-settings input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--chrome-accent); }
.dub-translation-settings .label-icon { width: 15px; height: 15px; }
.dub-translation-actions { padding-top: 8px; }
.dub-translation-actions button { min-height: 36px; font-size: 12px; }
.dub-casting-controls { padding: 12px; container-type: inline-size; }
.dub-casting-controls > div:first-child { gap: 12px; align-items: center; }
.dub-casting-controls .dub-cast__pair { display: flex; flex: 1 1 250px; flex-direction: row; align-items: center; gap: 8px; min-width: 0; padding: 0; }
.dub-casting-controls .dub-cast__pair > span { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; font-family: var(--font-sans); font-size: 12px; }
.dub-casting-controls .dub-cast__pair > .ss-wrap { flex: 1; min-width: 0; }
.dub-casting-controls .dub-cast__select { width: 100%; height: 40px; min-height: 40px; font-size: 12px; padding: 8px 12px; }
.dub-casting-controls .dub-cast__board-toggle { height: 40px; min-height: 40px; font-size: 12px; margin: 0; padding: 8px 10px; white-space: nowrap; }
@container (max-width: 450px) {
.dub-casting-controls .dub-cast__pair { flex-basis: 100%; }
.dub-casting-controls .dub-cast__pair > span { flex-basis: 90px; }
}
+79 -76
View File
@@ -12,6 +12,11 @@ import {
Copy,
ExternalLink,
ArrowRightLeft,
Cpu,
Gauge,
Hash,
MapPin,
RotateCcw,
} from 'lucide-react';
import { Button, Segmented, Progress } from '../../ui';
import { useAppStore } from '../../store';
@@ -21,7 +26,7 @@ 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 } from '../../utils/constants';
import SearchableSelect from '../SearchableSelect';
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
import { dubSegmentsText } from '../../api/dub';
import { copyText } from '../../utils/copyText';
@@ -29,6 +34,7 @@ import { openExternal } from '../../api/external';
import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap';
import CastingBoard from './CastingBoard';
import toast from 'react-hot-toast';
import './DubLeftColumn.css';
// ── Translation-settings bar utility class clusters ──────────────────────
const SETTINGS_SUMMARY =
@@ -36,12 +42,11 @@ const SETTINGS_SUMMARY =
const SUMMARY_TRIGGER =
'inline-flex items-center gap-[5px] flex-1 min-w-0 bg-transparent border-none text-fg-muted cursor-pointer py-[2px] px-0 [font:inherit] text-left';
const SETTINGS_BAR =
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)]';
const FIELD = 'flex flex-col gap-[1px] min-w-0';
const FIELD_RESP = 'max-[960px]:basis-full max-[960px]:min-w-0';
const FIELD_LABEL =
'label-row !text-[0.58rem] !text-fg-muted !m-0 whitespace-nowrap overflow-hidden text-ellipsis';
const FIELD_INPUT = 'input-base !w-full !text-[0.65rem] !px-[5px] !py-[3px]';
'dub-translation-settings flex flex-col gap-4 mb-2 p-4 bg-[var(--chrome-hover-bg)] border-0 rounded-lg';
const FIELD = 'flex flex-col gap-2 min-w-0';
const FIELD_RESP = '';
const FIELD_LABEL = 'flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]';
const FIELD_INPUT = 'input-base !w-full !text-sm !px-3 !py-2 min-h-10';
const ENGINE_CHIP =
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[color-mix(in_srgb,var(--color-brand)_14%,transparent)] border border-transparent text-[var(--color-brand)] rounded-[var(--radius-pill)] whitespace-nowrap transition-colors';
// Highlighted accent Install affordance — brand-filled pill, deliberately louder
@@ -420,69 +425,60 @@ export default function DubLeftColumn({
)}
{settingsOpen && (
<div className={SETTINGS_BAR}>
<div className="flex flex-wrap gap-x-[6px] gap-y-[4px] items-end">
<div className="dub-translation-fields">
<button
type="button"
className={`${SUMMARY_TRIGGER} flex-[0_0_auto] !px-[4px] self-center`}
className={`${SUMMARY_TRIGGER} col-span-full !py-2`}
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
<Languages size={16} aria-hidden="true" />
{t('dub.edit_settings')}
<ChevronUp size={14} className="ml-auto" aria-hidden="true" />
</button>
<div className={`${FIELD} flex-[1_1_100px] min-w-[70px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>
<Globe className="label-icon" size={9} /> {t('dub.language')}
<Globe size={15} aria-hidden="true" /> {t('dub.language')}
</div>
<select
className={FIELD_INPUT}
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
<MultiLangPicker
single
ariaLabel={t('dub.language')}
selected={[{ lang: dubLang, code: dubLangCode }]}
options={ALL_LANGUAGES.map((label) => ({
label,
code: LANG_CODES.find((item) => item.label === label)?.code || label,
}))}
onChange={([item]) => {
setDubLang(item.lang);
const match = LANG_CODES.find(
(lc) => lc.label.toLowerCase() === lang.toLowerCase(),
(lc) => lc.label.toLowerCase() === item.lang.toLowerCase(),
);
if (match) {
setDubLangCode(match.code);
// #280: a dialect belongs to one language — clear it
// whenever the new target doesn't match.
if (!dialectMatchesLang(dubDialect, match.code)) setDubDialect('');
}
}}
>
<optgroup label={t('dub.popular')}>
{POPULAR_LANGS.map((l) => (
<option key={`p-${l}`} value={l}>
{l}
</option>
))}
</optgroup>
<optgroup label={t('dub.all_languages')}>
{ALL_LANGUAGES.filter((l) => !POPULAR_LANGS.includes(l)).map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</optgroup>
</select>
/>
</div>
<div className={`${FIELD} flex-[0_1_72px] min-w-[52px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>{t('dub.iso_code')}</div>
<select
className={FIELD_INPUT}
<div className={FIELD_LABEL}>
<Hash size={15} aria-hidden="true" />
{t('dub.iso_code')}
</div>
<SearchableSelect
ariaLabel={t('dub.iso_code')}
menuPortal
buttonClassName={FIELD_INPUT}
value={dubLangCode}
onChange={(e) => {
const code = e.target.value;
options={LANG_CODES.map((lc) => ({
value: lc.code,
label: lc.code + ' — ' + lc.label,
}))}
onChange={(code) => {
setDubLangCode(code);
if (!dialectMatchesLang(dubDialect, code)) setDubDialect('');
}}
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.code} {lc.label}
</option>
))}
</select>
/>
</div>
{/* #280: regional dialect / vocabulary. Only rendered for
languages with curated variants; region names come from
@@ -490,24 +486,28 @@ export default function DubLeftColumn({
{dialectOptionsFor(dubLangCode).length > 0 && (
<div className={`${FIELD} flex-[0_1_110px] min-w-[80px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL} title={t('dub.dialect_title')}>
<MapPin size={15} aria-hidden="true" />
{t('dub.dialect_label')}
</div>
<select
className={FIELD_INPUT}
<SearchableSelect
ariaLabel={t('dub.dialect_label')}
menuPortal
buttonClassName={FIELD_INPUT}
value={dialectMatchesLang(dubDialect, dubLangCode) ? dubDialect : ''}
onChange={(e) => setDubDialect(e.target.value)}
>
<option value="">{t('dub.dialect_default')}</option>
{dialectOptionsFor(dubLangCode).map((d) => (
<option key={d} value={d}>
{dialectLabel(d, i18n.language)}
</option>
))}
</select>
onChange={setDubDialect}
options={[
{ value: '', label: t('dub.dialect_default') },
...dialectOptionsFor(dubLangCode).map((d) => ({
value: d,
label: dialectLabel(d, i18n.language),
})),
]}
/>
</div>
)}
<div className={`${FIELD} flex-[1.4_1_130px] min-w-[90px] ${FIELD_RESP}`}>
<div className={`${FIELD_LABEL} !overflow-visible flex items-center`}>
<Cpu size={15} aria-hidden="true" />
{t('dub.engine_label')}
{/* FROM-SOURCE lane: pip install works (uv pip install runs
in-process). Promote the muted chip to a highlighted accent
@@ -602,26 +602,27 @@ export default function DubLeftColumn({
</span>
)}
</div>
<select
className={FIELD_INPUT}
<SearchableSelect
ariaLabel={t('dub.engine_label')}
menuPortal
buttonClassName={FIELD_INPUT}
value={translateProvider}
onChange={(e) => setTranslateProvider(e.target.value)}
>
{(engines.length ? engines : []).map((p) => (
<option key={p.id} value={p.id}>
{p.installed
? p.display_name
: `${p.display_name}${t('dub.needs_install_suffix')}`}
</option>
))}
</select>
onChange={setTranslateProvider}
options={engines.map((p) => ({
value: p.id,
label: p.installed
? p.display_name
: p.display_name + t('dub.needs_install_suffix'),
}))}
/>
</div>
<div className={`${FIELD} flex-[0_1_auto] min-w-[80px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL} title={t('dub.quality_title')}>
<Gauge size={15} aria-hidden="true" />
{t('dub.quality_label')}
</div>
<Segmented
className="w-full"
className="w-full [&_button]:min-h-8 [&_button]:text-xs"
size="sm"
value={translateQuality}
onChange={setTranslateQuality}
@@ -684,19 +685,20 @@ export default function DubLeftColumn({
)}
<div className={`${FIELD} flex-[1_1_90px] min-w-[64px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>
<UserSquare2 className="label-icon" size={9} /> {t('dub.style')}{' '}
<UserSquare2 size={15} aria-hidden="true" /> {t('dub.style')}{' '}
<span className="text-[0.52rem] text-fg-subtle italic ml-[2px]">
{t('dub.optional')}
</span>
</div>
<input
className={FIELD_INPUT}
aria-label={t('dub.style')}
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</div>
<div className={`${FIELD} basis-full pt-[3px] border-t border-transparent mt-[1px]`}>
<div className={`${FIELD} col-span-full pt-2`}>
<label className="flex items-center gap-[6px] text-[0.65rem] text-[var(--chrome-fg-muted)] cursor-pointer mb-[2px]">
<input
type="checkbox"
@@ -716,7 +718,7 @@ export default function DubLeftColumn({
)}
</div>
</div>
<div className="flex justify-end gap-[6px] flex-wrap">
<div className="dub-translation-actions flex justify-end gap-2 flex-wrap">
{failedTranslationCount > 0 && (
<>
<Button
@@ -760,6 +762,7 @@ export default function DubLeftColumn({
}
disabled={!dubSegments.some((s) => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
leading={<RotateCcw size={15} aria-hidden="true" />}
>
{t('dub.restore')}
</Button>
@@ -769,7 +772,7 @@ export default function DubLeftColumn({
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
leading={<Wand2 size={15} aria-hidden="true" />}
>
{t('dub.clean_up')}
</Button>
@@ -0,0 +1,13 @@
.dub-output-settings { padding: 16px; border-radius: 10px; background: var(--chrome-hover-bg); margin-bottom: 12px; }
.dub-output-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,190px),1fr)); gap: 12px; margin-bottom: 16px; align-items: end; }
.dub-output-settings > div:not(.dub-output-grid) { gap: 10px; margin-top: 12px; padding: 0; }
.dub-output-settings > div > span { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-sans); font-size: 12px; text-transform: none; letter-spacing: normal; }
.dub-output-settings .ui-seg { flex-wrap: wrap; border-radius: 8px; }
.dub-output-settings .ui-seg button { min-height: 34px; font-size: 12px; }
.dub-panel-right .dub-transcript-toggle__inner { width: 100%; min-height: 42px; border: 0; border-radius: 8px; padding: 10px 12px; background: var(--chrome-hover-bg); }
.dub-panel-right .dub-transcript-toggle__inner > span { display: inline-flex; align-items: center; gap: 6px; }
.dub-panel-right .ui-table-toolbar { flex-wrap: wrap; gap: 8px; padding-block: 10px; }
.dub-panel-right .ui-table-toolbar__search { flex: 1 1 160px; }
.dub-panel-right .ui-table-toolbar__search-input { min-height: 40px; font-size: 12px; }
.dub-panel-right .ui-table-toolbar > .ss-wrap { flex: 1 1 160px; }
.dub-panel-right .ui-table-toolbar .dub-setting-toggle { white-space: nowrap; flex-shrink: 0; }
+77 -57
View File
@@ -1,5 +1,21 @@
import { Suspense, lazy, useState } from 'react';
import { ChevronUp, ChevronDown, FileText, ClipboardPaste } from 'lucide-react';
import {
ChevronUp,
ChevronDown,
FileText,
ClipboardPaste,
AudioLines,
Languages,
Captions,
ListMusic,
Timer,
Fingerprint,
BookOpen,
Settings2,
} from 'lucide-react';
import DubToggle from './DubToggle';
import SearchableSelect from '../SearchableSelect';
import './DubRightColumn.css';
import { Button, Segmented } from '../../ui';
import GlossaryPanel from '../GlossaryPanel';
import CheckpointBanner from '../CheckpointBanner';
@@ -15,11 +31,8 @@ const LazyFallback = () => <div className="p-[12px] text-[#6b6657] text-[0.7rem]
// ── Output-options + bulk-select utility clusters ────────────────────────
const OUT_ROW =
'flex items-center gap-[var(--space-3)] mb-[2px] px-[var(--space-2)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] flex-wrap';
const OUT_LABEL =
'flex items-center gap-[var(--space-2)] cursor-pointer hover:text-[var(--chrome-fg)]';
const OUT_TITLE =
'font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold';
const CHK = 'accent-[var(--color-brand)]';
const BULK_SELECT = 'input-base !text-[0.62rem] !px-[4px] !py-[2px]';
export default function DubRightColumn({
@@ -87,62 +100,63 @@ export default function DubRightColumn({
return (
<div className="studio-panel dub-panel-col dub-panel-right">
{/* Output options + timing — moved to the top of the right section. */}
<div>
<div className={OUT_ROW}>
<span className={OUT_TITLE}>{t('dub.output_options')}</span>
<label className={OUT_LABEL}>
<input
type="checkbox"
className={CHK}
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>{' '}
{t('dub.mix_bg_audio')}
</label>
<label className={OUT_LABEL} title={t('dub.dual_subs_title')}>
<input
type="checkbox"
className={CHK}
checked={!!dualSubs}
onChange={(e) => setDualSubs(e.target.checked)}
/>{' '}
{t('dub.dual_subs')}
</label>
<label className={OUT_LABEL} title={t('dub.burn_subs_title')}>
<input
type="checkbox"
className={CHK}
checked={!!burnSubs}
onChange={(e) => setBurnSubs(e.target.checked)}
/>{' '}
{t('dub.burn_subs')}
</label>
<label className={OUT_LABEL}>
{t('dub.default_track')}
<select
className="input-base !text-[0.6rem] !px-[4px] !py-[2px] !w-[120px]"
<div className="dub-output-settings">
<div className="dub-output-grid">
<span className={`${OUT_TITLE} col-span-full inline-flex items-center gap-2`}>
<Settings2 size={16} aria-hidden="true" />
{t('dub.output_options')}
</span>
<DubToggle
label={t('dub.mix_bg_audio')}
Icon={AudioLines}
checked={preserveBg}
onChange={setPreserveBg}
/>
<DubToggle
label={t('dub.dual_subs')}
title={t('dub.dual_subs_title')}
Icon={Languages}
checked={dualSubs}
onChange={setDualSubs}
/>
<DubToggle
label={t('dub.burn_subs')}
title={t('dub.burn_subs_title')}
Icon={Captions}
checked={burnSubs}
onChange={setBurnSubs}
/>
<div className="flex flex-col gap-2 min-w-0">
<span className="inline-flex items-center gap-2 text-sm">
<ListMusic size={15} aria-hidden="true" />
{t('dub.default_track')}
</span>
<SearchableSelect
ariaLabel={t('dub.default_track')}
menuPortal
value={resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks)}
onChange={(e) => setDefaultTrack(e.target.value)}
>
<option value="original">{t('dub.original_track')}</option>
{dubLangCode && (
<option value={dubLangCode}>{t('dub.selected_dub', { code: dubLangCode })}</option>
)}
{dubTracks
.filter((tr) => tr !== dubLangCode)
.map((tr) => (
<option key={tr} value={tr}>
{t('dub.dub_track', { code: tr })}
</option>
))}
</select>
</label>
onChange={setDefaultTrack}
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
options={[
{ value: 'original', label: t('dub.original_track') },
...(dubLangCode
? [{ value: dubLangCode, label: t('dub.selected_dub', { code: dubLangCode }) }]
: []),
...dubTracks
.filter((tr) => tr !== dubLangCode)
.map((tr) => ({ value: tr, label: t('dub.dub_track', { code: tr }) })),
]}
/>
</div>
</div>
<div
className={OUT_ROW}
title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline."
>
<span className={OUT_TITLE}>Timing:</span>
<span className={OUT_TITLE}>
<Timer size={16} aria-hidden="true" />
Timing:
</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
@@ -176,7 +190,10 @@ export default function DubRightColumn({
(best prosody, identity may drift) or every line of a speaker
shares ONE reference (steady identity). */}
<div className={OUT_ROW} title={t('dub.voice_match_title')}>
<span className={OUT_TITLE}>{t('dub.voice_match')}</span>
<span className={OUT_TITLE}>
<Fingerprint size={16} aria-hidden="true" />
{t('dub.voice_match')}
</span>
<Segmented
value={voiceMatch}
onChange={setVoiceMatch}
@@ -198,7 +215,9 @@ export default function DubRightColumn({
{dubTranscript && (
<div className="mb-[4px]">
<div
<button
type="button"
aria-expanded={showTranscript}
className="override-toggle dub-transcript-toggle__inner"
onClick={() => setShowTranscript(!showTranscript)}
>
@@ -206,7 +225,7 @@ export default function DubRightColumn({
<FileText size={10} className="align-middle mr-[3px]" /> {t('dub.transcript')}
</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
</button>
{showTranscript && (
<div className="bg-[var(--chrome-bg)] border border-transparent border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
{dubTranscript}
@@ -227,6 +246,7 @@ export default function DubRightColumn({
}}
title={t('dub.glossary_title')}
>
<BookOpen size={15} aria-hidden="true" className="mr-2" />
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
@@ -94,6 +94,8 @@ describe('DubRightColumn language targets', () => {
}),
);
expect(screen.getByRole('combobox', { name: 'dub.default_track' })).toHaveValue('es');
expect(screen.getByRole('button', { name: 'dub.default_track' })).toHaveTextContent(
'dub.dub_track',
);
});
});
+26
View File
@@ -0,0 +1,26 @@
export default function DubToggle({ label, title, checked, onChange, Icon }) {
return (
<button
type="button"
role="switch"
aria-checked={!!checked}
aria-label={label}
title={title}
onClick={() => onChange(!checked)}
className="dub-setting-toggle flex items-center justify-between gap-3 min-h-10 px-3 py-2 rounded-lg border-0 bg-[var(--chrome-hover-bg)] text-sm text-[var(--chrome-fg)] cursor-pointer hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
>
<span className="inline-flex items-center gap-2">
{Icon && <Icon size={15} aria-hidden="true" />}
{label}
</span>
<span
aria-hidden="true"
className={`relative shrink-0 w-8 h-[18px] rounded-full ${checked ? 'bg-[var(--chrome-accent)]' : 'bg-[var(--chrome-fg-dim)]'}`}
>
<span
className={`absolute top-0.5 left-0.5 size-3.5 rounded-full bg-[var(--color-bg)] transition-transform motion-reduce:transition-none ${checked ? 'translate-x-3.5' : ''}`}
/>
</span>
</button>
);
}
@@ -0,0 +1,15 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import DubToggle from './DubToggle';
describe('DubToggle', () => {
it('announces its state and updates through the supplied handler', () => {
const onChange = vi.fn();
const { rerender } = render(<DubToggle label="Preview" checked={false} onChange={onChange} />);
fireEvent.click(screen.getByRole('switch', { name: 'Preview', checked: false }));
expect(onChange).toHaveBeenCalledWith(true);
rerender(<DubToggle label="Preview" checked onChange={onChange} />);
expect(screen.getByRole('switch', { name: 'Preview' })).toHaveAttribute('aria-checked', 'true');
});
});
@@ -152,10 +152,8 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
{ id: 'argos', display_name: 'Argos', installed: true, install_command: null },
];
render(<DubLeftColumn {...makeProps({ engines, setTranslateProvider })} />);
// The engine <select> is the only combobox whose current value is 'google'.
const select = screen.getAllByRole('combobox').find((el) => el.value === 'google');
expect(select).toBeTruthy();
fireEvent.change(select, { target: { value: 'argos' } });
fireEvent.click(screen.getByRole('button', { name: 'Engine', exact: true }));
fireEvent.mouseDown(screen.getByRole('option', { name: 'Argos', exact: true }));
expect(setTranslateProvider).toHaveBeenCalledWith('argos');
});
});
@@ -156,9 +156,8 @@ describe('review round: partial translations + dialect guard', () => {
/>,
);
fireEvent.change(document.querySelector('.dub-cast__select'), {
target: { value: 'voice-new' },
});
fireEvent.click(document.querySelector('.dub-cast__select'));
fireEvent.mouseDown(screen.getByRole('option', { name: 'New voice', exact: true }));
const update = setDubSegments.mock.calls[0][0][0];
expect(update.profile_id).toBe('voice-new');
@@ -188,9 +187,8 @@ describe('review round: partial translations + dialect guard', () => {
/>,
);
fireEvent.change(document.querySelectorAll('.dub-cast__select')[1], {
target: { value: 'voice-new' },
});
fireEvent.click(document.querySelectorAll('.dub-cast__select')[1]);
fireEvent.mouseDown(screen.getByRole('option', { name: 'New voice', exact: true }));
const update = setDubSegments.mock.calls[0][0];
expect(update[0].profile_id).toBe('voice-anna');