Skip to content

refactor(dub): modularize DubTab page (1593→380 lines, all files under 500) - #759

Merged
debpalash merged 2 commits into
mainfrom
refactor/dubtab-modularization
Jun 29, 2026
Merged

refactor(dub): modularize DubTab page (1593→380 lines, all files under 500)#759
debpalash merged 2 commits into
mainfrom
refactor/dubtab-modularization

Conversation

@debpalash

@debpalash debpalash commented Jun 29, 2026

Copy link
Copy Markdown
Owner

What & why

DubTab.jsx was 1593 lines — the second-largest page after Settings. This decomposes it into a thin orchestrator plus focused components under components/dub/, applying the same standard PR #758 set. Pure-mechanical, no behavior change.

DubTab is harder than Settings: one ~1000-line JSX return wired to 28 hooks of shared state, with no render-test coverage. The split was done in two safe passes.

Result

Before After
pages/DubTab.jsx 1593 380

Pass 1 — sibling sub-components (already-separate, props-only): DubFailureNotice, DubPipelineStepper, PrepOverlay, TranscribeOverlay, FooterBtn (+ their private helpers).

Pass 2 — JSX section split: the ~1000-line return became five section components — IdleSkeleton (368), DubLeftColumn (336), DubRightColumn (172), DubFooter (78), DubHeader (63). All state/hooks/handlers stay in DubTab; only markup moved (verbatim).

Every dub file is now under the 500-line cap.

Safety contract (no render test exists for DubTab)

  • Explicit named props on every section — no bag/context object — so ESLint no-undef verifies prop completeness on both ends. A dropped value is a build error, not a silent undefined. ✅ 0 no-undef across all files.
  • All 137 classNames preserved (diffed original main vs the new file set) — proves no JSX was lost.

Verification

  • vite build passes
  • Full frontend suite: 638/638 tests pass
  • ✅ 0 no-undef, no new no-unused-vars; remaining lint errors are pre-existing set-state-in-effect on untouched logic

Depends conceptually on the standard introduced in #758 (max-lines guardrail + CONTRIBUTING.md), but touches disjoint files.

🤖 Generated with Claude Code

Refactored DubTab.jsx into a thin orchestrator and moved the dub studio UI into focused components under components/dub/:

  • extracted presentational pieces: DubFailureNotice, DubPipelineStepper, PrepOverlay, TranscribeOverlay, FooterBtn
  • split the main JSX into: IdleSkeleton, DubHeader, DubLeftColumn, DubRightColumn, DubFooter
  • kept state, hooks, and handlers in DubTab; no behavior change intended

Result: pages/DubTab.jsx is much smaller, dub files stay under the size cap, and the existing classNames/UI wiring were preserved.

Layout sketch:

Before

DubTab
├─ pipeline + idle UI
├─ header
├─ left editor column
├─ right editor column
└─ footer

After

DubTab
├─ DubPipelineStepper
├─ IdleSkeleton
├─ DubHeader
├─ DubLeftColumn
├─ DubRightColumn
└─ DubFooter

mergetest and others added 2 commits June 30, 2026 01:56
…dub (1593→1361)

Phase 2 (partial). Move the 5 self-contained presentational sub-components out
of the oversized DubTab.jsx into a new components/dub/ folder, matching the
components/settings/ pattern. Pure-mechanical, logic byte-for-byte identical.

Extracted (each with its own private helpers/constants):
- DubFailureNotice, DubPipelineStepper (+DUB_PIPELINE/DUB_PHASE_BY_STEP),
  PrepOverlay (+PREP_FULL/PREP_CACHED/fmtBytesRate/fmtEta), TranscribeOverlay,
  FooterBtn. fmtDur stays — it's used by the main component.

Pruned imports orphaned by the moves (copyText, errorDocsMap, a few icons).

Verified: vite build passes; dub tests (dubExpiredJobError + DubbingDemo)
11/11 pass; no new lint errors.

NOTE: DubTab.jsx is still 1361 lines — the main component is one ~1000-line
stateful JSX return over 28 hooks. Getting it under the 500 cap needs that JSX
split into section components, a higher-risk change deferred for a careful,
test-backed pass (see docs/maintenance-pages-modularization.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l files <500)

Completes Phase 2. The DubTab component was one ~1000-line stateful JSX return.
Split that markup into five section components under components/dub/, keeping
ALL state/hooks/handlers/effects inside DubTab — only the JSX moved (verbatim,
by line-slicing).

Safety contract (this is behavior-critical and has no render test):
- Explicit NAMED props on every section (no bag/context object), so eslint
  no-undef verifies prop completeness on BOTH ends — a dropped value becomes a
  build error, not a silent runtime undefined. Verified: 0 no-undef across all files.
- All 137 classNames from the original are preserved (diffed main vs new set).

New sections: IdleSkeleton (368), DubLeftColumn (336), DubRightColumn (172),
DubFooter (78), DubHeader (63). DubTab.jsx is now a thin composition (380).

Verified: vite build passes; FULL frontend suite 638/638 pass; every settings &
dub file now under the 500-line cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

DubTab.jsx is refactored from a ~1200-line monolith into a composition of eight new dedicated components: DubPipelineStepper, IdleSkeleton, PrepOverlay, TranscribeOverlay, DubHeader, DubLeftColumn, DubRightColumn, DubFooter, plus shared primitives FooterBtn and DubFailureNotice. Props are threaded from DubTab into each subcomponent unchanged.

Changes

DubTab UI Decomposition

Layer / File(s) Summary
Shared primitives
frontend/src/components/dub/FooterBtn.jsx, frontend/src/components/dub/DubFailureNotice.jsx
FooterBtn is a forwardRef button with tone/size class composition. DubFailureNotice conditionally renders hint text, a docs deep-link via classifyError, and an async diagnostic copy action with toast feedback.
Pipeline stepper
frontend/src/components/dub/DubPipelineStepper.jsx
Defines the 6-step Upload→Export pipeline config with icons, maps dubStep to a step index, swaps active-step icon to a spinner when busy, and applies done/active/reached CSS classes with ARIA list roles.
Progress overlays
frontend/src/components/dub/PrepOverlay.jsx, frontend/src/components/dub/TranscribeOverlay.jsx
PrepOverlay formats download rate/ETA, ticks elapsed via setInterval from stageStartedAt, renders stage chips with cached shortlist, and shows an abort button. TranscribeOverlay computes a capped ETA from elapsed/duration, clamps the progress bar at 95%, and provides an abort button.
Idle skeleton
frontend/src/components/dub/IdleSkeleton.jsx
Full idle-state UI: header with transcription-failure banner (retry + SRT import), left-panel upload landing (drag-drop, ingest URL, YouTube captions, advanced speaker/style options), waveform skeleton with overlay slots, and a ghost right column and footer with disabled Generate/Export.
DubHeader
frontend/src/components/dub/DubHeader.jsx
Renders filename/duration/segment count metadata, icon-only save/reset controls, and step-conditional primary actions: spinner (stopping), stop button (generating), QC + export (done), generate (idle). Also shows an incremental "regen changed" button when incrementalPlan.stale is non-empty.
DubLeftColumn
frontend/src/components/dub/DubLeftColumn.jsx
Preview-language radio, WaveformTimeline with generating/stopping overlay, per-speaker <select> cast assignment, collapsed translation settings summary with translate-all/clean-up, and expanded settings bar covering language, dialect, engine install chip, quality guard (cinematic blocked without LLM endpoint), style input, and multi-language picker.
DubRightColumn
frontend/src/components/dub/DubRightColumn.jsx
Output checkboxes (preserve-bg, dual/burn subs), default-track selector, timing Segmented control, transcript toggle, glossary chip + GlossaryPanel, bulk-selection voice/language/delete row, CheckpointBanner, and lazy-loaded DubSegmentTable inside Suspense.
DubFooter
frontend/src/components/dub/DubFooter.jsx
Done/error status banners (error path includes DubFailureNotice), export-tracks checkbox row, and an IIFE that computes hot-segment compression warning when >10% of segments exceed rate_ratio 1.3, with quality-dependent remediation advice.
DubTab wiring
frontend/src/pages/DubTab.jsx
Swaps in-file helpers for subcomponent imports, replaces inline render logic with the composed subcomponents, and removes the ~1200 lines of previously inlined implementations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • debpalash/OmniVoice-Studio#133: DubFailureNotice calls classifyError and openDocsFor, which depend on the error-docs classification and deep-link logic introduced in this PR.
  • debpalash/OmniVoice-Studio#374: Directly overlaps — introduces the dub pipeline UX and studio layout that this PR extracts into dedicated subcomponents.
  • debpalash/OmniVoice-Studio#458: The QC verify-timing button now lives in DubHeader (dubStep === 'done'), directly corresponding to the timing QC flow implemented in that PR.

Panel notes from the engineers:

ML/inference: DubFooter computes the hot-segment warning with a hardcoded rate_ratio > 1.3 threshold and a >= 10% gate inline as an IIFE. That threshold is a model-tuning parameter; if it ever needs adjustment it lives in JSX with no named constant. Extract to a named constant (COMPRESSION_HOT_RATIO, COMPRESSION_WARN_PCT) at module top.

Audio DSP: TranscribeOverlay clamps the progress bar at 95% (Math.min(elapsed / est, 0.95)), but est is derived from duration alone with no real progress signal from the backend. When elapsed > duration the bar freezes at 95% indefinitely. If the backend emits a progress field, wire it; otherwise document the known limitation so future engineers don't mistake the freeze for a hang.

Desktop systems: PrepOverlay starts a setInterval to tick elapsedS from progress.stageStartedAt. The cleanup path is clearInterval on unmount, which is correct — but if progress.stageStartedAt changes mid-stage (e.g., stage restart), the interval is not restarted. The useEffect dependency array should include progress.stageStartedAt.

Product polish: DubLeftColumn shows a toast and returns early when the user selects "cinematic" without an LLM endpoint. The early return happens inside an onChange handler, which is fine, but the Segmented control's selected value will visually snap to "cinematic" for one render frame before the state is not updated. Confirm the segmented control is fully controlled (value driven by translateQuality prop) so there's no flicker.

