mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 08:15:42 +00:00
feat(captions): add timeline track and side-panel editor for captions
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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%)`,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -103,6 +103,7 @@ export type EditorEffectSection =
|
||||
| "scene"
|
||||
| "cursor"
|
||||
| "captions"
|
||||
| "caption"
|
||||
| "webcam"
|
||||
| "settings"
|
||||
| "zoom"
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"sections": {
|
||||
"scene": "장면",
|
||||
"captions": "자막",
|
||||
"caption": "자막",
|
||||
"zoom": "확대",
|
||||
"cursor": "커서",
|
||||
"webcam": "웹캠",
|
||||
@@ -117,6 +118,7 @@
|
||||
"crop": "자르기"
|
||||
},
|
||||
"captions": {
|
||||
"selectOnTimeline": "타임라인에서 자막을 선택하여 편집하세요.",
|
||||
"enabled": "표시",
|
||||
"language": "언어",
|
||||
"downloading": "다운로드 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"sections": {
|
||||
"scene": "Сцена",
|
||||
"captions": "Субтитры",
|
||||
"caption": "Субтитр",
|
||||
"zoom": "Зум",
|
||||
"cursor": "Курсор",
|
||||
"webcam": "Веб-камера",
|
||||
@@ -139,6 +140,7 @@
|
||||
"crop": "Обрезать видео"
|
||||
},
|
||||
"captions": {
|
||||
"selectOnTimeline": "Выберите субтитры на таймлайне, чтобы отредактировать их.",
|
||||
"enabled": "Показать",
|
||||
"language": "Язык",
|
||||
"downloading": "Загрузка...",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"sections": {
|
||||
"scene": "场景",
|
||||
"captions": "字幕",
|
||||
"caption": "字幕",
|
||||
"zoom": "缩放",
|
||||
"cursor": "光标",
|
||||
"webcam": "摄像头",
|
||||
@@ -134,6 +135,7 @@
|
||||
"crop": "裁剪"
|
||||
},
|
||||
"captions": {
|
||||
"selectOnTimeline": "在时间轴上选择字幕进行编辑。",
|
||||
"enabled": "显示",
|
||||
"language": "语言",
|
||||
"downloading": "下载中...",
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"sections": {
|
||||
"scene": "場景",
|
||||
"captions": "字幕",
|
||||
"caption": "字幕",
|
||||
"zoom": "縮放",
|
||||
"cursor": "游標",
|
||||
"webcam": "網路攝影機",
|
||||
@@ -117,6 +118,7 @@
|
||||
"crop": "裁切"
|
||||
},
|
||||
"captions": {
|
||||
"selectOnTimeline": "在時間軸上選擇字幕進行編輯。",
|
||||
"enabled": "顯示",
|
||||
"language": "語言",
|
||||
"downloading": "下載中...",
|
||||
|
||||
Reference in New Issue
Block a user