Merge branch 'fix/dubbing-demo-editor' into fix/linux-electron-setup-sidebar

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-17 01:21:27 +05:30
27 changed files with 463 additions and 169 deletions
+1
View File
@@ -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)
+6
View File
@@ -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": "正在載入配音演示...",