🚥 Pre-merge checks | ✅ 6 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title uses conventional-commit scope and matches the refactor, but it omits the required issue reference in title or body. Add the issue reference to the title or PR body, e.g. refactor(dub): modularize DubTab page (#1593).
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
I18n Completeness (21 Locales) ⚠️ Warning FAIL: dub.phase_* are missing from all 21 locale packs; dub.qc_* are missing from 20 packs; raw strings remain in DubFooter, DubRightColumn, and PrepOverlay. Add the six dub.phase_* keys to every locale, add dub.qc_* to the 20 non-English packs, and move the hardcoded footer/timing/ETA/install copy behind t(...).
✅ Passed checks (6 passed)
Check name Status Explanation
Description check ✅ Passed The PR description covers summary, changes, type, and testing, but it does not follow the repository template headings or checklist format.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cross-Platform Default Parity ✅ Passed PASS: Touched files add no macOS/Windows/Linux branches or opt-in platform gates; only a browser SSR guard at DubTab.jsx:15-18, which is OS-agnostic.
Local-First Guarantee ✅ Passed PASS: The new dub components are presentational; no new telemetry/API-key use or forced cloud calls. Any remote actions are opt-in via existing handlers (docs/install/upload).
Backward Compatibility ✅ Passed Only frontend DubTab UI was refactored; no backend, DB schema, alembic migration, or engine/model storage code changed.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/dubtab-modularization

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Pure mechanical decomposition of DubTab.jsx (1593 → 380 lines) into ten focused components under components/dub/, following the same pattern established in PR #758. All state, hooks, and handlers remain in the orchestrator; only JSX sections moved verbatim.

  • Pass 1 extracted already-isolated sub-components (DubFailureNotice, DubPipelineStepper, PrepOverlay, TranscribeOverlay, FooterBtn) as self-contained files calling useTranslation() directly.
  • Pass 2 split the ~1000-line JSX return into five section components (IdleSkeleton, DubLeftColumn, DubRightColumn, DubFooter, DubHeader) wired via explicit named props; all five receive t as a prop from the parent rather than calling the hook themselves.

ASCII layout (unchanged — refactor only):

┌─ DubTab (orchestrator) ──────────────────────────────────┐
│  DubPipelineStepper                                       │
│  ┌─ IdleSkeleton (idle/upload) ─────────────────────────┐│
│  │  PrepOverlay | TranscribeOverlay | DubFailureNotice   ││
│  └──────────────────────────────────────────────────────┘│
│  ┌─ DubHeader ──────────────────────────────────────────┐│
│  │  FooterBtn × N  (Generate / Stop / QC / Export)      ││
│  └──────────────────────────────────────────────────────┘│
│  ┌─ DubLeftColumn ──┐  ┌─ DubRightColumn ──────────────┐│
│  │  WaveformTimeline│  │  Outputs · Transcript · Segs  ││
│  │  Cast · Settings │  │  GlossaryPanel · Checkpoint   ││
│  └──────────────────┘  └───────────────────────────────┘│
│  DubFooter (export track checkboxes · compression warn)   │
└──────────────────────────────────────────────────────────┘

Confidence Score: 4/5

Safe to merge — the change is a verbatim JSX shuffle with no logic moves; the build and 638-test suite pass.

The only live concern is the t / track shadowing in DubFooter, which is a carried-forward variable name collision from the original file. It does not break any current behavior since no translation call is made inside the offending map callback — but it is a trap for the next person who edits that block. Everything else is clean mechanical extraction.

frontend/src/components/dub/DubFooter.jsx — the dubTracks.map(t => …) iterator shadows the t prop; the other ten files are straightforward extractions.

Important Files Changed

Filename Overview
frontend/src/pages/DubTab.jsx Thin orchestrator: all 28+ hooks/state/handlers remain here; only JSX sections delegated to new components via explicit named props.
frontend/src/components/dub/DubFooter.jsx Receives t (translation fn) as prop, then shadows it with dubTracks.map(t => …) — benign now but a latent trap for future translatable strings inside the map callback.
frontend/src/components/dub/DubLeftColumn.jsx 336-line settings + waveform panel; verbatim markup from original with all 28+ explicit props threaded from DubTab; no logic changes.
frontend/src/components/dub/IdleSkeleton.jsx 368-line idle/upload state; largest of the new files but within the 500-line cap; verbatim JSX move with t threaded as prop.
frontend/src/components/dub/DubHeader.jsx 63-line header bar with Save/Reset/Generate/Export actions; clean extraction, receives t as prop like sibling sections.
frontend/src/components/dub/DubRightColumn.jsx 172-line transcript/glossary/segment-table panel; re-declares LazyFallback and lazy DubSegmentTable import locally.
frontend/src/components/dub/DubPipelineStepper.jsx 53-line self-contained pipeline step indicator; calls useTranslation() directly, no t prop needed.
frontend/src/components/dub/PrepOverlay.jsx 104-line upload-stage overlay with elapsed timer and progress bar; self-contained with useTranslation().
frontend/src/components/dub/TranscribeOverlay.jsx 35-line Whisper progress overlay; self-contained with useTranslation().
frontend/src/components/dub/DubFailureNotice.jsx 42-line structured error detail panel; self-contained with useTranslation().
frontend/src/components/dub/FooterBtn.jsx 26-line forwardRef button; needed for Export menu triggerRef coord computation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    DT[DubTab.jsx<br/>1593→380 lines]

    subgraph After["After (11 files)"]
        DT2[DubTab.jsx<br/>380 lines<br/>All state/hooks/handlers]
        DT2 --> DPS[DubPipelineStepper]
        DT2 --> IS[IdleSkeleton<br/>368 lines]
        DT2 --> DH[DubHeader<br/>63 lines]
        DT2 --> DLC[DubLeftColumn<br/>336 lines]
        DT2 --> DRC[DubRightColumn<br/>172 lines]
        DT2 --> DF[DubFooter<br/>78 lines]
        IS --> PO[PrepOverlay]
        IS --> TO[TranscribeOverlay]
        IS --> DFN1[DubFailureNotice]
        DH --> FB[FooterBtn]
        DF --> DFN2[DubFailureNotice]
    end

    DT -.->|refactored into| DT2
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    DT[DubTab.jsx<br/>1593→380 lines]

    subgraph After["After (11 files)"]
        DT2[DubTab.jsx<br/>380 lines<br/>All state/hooks/handlers]
        DT2 --> DPS[DubPipelineStepper]
        DT2 --> IS[IdleSkeleton<br/>368 lines]
        DT2 --> DH[DubHeader<br/>63 lines]
        DT2 --> DLC[DubLeftColumn<br/>336 lines]
        DT2 --> DRC[DubRightColumn<br/>172 lines]
        DT2 --> DF[DubFooter<br/>78 lines]
        IS --> PO[PrepOverlay]
        IS --> TO[TranscribeOverlay]
        IS --> DFN1[DubFailureNotice]
        DH --> FB[FooterBtn]
        DF --> DFN2[DubFailureNotice]
    end

    DT -.->|refactored into| DT2
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "refactor(dub): split DubTab JSX into sec..." | Re-trigger Greptile

Comment on lines +41 to +46
{dubTracks.map(t => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[t] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [t]: e.target.checked }))} />
<span className="code">{t}</span>
</label>
))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The dubTracks.map(t => …) callback shadows the t prop (the i18n translation function). Inside this block t becomes a plain language-code string, so any future t('some.key') added here would throw "t is not a function" at runtime. It works today only because the map body only renders {t} as the code string — but the collision is a maintenance trap. Rename to track to make the intent explicit and remove the hazard.

