OmniVoice-Studio / frontend /src /components /DubSegmentRow.jsx
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
13.8 kB
import React, { memo, useRef } from 'react';
import {
CheckCircle, AlertCircle, Circle, Trash2, Loader, Headphones, Scissors, Merge,
MoreHorizontal, Sparkles,
} from 'lucide-react';
import { formatTime } from '../utils/format';
import { LANG_CODES } from '../utils/languages';
import { PRESETS } from '../utils/constants';
import { Menu, Button, Badge } from '../ui';
import './DubSegmentRow.css';
const CHAR_BUDGET_RATIO = 1.3;
const SENTENCE_END = /[.!?。!?]/;
function rowClass(isActive, isDone, selected) {
return `segment-row${isActive ? ' segment-active' : ''}${isDone ? ' segment-done' : ''}${selected ? ' segment-selected' : ''}`;
}
// Best split point for the Scissors menu when the user hasn't placed a cursor —
// prefer the sentence boundary nearest the middle, then a whitespace boundary,
// then the literal midpoint as a last resort.
function bestSplitPoint(text) {
const mid = Math.floor(text.length / 2);
let best = -1, bestDist = Infinity;
for (let i = 0; i < text.length; i++) {
if (SENTENCE_END.test(text[i])) {
const d = Math.abs(i + 1 - mid);
if (d < bestDist) { best = i + 1; bestDist = d; }
}
}
if (best > 0 && best < text.length) return best;
for (let r = 0; r < text.length; r++) {
for (const i of [mid - r, mid + r]) {
if (i > 0 && i < text.length && /\s/.test(text[i])) return i;
}
}
return mid;
}
// Accept "m:ss[.s]" or raw seconds; return null on garbage so the field can revert.
function parseTime(s) {
const m = /^\s*(\d+):([0-5]?\d(?:\.\d+)?)\s*$/.exec(s);
if (m) return parseInt(m[1], 10) * 60 + parseFloat(m[2]);
const n = parseFloat(s);
return Number.isFinite(n) ? n : null;
}
function DubSegmentRow({
seg, idx, style, disabled, isActive, isDone, previewLoading, selected,
profiles, speakerClones, onEditField, onDelete, onRestore, onPreview, onSelect, onSplit, onMerge, canMerge,
onDirect, onSeek,
}) {
const textInputRef = useRef(null);
// Remember where the caret was inside the text field even after it loses
// focus — Radix's menu trigger steals focus, so by the time the Scissors
// item fires, selectionStart on the input would otherwise read as null.
const lastCursorRef = useRef(null);
const speakerOptions = speakerClones ? Object.keys(speakerClones) : [];
const speakerListId = `seg-speakers-${seg.id}`;
const syncColor = seg.sync_ratio === undefined ? null
: (seg.sync_ratio >= 0.95 && seg.sync_ratio <= 1.05) ? '#b8bb26'
: seg.sync_ratio > 1.25 ? '#fb4934'
: '#fabd2f';
const SyncIcon = seg.sync_ratio === undefined ? null
: (seg.sync_ratio >= 0.95 && seg.sync_ratio <= 1.05) ? CheckCircle
: seg.sync_ratio > 1.25 ? AlertCircle
: Circle;
const overBudget = seg.text_original
&& seg.text.length > Math.ceil(seg.text_original.length * CHAR_BUDGET_RATIO);
const handleTextKeyDown = (e) => {
// Ctrl/Cmd+D → split at cursor, Ctrl/Cmd+M → merge with next
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
const pos = e.target.selectionStart ?? seg.text.length;
onSplit(seg.id, pos);
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'm' || e.key === 'M')) {
e.preventDefault();
if (canMerge) onMerge(seg.id);
}
};
const captureCursor = (e) => {
const pos = e.target.selectionStart;
if (pos != null) lastCursorRef.current = pos;
};
// Row click → seek the player to this segment. We don't fire when the click
// originates in an interactive element (input, button, the actions cluster)
// so per-field editing keeps working.
const handleRowClick = (e) => {
if (!onSeek) return;
if (e.target.closest('input, button, select, textarea, label, [data-noseek]')) return;
onSeek(seg.start);
};
return (
<div style={style} className={rowClass(isActive, isDone, selected)} onClick={handleRowClick}>
<input
type="checkbox"
checked={!!selected}
onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)}
onClick={(e) => onSelect(seg.id, idx, e.shiftKey)}
disabled={disabled}
style={{ accentColor: '#d3869b' }}
className="seg-check"
title="Select segment (shift+click for range)"
/>
<span className="segment-time seg-time">
<span className="seg-time-row">
<input
type="text"
className="seg-time-input"
defaultValue={formatTime(seg.start)}
key={`start-${seg.id}-${seg.start}`}
disabled={disabled}
title="Click to edit start time (m:ss.s). Enter to commit, Esc to cancel."
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') e.target.blur();
if (e.key === 'Escape') { e.target.value = formatTime(seg.start); e.target.blur(); }
}}
onBlur={(e) => {
const v = parseTime(e.target.value);
if (v == null || v < 0 || v >= seg.end) {
e.target.value = formatTime(seg.start);
return;
}
if (Math.abs(v - seg.start) > 1e-3) {
onEditField(seg.id, 'start', +v.toFixed(3));
} else {
e.target.value = formatTime(seg.start);
}
}}
/>
<span className="seg-time-sep"></span>
<span className="seg-time-end">{formatTime(seg.end)}</span>
{seg.speed && seg.speed !== 1.0 && (
<span className="seg-speed-badge" style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}>
{seg.speed.toFixed(2)}x
</span>
)}
</span>
{SyncIcon && (
<span
className="seg-sync-badge"
style={{ color: syncColor }}
title={`Generated audio is ${Math.round(seg.sync_ratio * 100)}% the duration of original`}
>
<SyncIcon size={8} /> Sync: {Math.round(seg.sync_ratio * 100)}%
</span>
)}
{seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && (
<span
className="seg-rate-badge"
style={{ color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984' }}
title={`Speech-rate fit: ${seg.rate_ratio.toFixed(2)}× relative to slot${seg.rate_error ? ` (${seg.rate_error})` : ''}`}
>
📖 {seg.rate_ratio.toFixed(2)}×
</span>
)}
</span>
<input
className="input-base seg-speaker-input"
value={seg.speaker_id || ''}
onChange={(e) => onEditField(seg.id, 'speaker_id', e.target.value)}
onClick={(e) => e.stopPropagation()}
disabled={disabled}
list={speakerOptions.length ? speakerListId : undefined}
placeholder={speakerOptions.length ? 'Pick…' : ''}
title={speakerOptions.length
? 'Speaker — pick from detected, or type a custom name'
: 'Speaker — type a name (no diarization clones detected)'}
/>
{speakerOptions.length > 0 && (
<datalist id={speakerListId}>
{speakerOptions.map(spk => <option key={spk} value={spk} />)}
</datalist>
)}
<span className="seg-text-col">
<input
ref={textInputRef}
className="input-base segment-input"
value={seg.text}
onChange={(e) => onEditField(seg.id, 'text', e.target.value)}
onKeyDown={handleTextKeyDown}
onKeyUp={captureCursor}
onSelect={captureCursor}
onClick={(e) => { e.stopPropagation(); captureCursor(e); }}
disabled={disabled}
title={seg.translate_error
? `Translation error: ${seg.translate_error}`
: overBudget
? `Text is ${Math.round((seg.text.length / seg.text_original.length) * 100)}% of original — consider higher speed or shorter phrasing`
: 'Ctrl+D to split at cursor · Ctrl+M to merge with next'}
style={
overBudget ? { borderColor: 'rgba(250,189,47,0.6)', background: 'rgba(250,189,47,0.06)' }
: seg.translate_error ? { borderColor: 'rgba(251,73,52,0.5)' }
: undefined
}
/>
{seg.text_original && seg.text_original !== seg.text && (
<span className="seg-orig-row">
<span className="seg-orig-label">orig</span>
<span className="seg-orig-text" title={seg.text_original}>
{seg.text_original}
</span>
{overBudget && (
<span className="seg-budget-warn">
{Math.round((seg.text.length / seg.text_original.length) * 100)}%
</span>
)}
<button
onClick={() => onRestore(seg.id)}
disabled={disabled}
title="Restore original text"
className="seg-restore-btn"
>
</button>
</span>
)}
</span>
<select
className="input-base seg-lang-select"
value={seg.target_lang || ''}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
>
<option value="">(Def)</option>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>
))}
</select>
<select
className="input-base seg-profile-select"
value={seg.profile_id || ''}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'profile_id', e.target.value)}
>
<option value="">Default</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label="From Video">
{Object.keys(speakerClones).map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
})}
</optgroup>
)}
{profiles.length > 0 && (
<optgroup label="Clone Profiles">
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label="Design Presets">
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name}</option>)}
</optgroup>
)}
</select>
<input
type="range"
min="0" max="200"
value={Math.round((seg.gain ?? 1.0) * 100)}
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
className="seg-gain-slider"
style={{ accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984' }}
/>
<div className="seg-actions">
<button
className="segment-play"
disabled={disabled}
title="Live Preview"
onClick={(e) => onPreview(seg, e)}
>
{previewLoading ? <Loader className="spinner" size={9} /> : <Headphones size={9} />}
</button>
<Menu
placement="bottom-end"
disabled={disabled}
items={[
{
id: 'direct',
label: seg.direction ? 'Edit direction…' : 'Set direction…',
icon: Sparkles,
onSelect: () => onDirect?.(seg),
},
'separator',
{
id: 'split',
label: 'Split at cursor',
icon: Scissors,
shortcut: '⌘D',
onSelect: () => {
// Prefer the live cursor if the text input still has focus,
// otherwise fall back to the last remembered position, then
// to a sentence-boundary heuristic. Splitting at the literal
// midpoint (the old behaviour) felt random to users.
let pos = textInputRef.current?.selectionStart;
if (pos == null || pos <= 0 || pos >= seg.text.length) {
pos = lastCursorRef.current;
}
if (pos == null || pos <= 0 || pos >= seg.text.length) {
pos = bestSplitPoint(seg.text);
}
onSplit(seg.id, pos);
},
},
{
id: 'merge',
label: 'Merge with next',
icon: Merge,
shortcut: '⌘M',
disabled: !canMerge,
onSelect: () => onMerge(seg.id),
},
]}
>
<button
className={`segment-play ${seg.direction ? 'has-direction' : ''}`}
disabled={disabled}
title={seg.direction ? `Direction: ${seg.direction}` : 'More actions'}
>
{seg.direction ? <Sparkles size={9} /> : <MoreHorizontal size={9} />}
</button>
</Menu>
<button
className="segment-del"
disabled={disabled}
onClick={() => onDelete(seg.id)}
>
<Trash2 size={9} />
</button>
</div>
</div>
);
}
export default memo(DubSegmentRow, (prev, next) => (
prev.seg === next.seg &&
prev.disabled === next.disabled &&
prev.isActive === next.isActive &&
prev.isDone === next.isDone &&
prev.previewLoading === next.previewLoading &&
prev.onDirect === next.onDirect &&
prev.onSeek === next.onSeek &&
prev.selected === next.selected &&
prev.canMerge === next.canMerge &&
prev.profiles === next.profiles &&
prev.speakerClones === next.speakerClones &&
prev.idx === next.idx
));