Merge branch 'fix/dubbing-demo-editor' into fix/linux-electron-setup-sidebar
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -36,6 +36,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
### Fixed
|
||||
|
||||
- Tauri and Electron now share native dictation, watch-folder, and Wayland shortcut contracts; focused paste stays ordered and first-run uv stays pinned at 0.12.13 (#2122)
|
||||
- Dubbing demos synchronize playheads without simultaneous playback and let you open a sample in the editor (#2131)
|
||||
- Dubbing timelines keep short segments proportional, support zoom, and remove timestamp-confirmed duplicate ASR context (#2129)
|
||||
|
||||
- Dubbing translation shares the agent footer with live logs, validated output, cancellation and contextual retries (#2129)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Electron dubbing workspace
|
||||
|
||||
The idle workspace includes an original/dubbed demo comparison with compact
|
||||
player controls. Sync playheads aligns positions without starting both videos.
|
||||
Sample transcript edits are retained per language while the demo is mounted;
|
||||
they do not regenerate the prerecorded audio. Edit on the dubbed card imports
|
||||
that sample video into the normal upload/transcription and editing workflow.
|
||||
|
||||
Open Dub from the cloning sidebar or command search. Upload or drop audio/video, or explicitly submit a video URL;
|
||||
preparation completes before transcription starts. The editor shows source text,
|
||||
editable translated text, and per-segment voice/timing controls. Translation uses
|
||||
|
||||
@@ -37,6 +37,7 @@ export const VideoPlayer = memo(function VideoPlayer({
|
||||
onPause,
|
||||
onSeeked,
|
||||
onCanPlay,
|
||||
controls = 'full',
|
||||
}: {
|
||||
src: MediaPlayerProps['src'];
|
||||
source?: string;
|
||||
@@ -48,6 +49,7 @@ export const VideoPlayer = memo(function VideoPlayer({
|
||||
onPause?: MediaPlayerProps['onPause'];
|
||||
onSeeked?: MediaPlayerProps['onSeeked'];
|
||||
onCanPlay?: MediaPlayerProps['onCanPlay'];
|
||||
controls?: 'full' | 'compact';
|
||||
}) {
|
||||
const localPlayerRef = useRef<MediaPlayerInstance>(null);
|
||||
const player = externalPlayerRef ?? localPlayerRef;
|
||||
@@ -76,12 +78,14 @@ export const VideoPlayer = memo(function VideoPlayer({
|
||||
loaders={videoLoaders}
|
||||
className="relative aspect-video [&_[data-remotion-canvas]]:h-full [&_[data-remotion-canvas]]:w-full [&_[data-remotion-container]]:h-full [&_[data-remotion-container]]:w-full [&_video]:h-full [&_video]:w-full [&_iframe]:h-full [&_iframe]:w-full"
|
||||
>
|
||||
<Poster
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-0 data-[visible]:opacity-100 data-[hidden]:hidden"
|
||||
/>
|
||||
<Poster alt="" className="absolute inset-0 h-full w-full object-contain opacity-0 data-[visible]:opacity-100 data-[hidden]:hidden" />
|
||||
</MediaProvider>
|
||||
<VideoControls player={player} source={source} sourceIdentity={sourceIdentity} />
|
||||
<VideoControls
|
||||
player={player}
|
||||
source={source}
|
||||
sourceIdentity={sourceIdentity}
|
||||
compact={controls === 'compact'}
|
||||
/>
|
||||
</StudioMediaPlayer>
|
||||
);
|
||||
});
|
||||
@@ -89,10 +93,12 @@ function VideoControls({
|
||||
player,
|
||||
source,
|
||||
sourceIdentity,
|
||||
compact,
|
||||
}: {
|
||||
player: React.RefObject<MediaPlayerInstance | null>;
|
||||
source: string;
|
||||
sourceIdentity: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const remote = useMediaRemote(player);
|
||||
@@ -191,32 +197,36 @@ function VideoControls({
|
||||
<PauseIcon />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="hover:bg-white/15 hover:text-white"
|
||||
aria-label={`${t('player.seek')} -10s`}
|
||||
onClick={() => {
|
||||
if (player.current) player.current.currentTime = Math.max(0, time - 10);
|
||||
}}
|
||||
>
|
||||
<RotateCcwIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="hover:bg-white/15 hover:text-white"
|
||||
aria-label={`${t('player.seek')} +10s`}
|
||||
onClick={() => {
|
||||
if (player.current)
|
||||
player.current.currentTime = Math.min(
|
||||
Number.isFinite(duration) ? duration : time + 10,
|
||||
time + 10,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
{!compact && (
|
||||
<>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="hover:bg-white/15 hover:text-white"
|
||||
aria-label={`${t('player.seek')} -10s`}
|
||||
onClick={() => {
|
||||
if (player.current) player.current.currentTime = Math.max(0, time - 10);
|
||||
}}
|
||||
>
|
||||
<RotateCcwIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="hover:bg-white/15 hover:text-white"
|
||||
aria-label={`${t('player.seek')} +10s`}
|
||||
onClick={() => {
|
||||
if (player.current)
|
||||
player.current.currentTime = Math.min(
|
||||
Number.isFinite(duration) ? duration : time + 10,
|
||||
time + 10,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@@ -229,53 +239,59 @@ function VideoControls({
|
||||
>
|
||||
{muted ? <VolumeXIcon /> : <Volume2Icon />}
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
aria-label={t('player.volume')}
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
className="hidden h-1 w-14 shrink-0 cursor-pointer appearance-none rounded-full bg-white/25 accent-primary [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white @min-[420px]/player:block"
|
||||
onInput={(event) => {
|
||||
if (player.current) {
|
||||
player.current.muted = false;
|
||||
player.current.volume = Number(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="min-w-10 px-1.5 text-[10px] tabular-nums hover:bg-white/15 hover:text-white"
|
||||
aria-label={t('clone.speed')}
|
||||
onClick={() => {
|
||||
const rates = [0.75, 1, 1.25, 1.5, 2];
|
||||
const next = rates[(rates.indexOf(playbackRate) + 1) % rates.length];
|
||||
setPlaybackRate(next);
|
||||
if (player.current) player.current.playbackRate = next;
|
||||
}}
|
||||
>
|
||||
{playbackRate}×
|
||||
</Button>
|
||||
{!compact && (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
aria-label={t('player.volume')}
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
className="hidden h-1 w-14 shrink-0 cursor-pointer appearance-none rounded-full bg-white/25 accent-primary [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white sm:block"
|
||||
onInput={(event) => {
|
||||
if (player.current) {
|
||||
player.current.muted = false;
|
||||
player.current.volume = Number(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="min-w-10 px-1.5 text-[10px] tabular-nums hover:bg-white/15 hover:text-white"
|
||||
aria-label={t('clone.speed')}
|
||||
onClick={() => {
|
||||
const rates = [0.75, 1, 1.25, 1.5, 2];
|
||||
const next = rates[(rates.indexOf(playbackRate) + 1) % rates.length];
|
||||
setPlaybackRate(next);
|
||||
if (player.current) player.current.playbackRate = next;
|
||||
}}
|
||||
>
|
||||
{playbackRate}×
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto whitespace-nowrap text-[10px] tabular-nums">
|
||||
{formatClock(time)} / {formatClock(duration)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canFullscreen}
|
||||
aria-label={t(fullscreen ? 'player.exit_fullscreen' : 'player.fullscreen')}
|
||||
className="hover:bg-white/15 hover:text-white"
|
||||
onClick={() => {
|
||||
const action = fullscreen
|
||||
? player.current?.exitFullscreen()
|
||||
: player.current?.enterFullscreen();
|
||||
void action?.catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{fullscreen ? <MinimizeIcon /> : <MaximizeIcon />}
|
||||
</Button>
|
||||
{!compact && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canFullscreen}
|
||||
aria-label={t(fullscreen ? 'player.exit_fullscreen' : 'player.fullscreen')}
|
||||
className="hover:bg-white/15 hover:text-white"
|
||||
onClick={() => {
|
||||
const action = fullscreen
|
||||
? player.current?.exitFullscreen()
|
||||
: player.current?.enterFullscreen();
|
||||
void action?.catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{fullscreen ? <MinimizeIcon /> : <MaximizeIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1677,8 +1677,8 @@ export function DubPage() {
|
||||
/>
|
||||
)}
|
||||
{!session.segments.length && (!session.recovery || busy) && (
|
||||
<div className="mx-auto flex min-h-[28rem] w-full max-w-5xl flex-col items-center justify-center px-6 text-center">
|
||||
<div className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<div className="mx-auto flex min-h-[24rem] w-full max-w-5xl flex-col items-center justify-center px-6 text-center">
|
||||
<div className="mb-3 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
{busy ? (
|
||||
<LoaderCircleIcon className="size-6 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
@@ -1696,9 +1696,23 @@ export function DubPage() {
|
||||
{t('dub.supported_formats')}
|
||||
</p>
|
||||
{!session.jobId && !demoDismissed && (
|
||||
<div className="mt-6 w-full">
|
||||
<div className="mt-4 w-full">
|
||||
<DubbingDemo
|
||||
onTry={() => input.current?.click()}
|
||||
onEdit={async ({ path, filename }) => {
|
||||
try {
|
||||
const response = await apiFetch(path);
|
||||
const blob = await response.blob();
|
||||
setPreview('original');
|
||||
await uploadDub(
|
||||
new File([blob], filename, {
|
||||
type: blob.type || 'video/mp4',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(describeError(error));
|
||||
}
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setDemoDismissed(true);
|
||||
try {
|
||||
@@ -1710,7 +1724,7 @@ export function DubPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ol className="mt-6 grid w-full grid-cols-3 gap-2 text-left max-sm:grid-cols-1">
|
||||
<ol className="mt-4 grid w-full grid-cols-3 gap-2 text-left max-sm:grid-cols-1">
|
||||
{[
|
||||
['1', 'dub.upload_transcribe'],
|
||||
['2', 'dub.translate'],
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
videoProps: [] as Array<{ controls?: string; source: string }>,
|
||||
players: new Map<
|
||||
string,
|
||||
{
|
||||
currentTime: number;
|
||||
paused: boolean;
|
||||
play: ReturnType<typeof vi.fn>;
|
||||
pause: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock('@/lib/api/client', () => ({ apiJson: mocks.api, apiPath: (path: string) => path }));
|
||||
vi.mock('@/components/video-player', () => ({
|
||||
VideoPlayer: ({ controls, playerRef, onPlay, source }: Record<string, any>) => {
|
||||
mocks.videoProps.push({ controls, source });
|
||||
let player = mocks.players.get(source);
|
||||
if (!player) {
|
||||
player = {
|
||||
currentTime: 0,
|
||||
paused: true,
|
||||
play: vi.fn(async () => {
|
||||
player!.paused = false;
|
||||
}),
|
||||
pause: vi.fn(async () => {
|
||||
player!.paused = true;
|
||||
}),
|
||||
};
|
||||
mocks.players.set(source, player);
|
||||
}
|
||||
playerRef.current = player;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={source}
|
||||
onClick={() => {
|
||||
player!.paused = false;
|
||||
onPlay?.({});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { DubbingDemo } from './dubbing-demo';
|
||||
|
||||
const manifest = {
|
||||
source: {
|
||||
code: 'en',
|
||||
label: 'English',
|
||||
video: 'source.mp4',
|
||||
script: 'Original script',
|
||||
},
|
||||
dubbed: [
|
||||
{
|
||||
code: 'es',
|
||||
label: 'Español',
|
||||
video: 'dubbed_es.mp4',
|
||||
script: 'Spanish script',
|
||||
dir: 'ltr' as const,
|
||||
},
|
||||
{
|
||||
code: 'fr',
|
||||
label: 'Français',
|
||||
video: 'dubbed_fr.mp4',
|
||||
script: 'French script',
|
||||
dir: 'ltr' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mocks.players.clear();
|
||||
mocks.videoProps.length = 0;
|
||||
});
|
||||
|
||||
function mount(onEdit = vi.fn()) {
|
||||
mocks.api.mockResolvedValue(manifest);
|
||||
render(<DubbingDemo onDismiss={vi.fn()} onTry={vi.fn()} onEdit={onEdit} />);
|
||||
}
|
||||
|
||||
it('keeps A/B playback exclusive while synchronizing the peer playhead', async () => {
|
||||
mount();
|
||||
const sourceButton = await screen.findByRole('button', {
|
||||
name: 'dubbing-demo-comparison-demo.original_tag',
|
||||
});
|
||||
const source = mocks.players.get('dubbing-demo-comparison-demo.original_tag')!;
|
||||
const dubbed = mocks.players.get('dubbing-demo-comparison-demo.dubbed_tag')!;
|
||||
expect(mocks.videoProps.slice(0, 2).map(({ controls }) => controls)).toEqual([
|
||||
'compact',
|
||||
'compact',
|
||||
]);
|
||||
source.currentTime = 4.25;
|
||||
|
||||
fireEvent.click(sourceButton);
|
||||
|
||||
expect(dubbed.currentTime).toBe(4.25);
|
||||
expect(dubbed.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps editable transcript drafts for each sample language', async () => {
|
||||
mount();
|
||||
const transcripts = await screen.findAllByRole('textbox');
|
||||
expect(transcripts).toHaveLength(2);
|
||||
fireEvent.change(transcripts[1]!, { target: { value: 'Edited Spanish script' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Français' }));
|
||||
expect(screen.getAllByRole('textbox')[1]).toHaveValue('French script');
|
||||
fireEvent.change(screen.getAllByRole('textbox')[1]!, {
|
||||
target: { value: 'Edited French script' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Español' }));
|
||||
expect(screen.getAllByRole('textbox')[1]).toHaveValue('Edited Spanish script');
|
||||
});
|
||||
|
||||
it('opens the selected dubbed sample in the editor', async () => {
|
||||
const onEdit = vi.fn();
|
||||
mount(onEdit);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'clone.edit Español' }));
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith({
|
||||
path: '/demo_audio/demo/dubbing/dubbed_es.mp4',
|
||||
filename: 'dubbed_es.mp4',
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FilmIcon, LoaderCircleIcon, PlayIcon, XIcon } from 'lucide-react';
|
||||
import {
|
||||
FilmIcon,
|
||||
LoaderCircleIcon,
|
||||
PencilIcon,
|
||||
PlayIcon,
|
||||
RotateCcwIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import type { MediaPlayerInstance } from '@/components/media-player';
|
||||
import { VideoPlayer } from '@/components/video-player';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiJson, apiPath } from '@/lib/api/client';
|
||||
import { runRendererTask } from '@/lib/global-error-recovery';
|
||||
|
||||
interface DemoManifest {
|
||||
source: {
|
||||
@@ -26,15 +35,31 @@ interface DemoManifest {
|
||||
const DEMO_BASE = '/demo_audio/demo/dubbing';
|
||||
const PLAYBACK_GROUP = 'dubbing-demo-comparison';
|
||||
|
||||
export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry: () => void }) {
|
||||
interface EditableDemoVideo {
|
||||
path: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function DubbingDemo({
|
||||
onDismiss,
|
||||
onTry,
|
||||
onEdit,
|
||||
}: {
|
||||
onDismiss: () => void;
|
||||
onTry: () => void;
|
||||
onEdit: (sample: EditableDemoVideo) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [manifest, setManifest] = useState<DemoManifest | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [language, setLanguage] = useState('es');
|
||||
const [synchronized, setSynchronized] = useState(true);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [editingVideo, setEditingVideo] = useState(false);
|
||||
const sourcePlayer = useRef<MediaPlayerInstance>(null);
|
||||
const dubbedPlayer = useRef<MediaPlayerInstance>(null);
|
||||
const mirroring = useRef(false);
|
||||
const activePlayer = useRef<MediaPlayerInstance | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -44,6 +69,11 @@ export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void sourcePlayer.current?.pause().catch(() => {});
|
||||
void dubbedPlayer.current?.pause().catch(() => {});
|
||||
}, [language]);
|
||||
|
||||
if (failed) return null;
|
||||
if (!manifest)
|
||||
return (
|
||||
@@ -56,17 +86,14 @@ export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry
|
||||
const dubbed = manifest.dubbed.find((item) => item.code === language) || manifest.dubbed[0];
|
||||
if (!dubbed) return null;
|
||||
|
||||
const mirror = (
|
||||
const synchronizePosition = (
|
||||
from: MediaPlayerInstance | null,
|
||||
to: MediaPlayerInstance | null,
|
||||
action: 'play' | 'pause' | 'seek',
|
||||
) => {
|
||||
if (!synchronized || mirroring.current || !from || !to) return;
|
||||
mirroring.current = true;
|
||||
try {
|
||||
to.currentTime = from.currentTime;
|
||||
if (action === 'play' && to.paused) void to.play().catch(() => {});
|
||||
if (action === 'pause' && !to.paused) void to.pause();
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
mirroring.current = false;
|
||||
@@ -75,6 +102,8 @@ export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry
|
||||
};
|
||||
|
||||
const card = (
|
||||
channel: 'A' | 'B',
|
||||
code: string,
|
||||
label: string,
|
||||
tag: string,
|
||||
video: string,
|
||||
@@ -82,34 +111,108 @@ export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry
|
||||
player: React.RefObject<MediaPlayerInstance | null>,
|
||||
peer: React.RefObject<MediaPlayerInstance | null>,
|
||||
direction?: 'ltr' | 'rtl',
|
||||
) => (
|
||||
<article className="min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{tag}</span>
|
||||
</div>
|
||||
<VideoPlayer
|
||||
playerRef={player}
|
||||
playbackGroup={PLAYBACK_GROUP}
|
||||
load="eager"
|
||||
src={{ src: apiPath(`${DEMO_BASE}/${video}`), type: 'video/mp4' }}
|
||||
source={`${PLAYBACK_GROUP}-${tag}`}
|
||||
onPlay={() => mirror(player.current, peer.current, 'play')}
|
||||
onPause={() => mirror(player.current, peer.current, 'pause')}
|
||||
onSeeked={() => mirror(player.current, peer.current, 'seek')}
|
||||
/>
|
||||
<p
|
||||
dir={direction}
|
||||
className="line-clamp-3 rounded-lg bg-background/35 p-2 text-left text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{script}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
editableVideo?: EditableDemoVideo,
|
||||
) => {
|
||||
const value = scripts[code] ?? script;
|
||||
const edited = value !== script;
|
||||
return (
|
||||
<article className="min-w-0 overflow-hidden rounded-2xl border border-border/55 bg-background/25 shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_4%,transparent)]">
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5 text-xs font-medium">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-primary/12 font-mono text-[10px] font-semibold text-primary">
|
||||
{channel}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{edited && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
aria-label={`${t('preferences.reset')} ${label}`}
|
||||
title={t('preferences.reset')}
|
||||
onClick={() =>
|
||||
setScripts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[code];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<RotateCcwIcon />
|
||||
</Button>
|
||||
)}
|
||||
{editableVideo && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
disabled={editingVideo}
|
||||
aria-label={`${t('clone.edit')} ${label}`}
|
||||
onClick={() => {
|
||||
setEditingVideo(true);
|
||||
runRendererTask('Edit dubbing demo', async () => {
|
||||
try { await onEdit(editableVideo); }
|
||||
finally { setEditingVideo(false); }
|
||||
});
|
||||
}}
|
||||
>
|
||||
{editingVideo ? (
|
||||
<LoaderCircleIcon className="animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PencilIcon />
|
||||
)}
|
||||
{t('clone.edit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2.5">
|
||||
<VideoPlayer
|
||||
playerRef={player}
|
||||
controls="compact"
|
||||
load="eager"
|
||||
src={{ src: apiPath(`${DEMO_BASE}/${video}`), type: 'video/mp4' }}
|
||||
source={`${PLAYBACK_GROUP}-${tag}`}
|
||||
onPlay={() => {
|
||||
const incoming = player.current;
|
||||
const outgoing = activePlayer.current;
|
||||
if (synchronized && incoming && outgoing && incoming !== outgoing) {
|
||||
incoming.currentTime = outgoing.currentTime;
|
||||
}
|
||||
activePlayer.current = incoming;
|
||||
void peer.current?.pause().catch(() => {});
|
||||
synchronizePosition(incoming, peer.current);
|
||||
}}
|
||||
onPause={() => {
|
||||
if (activePlayer.current === player.current) synchronizePosition(player.current, peer.current);
|
||||
}}
|
||||
onSeeked={() => synchronizePosition(player.current, peer.current)}
|
||||
/>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor={`dubbing-demo-script-${code}`}>
|
||||
{label} — {t('dub.transcript')}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`dubbing-demo-script-${code}`}
|
||||
dir={direction}
|
||||
value={value}
|
||||
rows={3}
|
||||
spellCheck
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
setScripts((current) => ({ ...current, [code]: nextValue }));
|
||||
}}
|
||||
className="m-2.5 mt-3 h-24 min-h-20 max-h-40 w-[calc(100%-1.25rem)] resize-y rounded-xl border-border/45 bg-background/45 text-xs leading-5 text-muted-foreground [field-sizing:fixed] focus-visible:text-foreground"
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="glass-panel w-full space-y-4 rounded-2xl border border-border/60 bg-card/35 p-4 text-left shadow-sm">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<section className="glass-panel @container/dubbing-demo w-full space-y-3 rounded-2xl border border-border/60 bg-card/35 p-4 text-left shadow-sm">
|
||||
<header className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FilmIcon className="size-4" />
|
||||
</span>
|
||||
@@ -117,39 +220,31 @@ export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry
|
||||
<h3 className="text-sm font-medium">{t('demo.dubbing_title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('demo.dubbing_picker')}</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{t('demo.dubbing_sync')}
|
||||
<Switch checked={synchronized} onCheckedChange={setSynchronized} />
|
||||
</label>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={t('demo.dubbing_dismiss')}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={t('demo.dubbing_cta')}
|
||||
title={t('demo.dubbing_cta')}
|
||||
onClick={onTry}
|
||||
>
|
||||
<PlayIcon />
|
||||
<span className="hidden @min-[560px]:inline">{t('demo.dubbing_cta')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={t('demo.dubbing_dismiss')}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid gap-4 min-[1100px]:grid-cols-2">
|
||||
{card(
|
||||
manifest.source.label,
|
||||
t('demo.original_tag'),
|
||||
manifest.source.video,
|
||||
manifest.source.script,
|
||||
sourcePlayer,
|
||||
dubbedPlayer,
|
||||
)}
|
||||
{card(
|
||||
dubbed.label,
|
||||
t('demo.dubbed_tag'),
|
||||
dubbed.video,
|
||||
dubbed.script,
|
||||
dubbedPlayer,
|
||||
sourcePlayer,
|
||||
dubbed.dir,
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex flex-wrap items-center gap-1.5">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('demo.dubbing_picker')}
|
||||
className="flex flex-wrap items-center gap-1.5 rounded-xl border border-border/45 bg-background/30 p-1.5"
|
||||
>
|
||||
{manifest.dubbed.map((item) => (
|
||||
<Button
|
||||
key={item.code}
|
||||
@@ -161,11 +256,38 @@ export function DubbingDemo({ onDismiss, onTry }: { onDismiss: () => void; onTry
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" className="ml-auto" onClick={onTry}>
|
||||
<PlayIcon />
|
||||
{t('demo.dubbing_cta')}
|
||||
</Button>
|
||||
</footer>
|
||||
<label className="ml-auto inline-flex items-center gap-2 px-1.5 text-xs text-muted-foreground">
|
||||
{t('demo.dubbing_sync')}
|
||||
<Switch checked={synchronized} onCheckedChange={setSynchronized} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 min-[1100px]:grid-cols-2">
|
||||
{card(
|
||||
'A',
|
||||
manifest.source.code,
|
||||
manifest.source.label,
|
||||
t('demo.original_tag'),
|
||||
manifest.source.video,
|
||||
manifest.source.script,
|
||||
sourcePlayer,
|
||||
dubbedPlayer,
|
||||
)}
|
||||
{card(
|
||||
'B',
|
||||
dubbed.code,
|
||||
dubbed.label,
|
||||
t('demo.dubbed_tag'),
|
||||
dubbed.video,
|
||||
dubbed.script,
|
||||
dubbedPlayer,
|
||||
sourcePlayer,
|
||||
dubbed.dir,
|
||||
{
|
||||
path: `${DEMO_BASE}/${dubbed.video}`,
|
||||
filename: dubbed.video,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1973,7 +1973,7 @@
|
||||
"dictation_replay": "إعادة التشغيل",
|
||||
"dictation_transcribing": "جارٍ النسخ…",
|
||||
"dubbing_title": "شاهد الدبلجة أثناء العمل",
|
||||
"dubbing_sync": "التشغيل المتزامن",
|
||||
"dubbing_sync": "مزامنة موضع التشغيل",
|
||||
"dubbing_picker": "جرب لغة أخرى:",
|
||||
"dubbing_cta": "قم بتشغيل هذا على الفيديو الخاص بك →",
|
||||
"dubbing_loading": "جارٍ تحميل العرض التوضيحي للدبلجة...",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "Wiederholung",
|
||||
"dictation_transcribing": "Transkribieren…",
|
||||
"dubbing_title": "Erleben Sie Synchronisation in Aktion",
|
||||
"dubbing_sync": "Synchronisierte Wiedergabe",
|
||||
"dubbing_sync": "Abspielposition synchronisieren",
|
||||
"dubbing_picker": "Versuchen Sie es mit einer anderen Sprache:",
|
||||
"dubbing_cta": "Führen Sie dies in Ihrem eigenen Video aus →",
|
||||
"dubbing_loading": "Synchronisationsdemo wird geladen…",
|
||||
|
||||
@@ -2014,7 +2014,7 @@
|
||||
"dictation_replay": "Replay",
|
||||
"dictation_transcribing": "Transcribing…",
|
||||
"dubbing_title": "See dubbing in action",
|
||||
"dubbing_sync": "Synced playback",
|
||||
"dubbing_sync": "Sync playheads",
|
||||
"dubbing_picker": "Try another language:",
|
||||
"dubbing_cta": "Run this on your own video →",
|
||||
"dubbing_loading": "Loading dubbing demo…",
|
||||
|
||||
@@ -1967,7 +1967,7 @@
|
||||
"dictation_replay": "Reproducir",
|
||||
"dictation_transcribing": "Transcribiendo…",
|
||||
"dubbing_title": "Ver doblaje en acción",
|
||||
"dubbing_sync": "Reproducción sincronizada",
|
||||
"dubbing_sync": "Sincronizar posición",
|
||||
"dubbing_picker": "Prueba con otro idioma:",
|
||||
"dubbing_cta": "Ejecute esto en su propio video →",
|
||||
"dubbing_loading": "Cargando demostración de doblaje…",
|
||||
|
||||
@@ -1967,7 +1967,7 @@
|
||||
"dictation_replay": "Rejouer",
|
||||
"dictation_transcribing": "Transcription…",
|
||||
"dubbing_title": "Voir le doublage en action",
|
||||
"dubbing_sync": "Lecture synchronisée",
|
||||
"dubbing_sync": "Synchroniser la position",
|
||||
"dubbing_picker": "Essayez une autre langue :",
|
||||
"dubbing_cta": "Exécutez ceci sur votre propre vidéo →",
|
||||
"dubbing_loading": "Chargement de la démo de doublage…",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "पुनः चलाएँ",
|
||||
"dictation_transcribing": "प्रतिलेखन...",
|
||||
"dubbing_title": "डबिंग क्रिया देखें",
|
||||
"dubbing_sync": "समन्वयित प्लेबैक",
|
||||
"dubbing_sync": "प्लेबैक स्थिति सिंक करें",
|
||||
"dubbing_picker": "दूसरी भाषा आज़माएँ:",
|
||||
"dubbing_cta": "इसे अपने वीडियो पर चलाएँ →",
|
||||
"dubbing_loading": "डबिंग डेमो लोड हो रहा है...",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "Putar ulang",
|
||||
"dictation_transcribing": "Mentranskripsikan…",
|
||||
"dubbing_title": "Lihat aksi sulih suara",
|
||||
"dubbing_sync": "Pemutaran yang disinkronkan",
|
||||
"dubbing_sync": "Sinkronkan posisi",
|
||||
"dubbing_picker": "Coba bahasa lain:",
|
||||
"dubbing_cta": "Jalankan ini di video Anda sendiri →",
|
||||
"dubbing_loading": "Memuat demo sulih suara…",
|
||||
|
||||
@@ -1967,7 +1967,7 @@
|
||||
"dictation_replay": "Riproduci",
|
||||
"dictation_transcribing": "Trascrizione…",
|
||||
"dubbing_title": "Guarda il doppiaggio in azione",
|
||||
"dubbing_sync": "Riproduzione sincronizzata",
|
||||
"dubbing_sync": "Sincronizza posizione",
|
||||
"dubbing_picker": "Prova un'altra lingua:",
|
||||
"dubbing_cta": "Eseguilo sul tuo video →",
|
||||
"dubbing_loading": "Caricamento demo del doppiaggio…",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "リプレイ",
|
||||
"dictation_transcribing": "文字起こし中…",
|
||||
"dubbing_title": "吹き替えの様子をご覧ください",
|
||||
"dubbing_sync": "同期再生",
|
||||
"dubbing_sync": "再生位置を同期",
|
||||
"dubbing_picker": "別の言語を試してください:",
|
||||
"dubbing_cta": "これを自分のビデオで実行します→",
|
||||
"dubbing_loading": "ダビングデモを読み込み中…",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "재생",
|
||||
"dictation_transcribing": "받아쓰는 중…",
|
||||
"dubbing_title": "실제 더빙 보기",
|
||||
"dubbing_sync": "동기화된 재생",
|
||||
"dubbing_sync": "재생 위치 동기화",
|
||||
"dubbing_picker": "다른 언어를 사용해 보세요:",
|
||||
"dubbing_cta": "자신의 비디오에서 이것을 실행 →",
|
||||
"dubbing_loading": "더빙 데모 로드 중…",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "Opnieuw afspelen",
|
||||
"dictation_transcribing": "Transcriberen…",
|
||||
"dubbing_title": "Zie nasynchronisatie in actie",
|
||||
"dubbing_sync": "Gesynchroniseerd afspelen",
|
||||
"dubbing_sync": "Afspeelpositie synchroniseren",
|
||||
"dubbing_picker": "Probeer een andere taal:",
|
||||
"dubbing_cta": "Voer dit uit op uw eigen video →",
|
||||
"dubbing_loading": "Dubdemo laden…",
|
||||
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"dictation_replay": "Odtwórz ponownie",
|
||||
"dictation_transcribing": "Transkrypcja…",
|
||||
"dubbing_title": "Zobacz dubbing w akcji",
|
||||
"dubbing_sync": "Zsynchronizowane odtwarzanie",
|
||||
"dubbing_sync": "Synchronizuj pozycję",
|
||||
"dubbing_picker": "Spróbuj innego języka:",
|
||||
"dubbing_cta": "Uruchom to na swoim własnym filmie →",
|
||||
"dubbing_loading": "Ładowanie wersji demonstracyjnej kopiowania…",
|
||||
|
||||
@@ -1967,7 +1967,7 @@
|
||||
"dictation_replay": "Repetir",
|
||||
"dictation_transcribing": "Transcrevendo…",
|
||||
"dubbing_title": "Veja a dublagem em ação",
|
||||
"dubbing_sync": "Reprodução sincronizada",
|
||||
"dubbing_sync": "Sincronizar posição",
|
||||
"dubbing_picker": "Tente outro idioma:",
|
||||
"dubbing_cta": "Execute isso em seu próprio vídeo →",
|
||||
"dubbing_loading": "Carregando demonstração de dublagem…",
|
||||
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"dictation_replay": "Повтор",
|
||||
"dictation_transcribing": "Расшифровка…",
|
||||
"dubbing_title": "Посмотрите дубляж в действии",
|
||||
"dubbing_sync": "Синхронизированное воспроизведение",
|
||||
"dubbing_sync": "Синхронизировать позицию",
|
||||
"dubbing_picker": "Попробуйте другой язык:",
|
||||
"dubbing_cta": "Запустите это на своем собственном видео →",
|
||||
"dubbing_loading": "Загрузка демо-версии дубляжа…",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "Spela om",
|
||||
"dictation_transcribing": "Transkriberar...",
|
||||
"dubbing_title": "Se dubbning i aktion",
|
||||
"dubbing_sync": "Synkroniserad uppspelning",
|
||||
"dubbing_sync": "Synka uppspelningsposition",
|
||||
"dubbing_picker": "Prova ett annat språk:",
|
||||
"dubbing_cta": "Kör detta på din egen video →",
|
||||
"dubbing_loading": "Laddar dubbningsdemo...",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "เล่นซ้ำ",
|
||||
"dictation_transcribing": "กำลังถอดเสียง...",
|
||||
"dubbing_title": "ดูการดำเนินการพากย์",
|
||||
"dubbing_sync": "การเล่นแบบซิงโครไนซ์",
|
||||
"dubbing_sync": "ซิงค์ตำแหน่งการเล่น",
|
||||
"dubbing_picker": "ลองภาษาอื่น:",
|
||||
"dubbing_cta": "เรียกใช้สิ่งนี้ในวิดีโอของคุณเอง →",
|
||||
"dubbing_loading": "กำลังโหลดการสาธิตการพากย์...",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "Tekrar oynat",
|
||||
"dictation_transcribing": "Metne dönüştürülüyor…",
|
||||
"dubbing_title": "Dublajı çalışırken görün",
|
||||
"dubbing_sync": "Senkronize oynatma",
|
||||
"dubbing_sync": "Oynatma konumunu eşitle",
|
||||
"dubbing_picker": "Başka bir dil deneyin:",
|
||||
"dubbing_cta": "Bunu kendi videonuzda çalıştırın →",
|
||||
"dubbing_loading": "Dublaj demosu yükleniyor…",
|
||||
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"dictation_replay": "Повтор",
|
||||
"dictation_transcribing": "Транскрибування…",
|
||||
"dubbing_title": "Перегляньте дубляж у дії",
|
||||
"dubbing_sync": "Синхронізоване відтворення",
|
||||
"dubbing_sync": "Синхронізувати позицію",
|
||||
"dubbing_picker": "Спробуйте іншу мову:",
|
||||
"dubbing_cta": "Запустіть це на власному відео →",
|
||||
"dubbing_loading": "Завантаження демонстрації дубляжу…",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "Phát lại",
|
||||
"dictation_transcribing": "Phiên âm…",
|
||||
"dubbing_title": "Xem lồng tiếng đang hoạt động",
|
||||
"dubbing_sync": "Phát lại được đồng bộ hóa",
|
||||
"dubbing_sync": "Đồng bộ vị trí phát",
|
||||
"dubbing_picker": "Hãy thử một ngôn ngữ khác:",
|
||||
"dubbing_cta": "Chạy cái này trên video của riêng bạn →",
|
||||
"dubbing_loading": "Đang tải bản demo lồng tiếng…",
|
||||
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"dictation_replay": "重放",
|
||||
"dictation_transcribing": "转录中…",
|
||||
"dubbing_title": "查看配音效果",
|
||||
"dubbing_sync": "同步播放",
|
||||
"dubbing_sync": "同步播放位置",
|
||||
"dubbing_picker": "试试其他语言:",
|
||||
"dubbing_cta": "在你自己的视频上运行 →",
|
||||
"dubbing_loading": "正在加载配音演示...",
|
||||
|
||||
@@ -1965,7 +1965,7 @@
|
||||
"dictation_replay": "重播",
|
||||
"dictation_transcribing": "正在抄寫…",
|
||||
"dubbing_title": "看實際配音",
|
||||
"dubbing_sync": "同步播放",
|
||||
"dubbing_sync": "同步播放位置",
|
||||
"dubbing_picker": "嘗試另一種語言:",
|
||||
"dubbing_cta": "在您自己的影片上運行此→",
|
||||
"dubbing_loading": "正在載入配音演示...",
|
||||
|
||||
Reference in New Issue
Block a user