Suggested change
{dubTracks.map(t => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[t] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [t]: e.target.checked }))} />
<span className="code">{t}</span>
</label>
))}
{dubTracks.map(track => (
<label key={track} className={exportTracks[track] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[track] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [track]: e.target.checked }))} />
<span className="code">{track}</span>
</label>
))}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/components/dub/DubFailureNotice.jsx`:
- Around line 15-20: The copy flow in copyDiagnostic currently treats any
resolved promise from copyText as success, but copyText can resolve false when
both clipboard paths fail. Update copyDiagnostic in DubFailureNotice to inspect
the return value from copyText and show the success toast only when it returns
true; otherwise fall through to the failure toast, keeping the existing
try/catch around the async call.

In `@frontend/src/components/dub/DubFooter.jsx`:
- Around line 63-70: The compression warning banner in DubFooter is hardcoding
user-facing English copy instead of using i18n. Move the full warning text in
the DubFooter JSX into translation keys referenced via t('...'), including the
“segments need >1.3× compression” and the follow-up guidance text, and keep only
dynamic values like hot.length, dubSegments.length, worst.rate_ratio, and
translateQuality branching in the component. After adding the keys, verify they
exist in all locale JSON files under frontend/src/i18n/locales/.

In `@frontend/src/components/dub/DubLeftColumn.jsx`:
- Around line 236-245: The engine-install chip label in DubLeftColumn is
hardcoded and bypasses i18n. Update the non-installing branch of the button
label to use a translated key with interpolation for the pip package name,
matching the existing t(...) usage in DubLeftColumn and the
activeEngineEntry/engineInstalling logic. Then add that new locale key to every
frontend/src/i18n/locales/*.json file so the label is translated consistently
across all languages.
- Around line 266-275: In DubLeftColumn’s onChange handler for translate
quality, the Cinematic guard only blocks when llmEndpoint exists but is
unavailable, so null/undefined still slips through. Update the cinematic check
to reject selection whenever no LLM endpoint is configured or the endpoint is
unavailable, and keep the existing toast message and setTranslateQuality path
unchanged for other values.

In `@frontend/src/components/dub/DubPipelineStepper.jsx`:
- Around line 20-45: The DubPipelineStepper component already references
localized phase labels via t(p.key), but the six dub.phase_* translation keys
are missing from the locale packs, causing fallback text in localized builds.
Add dub.phase_upload, dub.phase_prepare, dub.phase_transcribe, dub.phase_edit,
dub.phase_generate, and dub.phase_export to every locale JSON file so
DubPipelineStepper.jsx resolves all pipeline step labels through translations.

In `@frontend/src/components/dub/DubRightColumn.jsx`:
- Around line 10-12: The new loading and timing copy in DubRightColumn is using
raw UI strings instead of i18n keys. Update LazyFallback and the related timing
UI in DubRightColumn.jsx so text like Loading…, Timing:, Concise, Stretch Video,
Strict slot, and the new tooltips are all pulled through t('...') keys. Add the
new keys to every locale file under frontend/src/i18n/locales and keep the
symbols LazyFallback and the timing controls in sync with the locale contract.

In `@frontend/src/components/dub/IdleSkeleton.jsx`:
- Around line 154-167: The async preview setup in IdleSkeleton’s file selection
flow can apply stale `fileToMediaUrl` results after a newer selection or reset.
Move the file-handling logic behind a parent-owned helper such as
`onSelectMediaFile` in `DubTab`, stamp each request with a sequence/ref, and
only call `setDubLocalBlobUrl` when the result matches the latest selection. If
a late result is discarded, revoke its blob URLs, and apply the same
stale-result guard to both `onDrop` and the other file selection path that also
calls `fileToMediaUrl`.

In `@frontend/src/components/dub/PrepOverlay.jsx`:
- Around line 17-23: The ETA/elapsed labels in PrepOverlay are built from
English fragments, so they need to be moved behind i18n. Update fmtEta and the
related upload/prep status text in PrepOverlay to use t(...) keys for the full
phrases, letting translators control wording, spacing, and token order instead
of concatenating “m”, “s”, and “left” inline. Keep the logic that formats the
numeric values, but route all user-facing text through the existing translation
mechanism in the component.

In `@frontend/src/components/dub/TranscribeOverlay.jsx`:
- Around line 10-21: The transcribe overlay is hardcoding the elapsed and
remaining status sentence structure in TranscribeOverlay, which blocks proper
localization and pluralization. Update the JSX to delegate the full
elapsed/remaining text to i18n via t(...) with interpolated values instead of
concatenating mm:ss and the remaining seconds directly in the component. Use the
existing TranscribeOverlay rendering and
dub.transcribing/dub.elapsed/dub.remaining keys as the place to keep user-facing
text fully localized.

In `@frontend/src/pages/DubTab.jsx`:
- Around line 339-343: The editor gate in DubTab currently hides the mounted
editor during the stopping state, which prevents DubHeader and DubLeftColumn
from showing their stopping UI. Update the conditional around the main dub
editor render to include stopping alongside editing/generating/done, and make
showIdleSkeleton derive from the same shared predicate so the
cancellation/progress state stays visible. Use the existing DubTab render
condition and the DubHeader/DubLeftColumn props as the key locations to adjust.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 145fd828-4a16-49e1-befd-4e1e01246917

📥 Commits

Reviewing files that changed from the base of the PR and between e2f0232 and 3acdf4c.

📒 Files selected for processing (11)
  • frontend/src/components/dub/DubFailureNotice.jsx
  • frontend/src/components/dub/DubFooter.jsx
  • frontend/src/components/dub/DubHeader.jsx
  • frontend/src/components/dub/DubLeftColumn.jsx
  • frontend/src/components/dub/DubPipelineStepper.jsx
  • frontend/src/components/dub/DubRightColumn.jsx
  • frontend/src/components/dub/FooterBtn.jsx
  • frontend/src/components/dub/IdleSkeleton.jsx
  • frontend/src/components/dub/PrepOverlay.jsx
  • frontend/src/components/dub/TranscribeOverlay.jsx
  • frontend/src/pages/DubTab.jsx

Comment on lines +15 to +20
const copyDiagnostic = async () => {
try {
await copyText(failure.diagnostic || failure.reason);
toast.success(t('dub.diagnostic_copied'));
} catch {
toast.error(t('dub.copy_failed'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle copyText()'s false result.

frontend/src/utils/copyText.js:14-38 resolves false when both clipboard paths fail. In this handler that still lands in the try path, so users get a “copied” toast even though nothing was copied.

Suggested fix
   const copyDiagnostic = async () => {
     try {
-      await copyText(failure.diagnostic || failure.reason);
-      toast.success(t('dub.diagnostic_copied'));
+      const copied = await copyText(failure.diagnostic || failure.reason);
+      if (copied) {
+        toast.success(t('dub.diagnostic_copied'));
+      } else {
+        toast.error(t('dub.copy_failed'));
+      }
     } catch {
       toast.error(t('dub.copy_failed'));
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const copyDiagnostic = async () => {
try {
await copyText(failure.diagnostic || failure.reason);
toast.success(t('dub.diagnostic_copied'));
} catch {
toast.error(t('dub.copy_failed'));
const copyDiagnostic = async () => {
try {
const copied = await copyText(failure.diagnostic || failure.reason);
if (copied) {
toast.success(t('dub.diagnostic_copied'));
} else {
toast.error(t('dub.copy_failed'));
}
} catch {
toast.error(t('dub.copy_failed'));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/DubFailureNotice.jsx` around lines 15 - 20, The
copy flow in copyDiagnostic currently treats any resolved promise from copyText
as success, but copyText can resolve false when both clipboard paths fail.
Update copyDiagnostic in DubFailureNotice to inspect the return value from
copyText and show the success toast only when it returns true; otherwise fall
through to the failure toast, keeping the existing try/catch around the async
call.

Comment on lines +63 to +70
<div className="dub-compression-warn" role="status">
<span className="dub-compression-warn__icon">⚠</span>
<span className="dub-compression-warn__body">
<strong>{hot.length} of {dubSegments.length}</strong> segments need {'>'}1.3× compression
(worst: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{worst.rate_ratio.toFixed(2)}×</span>).
Output will be intelligible (pitch-preserving stretch) but stressed —
{translateQuality === 'fast' ? ' switch to Cinematic and Re-translate' : ' shorten the worst segments'}
{' '}for cleaner audio.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move the compression warning copy into i18n.

This banner hardcodes new user-facing English text (segments need >1.3× compression, Output will be intelligible..., switch to Cinematic..., shorten the worst segments) instead of t('...'). That will leak untranslated copy in every non-English locale.

