OmniVoice-Studio / frontend /src /components /CastingView.jsx
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
7.28 kB
import React, { useState, useCallback } from 'react';
import { User, Mic, ChevronDown, Check, Shuffle, Volume2 } from 'lucide-react';
import './CastingView.css';
/**
* CastingView — assign voice profiles to speakers for dubbing projects.
*
* Shows each detected speaker as a row, with a dropdown to pick a voice
* profile (from saved profiles or auto-clones from the video). Drag-and-drop
* is scaffolded for a future pass.
*
* Props:
* speakers: [{ id, label, segments_count }]
* profiles: [{ id, name, type, personality }]
* autoClones: { speaker_id: { ref_audio, ref_text } }
* assignments: { speaker_id: profile_id | "auto:speaker_id" }
* onChange: (assignments) => void
* onPreview: (profile_id) => void
*/
export default function CastingView({
speakers = [],
profiles = [],
autoClones = {},
assignments = {},
onChange,
onPreview,
}) {
const [openDropdown, setOpenDropdown] = useState(null);
const assign = useCallback((speakerId, profileId) => {
const next = { ...assignments, [speakerId]: profileId };
onChange?.(next);
setOpenDropdown(null);
}, [assignments, onChange]);
const autoAssignAll = useCallback(() => {
const next = {};
speakers.forEach((s) => {
// Prefer auto-clone if available, else keep existing assignment
if (autoClones[s.id]) {
next[s.id] = `auto:${s.id}`;
} else if (assignments[s.id]) {
next[s.id] = assignments[s.id];
}
});
onChange?.(next);
}, [speakers, autoClones, assignments, onChange]);
if (speakers.length === 0) return null;
const allAssigned = speakers.every(s => assignments[s.id]);
return (
<div className="casting-view">
<div className="casting-view__header">
<h3 className="casting-view__title">
<User size={14} /> Speaker Casting
</h3>
<div className="casting-view__actions">
<button
className="casting-view__auto-btn"
onClick={autoAssignAll}
title="Auto-assign voices from extracted speaker clones"
>
<Shuffle size={12} /> Auto-cast
</button>
{allAssigned && (
<span className="casting-view__badge">
<Check size={10} /> All cast
</span>
)}
</div>
</div>
<div className="casting-view__grid">
{speakers.map((speaker) => {
const currentAssignment = assignments[speaker.id];
const isAuto = currentAssignment?.startsWith('auto:');
let autoName = speaker.label;
if (isAuto && currentAssignment) {
const match = Object.keys(autoClones || {}).find(spk => `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}` === currentAssignment);
if (match) autoName = match;
}
const assignedProfile = isAuto
? { name: `🎤 From video (${autoName})`, type: 'clone' }
: profiles.find(p => p.id === currentAssignment);
return (
<div
key={speaker.id}
className={`casting-row ${currentAssignment ? 'casting-row--assigned' : ''}`}
>
{/* Speaker info */}
<div className="casting-row__speaker">
<span className="casting-row__avatar">
{speaker.label?.slice(0, 2).toUpperCase() || 'S'}
</span>
<div className="casting-row__info">
<span className="casting-row__name">{speaker.label || speaker.id}</span>
<span className="casting-row__meta">
{speaker.segments_count || 0} segments
</span>
</div>
</div>
{/* Arrow */}
<span className="casting-row__arrow"></span>
{/* Voice assignment dropdown */}
<div className="casting-row__voice">
<button
className="casting-row__picker"
onClick={() => setOpenDropdown(openDropdown === speaker.id ? null : speaker.id)}
>
{assignedProfile ? (
<>
<Mic size={12} />
<span>{assignedProfile.name}</span>
</>
) : (
<>
<span className="casting-row__unassigned">Assign voice…</span>
</>
)}
<ChevronDown size={12} />
</button>
{/* Dropdown */}
{openDropdown === speaker.id && (
<div className="casting-dropdown">
{/* Auto-clone options */}
{autoClones && Object.keys(autoClones).length > 0 && (
<>
{Object.keys(autoClones).map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
const isActiveAuto = currentAssignment === autoId;
return (
<button
key={autoId}
className={`casting-dropdown__item ${isActiveAuto ? 'is-active' : ''}`}
onClick={() => assign(speaker.id, autoId)}
>
<Shuffle size={11} />
<span>🎤 {spk}</span>
{isActiveAuto && <Check size={11} />}
</button>
);
})}
{profiles.length > 0 && <div className="casting-dropdown__divider" />}
</>
)}
{/* Saved profiles */}
{profiles.map(p => (
<button
key={p.id}
className={`casting-dropdown__item ${currentAssignment === p.id ? 'is-active' : ''}`}
onClick={() => assign(speaker.id, p.id)}
>
<Mic size={11} />
<span>{p.name}</span>
{p.personality && (
<span className="casting-dropdown__tag">{p.personality}</span>
)}
{currentAssignment === p.id && <Check size={11} />}
</button>
))}
{profiles.length === 0 && !autoClones[speaker.id] && (
<div className="casting-dropdown__empty">
No voice profiles saved yet.
</div>
)}
</div>
)}
</div>
{/* Preview button */}
{currentAssignment && onPreview && (
<button
className="casting-row__preview"
onClick={() => onPreview(currentAssignment)}
title="Preview voice"
>
<Volume2 size={12} />
</button>
)}
</div>
);
})}
</div>
</div>
);
}