refactor(dub): modularize DubTab page (1593→380 lines, all files under 500) - #759
Conversation
…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>
📝 WalkthroughWalkthrough
ChangesDubTab UI Decomposition
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Panel notes from the engineers: ML/inference: Audio DSP: Desktop systems: Product polish: 🚥 Pre-merge checks | ✅ 6 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (6 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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
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. Comment |
|
| 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
%%{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
Reviews (1): Last reviewed commit: "refactor(dub): split DubTab JSX into sec..." | Re-trigger Greptile
| {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> | ||
| ))} |
There was a problem hiding this comment.
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.
| {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!
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
frontend/src/components/dub/DubFailureNotice.jsxfrontend/src/components/dub/DubFooter.jsxfrontend/src/components/dub/DubHeader.jsxfrontend/src/components/dub/DubLeftColumn.jsxfrontend/src/components/dub/DubPipelineStepper.jsxfrontend/src/components/dub/DubRightColumn.jsxfrontend/src/components/dub/FooterBtn.jsxfrontend/src/components/dub/IdleSkeleton.jsxfrontend/src/components/dub/PrepOverlay.jsxfrontend/src/components/dub/TranscribeOverlay.jsxfrontend/src/pages/DubTab.jsx
| const copyDiagnostic = async () => { | ||
| try { | ||
| await copyText(failure.diagnostic || failure.reason); | ||
| toast.success(t('dub.diagnostic_copied')); | ||
| } catch { | ||
| toast.error(t('dub.copy_failed')); |
There was a problem hiding this comment.
🎯 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.
| 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.
| <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. |
There was a problem hiding this comment.
🎯 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
| {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> |
There was a problem hiding this comment.
🎯 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
| 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); | ||
| }} |
There was a problem hiding this comment.
🎯 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.
| 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.
| 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> |
There was a problem hiding this comment.
🎯 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.")
PYRepository: 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
| const LazyFallback = () => ( | ||
| <div className="dub-lazy-fallback">Loading…</div> | ||
| ); |
There was a problem hiding this comment.
🎯 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
| <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)); | ||
| } |
There was a problem hiding this comment.
🩺 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
| 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`; | ||
| } |
There was a problem hiding this comment.
🎯 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
| 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>} |
There was a problem hiding this comment.
🎯 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
| {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} /> |
There was a problem hiding this comment.
🎯 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.
| {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.
…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>
What & why
DubTab.jsxwas 1593 lines — the second-largest page after Settings. This decomposes it into a thin orchestrator plus focused components undercomponents/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
pages/DubTab.jsxPass 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)
no-undefverifies prop completeness on both ends. A dropped value is a build error, not a silentundefined. ✅ 0no-undefacross all files.mainvs the new file set) — proves no JSX was lost.Verification
vite buildpassesno-undef, no newno-unused-vars; remaining lint errors are pre-existingset-state-in-effecton untouched logicDepends conceptually on the standard introduced in #758 (
max-linesguardrail +CONTRIBUTING.md), but touches disjoint files.🤖 Generated with Claude Code
Refactored
DubTab.jsxinto a thin orchestrator and moved the dub studio UI into focused components undercomponents/dub/:DubFailureNotice,DubPipelineStepper,PrepOverlay,TranscribeOverlay,FooterBtnIdleSkeleton,DubHeader,DubLeftColumn,DubRightColumn,DubFooterDubTab; no behavior change intendedResult:
pages/DubTab.jsxis much smaller, dub files stay under the size cap, and the existing classNames/UI wiring were preserved.Layout sketch:
Before
After