As per coding guidelines, “Every new or changed t('...') key in frontend code, verify the key exists in all 21 files under frontend/src/i18n/locales/. List any locale files missing the key. Also flag hardcoded user-facing strings that bypass i18n entirely.” As per path instructions, “Every new user-facing string must be an i18n t('...') key present in ALL 21 frontend/src/i18n/locales/*.json files — flag hardcoded UI strings and keys missing from any locale.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/DubFooter.jsx` around lines 63 - 70, The
compression warning banner in DubFooter is hardcoding user-facing English copy
instead of using i18n. Move the full warning text in the DubFooter JSX into
translation keys referenced via t('...'), including the “segments need >1.3×
compression” and the follow-up guidance text, and keep only dynamic values like
hot.length, dubSegments.length, worst.rate_ratio, and translateQuality branching
in the component. After adding the keys, verify they exist in all locale JSON
files under frontend/src/i18n/locales/.

Sources: Coding guidelines, Path instructions

Comment on lines +236 to +245
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={t('dub.install_engine')}
>
{engineInstalling === translateProvider ? t('dub.installing_engine') : `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Localize the engine-install chip label.

Line 244 adds + install ${activeEngineEntry?.pip_package || ''} as raw UI copy, so this path bypasses i18n and will never be translated with the rest of the frontend. Make this a t('...') key with interpolation and add it to the locale set.

As per coding guidelines, “All UI strings go through i18n (t('...') keys in locales/*.json)”; as per path instructions, “Every new user-facing string must be an i18n t('...') key present in ALL 21 frontend/src/i18n/locales/*.json files.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/DubLeftColumn.jsx` around lines 236 - 245, The
engine-install chip label in DubLeftColumn is hardcoded and bypasses i18n.
Update the non-installing branch of the button label to use a translated key
with interpolation for the pip package name, matching the existing t(...) usage
in DubLeftColumn and the activeEngineEntry/engineInstalling logic. Then add that
new locale key to every frontend/src/i18n/locales/*.json file so the label is
translated consistently across all languages.

Sources: Coding guidelines, Path instructions

Comment on lines +266 to +275
onChange={(v) => {
// #372: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
toast(t('dub.cinematic_needs_llm_hint', { defaultValue: 'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).' }), { icon: 'ℹ️', duration: 8000 });
return;
}
setTranslateQuality(v);
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Block Cinematic when no LLM endpoint is configured, not only when a failing object exists.

Line 270 only rejects cinematic when llmEndpoint is truthy and unavailable. When no endpoint is configured at all (null/undefined), this still allows the selection and drops users back into the exact failure path the comment says we are fixing.

Suggested fix
- if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
+ if (v === 'cinematic' && (!llmEndpoint || !llmEndpoint.available)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onChange={(v) => {
// #372: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
toast(t('dub.cinematic_needs_llm_hint', { defaultValue: 'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).' }), { icon: 'ℹ️', duration: 8000 });
return;
}
setTranslateQuality(v);
}}
onChange={(v) => {
// `#372`: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && (!llmEndpoint || !llmEndpoint.available)) {
toast(t('dub.cinematic_needs_llm_hint', { defaultValue: 'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).' }), { icon: 'ℹ️', duration: 8000 });
return;
}
setTranslateQuality(v);
}}
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 273-273: Avoid using the initial state variable in setState
Context: setTranslateQuality(v)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/DubLeftColumn.jsx` around lines 266 - 275, In
DubLeftColumn’s onChange handler for translate quality, the Cinematic guard only
blocks when llmEndpoint exists but is unavailable, so null/undefined still slips
through. Update the cinematic check to reject selection whenever no LLM endpoint
is configured or the endpoint is unavailable, and keep the existing toast
message and setTranslateQuality path unchanged for other values.

Comment on lines +20 to +45
const { t } = useTranslation();
const current = DUB_PHASE_BY_STEP[dubStep] ?? 0;
const busy = dubStep === 'uploading' || dubStep === 'transcribing'
|| dubStep === 'generating' || dubStep === 'stopping';
return (
<div className="dub-stepper" role="list" aria-label={t('dub.pipeline', { defaultValue: 'Dubbing pipeline' })}>
{DUB_PIPELINE.map((p, i) => {
const done = i < current;
const active = i === current;
const spinning = active && busy;
const Icon = done ? Check : (spinning ? Loader : p.Icon);
return (
<div
key={p.id}
role="listitem"
className={[
'dub-stepper__step',
done ? 'is-done' : '',
active ? 'is-active' : '',
i <= current ? 'is-reached' : '',
].filter(Boolean).join(' ')}
>
<span className="dub-stepper__icon">
<Icon size={13} className={spinning ? 'dub-stepper__spin' : ''} />
</span>
<span className="dub-stepper__label">{t(p.key, { defaultValue: p.fallback })}</span>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import json
import pathlib
import sys

locale_dir = pathlib.Path("frontend/src/i18n/locales")
keys = [
    "dub.pipeline",
    "dub.phase_upload",
    "dub.phase_prepare",
    "dub.phase_transcribe",
    "dub.phase_edit",
    "dub.phase_generate",
    "dub.phase_export",
]

def has_key(obj, dotted):
    cur = obj
    for part in dotted.split("."):
        if not isinstance(cur, dict) or part not in cur:
            return False
        cur = cur[part]
    return True

missing = {}
for path in sorted(locale_dir.glob("*.json")):
    data = json.loads(path.read_text(encoding="utf-8"))
    absent = [k for k in keys if not has_key(data, k)]
    if absent:
        missing[path.name] = absent

if missing:
    for name, absent in missing.items():
        print(f"{name}: missing {', '.join(absent)}")
    sys.exit(1)

print("All locale files contain the DubPipelineStepper keys.")
PY

Repository: debpalash/OmniVoice-Studio

Length of output: 2879


Add the six dub.phase_* keys to every locale pack
frontend/src/components/dub/DubPipelineStepper.jsx:20-45 already has dub.pipeline; the six phase labels are missing from all 21 locale JSON files, so localized builds will fall back to the English defaultValue. Add dub.phase_upload, dub.phase_prepare, dub.phase_transcribe, dub.phase_edit, dub.phase_generate, and dub.phase_export to each locale file.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 41-43: A list component should have a key to prevent re-rendering
Context:
<Icon size={13} className={spinning ? 'dub-stepper__spin' : ''} />

Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(list-component-needs-key)


[warning] 42-42: A list component should have a key to prevent re-rendering
Context: <Icon size={13} className={spinning ? 'dub-stepper__spin' : ''} />
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(list-component-needs-key)


[warning] 44-44: A list component should have a key to prevent re-rendering
Context: {t(p.key, { defaultValue: p.fallback })}
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(list-component-needs-key)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/DubPipelineStepper.jsx` around lines 20 - 45, The
DubPipelineStepper component already references localized phase labels via
t(p.key), but the six dub.phase_* translation keys are missing from the locale
packs, causing fallback text in localized builds. Add dub.phase_upload,
dub.phase_prepare, dub.phase_transcribe, dub.phase_edit, dub.phase_generate, and
dub.phase_export to every locale JSON file so DubPipelineStepper.jsx resolves
all pipeline step labels through translations.

Sources: Coding guidelines, Path instructions

Comment on lines +10 to +12
const LazyFallback = () => (
<div className="dub-lazy-fallback">Loading…</div>
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move the new loading and timing copy behind i18n keys.

Loading…, Timing:, Concise, Stretch Video, Strict slot, and the new timing tooltips are all raw UI strings. This breaks the frontend localization contract and skips the required locale-file sync for new keys.

As per coding guidelines, “All UI strings go through i18n (t('...') keys in locales/*.json)”; as per path instructions, “Every new user-facing string must be an i18n t('...') key present in ALL 21 frontend/src/i18n/locales/*.json files.”

Also applies to: 42-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/DubRightColumn.jsx` around lines 10 - 12, The new
loading and timing copy in DubRightColumn is using raw UI strings instead of
i18n keys. Update LazyFallback and the related timing UI in DubRightColumn.jsx
so text like Loading…, Timing:, Concise, Stretch Video, Strict slot, and the new
tooltips are all pulled through t('...') keys. Add the new keys to every locale
file under frontend/src/i18n/locales and keep the symbols LazyFallback and the
timing controls in sync with the locale contract.

Sources: Coding guidelines, Path instructions

Comment on lines +154 to +167
<label htmlFor="video-upload" className="dub-idle-drop"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-dragging'); }}
onDragLeave={e => { e.currentTarget.classList.remove('is-dragging'); }}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (file && (file.type.startsWith('video/') || file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))) {
setDubVideoFile(file);
// #119: an audio file → audio-only dubbing (skip video work, output audio).
setDubInputType(file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name) ? 'audio' : 'video');
setDubStep('idle');
fileToMediaUrl(file, null).then(urls => setDubLocalBlobUrl(urls));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard fileToMediaUrl results against stale file selections.

Lines 166 and 283 commit async blob URLs unconditionally. If the user picks a second file before the first fileToMediaUrl(...) resolves, the late first result can overwrite the newer preview; if reset happens first, the late result can repopulate cleared state and leak orphaned blob URLs. This flow should live behind one parent-owned “select media file” helper that stamps each request and discards/revokes stale resolutions.

Proposed direction
- fileToMediaUrl(file, null).then(urls => setDubLocalBlobUrl(urls));
+ onSelectMediaFile(file);

- setDubLocalBlobUrl(prev => { fileToMediaUrl(file, prev).then(urls => setDubLocalBlobUrl(urls)); return prev; });
+ onSelectMediaFile(file);
// in DubTab.jsx
const mediaLoadSeq = useRef(0);

const onSelectMediaFile = async (file) => {
  const seq = ++mediaLoadSeq.current;

  setDubVideoFile(file);
  setDubInputType(isAudioFile(file) ? 'audio' : 'video');
  setDubStep('idle');

  const urls = await fileToMediaUrl(file, currentBlobUrlsRef.current);
  if (seq !== mediaLoadSeq.current) {
    revokeBlobUrls(urls);
    return;
  }
  setDubLocalBlobUrl(urls);
};

As per path instructions, frontend reviews must check for “stale state and races (async results landing after unmount or after newer requests).”

Also applies to: 275-284

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 161-161: Avoid using the initial state variable in setState
Context: setDubVideoFile(file)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 165-165: Avoid using the initial state variable in setState
Context: setDubLocalBlobUrl(urls)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/IdleSkeleton.jsx` around lines 154 - 167, The
async preview setup in IdleSkeleton’s file selection flow can apply stale
`fileToMediaUrl` results after a newer selection or reset. Move the
file-handling logic behind a parent-owned helper such as `onSelectMediaFile` in
`DubTab`, stamp each request with a sequence/ref, and only call
`setDubLocalBlobUrl` when the result matches the latest selection. If a late
result is discarded, revoke its blob URLs, and apply the same stale-result guard
to both `onDrop` and the other file selection path that also calls
`fileToMediaUrl`.

Source: Path instructions

Comment on lines +17 to +23
function fmtEta(seconds) {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
const s = Math.round(seconds);
if (s < 60) return `${s}s left`;
const m = Math.floor(s / 60), rem = s % 60;
return rem ? `${m}m ${rem}s left` : `${m}m left`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Localize the ETA/elapsed text instead of composing English fragments inline.

Lines 17-23 and 56-59 bake left, m, and s into the prep overlay, so non-English locales still show English during the default upload/prep flow. Move the full elapsed/ETA phrases behind t(...) so translators control wording, spacing, and token order. As per coding guidelines, every new user-facing frontend string must go through i18n, and as per path instructions every new user-facing string in frontend/src/** must be an i18n-backed key.

Also applies to: 56-62

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/PrepOverlay.jsx` around lines 17 - 23, The
ETA/elapsed labels in PrepOverlay are built from English fragments, so they need
to be moved behind i18n. Update fmtEta and the related upload/prep status text
in PrepOverlay to use t(...) keys for the full phrases, letting translators
control wording, spacing, and token order instead of concatenating “m”, “s”, and
“left” inline. Keep the logic that formats the numeric values, but route all
user-facing text through the existing translation mechanism in the component.

Sources: Coding guidelines, Path instructions

Comment on lines +10 to +21
const est = duration > 0 ? Math.max(10, Math.ceil(duration / 60) * 3 + 8) : 0;
const mm = Math.floor(elapsed / 60);
const ss = String(elapsed % 60).padStart(2, '0');
return (
<div className="dub-trans-overlay">
<div className="dub-trans-overlay__head">
<Loader className="spinner" size={18} color="#d3869b" />
<span className="dub-trans-overlay__title">{t('dub.transcribing')}</span>
</div>
<div className="dub-trans-overlay__stats">
<span>⏱ {mm}:{ss} {t('dub.elapsed')}</span>
{est > 0 && <span>~{Math.max(0, est - elapsed)}{t('dub.remaining')}</span>}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Let translations own the elapsed/remaining sentence structure.

Lines 10-21 force the number/symbol order in JSX (mm:ss …, ~${seconds}${t('dub.remaining')}), which prevents locales from reordering tokens or handling pluralization cleanly. Push the full status strings through t(...) with interpolated values instead of concatenating UI fragments in the component. As per coding guidelines, every new user-facing frontend string must go through i18n, and as per path instructions frontend user-facing strings must be fully localized.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/dub/TranscribeOverlay.jsx` around lines 10 - 21, The
transcribe overlay is hardcoding the elapsed and remaining status sentence
structure in TranscribeOverlay, which blocks proper localization and
pluralization. Update the JSX to delegate the full elapsed/remaining text to
i18n via t(...) with interpolated values instead of concatenating mm:ss and the
remaining seconds directly in the component. Use the existing TranscribeOverlay
rendering and dub.transcribing/dub.elapsed/dub.remaining keys as the place to
keep user-facing text fully localized.

Sources: Coding guidelines, Path instructions

Comment on lines 339 to +343
{dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done') && (
<div className="dub-col">
<div className="dub-head">
<div className="label-row dub-head__title">
<FileText className="label-icon" size={11} />
<span className="dub-head__filename">{dubFilename}</span>
<span className="dub-head__meta">· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project">— {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
{/* Icon-only secondary actions (tooltips carry the labels);
Generate Dub keeps its label as the primary verb. */}
<Button variant="subtle" size="sm" onClick={saveProject}
title={t('dub.save')} aria-label={t('dub.save')}><Save size={12} /></Button>
<Button variant="danger" size="sm" onClick={resetDub}
title={t('dub.reset')} aria-label={t('dub.reset')}><RotateCcw size={12} /></Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="dub-head__primary">
{dubStep === 'stopping' ? (
<FooterBtn sm tone="stopping" disabled icon={<Loader className="spinner" size={9} />} label={t('dub.stopping')} />
) : dubStep === 'generating' ? (
<FooterBtn sm tone="danger" onClick={handleDubStop} icon={<Square size={9} />}
label={t('dub.stop_progress', { current: dubProgress.current, total: dubProgress.total })} />
) : (
<>
<FooterBtn sm tone={dubSegments.length ? 'pink' : 'idle'} onClick={onGenerateClick}
disabled={!dubSegments.length} icon={<Play size={11} />}
label={multiLangMode && multiLangs.length > 1
? t('dub.generate_dub_multi', { count: multiLangs.length, defaultValue: 'Generate {{count}} dubs' })
: t('dub.generate_dub')} />
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn sm tone="pink"
onClick={() => handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })} />
)}
</>
)}
{dubStep === 'done' && (
<FooterBtn sm tone="idle"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />}
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
aria-label={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })} />
)}
<FooterBtn sm tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')} aria-label={t('dub.export_btn')} />
</div>
</div>
</div>

