fix(dub): serialize workflow actions
This commit is contained in:
@@ -36,6 +36,9 @@ export default function DubHeader({
|
||||
pipelineSteps,
|
||||
onPipelineStep,
|
||||
}) {
|
||||
const workflowActionBusy =
|
||||
qcRunning || isTranslating || dubStep === 'generating' || dubStep === 'stopping';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[2px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
|
||||
{/* Row 1: project title (left) + actions (right). Row 2: the pipeline
|
||||
@@ -87,7 +90,7 @@ export default function DubHeader({
|
||||
sm
|
||||
tone="stopping"
|
||||
disabled
|
||||
className="!flex-none [.shell-mini_&]:flex-1"
|
||||
className="flex-none! [.shell-mini_&]:flex-1!"
|
||||
icon={<Loader className="spinner" size={9} aria-hidden="true" />}
|
||||
label={t('dub.stopping')}
|
||||
aria-busy="true"
|
||||
@@ -96,7 +99,7 @@ export default function DubHeader({
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="danger"
|
||||
className="!flex-none hover:-translate-y-px active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1"
|
||||
className="flex-none! hover:-translate-y-px active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1!"
|
||||
onClick={handleDubStop}
|
||||
icon={<Square size={9} aria-hidden="true" />}
|
||||
label={t('dub.stop_progress', {
|
||||
@@ -109,12 +112,12 @@ export default function DubHeader({
|
||||
<FooterBtn
|
||||
sm
|
||||
tone={dubSegments.length && !isTranslating ? 'pink' : 'idle'}
|
||||
className="dub-action-btn--generate !h-[30px] !flex-none !px-[11px] !text-[0.68rem] !font-semibold !tracking-[0.015em] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-accent)_18%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-accent)_26%,transparent)] [.shell-mini_&]:flex-1"
|
||||
className="dub-action-btn--generate !h-[30px] flex-none! !px-[11px] !text-[0.68rem] !font-semibold !tracking-[0.015em] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-accent)_18%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-accent)_26%,transparent)] [.shell-mini_&]:flex-1!"
|
||||
onClick={onGenerateClick}
|
||||
// The multi-language batch translates between generates while
|
||||
// dubStep briefly sits back at 'editing' — keep the CTA inert
|
||||
// during that phase so a re-click can't start a second batch.
|
||||
disabled={!dubSegments.length || isTranslating}
|
||||
disabled={!dubSegments.length || qcRunning || isTranslating}
|
||||
icon={<Play className="fill-current" size={11} aria-hidden="true" />}
|
||||
label={
|
||||
multiLangMode && multiLangs.length > 1
|
||||
@@ -129,7 +132,8 @@ export default function DubHeader({
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="pink"
|
||||
className="!h-[30px] !flex-none !px-[10px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1"
|
||||
className="!h-[30px] flex-none! !px-[10px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1!"
|
||||
disabled={workflowActionBusy}
|
||||
onClick={() =>
|
||||
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
|
||||
}
|
||||
@@ -143,7 +147,7 @@ export default function DubHeader({
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="idle"
|
||||
className="dub-action-btn--verify !h-[30px] !flex-none !px-[9px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:hover:shadow-[var(--shadow-sm)] [.shell-mini_&]:flex-1"
|
||||
className="dub-action-btn--verify !h-[30px] flex-none! !px-[9px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:hover:shadow-[var(--shadow-sm)] [.shell-mini_&]:flex-1!"
|
||||
disabled={qcRunning || !dubSegments.length}
|
||||
onClick={handleDubQc}
|
||||
icon={
|
||||
@@ -162,8 +166,8 @@ export default function DubHeader({
|
||||
<FooterBtn
|
||||
sm
|
||||
tone={dubStep === 'done' ? 'green' : 'idle'}
|
||||
className="dub-action-btn--export !h-[30px] !flex-none !px-[10px] !font-semibold enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-severity-ok)_13%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-severity-ok)_20%,transparent)] [.shell-mini_&]:flex-1"
|
||||
disabled={dubStep !== 'done' && !dubSegments.length}
|
||||
className="dub-action-btn--export !h-[30px] flex-none! !px-[10px] !font-semibold enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-severity-ok)_13%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-severity-ok)_20%,transparent)] [.shell-mini_&]:flex-1!"
|
||||
disabled={workflowActionBusy || (dubStep !== 'done' && !dubSegments.length)}
|
||||
onClick={() => setExportOpen(true)}
|
||||
icon={<Download size={12} aria-hidden="true" />}
|
||||
label={t('dub.export_btn')}
|
||||
|
||||
@@ -568,9 +568,13 @@ export default function DubTab(props) {
|
||||
try {
|
||||
const lang = previewMode !== 'original' ? previewMode : undefined;
|
||||
const res = await dubQc(dubJobId, lang);
|
||||
// A generation that finishes while QC is in flight invalidates these
|
||||
// measurements. Ignore the stale response instead of attaching timing
|
||||
// results from the previous audio to the new dub.
|
||||
if (useAppStore.getState().dubGenNonce !== dubGenNonce) return;
|
||||
const byId = new Map((res.segments || []).map((q) => [String(q.seg_id), q]));
|
||||
setDubSegments(
|
||||
dubSegments.map((s, i) => {
|
||||
setDubSegments((currentSegments) =>
|
||||
currentSegments.map((s, i) => {
|
||||
const q = byId.get(String(s.id ?? i));
|
||||
if (!q) return s;
|
||||
return {
|
||||
@@ -614,7 +618,7 @@ export default function DubTab(props) {
|
||||
} finally {
|
||||
setQcRunning(false);
|
||||
}
|
||||
}, [dubJobId, qcRunning, previewMode, dubSegments, setDubSegments, t]);
|
||||
}, [dubJobId, qcRunning, previewMode, dubGenNonce, setDubSegments, t]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('DubHeader — polished workflow actions', () => {
|
||||
const group = screen.getByTestId('dub-primary-actions');
|
||||
expect(group).toHaveClass('flex-wrap', '[.shell-mini_&]:w-full');
|
||||
for (const button of within(group).getAllByRole('button')) {
|
||||
expect(button).toHaveClass('[.shell-mini_&]:flex-1', 'motion-reduce:transform-none');
|
||||
expect(button).toHaveClass('[.shell-mini_&]:flex-1!', 'motion-reduce:transform-none');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,6 +81,8 @@ describe('DubHeader — polished workflow actions', () => {
|
||||
const verify = screen.getByRole('button', { name: t('dub.qc_btn') });
|
||||
expect(verify).toBeDisabled();
|
||||
expect(verify).toHaveAttribute('aria-busy', 'true');
|
||||
expect(screen.getByRole('button', { name: t('dub.generate_dub') })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: t('dub.export_btn') })).toBeDisabled();
|
||||
|
||||
rerender(
|
||||
<DubHeader {...makeProps({ dubStep: 'editing', dubSegments: [], incrementalPlan: null })} />,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useAppStore } from '../store';
|
||||
|
||||
// Heavy children are stubbed; DubLeftColumn is the probe — DubTab owns both
|
||||
// `hasDubbedTrack` and the previewMode auto-jump, and hands them down as props.
|
||||
const captured = vi.hoisted(() => ({ left: [] }));
|
||||
const captured = vi.hoisted(() => ({ left: [], header: [] }));
|
||||
vi.mock('../components/dub/DubLeftColumn', () => ({
|
||||
default: (props) => {
|
||||
captured.left.push(props);
|
||||
@@ -24,18 +24,22 @@ vi.mock('../components/dub/DubLeftColumn', () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock('../components/dub/DubHeader', () => ({
|
||||
default: ({ resetDub, pipelineSteps = [], onPipelineStep }) => (
|
||||
<div>
|
||||
<button data-testid="reset-dub" onClick={resetDub}>
|
||||
reset
|
||||
</button>
|
||||
{pipelineSteps.map((step) => (
|
||||
<button key={step} onClick={() => onPipelineStep(step)}>
|
||||
{step}
|
||||
default: (props) => {
|
||||
captured.header.push(props);
|
||||
const { resetDub, pipelineSteps = [], onPipelineStep } = props;
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="reset-dub" onClick={resetDub}>
|
||||
reset
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
{pipelineSteps.map((step) => (
|
||||
<button key={step} onClick={() => onPipelineStep(step)}>
|
||||
{step}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock('../components/dub/DubRightColumn', () => ({ default: () => null }));
|
||||
vi.mock('../components/dub/DubFooter', () => ({ default: () => null }));
|
||||
@@ -80,6 +84,7 @@ vi.mock('../api/client', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import DubTab from '../pages/DubTab';
|
||||
import { dubQc } from '../api/dub';
|
||||
|
||||
const noop = () => {};
|
||||
function makeProps() {
|
||||
@@ -151,6 +156,8 @@ describe('DubTab — completed tracks always show their tabs (restore P0)', () =
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(baseState, true);
|
||||
captured.left.length = 0;
|
||||
captured.header.length = 0;
|
||||
vi.mocked(dubQc).mockReset();
|
||||
});
|
||||
|
||||
it("restored project (tracks ['bn'], language_code frozen at 'und'): switcher shows and preview jumps to the track", () => {
|
||||
@@ -239,4 +246,46 @@ describe('DubTab — completed tracks always show their tabs (restore P0)', () =
|
||||
fireEvent.click(screen.getByRole('button', { name: 'transcribe' }));
|
||||
expect(retry).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('discards QC results when a newer generation finishes while verification is in flight', async () => {
|
||||
let resolveQc;
|
||||
vi.mocked(dubQc).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveQc = resolve;
|
||||
}),
|
||||
);
|
||||
useAppStore.setState({
|
||||
dubJobId: 'job1',
|
||||
dubStep: 'done',
|
||||
dubTracks: ['es'],
|
||||
dubLangCode: 'es',
|
||||
dubGenNonce: 1,
|
||||
dubSegments: [{ id: 's1', text: 'Old audio' }],
|
||||
});
|
||||
render(<DubTab {...makeProps()} />);
|
||||
|
||||
let qcPromise;
|
||||
act(() => {
|
||||
qcPromise = captured.header.at(-1).handleDubQc();
|
||||
});
|
||||
expect(dubQc).toHaveBeenCalledWith('job1', 'es');
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
dubGenNonce: 2,
|
||||
dubSegments: [{ id: 's1', text: 'New audio' }],
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
resolveQc({
|
||||
segments: [{ seg_id: 's1', drift: 0.8, flagged: true, recognized_text: 'Old' }],
|
||||
flagged_count: 1,
|
||||
total: 1,
|
||||
});
|
||||
await qcPromise;
|
||||
});
|
||||
|
||||
expect(useAppStore.getState().dubSegments).toEqual([{ id: 's1', text: 'New audio' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user