feat(captions): add timeline track and side-panel editor for captions

This commit is contained in:
Joe Hachem
2026-06-25 07:37:16 +03:00
parent d8e34a1df3
commit 6f41330c07
29 changed files with 1202 additions and 232 deletions
@@ -0,0 +1,226 @@
import { ArrowsMerge, Scissors, Trash } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";
import type { CaptionRetimeSpan } from "./captionOps";
import type { CaptionCue } from "./types";
interface CaptionListPanelProps {
cues: CaptionCue[];
selectedCaptionId: string | null;
currentTimeMs: number;
onBeginCaptionEdit: (id: string) => void;
onCaptionTextEdit: (id: string, text: string) => void;
onCaptionRetime: (id: string, span: CaptionRetimeSpan) => void;
onCaptionSplit: (id: string, atMs: number) => void;
onCaptionMerge: (idA: string, idB: string) => void;
onCaptionDelete: (id: string) => void;
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function formatTimecode(ms: number): string {
const safeMs = Math.max(0, Math.round(ms));
const minutes = Math.floor(safeMs / 60_000);
const seconds = Math.floor((safeMs % 60_000) / 1_000);
const millis = safeMs % 1_000;
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}
function parseTimecode(value: string): number | null {
const match = value.trim().match(/^(?:(\d+):)?(\d{1,2})(?:\.(\d{1,3}))?$/);
if (!match) {
return null;
}
const minutes = match[1] ? Number.parseInt(match[1], 10) : 0;
const seconds = Number.parseInt(match[2], 10);
const millis = match[3] ? Number.parseInt(match[3].padEnd(3, "0"), 10) : 0;
return (minutes * 60 + seconds) * 1_000 + millis;
}
interface CaptionEditorProps {
cue: CaptionCue;
canMerge: boolean;
currentTimeMs: number;
onBeginEdit: (id: string) => void;
onTextEdit: (id: string, text: string) => void;
onRetime: (id: string, span: CaptionRetimeSpan) => void;
onSplit: (id: string, atMs: number) => void;
onMerge: (id: string) => void;
onDelete: (id: string) => void;
}
function CaptionEditor({
cue,
canMerge,
currentTimeMs,
onBeginEdit,
onTextEdit,
onRetime,
onSplit,
onMerge,
onDelete,
}: CaptionEditorProps) {
const [draftText, setDraftText] = useState(cue.text);
const [startValue, setStartValue] = useState(formatTimecode(cue.startMs));
const [endValue, setEndValue] = useState(formatTimecode(cue.endMs));
useEffect(() => {
setDraftText(cue.text);
setStartValue(formatTimecode(cue.startMs));
setEndValue(formatTimecode(cue.endMs));
}, [cue.text, cue.startMs, cue.endMs]);
const commitText = useCallback(() => {
const normalized = draftText.trim();
if (normalized && normalized !== cue.text) {
onTextEdit(cue.id, normalized);
} else {
setDraftText(cue.text);
}
}, [cue.id, cue.text, draftText, onTextEdit]);
const commitTiming = useCallback(() => {
const parsedStart = parseTimecode(startValue);
const parsedEnd = parseTimecode(endValue);
if (parsedStart === null || parsedEnd === null || parsedEnd <= parsedStart) {
setStartValue(formatTimecode(cue.startMs));
setEndValue(formatTimecode(cue.endMs));
return;
}
if (parsedStart !== cue.startMs || parsedEnd !== cue.endMs) {
onRetime(cue.id, { startMs: parsedStart, endMs: parsedEnd });
}
}, [cue.endMs, cue.id, cue.startMs, endValue, onRetime, startValue]);
return (
<div className="flex flex-col gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2.5">
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Text
</span>
<textarea
value={draftText}
rows={2}
onFocus={() => onBeginEdit(cue.id)}
onChange={(event) => setDraftText(event.target.value)}
onBlur={commitText}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
setDraftText(cue.text);
event.currentTarget.blur();
}
}}
className="w-full resize-none rounded-md border border-foreground/10 bg-background/60 px-2 py-1.5 text-sm text-foreground outline-none focus-visible:border-[#2563EB] focus-visible:ring-1 focus-visible:ring-[#2563EB]"
/>
</label>
<div className="flex items-center gap-2">
<label className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Start
</span>
<input
value={startValue}
onChange={(event) => setStartValue(event.target.value)}
onBlur={commitTiming}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className="w-full rounded-md border border-foreground/10 bg-background/60 px-2 py-1 font-mono text-xs tabular-nums text-foreground outline-none focus-visible:border-[#2563EB] focus-visible:ring-1 focus-visible:ring-[#2563EB]"
/>
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
End
</span>
<input
value={endValue}
onChange={(event) => setEndValue(event.target.value)}
onBlur={commitTiming}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className="w-full rounded-md border border-foreground/10 bg-background/60 px-2 py-1 font-mono text-xs tabular-nums text-foreground outline-none focus-visible:border-[#2563EB] focus-visible:ring-1 focus-visible:ring-[#2563EB]"
/>
</label>
</div>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() =>
onSplit(cue.id, clampNumber(currentTimeMs, cue.startMs, cue.endMs))
}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-foreground/10 bg-foreground/5 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10"
>
<Scissors className="h-4 w-4" />
Split
</button>
<button
type="button"
disabled={!canMerge}
onClick={() => onMerge(cue.id)}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-foreground/10 bg-foreground/5 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10 disabled:opacity-40"
>
<ArrowsMerge className="h-4 w-4" />
Merge
</button>
<button
type="button"
onClick={() => onDelete(cue.id)}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-destructive/30 bg-destructive/10 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
<Trash className="h-4 w-4" />
Delete
</button>
</div>
</div>
);
}
export default function CaptionListPanel({
cues,
selectedCaptionId,
currentTimeMs,
onBeginCaptionEdit,
onCaptionTextEdit,
onCaptionRetime,
onCaptionSplit,
onCaptionMerge,
onCaptionDelete,
}: CaptionListPanelProps) {
const index = cues.findIndex((cue) => cue.id === selectedCaptionId);
if (index < 0) {
return null;
}
const cue = cues[index];
const canMerge = index < cues.length - 1;
return (
<CaptionEditor
cue={cue}
canMerge={canMerge}
currentTimeMs={currentTimeMs}
onBeginEdit={onBeginCaptionEdit}
onTextEdit={onCaptionTextEdit}
onRetime={onCaptionRetime}
onSplit={onCaptionSplit}
onMerge={(id) => {
if (canMerge) {
onCaptionMerge(id, cues[index + 1].id);
}
}}
onDelete={onCaptionDelete}
/>
);
}
+90 -14
View File
@@ -45,6 +45,8 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext";
import type { AppLocale } from "../../i18n/config";
import { SUPPORTED_LOCALES } from "../../i18n/config";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import CaptionListPanel from "./CaptionListPanel";
import type { CaptionRetimeSpan } from "./captionOps";
import {
CURSOR_MOTION_PRESETS,
type CursorMotionPresetId,
@@ -179,9 +181,10 @@ function isHexWallpaper(value: string): boolean {
function hexToRgba(hex: string, alpha: number) {
const normalized = isHexWallpaper(hex) ? hex : DEFAULT_CURSOR_CLICK_EFFECT_COLOR;
const value = normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
const value =
normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
const color = Number.parseInt(value.slice(1), 16);
const red = (color >> 16) & 255;
const green = (color >> 8) & 255;
@@ -529,8 +532,23 @@ function CursorClickEffectPreview({
viewBox="0 0 40 40"
aria-hidden="true"
>
<circle cx="20" cy="20" r="11.5" fill="none" stroke="currentColor" strokeWidth="1.8" opacity="0.75" />
<path d="M12.5 27.5 27.5 12.5" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="2.2" opacity="0.92" />
<circle
cx="20"
cy="20"
r="11.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
opacity="0.75"
/>
<path
d="M12.5 27.5 27.5 12.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2.2"
opacity="0.92"
/>
</svg>
) : null}
{effect === "ripple" ? (
@@ -571,13 +589,17 @@ function CursorClickEffectPreview({
viewBox="0 0 48 48"
aria-hidden="true"
>
<g
fill="none"
stroke="currentColor"
>
<g fill="none" stroke="currentColor">
<circle cx="24" cy="24" r="9" strokeWidth="1.8" opacity="0.72" />
<circle cx="24" cy="24" r="14.5" strokeWidth="1.5" opacity="0.4" />
<circle cx="24" cy="24" r="4.25" fill="currentColor" opacity="0.22" stroke="none" />
<circle
cx="24"
cy="24"
r="4.25"
fill="currentColor"
opacity="0.22"
stroke="none"
/>
</g>
</svg>
) : null}
@@ -660,7 +682,10 @@ function CursorClickEffectCards({
>
<div className="flex h-full flex-col items-center justify-between gap-3">
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-[8px] px-2 py-1.5">
<CursorClickEffectPreview effect={effect.id} color={effectColor} />
<CursorClickEffectPreview
effect={effect.id}
color={effectColor}
/>
</div>
</div>
</ToggleGroupItem>
@@ -815,6 +840,14 @@ interface SettingsPanelProps {
onClearAutoCaptions?: () => void;
onDownloadWhisperSmallModel?: () => void;
onDeleteWhisperSmallModel?: () => void;
captionCurrentTimeMs?: number;
selectedCaptionId?: string | null;
onBeginCaptionEdit?: (id: string) => void;
onCaptionTextEdit?: (id: string, text: string) => void;
onCaptionRetime?: (id: string, span: CaptionRetimeSpan) => void;
onCaptionSplit?: (id: string, atMs: number) => void;
onCaptionMerge?: (idA: string, idB: string) => void;
onCaptionDelete?: (id: string) => void;
nativeCaptureUnavailableSession?: boolean;
onOpenNativeCaptureUnavailableModal?: () => void;
}
@@ -1249,6 +1282,14 @@ export function SettingsPanel({
onClearAutoCaptions,
onDownloadWhisperSmallModel,
onDeleteWhisperSmallModel,
captionCurrentTimeMs = 0,
selectedCaptionId = null,
onBeginCaptionEdit,
onCaptionTextEdit,
onCaptionRetime,
onCaptionSplit,
onCaptionMerge,
onCaptionDelete,
nativeCaptureUnavailableSession = false,
onOpenNativeCaptureUnavailableModal,
}: SettingsPanelProps) {
@@ -3549,6 +3590,34 @@ export function SettingsPanel({
</section>
);
const captionSectionContent = (
<section className="flex flex-col gap-2">
<SectionLabel>{tSettings("sections.caption", "Caption")}</SectionLabel>
{selectedCaptionId !== null ? (
<CaptionListPanel
cues={autoCaptions}
selectedCaptionId={selectedCaptionId}
currentTimeMs={captionCurrentTimeMs}
onBeginCaptionEdit={(id) => onBeginCaptionEdit?.(id)}
onCaptionTextEdit={(id, text) => onCaptionTextEdit?.(id, text)}
onCaptionRetime={(id, span) => onCaptionRetime?.(id, span)}
onCaptionSplit={(id, atMs) => onCaptionSplit?.(id, atMs)}
onCaptionMerge={(idA, idB) => onCaptionMerge?.(idA, idB)}
onCaptionDelete={(id) => onCaptionDelete?.(id)}
/>
) : (
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-6 text-center">
<p className="text-[11px] text-muted-foreground">
{tSettings(
"captions.selectOnTimeline",
"Select a caption on the timeline to edit it.",
)}
</p>
</div>
)}
</section>
);
switch (activeEffectSection) {
case "settings":
return settingsSectionContent;
@@ -3566,6 +3635,8 @@ export function SettingsPanel({
return sceneSectionContent;
case "captions":
return captionsSectionContent;
case "caption":
return captionSectionContent;
case "cursor":
return (
<section className="flex flex-col gap-2">
@@ -3694,12 +3765,15 @@ export function SettingsPanel({
<div className="flex flex-wrap gap-1.5">
{CLICK_EFFECT_COLOR_OPTIONS.map((color) => {
const isSelected =
cursorClickEffectColor.toLowerCase() === color.toLowerCase();
cursorClickEffectColor.toLowerCase() ===
color.toLowerCase();
return (
<button
key={color}
type="button"
onClick={() => onCursorClickEffectColorChange?.(color)}
onClick={() =>
onCursorClickEffectColorChange?.(color)
}
className={cn(
"h-6 w-6 rounded-[8px] border transition-transform hover:scale-[1.04]",
isSelected
@@ -3713,7 +3787,9 @@ export function SettingsPanel({
})}
<button
type="button"
onClick={() => cursorClickEffectColorInputRef.current?.click()}
onClick={() =>
cursorClickEffectColorInputRef.current?.click()
}
className="relative h-6 w-10 overflow-hidden rounded-[8px] border border-foreground/10 text-[8px] font-semibold uppercase tracking-[0.18em] text-foreground"
style={{
background: `linear-gradient(135deg, ${cursorClickEffectColor} 0%, ${cursorClickEffectColor} 58%, rgba(255,255,255,0.92) 58%, rgba(255,255,255,0.92) 100%)`,
+110 -4
View File
@@ -118,8 +118,13 @@ import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/a
import { extensionHost } from "@/lib/extensions";
import { useVideoEditorAudio } from "./audio/useVideoEditorAudio";
import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
import { type CaptionEditTarget, updateCaptionCuesForEditedTarget } from "./captionEditing";
import { CropControl } from "./CropControl";
import {
type CaptionEditTarget,
normalizeCaptionWords,
updateCaptionCuesForEditedTarget,
} from "./captionEditing";
import { type CaptionRetimeSpan, deleteCue, mergeCues, retimeCue, splitCue } from "./captionOps";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import ExtensionManager from "./ExtensionManager";
import {
@@ -399,9 +404,8 @@ export default function VideoEditor() {
const [projectSaveDialogDraft, setProjectSaveDialogDraft] = useState("");
const [isSavingProjectDialog, setIsSavingProjectDialog] = useState(false);
const [unsavedChangesDialogOpen, setUnsavedChangesDialogOpen] = useState(false);
const [unsavedChangesDialogActionLabel, setUnsavedChangesDialogActionLabel] = useState(
"continue",
);
const [unsavedChangesDialogActionLabel, setUnsavedChangesDialogActionLabel] =
useState("continue");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -538,6 +542,7 @@ export default function VideoEditor() {
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [audioRegions, setAudioRegions] = useState<AudioRegion[]>([]);
const [selectedAudioId, setSelectedAudioId] = useState<string | null>(null);
const [selectedCaptionId, setSelectedCaptionId] = useState<string | null>(null);
const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState<
Record<string, SourceAudioTrackSettings>
>({});
@@ -2850,6 +2855,9 @@ export default function VideoEditor() {
}
setAutoCaptions(result.cues);
if (result.cues.length > 0) {
setAutoCaptionSettings((prev) => ({ ...prev, enabled: true }));
}
toast.success(result.message || `Generated ${result.cues.length} captions`);
} catch (error) {
toast.error(getErrorMessage(error));
@@ -3522,6 +3530,16 @@ export default function VideoEditor() {
[zoomRegions, mapTimelineTimeToSourceTime],
);
const effectiveCaptionRegions = useMemo<CaptionCue[]>(
() =>
autoCaptions.map((cue) => ({
...cue,
startMs: mapSourceTimeToTimelineTime(cue.startMs),
endMs: mapSourceTimeToTimelineTime(cue.endMs),
})),
[autoCaptions, mapSourceTimeToTimelineTime],
);
const timelinePlayheadTime = useMemo(
() => mapSourceTimeToTimelineTime(currentTime * 1000) / 1000,
[currentTime, mapSourceTimeToTimelineTime],
@@ -3627,6 +3645,77 @@ export default function VideoEditor() {
[handleSeek],
);
const handleSelectCaption = useCallback(
(id: string | null) => {
setSelectedCaptionId(id);
if (!id) {
setActiveEffectSection((section) => (section === "caption" ? "scene" : section));
return;
}
setActiveEffectSection("caption");
setSelectedZoomId(null);
setSelectedClipId(null);
setSelectedAnnotationId(null);
setSelectedAudioId(null);
const cue = autoCaptions.find((value) => value.id === id);
if (cue) {
handleSeek(mapSourceTimeToTimelineTime(cue.startMs) / 1000, { pause: true });
}
},
[autoCaptions, handleSeek, mapSourceTimeToTimelineTime],
);
const handleBeginCaptionEdit = useCallback((id: string) => {
videoPlaybackRef.current?.cancelCaptionEdit();
setSelectedCaptionId(id);
}, []);
const handleCaptionTextEdit = useCallback((id: string, text: string) => {
setAutoCaptions((captions) => {
const cue = captions.find((value) => value.id === id);
if (!cue) {
return captions;
}
const words = normalizeCaptionWords(cue);
const target: CaptionEditTarget = {
id: cue.id,
startMs: cue.startMs,
endMs: cue.endMs,
text: cue.text,
words: words.map((word, index) => ({
cueId: cue.id,
cueWordIndex: index,
startMs: word.startMs,
endMs: word.endMs,
text: word.text,
leadingSpace: Boolean(word.leadingSpace),
})),
};
return updateCaptionCuesForEditedTarget(captions, target, text);
});
}, []);
const handleCaptionRetime = useCallback((id: string, span: CaptionRetimeSpan) => {
videoPlaybackRef.current?.cancelCaptionEdit();
setAutoCaptions((captions) => retimeCue(captions, id, span));
}, []);
const handleCaptionSplit = useCallback((id: string, atMs: number) => {
videoPlaybackRef.current?.cancelCaptionEdit();
setAutoCaptions((captions) => splitCue(captions, id, atMs));
}, []);
const handleCaptionMerge = useCallback((idA: string, idB: string) => {
videoPlaybackRef.current?.cancelCaptionEdit();
setAutoCaptions((captions) => mergeCues(captions, idA, idB));
}, []);
const handleCaptionDelete = useCallback((id: string) => {
videoPlaybackRef.current?.cancelCaptionEdit();
setSelectedCaptionId((prev) => (prev === id ? null : prev));
setAutoCaptions((captions) => deleteCue(captions, id));
}, []);
const handlePreviewSkipBack = useCallback(() => {
const currentMs = timelinePlayheadTime * 1000;
const keyframes = timelineRef.current?.keyframes ?? [];
@@ -6374,6 +6463,14 @@ export default function VideoEditor() {
onPickWhisperModel={handlePickWhisperModel}
onGenerateAutoCaptions={handleGenerateAutoCaptions}
onClearAutoCaptions={handleClearAutoCaptions}
captionCurrentTimeMs={Math.round(currentTime * 1000)}
selectedCaptionId={selectedCaptionId}
onBeginCaptionEdit={handleBeginCaptionEdit}
onCaptionTextEdit={handleCaptionTextEdit}
onCaptionRetime={handleCaptionRetime}
onCaptionSplit={handleCaptionSplit}
onCaptionMerge={handleCaptionMerge}
onCaptionDelete={handleCaptionDelete}
onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel}
onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel}
nativeCaptureUnavailableSession={sessionNativeCaptureUnavailable}
@@ -6693,6 +6790,15 @@ export default function VideoEditor() {
onAudioDelete={handleAudioDelete}
selectedAudioId={selectedAudioId}
onSelectAudio={handleSelectAudio}
captionRegions={effectiveCaptionRegions}
onCaptionSpanChange={(id, span) =>
handleCaptionRetime(id, {
startMs: mapTimelineTimeToSourceTime(span.start),
endMs: mapTimelineTimeToSourceTime(span.end),
})
}
selectedCaptionId={selectedCaptionId}
onSelectCaption={handleSelectCaption}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
+203 -166
View File
@@ -173,10 +173,7 @@ import {
} from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import {
getWebcamMediaTargetTimeSeconds,
shouldSeekWebcamMedia,
} from "./videoPlayback/webcamSync";
import { getWebcamMediaTargetTimeSeconds, shouldSeekWebcamMedia } from "./videoPlayback/webcamSync";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -425,6 +422,7 @@ export interface VideoPlaybackRef {
play: () => Promise<void>;
pause: () => void;
refreshFrame: () => Promise<void>;
cancelCaptionEdit: () => void;
}
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
@@ -1419,6 +1417,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
video.pause();
},
cancelCaptionEdit,
refreshFrame: async () => {
const video = videoRef.current;
if (!video || Number.isNaN(video.currentTime)) {
@@ -3117,14 +3116,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
</div>
</div>
) : null}
{activeCaptionLayout && autoCaptionSettings ? (
<div
className="absolute inset-x-0 flex justify-center"
style={{
bottom: `${autoCaptionSettings.bottomOffset}%`,
pointerEvents: onEditAutoCaption ? "auto" : "none",
}}
>
{activeCaptionLayout && autoCaptionSettings ? (
<div
className="absolute inset-x-0 flex justify-center"
style={{
bottom: `${autoCaptionSettings.bottomOffset}%`,
pointerEvents: onEditAutoCaption ? "auto" : "none",
}}
>
<div
style={{
maxWidth: `${autoCaptionSettings.maxWidth}%`,
@@ -3136,32 +3135,38 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
>
<div
ref={captionBoxRef}
role={
onEditAutoCaption && !isCaptionEditing ? "button" : undefined
}
tabIndex={onEditAutoCaption && !isCaptionEditing ? 0 : undefined}
aria-label={
onEditAutoCaption && !isCaptionEditing ? "Edit current caption" : undefined
}
onClick={(event) => {
event.stopPropagation();
if (!isCaptionEditing) {
beginCaptionEdit();
}
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (!onEditAutoCaption || isCaptionEditing) {
return;
}
role={
onEditAutoCaption && !isCaptionEditing
? "button"
: undefined
}
tabIndex={
onEditAutoCaption && !isCaptionEditing ? 0 : undefined
}
aria-label={
onEditAutoCaption && !isCaptionEditing
? "Edit current caption"
: undefined
}
onClick={(event) => {
event.stopPropagation();
if (!isCaptionEditing) {
beginCaptionEdit();
}
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (!onEditAutoCaption || isCaptionEditing) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
beginCaptionEdit();
}
}}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
beginCaptionEdit();
}
}}
style={{
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
fontFamily: getDefaultCaptionFontFamily(),
@@ -3199,122 +3204,138 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
),
)}px`,
boxSizing: "border-box",
cursor:
onEditAutoCaption && !isCaptionEditing ? "text" : undefined,
pointerEvents: onEditAutoCaption ? "auto" : undefined,
cursor:
onEditAutoCaption && !isCaptionEditing
? "text"
: undefined,
pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
{captionEditSession ? (
<textarea
ref={captionEditInputRef}
value={captionEditSession.draft}
onChange={(event) => {
const draft = event.target.value;
setCaptionEditSession((session) => {
const nextSession = session ? { ...session, draft } : session;
captionEditSessionRef.current = nextSession;
return nextSession;
});
}}
onBlur={commitCaptionEdit}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelCaptionEdit();
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
}
}}
rows={Math.max(1, activeCaptionLayout.visibleLines.length)}
aria-label="Edit current caption"
style={{
display: "block",
width: `${
captionEditTextMetrics?.widthPx ??
Math.max(
48,
activeCaptionLayout.visibleLines.reduce(
(width, line) => Math.max(width, line.width),
0,
),
)
}px`,
maxWidth: `${
captionEditTextMetrics?.maxTextWidthPx ??
getCaptionTextMaxWidth(
overlayRef.current?.clientWidth || 960,
autoCaptionSettings.maxWidth,
getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current?.clientWidth || 960,
autoCaptionSettings.maxWidth,
),
)
}px`,
minHeight: `${
Math.max(1, activeCaptionLayout.visibleLines.length) *
(captionEditTextMetrics?.fontSize ??
getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current?.clientWidth || 960,
autoCaptionSettings.maxWidth,
)) *
CAPTION_LINE_HEIGHT
}px`,
resize: "none",
border: "0",
outline: "0",
padding: "0",
margin: "0",
overflow: "hidden",
background: "transparent",
color: autoCaptionSettings.textColor,
font: "inherit",
lineHeight: "inherit",
textAlign: "center",
}}
/>
) : (
activeCaptionLayout.visibleLines.map((line) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
{captionEditSession ? (
<textarea
ref={captionEditInputRef}
value={captionEditSession.draft}
onChange={(event) => {
const draft = event.target.value;
setCaptionEditSession((session) => {
const nextSession = session
? { ...session, draft }
: session;
captionEditSessionRef.current = nextSession;
return nextSession;
});
}}
>
{line.words.map((word) => {
const visualState = getCaptionWordVisualState(
activeCaptionLayout.hasWordTimings,
word.state,
);
onBlur={commitCaptionEdit}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelCaptionEdit();
return;
}
return (
<span
key={`${activeCaptionLayout.blockKey}-${word.index}`}
style={{
display: "inline-block",
whiteSpace: "pre",
color: visualState.isInactive
? autoCaptionSettings.inactiveTextColor
: autoCaptionSettings.textColor,
opacity: visualState.opacity,
}}
>
{`${word.leadingSpace ? " " : ""}${word.text}`}
</span>
);
})}
</div>
))
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
}
}}
rows={Math.max(
1,
activeCaptionLayout.visibleLines.length,
)}
aria-label="Edit current caption"
style={{
display: "block",
width: `${
captionEditTextMetrics?.widthPx ??
Math.max(
48,
activeCaptionLayout.visibleLines.reduce(
(width, line) =>
Math.max(width, line.width),
0,
),
)
}px`,
maxWidth: `${
captionEditTextMetrics?.maxTextWidthPx ??
getCaptionTextMaxWidth(
overlayRef.current?.clientWidth || 960,
autoCaptionSettings.maxWidth,
getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current?.clientWidth ||
960,
autoCaptionSettings.maxWidth,
),
)
}px`,
minHeight: `${
Math.max(
1,
activeCaptionLayout.visibleLines.length,
) *
(
captionEditTextMetrics?.fontSize ??
getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current
?.clientWidth || 960,
autoCaptionSettings.maxWidth,
)
) *
CAPTION_LINE_HEIGHT
}px`,
resize: "none",
border: "0",
outline: "0",
padding: "0",
margin: "0",
overflow: "hidden",
background: "transparent",
color: autoCaptionSettings.textColor,
font: "inherit",
lineHeight: "inherit",
textAlign: "center",
}}
/>
) : (
activeCaptionLayout.visibleLines.map((line) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
}}
>
{line.words.map((word) => {
const visualState =
getCaptionWordVisualState(
activeCaptionLayout.hasWordTimings,
word.state,
);
return (
<span
key={`${activeCaptionLayout.blockKey}-${word.index}`}
style={{
display: "inline-block",
whiteSpace: "pre",
color: visualState.isInactive
? autoCaptionSettings.inactiveTextColor
: autoCaptionSettings.textColor,
opacity: visualState.opacity,
}}
>
{`${word.leadingSpace ? " " : ""}${word.text}`}
</span>
);
})}
</div>
))
)}
</div>
</div>
</div>
@@ -3335,32 +3356,44 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
top: annotationRecordingRect.y || 0,
width:
annotationRecordingRect.width ||
(overlayRef.current?.clientWidth || 800),
overlayRef.current?.clientWidth ||
800,
height:
annotationRecordingRect.height ||
(overlayRef.current?.clientHeight || 600),
overlayRef.current?.clientHeight ||
600,
}}
>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
if (
typeof annotation.startMs !== "number" ||
typeof annotation.endMs !== "number"
)
return false;
const filtered = (annotationRegions || []).filter(
(annotation) => {
if (
typeof annotation.startMs !== "number" ||
typeof annotation.endMs !== "number"
)
return false;
if (annotation.id === selectedAnnotationId) return true;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
const timeMs = Math.round(currentTime * 1000);
return (
timeMs >= annotation.startMs &&
timeMs <= annotation.endMs
);
},
);
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
const sorted = [...filtered].sort(
(a, b) => a.zIndex - b.zIndex,
);
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
if (clickedId === selectedAnnotationId && sorted.length > 1) {
if (
clickedId === selectedAnnotationId &&
sorted.length > 1
) {
const currentIndex = sorted.findIndex(
(a) => a.id === clickedId,
);
@@ -3378,21 +3411,25 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
isSelected={annotation.id === selectedAnnotationId}
containerWidth={
annotationRecordingRect.width ||
(overlayRef.current?.clientWidth || 800)
overlayRef.current?.clientWidth ||
800
}
containerHeight={
annotationRecordingRect.height ||
(overlayRef.current?.clientHeight || 600)
overlayRef.current?.clientHeight ||
600
}
recordingRect={{
x: 0,
y: 0,
width:
annotationRecordingRect.width ||
(overlayRef.current?.clientWidth || 800),
overlayRef.current?.clientWidth ||
800,
height:
annotationRecordingRect.height ||
(overlayRef.current?.clientHeight || 600),
overlayRef.current?.clientHeight ||
600,
}}
sceneTransform={{ scale: 1, x: 0, y: 0 }}
interactionScale={annotationSceneTransform.scale}
@@ -55,7 +55,7 @@ function buildCaptionWordsForEditedText(
});
}
function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
export function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
const validSourceWords = Array.isArray(cue.words)
? cue.words.filter(
(word): word is CaptionCueWord =>
@@ -87,14 +87,14 @@ function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
.filter((word) => word.text.length > 0);
}
function captionWordsToText(words: CaptionCueWord[]) {
export function captionWordsToText(words: CaptionCueWord[]) {
return words
.map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
.join("")
.trim();
}
function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] {
export function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] {
return words
.slice()
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs)
+24 -1
View File
@@ -131,7 +131,26 @@ function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
return null;
}
function flattenCaptionWords(cues: CaptionCue[]) {
function isWithinCaptionCoverage(cues: CaptionCue[], timeMs: number) {
const sorted = [...cues].sort((left, right) => left.startMs - right.startMs);
for (let index = 0; index < sorted.length; index += 1) {
const cue = sorted[index];
if (timeMs < cue.startMs) {
return false;
}
const next = sorted[index + 1];
const bridgesGap =
next !== undefined && next.startMs - cue.endMs < CAPTION_BLOCK_GAP_BREAK_MS;
const effectiveEndMs = bridgesGap ? Math.max(cue.endMs, next.startMs) : cue.endMs;
if (timeMs <= effectiveEndMs) {
return true;
}
}
return false;
}
export function flattenCaptionWords(cues: CaptionCue[]) {
const flattened: Array<{
cueId: string;
cueWordIndex: number;
@@ -421,6 +440,10 @@ export function buildActiveCaptionLayout(options: {
return null;
}
if (!isWithinCaptionCoverage(options.cues, options.timeMs)) {
return null;
}
let activeWordIndex = -1;
activeWordIndex = sourceWords.findIndex(
(word) => options.timeMs >= word.startMs && options.timeMs < word.endMs,
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import { flattenCaptionWords } from "./captionLayout";
import { deleteCue, mergeCues, retimeCue, splitCue } from "./captionOps";
import type { CaptionCue } from "./types";
function makeCues(): CaptionCue[] {
return [
{
id: "a",
startMs: 0,
endMs: 2_000,
text: "one two three four",
words: [
{ text: "one", startMs: 0, endMs: 500 },
{ text: "two", startMs: 500, endMs: 1_000, leadingSpace: true },
{ text: "three", startMs: 1_000, endMs: 1_500, leadingSpace: true },
{ text: "four", startMs: 1_500, endMs: 2_000, leadingSpace: true },
],
},
{
id: "b",
startMs: 2_200,
endMs: 3_000,
text: "five six",
words: [
{ text: "five", startMs: 2_200, endMs: 2_600 },
{ text: "six", startMs: 2_600, endMs: 3_000, leadingSpace: true },
],
},
];
}
function assertCuesValid(cues: CaptionCue[]) {
for (let index = 0; index < cues.length - 1; index += 1) {
expect(cues[index].startMs).toBeLessThanOrEqual(cues[index + 1].startMs);
expect(cues[index].endMs).toBeLessThanOrEqual(cues[index + 1].startMs);
}
const words = flattenCaptionWords(cues);
for (let index = 0; index < words.length; index += 1) {
expect(words[index].endMs).toBeGreaterThan(words[index].startMs);
if (index > 0) {
expect(words[index].startMs).toBeGreaterThanOrEqual(words[index - 1].startMs);
expect(words[index].startMs).toBeGreaterThanOrEqual(words[index - 1].endMs);
}
}
}
describe("captionOps.retimeCue", () => {
it("extends end and rescales word timings proportionally", () => {
const single: CaptionCue[] = [makeCues()[0]];
const result = retimeCue(single, "a", { startMs: 0, endMs: 4_000 });
const cue = result.find((value) => value.id === "a");
expect(cue?.endMs).toBe(4_000);
expect(cue?.words?.[cue.words.length - 1].endMs).toBe(4_000);
expect(cue?.words?.[1].startMs).toBe(1_000);
expect(cue?.text).toBe("one two three four");
assertCuesValid(result);
});
it("honors requested timing without clamping to neighbors", () => {
const result = retimeCue(makeCues(), "a", { startMs: 1_000, endMs: 2_500 });
const cue = result.find((value) => value.id === "a");
const neighbor = result.find((value) => value.id === "b");
expect(cue?.startMs).toBe(1_000);
expect(cue?.endMs).toBe(2_500);
expect(cue?.words?.[0].startMs).toBe(1_000);
expect(cue?.words?.[cue.words.length - 1].endMs).toBe(2_500);
expect(neighbor?.startMs).toBe(2_200);
});
it("returns cue-level timing when the cue has no words", () => {
const cues: CaptionCue[] = [{ id: "a", startMs: 0, endMs: 1_000, text: "hello" }];
const result = retimeCue(cues, "a", { startMs: 200, endMs: 1_500 });
expect(result[0].startMs).toBe(200);
expect(result[0].endMs).toBe(1_500);
expect(result[0].words).toBeUndefined();
});
it("is a no-op for an unknown id", () => {
const cues = makeCues();
expect(retimeCue(cues, "missing", { startMs: 0, endMs: 100 })).toBe(cues);
});
});
describe("captionOps.splitCue", () => {
it("splits at the nearest word boundary into two cues", () => {
const result = splitCue(makeCues(), "a", 1_000);
expect(result).toHaveLength(3);
const [left, right] = result;
expect(left.id).toBe("a");
expect(left.text).toBe("one two");
expect(right.id).not.toBe("a");
expect(right.text).toBe("three four");
expect(right.words?.[0].leadingSpace).toBeUndefined();
expect(left.endMs).toBeLessThanOrEqual(right.startMs);
assertCuesValid(result);
});
it("does not split a single-word cue", () => {
const cues: CaptionCue[] = [
{
id: "solo",
startMs: 0,
endMs: 500,
text: "word",
words: [{ text: "word", startMs: 0, endMs: 500 }],
},
];
expect(splitCue(cues, "solo", 250)).toBe(cues);
});
});
describe("captionOps.mergeCues", () => {
it("merges adjacent cues and re-derives spacing", () => {
const result = mergeCues(makeCues(), "a", "b");
expect(result).toHaveLength(1);
const merged = result[0];
expect(merged.id).toBe("a");
expect(merged.startMs).toBe(0);
expect(merged.endMs).toBe(3_000);
expect(merged.text).toBe("one two three four five six");
expect(merged.words?.[4].leadingSpace).toBe(true);
assertCuesValid(result);
});
it("rejects a non-adjacent merge", () => {
const cues: CaptionCue[] = [
...makeCues(),
{
id: "c",
startMs: 4_000,
endMs: 5_000,
text: "seven",
words: [{ text: "seven", startMs: 4_000, endMs: 5_000 }],
},
];
expect(mergeCues(cues, "a", "c")).toBe(cues);
});
});
describe("captionOps.deleteCue", () => {
it("removes the cue and keeps the rest sorted", () => {
const result = deleteCue(makeCues(), "a");
expect(result.map((cue) => cue.id)).toEqual(["b"]);
});
it("is a no-op for an unknown id", () => {
const cues = makeCues();
expect(deleteCue(cues, "missing")).toBe(cues);
});
});
+189
View File
@@ -0,0 +1,189 @@
import {
captionWordsToText,
normalizeCaptionWordSpacing,
normalizeCaptionWords,
} from "./captionEditing";
import type { CaptionCue, CaptionCueWord } from "./types";
export interface CaptionRetimeSpan {
startMs: number;
endMs: number;
}
function sortCaptionCues(cues: CaptionCue[]): CaptionCue[] {
return [...cues].sort(
(left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
);
}
function createCaptionCueId(): string {
return `caption-${globalThis.crypto.randomUUID()}`;
}
function rescaleWordsIntoSpan(
words: CaptionCueWord[],
oldStartMs: number,
oldEndMs: number,
newStartMs: number,
newEndMs: number,
): CaptionCueWord[] {
const oldSpan = oldEndMs - oldStartMs;
const newSpan = newEndMs - newStartMs;
const factor = oldSpan > 0 ? newSpan / oldSpan : 0;
let cursorMs = newStartMs;
const rescaled: CaptionCueWord[] = [];
words.forEach((word, index) => {
const mappedStartMs =
oldSpan > 0
? newStartMs + (word.startMs - oldStartMs) * factor
: newStartMs + (newSpan * index) / Math.max(1, words.length);
const mappedEndMs =
oldSpan > 0
? newStartMs + (word.endMs - oldStartMs) * factor
: newStartMs + (newSpan * (index + 1)) / Math.max(1, words.length);
const startMs = Math.min(newEndMs - 1, Math.max(cursorMs, Math.round(mappedStartMs)));
const endMs = Math.min(newEndMs, Math.max(startMs + 1, Math.round(mappedEndMs)));
cursorMs = endMs;
rescaled.push({
text: word.text,
startMs,
endMs,
...(word.leadingSpace ? { leadingSpace: true } : {}),
});
});
return normalizeCaptionWordSpacing(rescaled);
}
export function retimeCue(cues: CaptionCue[], id: string, span: CaptionRetimeSpan): CaptionCue[] {
const sorted = sortCaptionCues(cues);
const index = sorted.findIndex((cue) => cue.id === id);
if (index < 0) {
return cues;
}
const cue = sorted[index];
const newStartMs = Math.max(0, Math.round(span.startMs));
const newEndMs = Math.max(newStartMs + 1, Math.round(span.endMs));
if (newStartMs === cue.startMs && newEndMs === cue.endMs) {
return cues;
}
const hasWords = Array.isArray(cue.words) && cue.words.length > 0;
const nextWords = hasWords
? rescaleWordsIntoSpan(
normalizeCaptionWords(cue),
cue.startMs,
cue.endMs,
newStartMs,
newEndMs,
)
: null;
const nextCueValue: CaptionCue = {
id: cue.id,
startMs: newStartMs,
endMs: newEndMs,
text: nextWords ? captionWordsToText(nextWords) : cue.text,
...(nextWords ? { words: nextWords } : {}),
};
const nextCues = sorted.map((value, valueIndex) =>
valueIndex === index ? nextCueValue : value,
);
return sortCaptionCues(nextCues);
}
export function splitCue(cues: CaptionCue[], id: string, atMs: number): CaptionCue[] {
const sorted = sortCaptionCues(cues);
const index = sorted.findIndex((cue) => cue.id === id);
if (index < 0) {
return cues;
}
const cue = sorted[index];
const words = normalizeCaptionWords(cue);
if (words.length < 2) {
return cues;
}
let splitIndex = 1;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let candidate = 1; candidate < words.length; candidate += 1) {
const boundaryMs = (words[candidate - 1].endMs + words[candidate].startMs) / 2;
const distance = Math.abs(boundaryMs - atMs);
if (distance < nearestDistance) {
nearestDistance = distance;
splitIndex = candidate;
}
}
const leftWords = normalizeCaptionWordSpacing(words.slice(0, splitIndex));
const rightWords = normalizeCaptionWordSpacing(words.slice(splitIndex));
const leftCue: CaptionCue = {
id: cue.id,
startMs: cue.startMs,
endMs: leftWords[leftWords.length - 1].endMs,
text: captionWordsToText(leftWords),
words: leftWords,
};
const rightCue: CaptionCue = {
id: createCaptionCueId(),
startMs: rightWords[0].startMs,
endMs: cue.endMs,
text: captionWordsToText(rightWords),
words: rightWords,
};
const nextCues = sorted.flatMap((value, valueIndex) =>
valueIndex === index ? [leftCue, rightCue] : [value],
);
return sortCaptionCues(nextCues);
}
export function mergeCues(cues: CaptionCue[], idA: string, idB: string): CaptionCue[] {
const sorted = sortCaptionCues(cues);
const indexA = sorted.findIndex((cue) => cue.id === idA);
const indexB = sorted.findIndex((cue) => cue.id === idB);
if (indexA < 0 || indexB < 0 || Math.abs(indexA - indexB) !== 1) {
return cues;
}
const leftIndex = Math.min(indexA, indexB);
const left = sorted[leftIndex];
const right = sorted[leftIndex + 1];
const mergedWords = normalizeCaptionWordSpacing([
...normalizeCaptionWords(left),
...normalizeCaptionWords(right),
]);
const mergedCue: CaptionCue = {
id: left.id,
startMs: left.startMs,
endMs: right.endMs,
text: captionWordsToText(mergedWords),
words: mergedWords,
};
const nextCues = sorted.flatMap((value, valueIndex) => {
if (valueIndex === leftIndex) {
return [mergedCue];
}
if (valueIndex === leftIndex + 1) {
return [];
}
return [value];
});
return sortCaptionCues(nextCues);
}
export function deleteCue(cues: CaptionCue[], id: string): CaptionCue[] {
const next = cues.filter((cue) => cue.id !== id);
return next.length === cues.length ? cues : sortCaptionCues(next);
}
+7 -26
View File
@@ -47,30 +47,11 @@ export function getCaptionTextMaxWidth(
);
}
export function getCaptionWordVisualState(hasWordTimings: boolean, state: CaptionWordState) {
if (!hasWordTimings) {
return {
isInactive: false,
opacity: 1,
};
}
switch (state) {
case "upcoming":
return {
isInactive: true,
opacity: 0.82,
};
case "spoken":
return {
isInactive: false,
opacity: 0.72,
};
case "active":
default:
return {
isInactive: false,
opacity: 1,
};
}
export function getCaptionWordVisualState(_hasWordTimings: boolean, _state: CaptionWordState) {
// Per-word "spoken" highlighting is disabled: word-level timings from the
// transcriber are unreliable, so captions render as a single uniform block.
return {
isInactive: false,
opacity: 1,
};
}
@@ -35,7 +35,7 @@ interface ItemProps {
waveformGain?: number;
waveformNormalize?: boolean;
muted?: boolean;
variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "caption";
isLoading?: boolean;
loadingLabel?: string;
}
@@ -125,6 +125,7 @@ export default function Item({
const isClip = variant === "clip";
const isSpeed = variant === "speed";
const isAudio = variant === "audio";
const isCaption = variant === "caption";
const showAudioWaveform = isAudio && Boolean(waveformPeaks);
const clipSpeedLabel = isClip ? formatClipSpeedLabel(speedValue ?? 1) : null;
@@ -138,7 +139,9 @@ export default function Item({
? glassStyles.glassAmber
: isAudio
? glassStyles.glassDarkGreen
: glassStyles.glassYellow;
: isCaption
? glassStyles.glassCaption
: glassStyles.glassYellow;
const MIN_ITEM_PX = 6;
const handleSelect = () => {
@@ -152,6 +152,28 @@
z-index: 10;
}
.glassCaption {
position: relative;
border-radius: 8px;
background: #5b2746;
border: 1px solid #7a3157;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassCaption:hover {
background: #6d2f54;
border-color: #933a68;
}
.glassCaption.selected {
background: #7a3157;
border-color: #f472b6;
box-shadow: inset 0 0 0 1.5px #f472b6;
z-index: 10;
}
/* Pill resize handle — visible on hover */
.zoomEndCap {
position: absolute;
@@ -272,6 +294,20 @@
box-shadow: inset 0 0 0 1.5px #22c55e;
}
:global(:root:not(.dark)) .glassCaption {
background: linear-gradient(180deg, #fce7f3 0%, #f9a8d4 100%);
border-color: #f472b6;
}
:global(:root:not(.dark)) .glassCaption:hover {
background: linear-gradient(180deg, #fbcfe8 0%, #f472b6 100%);
border-color: #ec4899;
}
:global(:root:not(.dark)) .glassCaption.selected {
background: linear-gradient(180deg, #f9a8d4 0%, #ec4899 100%);
border-color: #ec4899;
box-shadow: inset 0 0 0 1.5px #ec4899;
}
:global(:root:not(.dark)) .zoomEndCap {
background: rgba(0, 0, 0, 0.5);
}
@@ -289,7 +325,8 @@
.glassAmber:hover .zoomEndCap,
.glassPurple:hover .zoomEndCap,
.glassCyan:hover .zoomEndCap,
.glassDarkGreen:hover .zoomEndCap {
.glassDarkGreen:hover .zoomEndCap,
.glassCaption:hover .zoomEndCap {
opacity: 1;
}
@@ -11,6 +11,7 @@ import { fromFileUrl } from "../projectPersistence";
import type {
AnnotationRegion,
AudioRegion,
CaptionCue,
ClipRegion,
CursorTelemetryPoint,
SpeedRegion,
@@ -68,6 +69,10 @@ export interface TimelineEditorProps {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
captionRegions?: CaptionCue[];
onCaptionSpanChange?: (id: string, span: Span) => void;
selectedCaptionId?: string | null;
onSelectCaption?: (id: string | null) => void;
videoPath?: string | null;
videoSourcePath?: string | null;
cursorTelemetrySourcePath?: string | null;
@@ -142,6 +147,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onAudioDelete,
selectedAudioId,
onSelectAudio,
captionRegions = [],
onCaptionSpanChange,
selectedCaptionId,
onSelectCaption,
videoPath,
videoSourcePath,
cursorTelemetrySourcePath,
@@ -361,6 +370,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onAudioDelete,
selectedAudioId,
onSelectAudio,
captionCues: captionRegions,
onCaptionSpanChange,
isMac,
keyShortcuts,
isTimelineFocusedRef,
@@ -453,10 +464,12 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onSelectClip={handleSelectClip}
onSelectAnnotation={handleSelectAnnotation}
onSelectAudio={handleSelectAudio}
onSelectCaption={onSelectCaption}
selectedZoomId={selectedZoomId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedAudioId={selectedAudioId}
selectedCaptionId={selectedCaptionId}
selectAllBlocksActive={selectAllBlocksActive}
onClearBlockSelection={clearSelectedBlocks}
keyframes={keyframes}
@@ -15,7 +15,12 @@ import type {
SourceAudioTrackWithPeaks,
} from "@/components/video-editor/audio/audioTypes";
import { cn } from "@/lib/utils";
import { CLIP_ROW_ID, SOURCE_AUDIO_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
import {
CAPTION_ROW_ID,
CLIP_ROW_ID,
SOURCE_AUDIO_ROW_ID,
ZOOM_ROW_ID,
} from "../../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
@@ -53,11 +58,13 @@ interface TimelineCanvasProps {
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
onSelectCaption?: (id: string | null) => void;
onAddZoomAtMs?: (startMs: number) => void;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
selectedCaptionId?: string | null;
selectAllBlocksActive?: boolean;
onClearBlockSelection?: () => void;
keyframes?: { id: string; time: number }[];
@@ -231,10 +238,12 @@ interface TimelineCanvasRowsProps {
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
selectedCaptionId?: string | null;
onSelectZoom?: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
onSelectCaption?: (id: string | null) => void;
sourceAudioTracks?: SourceAudioTrackWithPeaks[];
getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings;
showSourceAudioTrack?: boolean;
@@ -298,10 +307,12 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
selectedClipId,
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
onSelectCaption,
sourceAudioTracks = [],
getSourceAudioTrackSettingsForClip,
showSourceAudioTrack = false,
@@ -319,9 +330,10 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
onZoomRowClick,
}: TimelineCanvasRowsProps) {
const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]);
const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => {
const { clipItems, zoomItems, captionItems, annotationRows, audioRows } = useMemo(() => {
const nextClipItems: TimelineRenderItem[] = [];
const nextZoomItems: TimelineRenderItem[] = [];
const nextCaptionItems: TimelineRenderItem[] = [];
const annotationBuckets = new Map<number, TimelineRenderItem[]>();
const audioBuckets = new Map<number, TimelineRenderItem[]>();
@@ -334,6 +346,10 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
nextZoomItems.push(item);
continue;
}
if (item.rowId === CAPTION_ROW_ID) {
nextCaptionItems.push(item);
continue;
}
if (isAnnotationTrackRowId(item.rowId)) {
const trackIndex = getAnnotationTrackIndex(item.rowId);
const bucket = annotationBuckets.get(trackIndex);
@@ -365,6 +381,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
return {
clipItems: nextClipItems,
zoomItems: nextZoomItems,
captionItems: nextCaptionItems,
annotationRows: annotationRowsSorted,
audioRows: audioRowsSorted,
};
@@ -480,6 +497,24 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
))}
</Row>
{captionItems.length > 0 && (
<Row id={CAPTION_ROW_ID} isEmpty={false}>
{captionItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedCaptionId}
onSelectId={onSelectCaption}
variant="caption"
>
{item.label}
</Item>
))}
</Row>
)}
{annotationRows.map(({ rowId, items: rowItems }, index) => (
<Row
key={rowId}
@@ -537,10 +572,12 @@ export default function TimelineCanvas({
onSelectClip,
onSelectAnnotation,
onSelectAudio,
onSelectCaption,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
selectAllBlocksActive = false,
onClearBlockSelection,
keyframes = [],
@@ -578,6 +615,7 @@ export default function TimelineCanvas({
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
onSelectCaption?.(null);
}
const rect = e.currentTarget.getBoundingClientRect();
@@ -597,6 +635,7 @@ export default function TimelineCanvas({
onSelectClip,
onSelectAnnotation,
onSelectAudio,
onSelectCaption,
onClearBlockSelection,
videoDurationMs,
sidebarWidth,
@@ -633,6 +672,7 @@ export default function TimelineCanvas({
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
onSelectCaption?.(null);
}
const rect = localTimelineRef.current.getBoundingClientRect();
@@ -646,6 +686,7 @@ export default function TimelineCanvas({
onSeek,
onSelectAnnotation,
onSelectAudio,
onSelectCaption,
onSelectClip,
onSelectZoom,
videoDurationMs,
@@ -699,12 +740,16 @@ export default function TimelineCanvas({
const timelineRowCount = useMemo(() => {
const annotationRowIds = new Set<string>();
const audioRowIds = new Set<string>();
let hasCaptionRow = false;
for (const item of items) {
if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId);
if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId);
if (item.rowId === CAPTION_ROW_ID) hasCaptionRow = true;
}
const sourceAudioRows = showSourceAudioTrack ? sourceAudioTracks.length : 0;
return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size;
return (
2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size + (hasCaptionRow ? 1 : 0)
);
}, [items, showSourceAudioTrack, sourceAudioTracks.length]);
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
@@ -786,10 +831,12 @@ export default function TimelineCanvas({
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedAudioId={selectedAudioId}
selectedCaptionId={selectedCaptionId}
onSelectZoom={onSelectZoom}
onSelectClip={onSelectClip}
onSelectAnnotation={onSelectAnnotation}
onSelectAudio={onSelectAudio}
onSelectCaption={onSelectCaption}
sourceAudioTracks={sourceAudioTracks}
getSourceAudioTrackSettingsForClip={getSourceAudioTrackSettingsForClip}
showSourceAudioTrack={showSourceAudioTrack}
@@ -3,6 +3,7 @@ export const CLIP_ROW_ID = "row-clip";
export const ANNOTATION_ROW_ID = "row-annotation";
export const AUDIO_ROW_ID = "row-audio";
export const SOURCE_AUDIO_ROW_ID = "row-source-audio";
export const CAPTION_ROW_ID = "row-captions";
export const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`;
export const AUDIO_ROW_PREFIX = `${AUDIO_ROW_ID}-`;
@@ -1,5 +1,5 @@
import type { ShortcutBinding } from "@/lib/shortcuts";
import type { Span } from "dnd-timeline";
import type { ShortcutBinding } from "@/lib/shortcuts";
import type { ZoomMode } from "../../types";
export interface TimelineRegionSpan {
@@ -41,7 +41,7 @@ export interface TimelineRenderItem {
speedValue?: number;
showSourceAudio?: boolean;
muted?: boolean;
variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "caption";
}
export interface AudioPeaksData {
@@ -3,14 +3,20 @@ import { useCallback, useMemo } from "react";
import type {
AnnotationRegion,
AudioRegion,
CaptionCue,
ClipRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "../../types";
import type { TimelineRenderItem } from "../core/timelineTypes";
import { getAnnotationTrackIndex, getAudioTrackIndex, isAnnotationTrackRowId, isAudioTrackRowId } from "../core/rows";
import {
getAnnotationTrackIndex,
getAudioTrackIndex,
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../core/rows";
import { spansOverlap } from "../core/spans";
import type { TimelineRenderItem } from "../core/timelineTypes";
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel";
interface UseTimelineDndBindingsParams {
@@ -20,15 +26,25 @@ interface UseTimelineDndBindingsParams {
annotationRegions: AnnotationRegion[];
speedRegions: SpeedRegion[];
audioRegions: AudioRegion[];
captionCues: CaptionCue[];
onZoomSpanChange: (id: string, span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onClipSpanChange?: (id: string, span: Span) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onCaptionSpanChange?: (id: string, span: Span) => void;
}
type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null;
type TimelineItemKind =
| "zoom"
| "trim"
| "clip"
| "annotation"
| "speed"
| "audio"
| "caption"
| null;
export function useTimelineDndBindings({
zoomRegions,
@@ -37,12 +53,14 @@ export function useTimelineDndBindings({
annotationRegions,
speedRegions,
audioRegions,
captionCues,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
onCaptionSpanChange,
}: UseTimelineDndBindingsParams) {
const resolveItemKind = useCallback(
(id: string): TimelineItemKind => {
@@ -52,9 +70,18 @@ export function useTimelineDndBindings({
if (annotationRegions.some((r) => r.id === id)) return "annotation";
if (speedRegions.some((r) => r.id === id)) return "speed";
if (audioRegions.some((r) => r.id === id)) return "audio";
if (captionCues.some((c) => c.id === id)) return "caption";
return null;
},
[zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
[
zoomRegions,
trimRegions,
clipRegions,
annotationRegions,
speedRegions,
audioRegions,
captionCues,
],
);
const resolveTrackIndex = useCallback(
@@ -76,7 +103,7 @@ export function useTimelineDndBindings({
if (!excludeId) return false;
const itemKind = resolveItemKind(excludeId);
if (itemKind === "annotation") return false;
if (itemKind === "annotation" || itemKind === "caption") return false;
const checkOverlap = (
regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
@@ -118,8 +145,9 @@ export function useTimelineDndBindings({
clipRegions,
annotationRegions,
audioRegions,
captionCues,
}),
[zoomRegions, clipRegions, annotationRegions, audioRegions],
[zoomRegions, clipRegions, annotationRegions, audioRegions, captionCues],
);
const allRegionSpans = useMemo(
@@ -154,6 +182,8 @@ export function useTimelineDndBindings({
} else if (itemKind === "audio") {
const nextTrackIndex = resolveTrackIndex("audio", id, rowId);
onAudioSpanChange?.(id, span, nextTrackIndex);
} else if (itemKind === "caption") {
onCaptionSpanChange?.(id, span);
}
},
[
@@ -165,6 +195,7 @@ export function useTimelineDndBindings({
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
onCaptionSpanChange,
],
);
@@ -4,6 +4,7 @@ import { useCallback, useImperativeHandle } from "react";
import type {
AnnotationRegion,
AudioRegion,
CaptionCue,
ClipRegion,
CursorTelemetryPoint,
SpeedRegion,
@@ -59,6 +60,8 @@ interface UseTimelineEditorRuntimeParams {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
captionCues: CaptionCue[];
onCaptionSpanChange?: (id: string, span: Span) => void;
isMac: boolean;
keyShortcuts: TimelineShortcutBindings;
isTimelineFocusedRef: RefObject<boolean>;
@@ -103,6 +106,8 @@ export function useTimelineEditorRuntime({
onAudioDelete,
selectedAudioId,
onSelectAudio,
captionCues,
onCaptionSpanChange,
isMac,
keyShortcuts,
isTimelineFocusedRef,
@@ -175,12 +180,14 @@ export function useTimelineEditorRuntime({
annotationRegions,
speedRegions,
audioRegions,
captionCues,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
onCaptionSpanChange,
});
const {
@@ -2,10 +2,11 @@ import { formatClipSpeedLabel } from "../../clipSpeedChange";
import type {
AnnotationRegion,
AudioRegion,
CaptionCue,
ClipRegion,
ZoomRegion,
} from "../../types";
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
@@ -28,7 +29,17 @@ export function getAnnotationLabel(region: AnnotationRegion): string {
}
export function getAudioLabel(region: AudioRegion): string {
return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio";
return (
region.audioPath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || "Audio"
);
}
function getCaptionLabel(cue: CaptionCue): string {
const preview = cue.text.trim() || "Caption";
return preview.length > 24 ? `${preview.substring(0, 24)}...` : preview;
}
export function buildTimelineItems(params: {
@@ -36,8 +47,9 @@ export function buildTimelineItems(params: {
clipRegions: ClipRegion[];
annotationRegions: AnnotationRegion[];
audioRegions: AudioRegion[];
captionCues?: CaptionCue[];
}): TimelineRenderItem[] {
const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params;
const { zoomRegions, clipRegions, annotationRegions, audioRegions, captionCues = [] } = params;
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
id: region.id,
rowId: ZOOM_ROW_ID,
@@ -86,7 +98,15 @@ export function buildTimelineItems(params: {
variant: "audio",
}));
return [...zooms, ...clips, ...annotations, ...audios];
const captions: TimelineRenderItem[] = captionCues.map((cue) => ({
id: cue.id,
rowId: CAPTION_ROW_ID,
span: { start: cue.startMs, end: cue.endMs },
label: getCaptionLabel(cue),
variant: "caption",
}));
return [...zooms, ...clips, ...annotations, ...audios, ...captions];
}
export function buildAllRegionSpans(params: {
+1
View File
@@ -103,6 +103,7 @@ export type EditorEffectSection =
| "scene"
| "cursor"
| "captions"
| "caption"
| "webcam"
| "settings"
| "zoom"
+2
View File
@@ -158,6 +158,7 @@
"sections": {
"scene": "Scene",
"captions": "Captions",
"caption": "Caption",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Webcam",
@@ -165,6 +166,7 @@
"crop": "Crop"
},
"captions": {
"selectOnTimeline": "Select a caption on the timeline to edit it.",
"enabled": "Show",
"language": "Language",
"downloading": "Downloading...",
+2
View File
@@ -110,6 +110,7 @@
"sections": {
"scene": "Escena",
"captions": "Subtítulos",
"caption": "Subtítulo",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Cámara",
@@ -117,6 +118,7 @@
"crop": "Recorte"
},
"captions": {
"selectOnTimeline": "Selecciona un subtítulo en la línea de tiempo para editarlo.",
"enabled": "Mostrar",
"language": "Idioma",
"downloading": "Descargando...",
+2
View File
@@ -110,6 +110,7 @@
"sections": {
"scene": "Scène",
"captions": "Sous-titres",
"caption": "Sous-titre",
"zoom": "Zoom",
"cursor": "Curseur",
"webcam": "Webcam",
@@ -117,6 +118,7 @@
"crop": "Recadrage"
},
"captions": {
"selectOnTimeline": "Sélectionnez un sous-titre sur la timeline pour le modifier.",
"enabled": "Afficher",
"language": "Langue",
"downloading": "Téléchargement...",
+2
View File
@@ -132,6 +132,7 @@
"sections": {
"scene": "Scena",
"captions": "Sottotitoli",
"caption": "Sottotitolo",
"zoom": "Zoom",
"cursor": "Cursore",
"webcam": "Webcam",
@@ -139,6 +140,7 @@
"crop": "Ritaglio"
},
"captions": {
"selectOnTimeline": "Seleziona un sottotitolo sulla timeline per modificarlo.",
"enabled": "Mostra",
"language": "Lingua",
"downloading": "Download in corso...",
+2
View File
@@ -110,6 +110,7 @@
"sections": {
"scene": "장면",
"captions": "자막",
"caption": "자막",
"zoom": "확대",
"cursor": "커서",
"webcam": "웹캠",
@@ -117,6 +118,7 @@
"crop": "자르기"
},
"captions": {
"selectOnTimeline": "타임라인에서 자막을 선택하여 편집하세요.",
"enabled": "표시",
"language": "언어",
"downloading": "다운로드 중...",
+2
View File
@@ -110,6 +110,7 @@
"sections": {
"scene": "Scène",
"captions": "Ondertiteling",
"caption": "Ondertitel",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Webcam",
@@ -117,6 +118,7 @@
"crop": "Bijsnijden"
},
"captions": {
"selectOnTimeline": "Selecteer een ondertitel op de tijdlijn om deze te bewerken.",
"enabled": "Tonen",
"language": "Taal",
"downloading": "Downloaden...",
+2
View File
@@ -110,6 +110,7 @@
"sections": {
"scene": "Cena",
"captions": "Legendas",
"caption": "Legenda",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Webcam",
@@ -117,6 +118,7 @@
"crop": "Corte"
},
"captions": {
"selectOnTimeline": "Selecione uma legenda na linha do tempo para editá-la.",
"enabled": "Mostrar",
"language": "Idioma",
"downloading": "Baixando...",
+2
View File
@@ -132,6 +132,7 @@
"sections": {
"scene": "Сцена",
"captions": "Субтитры",
"caption": "Субтитр",
"zoom": "Зум",
"cursor": "Курсор",
"webcam": "Веб-камера",
@@ -139,6 +140,7 @@
"crop": "Обрезать видео"
},
"captions": {
"selectOnTimeline": "Выберите субтитры на таймлайне, чтобы отредактировать их.",
"enabled": "Показать",
"language": "Язык",
"downloading": "Загрузка...",
+2
View File
@@ -127,6 +127,7 @@
"sections": {
"scene": "场景",
"captions": "字幕",
"caption": "字幕",
"zoom": "缩放",
"cursor": "光标",
"webcam": "摄像头",
@@ -134,6 +135,7 @@
"crop": "裁剪"
},
"captions": {
"selectOnTimeline": "在时间轴上选择字幕进行编辑。",
"enabled": "显示",
"language": "语言",
"downloading": "下载中...",
+2
View File
@@ -110,6 +110,7 @@
"sections": {
"scene": "場景",
"captions": "字幕",
"caption": "字幕",
"zoom": "縮放",
"cursor": "游標",
"webcam": "網路攝影機",
@@ -117,6 +118,7 @@
"crop": "裁切"
},
"captions": {
"selectOnTimeline": "在時間軸上選擇字幕進行編輯。",
"enabled": "顯示",
"language": "語言",
"downloading": "下載中...",