<DubHeader t={t} dubFilename={dubFilename} dubDuration={dubDuration} dubSegments={dubSegments} activeProjectName={activeProjectName} saveProject={saveProject} resetDub={resetDub} dubStep={dubStep} handleDubStop={handleDubStop} dubProgress={dubProgress} onGenerateClick={onGenerateClick} multiLangMode={multiLangMode} multiLangs={multiLangs} incrementalPlan={incrementalPlan} handleDubGenerate={handleDubGenerate} qcRunning={qcRunning} handleDubQc={handleDubQc} setExportOpen={setExportOpen} />
<div className="dub-split-grid dub-split-2">
{/* LEFT: Waveform + Video */}
<div className="studio-panel dub-panel-col">
{hasDubbedTrack && (
<div className="dub-lang-switch" role="radiogroup" aria-label={t('dub.preview_language', { defaultValue: 'Preview language' })}>
<button
type="button"
role="radio"
aria-checked={previewMode === 'original'}
className={`dub-lang-pill ${previewMode === 'original' ? 'is-active' : ''}`}
onClick={() => setPreviewMode('original')}
>
{t('dub.original_audio')}
</button>
{dubTracks.map(code => {
const label = LANG_CODES.find(lc => lc.code === code)?.label || code.toUpperCase();
return (
<button
key={code}
type="button"
role="radio"
aria-checked={previewMode === code}
className={`dub-lang-pill ${previewMode === code ? 'is-active' : ''}`}
onClick={() => setPreviewMode(code)}
>
{label}
</button>
);
})}
</div>
)}
<WaveformTimeline
key={videoSrc}
ref={waveformRef}
audioSrc={`${API}/dub/audio/${dubJobId}`}
videoSrc={videoSrc}
segments={dubSegments}
onsets={timelineOnsets}
selectedSegId={timelineSelSegId}
onSelectSeg={setTimelineSelSegId}
incrementalPlan={incrementalPlan}
onSegmentCommit={segmentMoveResize}
onSegmentDelete={segmentDelete}
onPreviewSegment={onTimelinePreviewSegment}
disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
<div className="dub-gen-overlay">
<div className="dub-gen-overlay__head">
{dubStep === 'stopping' ? <Loader className="spinner" size={14} color="#a89984" /> : <Sparkles className="spinner" size={14} color="#d3869b" />}
<span className={`dub-gen-overlay__title ${dubStep === 'stopping' ? 'is-stopping' : ''}`}>
{dubStep === 'stopping' ? t('dub.stopping') : t('dub.generate_dub') + ` ${dubProgress.current}/${dubProgress.total}…`}
</span>
</div>
{dubStep === 'generating' && (
<>
<div className="dub-gen-overlay__stats">
<span>⏱ {fmtDur(genElapsed)} {t('dub.elapsed')}</span>
{genRemaining !== null && <span>~{fmtDur(genRemaining)} {t('dub.remaining')}</span>}
</div>
<div className="dub-gen-overlay__bar">
<Progress
value={dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0}
tone="brand"
size="sm"
/>
</div>
{dubProgress.text && <span className="dub-gen-overlay__text">{dubProgress.text}</span>}
</>
)}
</div>
) : null}
/>

{/* 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) && (
<div className="dub-cast">
<div className="dub-cast__row">
<span className="dub-cast__kicker" title={t('dub.cast_title')}>{t('dub.cast')}</span>
{[...new Set(dubSegments.map(s => s.speaker_id).filter(Boolean))].map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="dub-cast__label">{spk}:</span>
<select className="input-base dub-cast__select"
value={dubSegments.find(s => s.speaker_id === spk)?.profile_id || ''}
onChange={e => {
const val = e.target.value;
setDubSegments(dubSegments.map(s => s.speaker_id === spk ? { ...s, profile_id: val } : s));
}}>
{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>
)}

{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
<div className="dub-settings-summary">
<button
type="button"
className="dub-settings-summary__trigger"
onClick={() => setSettingsOpen(true)}
title={t('dub.edit_settings')}
>
<ChevronDown size={10} />
<span><strong>{dubLang}</strong> · {dubLangCode} · {translateQuality} · <span style={{ color: activeEngineUnavailable ? '#fb4934' : '#b8bb26' }}>●</span> {translateProvider}</span>
{dubInstruct && <span className="dub-settings-summary__style">{t('dub.style_label_prefix')}{dubInstruct}</span>}
</button>
<Button
variant="subtle" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : hasAnyTranslation ? t('dub.retranslate') : t('dub.translate_all')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
</div>
)}
{settingsOpen && (
<div className="dub-settings-bar">
<div className="dub-settings-bar__fields">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
</button>
<div className="dub-settings-field dub-settings-field--lang">
<div className="label-row"><Globe className="label-icon" size={9} /> {t('dub.language')}</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === 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="dub-settings-field dub-settings-field--iso">
<div className="label-row">{t('dub.iso_code')}</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => {
const code = e.target.value;
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
Intl.DisplayNames so they localize with the UI for free. */}
{dialectOptionsFor(dubLangCode).length > 0 && (
<div className="dub-settings-field dub-settings-field--dialect">
<div className="label-row" title={t('dub.dialect_title')}>{t('dub.dialect_label')}</div>
<select
className="input-base dub-cast__select"
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>
</div>
)}
<div className="dub-settings-field dub-settings-field--engine">
<div className="label-row">
{t('dub.engine_label')}
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={t('dub.install_engine')}
>
{engineInstalling === translateProvider ? t('dub.installing_engine') : `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>
)}
{activeEngineUnavailable && enginesSandboxed && (
<span className="dub-engine-install-chip dub-engine-install-chip--disabled" title={t('dub.install_disabled_title')}>
{t('dub.needs_dev_install')}
</span>
)}
</div>
<select className="input-base dub-engine-select" 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>
</div>
<div className="dub-settings-field dub-settings-field--quality">
<div className="label-row" title={t('dub.quality_title')}>{t('dub.quality_label')}</div>
<Segmented
size="sm"
value={translateQuality}
onChange={(v) => {
// #372: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
toast(t('dub.cinematic_needs_llm_hint', { defaultValue: 'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).' }), { icon: 'ℹ️', duration: 8000 });
return;
}
setTranslateQuality(v);
}}
items={[
{ value: 'fast', label: t('dub.fast_quality') },
{ value: 'cinematic', label: t('dub.cinematic_quality') },
]}
/>
</div>
<div className="dub-settings-field dub-settings-field--style">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> {t('dub.style')} <span className="dub-settings-field__hint">{t('dub.optional')}</span></div>
<input className="input-base input-base--xs" placeholder={t('dub.style_placeholder')} value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
</div>
<div className="dub-settings-field dub-settings-field--multi">
<label className="dub-multi-toggle">
<input
type="checkbox"
checked={multiLangMode}
onChange={e => setMultiLangMode(e.target.checked)}
/>
<span>{t('dub.multi_lang')}</span>
</label>
{multiLangMode && (
<MultiLangPicker
selected={multiLangs}
onChange={setMultiLangs}
disabled={dubStep === 'generating'}
/>
)}
</div>
</div>
<div className="dub-settings-bar__actions">
<Button
variant="subtle" size="sm"
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
>
{t('dub.restore')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
<Button
variant="primary" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : t('dub.translate_all')}
</Button>
</div>
</div>
)}
</div>

{/* RIGHT: Segment Table */}
<div className="studio-panel dub-panel-col">

{/* Output options + timing — moved to the top of the right section. */}
<div className="dub-right-outputs">
<div className="dub-outputs-row">
<span className="dub-outputs-title-strong">{t('dub.output_options')}</span>
<label>
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} /> {t('dub.mix_bg_audio')}
</label>
<label title={t('dub.dual_subs_title')}>
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> {t('dub.dual_subs')}
</label>
<label title={t('dub.burn_subs_title')}>
<input type="checkbox" checked={!!burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> {t('dub.burn_subs')}
</label>
<label>
{t('dub.default_track')}
<select className="input-base dub-outputs-default" value={defaultTrack} 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>
</div>
<div className="dub-outputs-row" title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline.">
<span className="dub-outputs-title-strong">Timing:</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
items={[
{ value: 'concise', label: 'Concise', title: 'Translator trims text to fit at natural rate. Overflows surface in the row badge so you can shorten the segment.' },
{ value: 'smart_fit', label: t('dub.timing_smart_fit'), title: t('dub.timing_smart_fit_title') },
{ value: 'stretch_video', label: 'Stretch Video', title: 'Audio plays at natural rate; each segment of the video is stretched (per-segment ffmpeg setpts) to fit. Total video duration grows. Requires a re-encode pass.' },
{ value: 'strict_slot', label: 'Strict slot', title: 'Legacy: compress audio to fit the original timing. Can sound rushed/chipmunky on high-density target languages.' },
]}
/>
</div>
</div>

{dubTranscript && (
<div className="dub-transcript-toggle-wrap">
<div className="override-toggle dub-transcript-toggle__inner" onClick={() => setShowTranscript(!showTranscript)}>
<span><FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
{showTranscript && (
<div className="dub-transcript-body">
{dubTranscript}
</div>
)}
</div>
)}

{/* Phase 1.3 — Project glossary. Hidden behind a chip until
the user wants it (or terms already exist). */}
{dubJobId && !glossaryVisible && (
<button
type="button"
className="dub-glossary-chip"
onClick={() => { setGlossaryOpen(true); setGlossaryHidden(false); }}
title={t('dub.glossary_title')}
>
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
{dubJobId && glossaryVisible && (
<div className="dub-glossary-wrap">
<GlossaryPanel
projectId={dubJobId}
sourceLang={dubLangCode && dubLang ? (dubLang.slice(0, 2).toLowerCase() || 'en') : 'en'}
targetLang={dubLangCode}
segments={dubSegments}
onChange={onGlossaryChange}
onClose={() => { setGlossaryHidden(true); setGlossaryOpen(false); }}
/>
</div>
)}

{/* "Apply Voice to All" row removed 2026-04-21 — redundant
with the CAST strip in the left column, which does the same
thing per-speaker (and handles the multi-speaker case cleanly). */}

{selectedSegIds.size > 0 && (
<div className="dub-bulk-row dub-bulk-row--select">
<span className="dub-bulk-row__label-brand">{t('dub.selected_count', { count: selectedSegIds.size })}</span>
<select className="input-base dub-bulk-select dub-bulk-select--voice"
value="" onChange={(e) => { const v = e.target.value; if (v === '__clear__') bulkApplyToSelected({ profile_id: '' }); else if (v) bulkApplyToSelected({ profile_id: v }); }}>
<option value="">{t('dub.set_voice')}</option>
<option value="__clear__">{t('dub.clear_voice')}</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label={t('dub.cast')}>
{Object.keys(speakerClones).map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
})}
</optgroup>
)}
{profiles.filter(p => !p.instruct).length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.filter(p => !p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{profiles.filter(p => !!p.instruct).length > 0 && (
<optgroup label={t('dub.design_presets')}>
{profiles.filter(p => !!p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
</select>
<select className="input-base dub-bulk-select dub-bulk-select--lang"
value="" onChange={(e) => { if (e.target.value === '__def__') bulkApplyToSelected({ target_lang: null }); else if (e.target.value) bulkApplyToSelected({ target_lang: e.target.value }); }}>
<option value="">{t('dub.set_lang')}</option>
<option value="__def__">{t('dub.default_lang')}</option>
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>)}
</select>
<Button variant="danger" size="sm" onClick={bulkDeleteSelected}>{t('dub.delete_selected')}</Button>
<Button variant="ghost" size="sm" onClick={clearSegSelection} className="dub-bulk-row__clear">{t('dub.clear_selection')}</Button>
</div>
)}

{showCheckpoint && (
<CheckpointBanner
stage={checkpointStage}
count={dubSegments.length}
onContinue={checkpointStage === 'done' ? null : onCheckpointContinue}
onDismiss={onCheckpointDismiss}
continueLoading={isTranslating}
/>
)}

<Suspense fallback={<LazyFallback />}>
<DubSegmentTable
segments={dubSegments}
profiles={profiles}
speakerClones={speakerClones}
dubStep={dubStep}
dubProgress={dubProgress}
previewLoadingId={segmentPreviewLoading}
selectedIds={selectedSegIds}
onSelect={toggleSegSelect}
onSelectAll={selectAllSegs}
onClearSelection={clearSegSelection}
onEditField={segmentEditField}
onDelete={segmentDelete}
onRestore={segmentRestoreOriginal}
onPreview={handleSegmentPreview}
onDirect={onDirectSegment}
onSplit={segmentSplit}
onMerge={segmentMerge}
onSeek={seekWaveform}
timelineSelectedId={timelineSelSegId}
/>
</Suspense>
</div>
</div>

{/* Actions footer */}
<div className="studio-panel dub-footer-panel">
{dubStep === 'done' && (
<div className="dub-footer-banner">
<Badge tone="success">
<Check size={11} /> {t('dub.tracks_done', { tracks: dubTracks.join(', ') })}
</Badge>
{incrementalPlan && incrementalPlan.stale?.length > 0 && (
<Badge tone="warn" className="dub-footer-banner__badge-gap">
{t('dub.segments_changed', { count: incrementalPlan.stale.length })}
</Badge>
)}
{incrementalPlan && incrementalPlan.stale?.length === 0 && incrementalPlan.fresh?.length > 0 && (
<Badge tone="neutral" className="dub-footer-banner__badge-gap">
{t('dub.all_up_to_date', { count: incrementalPlan.fresh.length })}
</Badge>
)}
</div>
)}
{dubError && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
</div>
)}
{/* Output options + Timing moved to the top of the right (transcript) section. */}
{dubTracks.length > 0 && (
<div className="dub-tracks-row">
<span className="dub-tracks-row__title">{t('dub.export_tracks')}</span>
<label className={exportTracks['original'] !== false ? 'is-on' : 'is-off'}>
<input type="checkbox" checked={exportTracks['original'] !== false} onChange={e => setExportTracks(prev => ({ ...prev, original: e.target.checked }))} />
<span>{t('dub.original_track')}</span>
</label>
{dubTracks.map(t => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[t] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [t]: e.target.checked }))} />
<span className="code">{t}</span>
</label>
))}
</div>
)}
{(() => {
// Pre-generation compression warning. Predicted by the
// translate response (see services/speech_rate.rate_ratio
// + dub_translate._maybe_cinematic), populated whenever
// segments carry a slot_seconds and translated text.
// Surfaces here so the user can act (re-translate in
// Cinematic, edit text, allow longer slots) before
// committing to a full Generate Dub run.
const hot = dubSegments.filter(s => (s.rate_ratio || 0) > 1.3);
if (hot.length === 0 || !dubSegments.length) return null;
const pctHot = Math.round((hot.length / dubSegments.length) * 100);
if (pctHot < 10) return null;
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
return (
<div className="dub-compression-warn" role="status">
<span className="dub-compression-warn__icon">⚠</span>
<span className="dub-compression-warn__body">
<strong>{hot.length} of {dubSegments.length}</strong> segments need {'>'}1.3× compression
(worst: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{worst.rate_ratio.toFixed(2)}×</span>).
Output will be intelligible (pitch-preserving stretch) but stressed —
{translateQuality === 'fast' ? ' switch to Cinematic and Re-translate' : ' shorten the worst segments'}
{' '}for cleaner audio.
</span>
</div>
);
})()}
{/* Generate / Export / Stop actions moved to the header bar (dub-head__primary). */}
<DubLeftColumn hasDubbedTrack={hasDubbedTrack} t={t} previewMode={previewMode} setPreviewMode={setPreviewMode} dubTracks={dubTracks} videoSrc={videoSrc} waveformRef={waveformRef} dubJobId={dubJobId} dubSegments={dubSegments} timelineOnsets={timelineOnsets} timelineSelSegId={timelineSelSegId} setTimelineSelSegId={setTimelineSelSegId} incrementalPlan={incrementalPlan} segmentMoveResize={segmentMoveResize} segmentDelete={segmentDelete} onTimelinePreviewSegment={onTimelinePreviewSegment} dubStep={dubStep} dubProgress={dubProgress} fmtDur={fmtDur} genElapsed={genElapsed} genRemaining={genRemaining} speakerClones={speakerClones} setDubSegments={setDubSegments} profiles={profiles} settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen} dubLang={dubLang} dubLangCode={dubLangCode} translateQuality={translateQuality} activeEngineUnavailable={activeEngineUnavailable} translateProvider={translateProvider} dubInstruct={dubInstruct} setDubInstruct={setDubInstruct} handleTranslateAll={handleTranslateAll} isTranslating={isTranslating} hasAnyTranslation={hasAnyTranslation} handleCleanupSegments={handleCleanupSegments} setDubLang={setDubLang} setDubLangCode={setDubLangCode} dubDialect={dubDialect} setDubDialect={setDubDialect} i18n={i18n} enginesSandboxed={enginesSandboxed} handleInstallEngine={handleInstallEngine} engineInstalling={engineInstalling} activeEngineEntry={activeEngineEntry} engines={engines} setTranslateProvider={setTranslateProvider} setTranslateQuality={setTranslateQuality} llmEndpoint={llmEndpoint} multiLangMode={multiLangMode} setMultiLangMode={setMultiLangMode} multiLangs={multiLangs} setMultiLangs={setMultiLangs} editSegments={editSegments} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the editor mounted during stopping.

Line 339 excludes stopping, but DubHeader and DubLeftColumn both implement stopping UI. Clicking Stop from generation will switch to IdleSkeleton, hiding the cancellation/progress state. Include stopping in the editor gate and derive showIdleSkeleton from the same predicate.

Concrete fix
-  const showIdleSkeleton = !(dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done'));
+  const isDubEditorActive = !!dubJobId && (
+    dubStep === 'editing' ||
+    dubStep === 'generating' ||
+    dubStep === 'stopping' ||
+    dubStep === 'done'
+  );
+  const showIdleSkeleton = !isDubEditorActive;
...
-      {dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done') && (
+      {isDubEditorActive && (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done') && (
<div className="dub-col">
<div className="dub-head">
<div className="label-row dub-head__title">
<FileText className="label-icon" size={11} />
<span className="dub-head__filename">{dubFilename}</span>
<span className="dub-head__meta">· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project">{activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
{/* Icon-only secondary actions (tooltips carry the labels);
Generate Dub keeps its label as the primary verb. */}
<Button variant="subtle" size="sm" onClick={saveProject}
title={t('dub.save')} aria-label={t('dub.save')}><Save size={12} /></Button>
<Button variant="danger" size="sm" onClick={resetDub}
title={t('dub.reset')} aria-label={t('dub.reset')}><RotateCcw size={12} /></Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="dub-head__primary">
{dubStep === 'stopping' ? (
<FooterBtn sm tone="stopping" disabled icon={<Loader className="spinner" size={9} />} label={t('dub.stopping')} />
) : dubStep === 'generating' ? (
<FooterBtn sm tone="danger" onClick={handleDubStop} icon={<Square size={9} />}
label={t('dub.stop_progress', { current: dubProgress.current, total: dubProgress.total })} />
) : (
<>
<FooterBtn sm tone={dubSegments.length ? 'pink' : 'idle'} onClick={onGenerateClick}
disabled={!dubSegments.length} icon={<Play size={11} />}
label={multiLangMode && multiLangs.length > 1
? t('dub.generate_dub_multi', { count: multiLangs.length, defaultValue: 'Generate {{count}} dubs' })
: t('dub.generate_dub')} />
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn sm tone="pink"
onClick={() => handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })} />
)}
</>
)}
{dubStep === 'done' && (
<FooterBtn sm tone="idle"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />}
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
aria-label={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })} />
)}
<FooterBtn sm tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')} aria-label={t('dub.export_btn')} />
</div>
</div>
</div>
<DubHeader t={t} dubFilename={dubFilename} dubDuration={dubDuration} dubSegments={dubSegments} activeProjectName={activeProjectName} saveProject={saveProject} resetDub={resetDub} dubStep={dubStep} handleDubStop={handleDubStop} dubProgress={dubProgress} onGenerateClick={onGenerateClick} multiLangMode={multiLangMode} multiLangs={multiLangs} incrementalPlan={incrementalPlan} handleDubGenerate={handleDubGenerate} qcRunning={qcRunning} handleDubQc={handleDubQc} setExportOpen={setExportOpen} />
<div className="dub-split-grid dub-split-2">
{/* LEFT: Waveform + Video */}
<div className="studio-panel dub-panel-col">
{hasDubbedTrack && (
<div className="dub-lang-switch" role="radiogroup" aria-label={t('dub.preview_language', { defaultValue: 'Preview language' })}>
<button
type="button"
role="radio"
aria-checked={previewMode === 'original'}
className={`dub-lang-pill ${previewMode === 'original' ? 'is-active' : ''}`}
onClick={() => setPreviewMode('original')}
>
{t('dub.original_audio')}
</button>
{dubTracks.map(code => {
const label = LANG_CODES.find(lc => lc.code === code)?.label || code.toUpperCase();
return (
<button
key={code}
type="button"
role="radio"
aria-checked={previewMode === code}
className={`dub-lang-pill ${previewMode === code ? 'is-active' : ''}`}
onClick={() => setPreviewMode(code)}
>
{label}
</button>
);
})}
</div>
)}
<WaveformTimeline
key={videoSrc}
ref={waveformRef}
audioSrc={`${API}/dub/audio/${dubJobId}`}
videoSrc={videoSrc}
segments={dubSegments}
onsets={timelineOnsets}
selectedSegId={timelineSelSegId}
onSelectSeg={setTimelineSelSegId}
incrementalPlan={incrementalPlan}
onSegmentCommit={segmentMoveResize}
onSegmentDelete={segmentDelete}
onPreviewSegment={onTimelinePreviewSegment}
disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
<div className="dub-gen-overlay">
<div className="dub-gen-overlay__head">
{dubStep === 'stopping' ? <Loader className="spinner" size={14} color="#a89984" /> : <Sparkles className="spinner" size={14} color="#d3869b" />}
<span className={`dub-gen-overlay__title ${dubStep === 'stopping' ? 'is-stopping' : ''}`}>
{dubStep === 'stopping' ? t('dub.stopping') : t('dub.generate_dub') + ` ${dubProgress.current}/${dubProgress.total}…`}
</span>
</div>
{dubStep === 'generating' && (
<>
<div className="dub-gen-overlay__stats">
<span>{fmtDur(genElapsed)} {t('dub.elapsed')}</span>
{genRemaining !== null && <span>~{fmtDur(genRemaining)} {t('dub.remaining')}</span>}
</div>
<div className="dub-gen-overlay__bar">
<Progress
value={dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0}
tone="brand"
size="sm"
/>
</div>
{dubProgress.text && <span className="dub-gen-overlay__text">{dubProgress.text}</span>}
</>
)}
</div>
) : null}
/>
{/* 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) && (
<div className="dub-cast">
<div className="dub-cast__row">
<span className="dub-cast__kicker" title={t('dub.cast_title')}>{t('dub.cast')}</span>
{[...new Set(dubSegments.map(s => s.speaker_id).filter(Boolean))].map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="dub-cast__label">{spk}:</span>
<select className="input-base dub-cast__select"
value={dubSegments.find(s => s.speaker_id === spk)?.profile_id || ''}
onChange={e => {
const val = e.target.value;
setDubSegments(dubSegments.map(s => s.speaker_id === spk ? { ...s, profile_id: val } : s));
}}>
{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>
)}
{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
<div className="dub-settings-summary">
<button
type="button"
className="dub-settings-summary__trigger"
onClick={() => setSettingsOpen(true)}
title={t('dub.edit_settings')}
>
<ChevronDown size={10} />
<span><strong>{dubLang}</strong> · {dubLangCode} · {translateQuality} · <span style={{ color: activeEngineUnavailable ? '#fb4934' : '#b8bb26' }}></span> {translateProvider}</span>
{dubInstruct && <span className="dub-settings-summary__style">{t('dub.style_label_prefix')}{dubInstruct}</span>}
</button>
<Button
variant="subtle" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : hasAnyTranslation ? t('dub.retranslate') : t('dub.translate_all')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
</div>
)}
{settingsOpen && (
<div className="dub-settings-bar">
<div className="dub-settings-bar__fields">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
</button>
<div className="dub-settings-field dub-settings-field--lang">
<div className="label-row"><Globe className="label-icon" size={9} /> {t('dub.language')}</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === 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="dub-settings-field dub-settings-field--iso">
<div className="label-row">{t('dub.iso_code')}</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => {
const code = e.target.value;
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
Intl.DisplayNames so they localize with the UI for free. */}
{dialectOptionsFor(dubLangCode).length > 0 && (
<div className="dub-settings-field dub-settings-field--dialect">
<div className="label-row" title={t('dub.dialect_title')}>{t('dub.dialect_label')}</div>
<select
className="input-base dub-cast__select"
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>
</div>
)}
<div className="dub-settings-field dub-settings-field--engine">
<div className="label-row">
{t('dub.engine_label')}
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={t('dub.install_engine')}
>
{engineInstalling === translateProvider ? t('dub.installing_engine') : `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>
)}
{activeEngineUnavailable && enginesSandboxed && (
<span className="dub-engine-install-chip dub-engine-install-chip--disabled" title={t('dub.install_disabled_title')}>
{t('dub.needs_dev_install')}
</span>
)}
</div>
<select className="input-base dub-engine-select" 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>
</div>
<div className="dub-settings-field dub-settings-field--quality">
<div className="label-row" title={t('dub.quality_title')}>{t('dub.quality_label')}</div>
<Segmented
size="sm"
value={translateQuality}
onChange={(v) => {
// #372: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
toast(t('dub.cinematic_needs_llm_hint', { defaultValue: 'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).' }), { icon: 'ℹ️', duration: 8000 });
return;
}
setTranslateQuality(v);
}}
items={[
{ value: 'fast', label: t('dub.fast_quality') },
{ value: 'cinematic', label: t('dub.cinematic_quality') },
]}
/>
</div>
<div className="dub-settings-field dub-settings-field--style">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> {t('dub.style')} <span className="dub-settings-field__hint">{t('dub.optional')}</span></div>
<input className="input-base input-base--xs" placeholder={t('dub.style_placeholder')} value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
</div>
<div className="dub-settings-field dub-settings-field--multi">
<label className="dub-multi-toggle">
<input
type="checkbox"
checked={multiLangMode}
onChange={e => setMultiLangMode(e.target.checked)}
/>
<span>{t('dub.multi_lang')}</span>
</label>
{multiLangMode && (
<MultiLangPicker
selected={multiLangs}
onChange={setMultiLangs}
disabled={dubStep === 'generating'}
/>
)}
</div>
</div>
<div className="dub-settings-bar__actions">
<Button
variant="subtle" size="sm"
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
>
{t('dub.restore')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
<Button
variant="primary" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : t('dub.translate_all')}
</Button>
</div>
</div>
)}
</div>
{/* RIGHT: Segment Table */}
<div className="studio-panel dub-panel-col">
{/* Output options + timing — moved to the top of the right section. */}
<div className="dub-right-outputs">
<div className="dub-outputs-row">
<span className="dub-outputs-title-strong">{t('dub.output_options')}</span>
<label>
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} /> {t('dub.mix_bg_audio')}
</label>
<label title={t('dub.dual_subs_title')}>
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> {t('dub.dual_subs')}
</label>
<label title={t('dub.burn_subs_title')}>
<input type="checkbox" checked={!!burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> {t('dub.burn_subs')}
</label>
<label>
{t('dub.default_track')}
<select className="input-base dub-outputs-default" value={defaultTrack} 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>
</div>
<div className="dub-outputs-row" title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline.">
<span className="dub-outputs-title-strong">Timing:</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
items={[
{ value: 'concise', label: 'Concise', title: 'Translator trims text to fit at natural rate. Overflows surface in the row badge so you can shorten the segment.' },
{ value: 'smart_fit', label: t('dub.timing_smart_fit'), title: t('dub.timing_smart_fit_title') },
{ value: 'stretch_video', label: 'Stretch Video', title: 'Audio plays at natural rate; each segment of the video is stretched (per-segment ffmpeg setpts) to fit. Total video duration grows. Requires a re-encode pass.' },
{ value: 'strict_slot', label: 'Strict slot', title: 'Legacy: compress audio to fit the original timing. Can sound rushed/chipmunky on high-density target languages.' },
]}
/>
</div>
</div>
{dubTranscript && (
<div className="dub-transcript-toggle-wrap">
<div className="override-toggle dub-transcript-toggle__inner" onClick={() => setShowTranscript(!showTranscript)}>
<span><FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
{showTranscript && (
<div className="dub-transcript-body">
{dubTranscript}
</div>
)}
</div>
)}
{/* Phase 1.3 Project glossary. Hidden behind a chip until
the user wants it (or terms already exist). */}
{dubJobId && !glossaryVisible && (
<button
type="button"
className="dub-glossary-chip"
onClick={() => { setGlossaryOpen(true); setGlossaryHidden(false); }}
title={t('dub.glossary_title')}
>
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
{dubJobId && glossaryVisible && (
<div className="dub-glossary-wrap">
<GlossaryPanel
projectId={dubJobId}
sourceLang={dubLangCode && dubLang ? (dubLang.slice(0, 2).toLowerCase() || 'en') : 'en'}
targetLang={dubLangCode}
segments={dubSegments}
onChange={onGlossaryChange}
onClose={() => { setGlossaryHidden(true); setGlossaryOpen(false); }}
/>
</div>
)}
{/* "Apply Voice to All" row removed 2026-04-21 redundant
with the CAST strip in the left column, which does the same
thing per-speaker (and handles the multi-speaker case cleanly). */}
{selectedSegIds.size > 0 && (
<div className="dub-bulk-row dub-bulk-row--select">
<span className="dub-bulk-row__label-brand">{t('dub.selected_count', { count: selectedSegIds.size })}</span>
<select className="input-base dub-bulk-select dub-bulk-select--voice"
value="" onChange={(e) => { const v = e.target.value; if (v === '__clear__') bulkApplyToSelected({ profile_id: '' }); else if (v) bulkApplyToSelected({ profile_id: v }); }}>
<option value="">{t('dub.set_voice')}</option>
<option value="__clear__">{t('dub.clear_voice')}</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label={t('dub.cast')}>
{Object.keys(speakerClones).map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
})}
</optgroup>
)}
{profiles.filter(p => !p.instruct).length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.filter(p => !p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{profiles.filter(p => !!p.instruct).length > 0 && (
<optgroup label={t('dub.design_presets')}>
{profiles.filter(p => !!p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
</select>
<select className="input-base dub-bulk-select dub-bulk-select--lang"
value="" onChange={(e) => { if (e.target.value === '__def__') bulkApplyToSelected({ target_lang: null }); else if (e.target.value) bulkApplyToSelected({ target_lang: e.target.value }); }}>
<option value="">{t('dub.set_lang')}</option>
<option value="__def__">{t('dub.default_lang')}</option>
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>)}
</select>
<Button variant="danger" size="sm" onClick={bulkDeleteSelected}>{t('dub.delete_selected')}</Button>
<Button variant="ghost" size="sm" onClick={clearSegSelection} className="dub-bulk-row__clear">{t('dub.clear_selection')}</Button>
</div>
)}
{showCheckpoint && (
<CheckpointBanner
stage={checkpointStage}
count={dubSegments.length}
onContinue={checkpointStage === 'done' ? null : onCheckpointContinue}
onDismiss={onCheckpointDismiss}
continueLoading={isTranslating}
/>
)}
<Suspense fallback={<LazyFallback />}>
<DubSegmentTable
segments={dubSegments}
profiles={profiles}
speakerClones={speakerClones}
dubStep={dubStep}
dubProgress={dubProgress}
previewLoadingId={segmentPreviewLoading}
selectedIds={selectedSegIds}
onSelect={toggleSegSelect}
onSelectAll={selectAllSegs}
onClearSelection={clearSegSelection}
onEditField={segmentEditField}
onDelete={segmentDelete}
onRestore={segmentRestoreOriginal}
onPreview={handleSegmentPreview}
onDirect={onDirectSegment}
onSplit={segmentSplit}
onMerge={segmentMerge}
onSeek={seekWaveform}
timelineSelectedId={timelineSelSegId}
/>
</Suspense>
</div>
</div>
{/* Actions footer */}
<div className="studio-panel dub-footer-panel">
{dubStep === 'done' && (
<div className="dub-footer-banner">
<Badge tone="success">
<Check size={11} /> {t('dub.tracks_done', { tracks: dubTracks.join(', ') })}
</Badge>
{incrementalPlan && incrementalPlan.stale?.length > 0 && (
<Badge tone="warn" className="dub-footer-banner__badge-gap">
{t('dub.segments_changed', { count: incrementalPlan.stale.length })}
</Badge>
)}
{incrementalPlan && incrementalPlan.stale?.length === 0 && incrementalPlan.fresh?.length > 0 && (
<Badge tone="neutral" className="dub-footer-banner__badge-gap">
{t('dub.all_up_to_date', { count: incrementalPlan.fresh.length })}
</Badge>
)}
</div>
)}
{dubError && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
</div>
)}
{/* Output options + Timing moved to the top of the right (transcript) section. */}
{dubTracks.length > 0 && (
<div className="dub-tracks-row">
<span className="dub-tracks-row__title">{t('dub.export_tracks')}</span>
<label className={exportTracks['original'] !== false ? 'is-on' : 'is-off'}>
<input type="checkbox" checked={exportTracks['original'] !== false} onChange={e => setExportTracks(prev => ({ ...prev, original: e.target.checked }))} />
<span>{t('dub.original_track')}</span>
</label>
{dubTracks.map(t => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[t] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [t]: e.target.checked }))} />
<span className="code">{t}</span>
</label>
))}
</div>
)}
{(() => {
// Pre-generation compression warning. Predicted by the
// translate response (see services/speech_rate.rate_ratio
// + dub_translate._maybe_cinematic), populated whenever
// segments carry a slot_seconds and translated text.
// Surfaces here so the user can act (re-translate in
// Cinematic, edit text, allow longer slots) before
// committing to a full Generate Dub run.
const hot = dubSegments.filter(s => (s.rate_ratio || 0) > 1.3);
if (hot.length === 0 || !dubSegments.length) return null;
const pctHot = Math.round((hot.length / dubSegments.length) * 100);
if (pctHot < 10) return null;
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
return (
<div className="dub-compression-warn" role="status">
<span className="dub-compression-warn__icon"></span>
<span className="dub-compression-warn__body">
<strong>{hot.length} of {dubSegments.length}</strong> segments need {'>'}1.3× compression
(worst: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{worst.rate_ratio.toFixed(2)}×</span>).
Output will be intelligible (pitch-preserving stretch) but stressed
{translateQuality === 'fast' ? ' switch to Cinematic and Re-translate' : ' shorten the worst segments'}
{' '}for cleaner audio.
</span>
</div>
);
})()}
{/* Generate / Export / Stop actions moved to the header bar (dub-head__primary). */}
<DubLeftColumn hasDubbedTrack={hasDubbedTrack} t={t} previewMode={previewMode} setPreviewMode={setPreviewMode} dubTracks={dubTracks} videoSrc={videoSrc} waveformRef={waveformRef} dubJobId={dubJobId} dubSegments={dubSegments} timelineOnsets={timelineOnsets} timelineSelSegId={timelineSelSegId} setTimelineSelSegId={setTimelineSelSegId} incrementalPlan={incrementalPlan} segmentMoveResize={segmentMoveResize} segmentDelete={segmentDelete} onTimelinePreviewSegment={onTimelinePreviewSegment} dubStep={dubStep} dubProgress={dubProgress} fmtDur={fmtDur} genElapsed={genElapsed} genRemaining={genRemaining} speakerClones={speakerClones} setDubSegments={setDubSegments} profiles={profiles} settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen} dubLang={dubLang} dubLangCode={dubLangCode} translateQuality={translateQuality} activeEngineUnavailable={activeEngineUnavailable} translateProvider={translateProvider} dubInstruct={dubInstruct} setDubInstruct={setDubInstruct} handleTranslateAll={handleTranslateAll} isTranslating={isTranslating} hasAnyTranslation={hasAnyTranslation} handleCleanupSegments={handleCleanupSegments} setDubLang={setDubLang} setDubLangCode={setDubLangCode} dubDialect={dubDialect} setDubDialect={setDubDialect} i18n={i18n} enginesSandboxed={enginesSandboxed} handleInstallEngine={handleInstallEngine} engineInstalling={engineInstalling} activeEngineEntry={activeEngineEntry} engines={engines} setTranslateProvider={setTranslateProvider} setTranslateQuality={setTranslateQuality} llmEndpoint={llmEndpoint} multiLangMode={multiLangMode} setMultiLangMode={setMultiLangMode} multiLangs={multiLangs} setMultiLangs={setMultiLangs} editSegments={editSegments} />
{isDubEditorActive && (
<div className="dub-col">
<DubHeader t={t} dubFilename={dubFilename} dubDuration={dubDuration} dubSegments={dubSegments} activeProjectName={activeProjectName} saveProject={saveProject} resetDub={resetDub} dubStep={dubStep} handleDubStop={handleDubStop} dubProgress={dubProgress} onGenerateClick={onGenerateClick} multiLangMode={multiLangMode} multiLangs={multiLangs} incrementalPlan={incrementalPlan} handleDubGenerate={handleDubGenerate} qcRunning={qcRunning} handleDubQc={handleDubQc} setExportOpen={setExportOpen} />
<div className="dub-split-grid dub-split-2">
<DubLeftColumn hasDubbedTrack={hasDubbedTrack} t={t} previewMode={previewMode} setPreviewMode={setPreviewMode} dubTracks={dubTracks} videoSrc={videoSrc} waveformRef={waveformRef} dubJobId={dubJobId} dubSegments={dubSegments} timelineOnsets={timelineOnsets} timelineSelSegId={timelineSelSegId} setTimelineSelSegId={setTimelineSelSegId} incrementalPlan={incrementalPlan} segmentMoveResize={segmentMoveResize} segmentDelete={segmentDelete} onTimelinePreviewSegment={onTimelinePreviewSegment} dubStep={dubStep} dubProgress={dubProgress} fmtDur={fmtDur} genElapsed={genElapsed} genRemaining={genRemaining} speakerClones={speakerClones} setDubSegments={setDubSegments} profiles={profiles} settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen} dubLang={dubLang} dubLangCode={dubLangCode} translateQuality={translateQuality} activeEngineUnavailable={activeEngineUnavailable} translateProvider={translateProvider} dubInstruct={dubInstruct} setDubInstruct={setDubInstruct} handleTranslateAll={handleTranslateAll} isTranslating={isTranslating} hasAnyTranslation={hasAnyTranslation} handleCleanupSegments={handleCleanupSegments} setDubLang={setDubLang} setDubLangCode={setDubLangCode} dubDialect={dubDialect} setDubDialect={setDubDialect} i18n={i18n} enginesSandboxed={enginesSandboxed} handleInstallEngine={handleInstallEngine} engineInstalling={engineInstalling} activeEngineEntry={activeEngineEntry} engines={engines} setTranslateProvider={setTranslateProvider} setTranslateQuality={setTranslateQuality} llmEndpoint={llmEndpoint} multiLangMode={multiLangMode} setMultiLangMode={setMultiLangMode} multiLangs={multiLangs} setMultiLangs={setMultiLangs} editSegments={editSegments} />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/DubTab.jsx` around lines 339 - 343, The editor gate in
DubTab currently hides the mounted editor during the stopping state, which
prevents DubHeader and DubLeftColumn from showing their stopping UI. Update the
conditional around the main dub editor render to include stopping alongside
editing/generating/done, and make showIdleSkeleton derive from the same shared
predicate so the cancellation/progress state stays visible. Use the existing
DubTab render condition and the DubHeader/DubLeftColumn props as the key
locations to adjust.

@debpalash
debpalash merged commit a7f813b into main Jun 29, 2026
15 checks passed
@debpalash
debpalash deleted the refactor/dubtab-modularization branch June 29, 2026 22:06
debpalash added a commit that referenced this pull request Jun 29, 2026
…00) (#760)

Phase 3 — same standard as #758/#759, applied to the last three over-cap pages.
Pure-mechanical, no behavior change.

- VoiceGallery.jsx 768 → 205: relocate the already-separate zone components
  (ArchetypesZone, ArchetypeCard, CommunityZone, ImportsZone) + shared helpers
  into components/gallery/.
- CloneDesignTab.jsx 837 → 395: split the ~540-line JSX return into section
  components (ScriptPanel, AudioMethodPanel, DesignMethodPanel, ActionBar) +
  MicButton, under components/clone/. State stays in the page.
- VoiceProfile.jsx 515 → 287: split the main return into ProfileHeader /
  ProfileDetails / ProfileActivity under components/profile/.

Safety contract for the JSX splits (no render tests): explicit NAMED props on
every section so eslint no-undef verifies completeness on both ends; JSX moved
verbatim. Verified: 0 no-undef across all changed files; every original
className preserved (diffed main vs new set); every file <500 lines.

Verified: vite build passes; FULL frontend suite 638/638 pass.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant