From a2ac6c226e2f17b854aba9d062c3048223cf71b8 Mon Sep 17 00:00:00 2001
From: Benjamin Freeman
Date: Mon, 24 Aug 2026 22:56:04 +0200
Subject: [PATCH 001/113] feat(editor): add audio-track data model for external
audio import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 of issue #350 (import voiceover / BGM / SFX). Adds the document
model for timeline audio tracks without any UI, IPC, or export wiring yet.
- Widen assetSchema.kind to enum(["video","audio"]) so an imported audio
file (no video stream) has its own kind. Additive — every existing doc
holds "video", which still validates, so no schemaVersion bump.
- Add audioTrackSchema: a timeline-global track addressed in OUTPUT
(post-trim/post-speed) timeline seconds, the same domain the compositor's
concatenated programme PCM lives in. That invariant is what will keep the
live preview and the export in sync in later phases.
- Add document.audioTracks[] (defaulted, so pre-#350 docs load unchanged),
the AxcutAudioTrack type, and a createAudioTrack factory.
- Tests cover defaults, trim/position validation, factory round-trip, the
kind widening, and that a document omitting audioTracks defaults to [].
- Fixture fallout: 15 test files + browserShim build full AxcutDocument
literals and now carry audioTracks: [] alongside their zoomRanges: [].
Co-Authored-By: Claude Opus 4.8
---
.../ai-edition/EditorEmptyState.test.tsx | 1 +
.../ExportDialog.showInFolder.test.tsx | 1 +
.../ai-edition/ExportDialog.test.ts | 1 +
.../ai-edition/WebcamOverlay.test.tsx | 1 +
.../ai-edition/document/outputFormat.test.ts | 1 +
src/lib/ai-edition/document/timeline.test.ts | 1 +
.../ai-edition/document/transcribe.test.ts | 1 +
src/lib/ai-edition/schema/index.test.ts | 102 ++++++++++++++++--
src/lib/ai-edition/schema/index.ts | 65 ++++++++++-
.../ai-edition/store/editorSettings.test.ts | 1 +
src/lib/ai-edition/store/projectStore.test.ts | 1 +
.../ai-edition/store/undo.modalGuard.test.tsx | 1 +
src/lib/ai-edition/store/useCaptions.test.ts | 1 +
.../store/useEditorSettings.test.ts | 1 +
src/lib/ai-edition/store/useTimeline.test.ts | 1 +
.../ai-edition/transcription/status.test.ts | 1 +
src/native/browserShim.ts | 1 +
src/native/sceneDescription.test.ts | 1 +
18 files changed, 175 insertions(+), 8 deletions(-)
diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx
index acb513e4b..be7f130b7 100644
--- a/src/components/ai-edition/EditorEmptyState.test.tsx
+++ b/src/components/ai-edition/EditorEmptyState.test.tsx
@@ -53,6 +53,7 @@ const sampleDoc = vi.hoisted(
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
}),
);
diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
index c5b45637c..52eb7b2ad 100644
--- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
+++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
@@ -77,6 +77,7 @@ const DOC: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts
index 3aa8d85b5..ed2f1ab1a 100644
--- a/src/components/ai-edition/ExportDialog.test.ts
+++ b/src/components/ai-edition/ExportDialog.test.ts
@@ -57,6 +57,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx
index 16da96666..0e5575b6a 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -74,6 +74,7 @@ function makeDocument(): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts
index 4ceac442f..c7e75ab40 100644
--- a/src/lib/ai-edition/document/outputFormat.test.ts
+++ b/src/lib/ai-edition/document/outputFormat.test.ts
@@ -67,6 +67,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index a561dd2f9..bb5172178 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -57,6 +57,7 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
...overrides,
};
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 648fed50b..e231d287a 100644
--- a/src/lib/ai-edition/document/transcribe.test.ts
+++ b/src/lib/ai-edition/document/transcribe.test.ts
@@ -49,6 +49,7 @@ function makeDoc(): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts
index bbba94fd8..2c6b25be4 100644
--- a/src/lib/ai-edition/schema/index.test.ts
+++ b/src/lib/ai-edition/schema/index.test.ts
@@ -3,8 +3,10 @@ import { migrateRawDocumentToCurrent } from "../document/migrate";
import {
annotationRegionSchema,
assetSchema,
+ audioTrackSchema,
axcutSchemaVersion,
clipSchema,
+ createAudioTrack,
createEmptyDocument,
documentSchema,
ensureDocument,
@@ -40,6 +42,7 @@ describe("axcut-schema v7", () => {
expect(doc.timeline.captionRanges).toEqual([]);
expect(doc.annotations).toEqual([]);
expect(doc.zoomRanges).toEqual([]);
+ expect(doc.audioTracks).toEqual([]);
expect(doc.transcripts).toEqual([]);
expect(doc.legacyEditor).toBeNull();
});
@@ -73,14 +76,22 @@ describe("axcut-schema v7", () => {
).toThrow();
});
- it("assetSchema requires kind = 'video'", () => {
+ it("assetSchema accepts kind 'video' and 'audio', defaulting to 'video'", () => {
+ // Widened from a literal when external-audio import landed (issue #350).
+ const video = assetSchema.parse({ id: "a1", label: "x", originalPath: "/x.mp4" });
+ expect(video.kind).toBe("video");
+ const audio = assetSchema.parse({
+ id: "a2",
+ kind: "audio",
+ label: "bgm",
+ originalPath: "/bgm.mp3",
+ });
+ expect(audio.kind).toBe("audio");
+ });
+
+ it("assetSchema rejects an unknown kind", () => {
expect(() =>
- assetSchema.parse({
- id: "asset_1",
- kind: "audio",
- label: "x",
- originalPath: "/x.mp4",
- }),
+ assetSchema.parse({ id: "a1", kind: "image", label: "x", originalPath: "/x.png" }),
).toThrow();
});
@@ -962,3 +973,80 @@ describe("v6 -> v7 trim clip-anchor migration", () => {
]);
});
});
+
+describe("audio tracks (issue #350)", () => {
+ it("applies defaults for gain, mute, trim, position, and label", () => {
+ const track = audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 42,
+ });
+ expect(track.timelineStartSec).toBe(0);
+ expect(track.trimStartSec).toBe(0);
+ expect(track.trimEndSec).toBeUndefined();
+ expect(track.gainDb).toBe(0);
+ expect(track.mute).toBe(false);
+ expect(track.label).toBe("");
+ });
+
+ it("rejects a trim window whose end precedes its start", () => {
+ expect(() =>
+ audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 10,
+ trimStartSec: 5,
+ trimEndSec: 2,
+ }),
+ ).toThrow();
+ });
+
+ it("rejects a negative timelineStartSec", () => {
+ expect(() =>
+ audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 10,
+ timelineStartSec: -1,
+ }),
+ ).toThrow();
+ });
+
+ it("createAudioTrack builds a schema-valid track with a prefixed id", () => {
+ const track = createAudioTrack({
+ assetId: "asset_1",
+ durationSec: 12.5,
+ timelineStartSec: 3,
+ label: "voiceover.mp3",
+ });
+ expect(track.id).toMatch(/^audio_/);
+ expect(track.assetId).toBe("asset_1");
+ expect(track.durationSec).toBe(12.5);
+ expect(track.timelineStartSec).toBe(3);
+ expect(track.label).toBe("voiceover.mp3");
+ // The factory output must itself round-trip through the schema.
+ expect(() => audioTrackSchema.parse(track)).not.toThrow();
+ });
+
+ it("defaults audioTracks to [] when a stored document omits the key", () => {
+ // A document written before issue #350 has no `audioTracks`; the defaulted
+ // array must fill in so older files load unchanged (no schemaVersion bump).
+ const { audioTracks: _drop, ...withoutAudio } = createEmptyDocument({
+ projectId: "p",
+ title: "t",
+ });
+ expect("audioTracks" in withoutAudio).toBe(false);
+ const parsed = documentSchema.parse(withoutAudio);
+ expect(parsed.audioTracks).toEqual([]);
+ });
+
+ it("round-trips a document carrying an audio track", () => {
+ const track = createAudioTrack({ assetId: "asset_1", durationSec: 8 });
+ const doc = {
+ ...createEmptyDocument({ projectId: "p", title: "t" }),
+ audioTracks: [track],
+ };
+ const parsed = documentSchema.parse(doc);
+ expect(parsed.audioTracks).toEqual([track]);
+ });
+});
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index 00c429de5..1995b5823 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -150,7 +150,12 @@ export const assetTranscriptionFailureSchema = z.object({
export const assetSchema = z.object({
id: z.string().min(1),
- kind: z.literal("video"),
+ // Widened from a `"video"` literal when external-audio import landed (issue
+ // #350). An imported voiceover / BGM / SFX file carries no video stream, so it
+ // needs its own kind; every document written before this only ever held
+ // `"video"`, which still validates, so the widening is additive (no
+ // schemaVersion bump — same rule as `transcriptionFailure` below).
+ kind: z.enum(["video", "audio"]).default("video"),
label: z.string().min(1),
originalPath: z.string().min(1),
proxyPath: z.string().optional(),
@@ -471,6 +476,38 @@ export const zoomRegionSchema = endGteStart(
"startMs",
);
+// External audio import (issue #350) — voiceover / BGM / SFX layered over the
+// assembled programme. Unlike zoom/speed/annotation/trim, an audio track is NOT
+// clip-anchored: it floats over the whole timeline, so it is addressed in OUTPUT
+// (post-trim, post-speed) timeline seconds — the same domain the compositor's
+// concatenated programme PCM lives in (see `SceneAudio` in
+// src/native/sceneDescription.ts and `audio.rs`). That single invariant is what
+// keeps the live preview and the export in sync without a per-track sync offset.
+//
+// `assetId` points at an asset with `kind: "audio"`. `timelineStartSec` places
+// the track's head on the programme; `trimStartSec`/`trimEndSec` window the
+// source file (both in source seconds); `gainDb` + `mute` set its level.
+export const audioTrackSchema = z
+ .object({
+ id: z.string().min(1),
+ assetId: z.string().min(1),
+ timelineStartSec: z.number().nonnegative().default(0),
+ // Full source duration of the underlying file, cached here so the timeline
+ // can lay out the pill before the asset is re-probed on load.
+ durationSec: z.number().nonnegative().default(0),
+ trimStartSec: z.number().nonnegative().default(0),
+ // Absent means "play to the end of the file". Explicit when the user trims
+ // the tail so the pill and the export agree on where the track stops.
+ trimEndSec: z.number().nonnegative().optional(),
+ gainDb: z.number().default(0),
+ mute: z.boolean().default(false),
+ label: z.string().default(""),
+ })
+ .refine((data) => data.trimEndSec === undefined || data.trimEndSec >= data.trimStartSec, {
+ message: "trimEndSec must be greater than or equal to trimStartSec",
+ path: ["trimEndSec"],
+ });
+
// Legacy OpenScreen appearance / export settings that the v3 schema doesn't
// normalize into the timeline / assets model. They are applied at export time
// by the existing pipeline (see technical-documentation/architecture/document-model.md).
@@ -503,6 +540,9 @@ const documentSchemaShape = z.object({
}),
annotations: z.array(annotationRegionSchema).default([]),
zoomRanges: z.array(zoomRegionSchema).default([]),
+ // Imported audio tracks (issue #350). Defaulted so every document written
+ // before this loads unchanged; an older build simply strips the key on save.
+ audioTracks: z.array(audioTrackSchema).default([]),
legacyEditor: legacyEditorSchema.nullable().default(null),
});
@@ -942,6 +982,7 @@ export type AxcutTimelineOperation = z.infer;
export type AxcutAnnotationRegion = z.infer;
export type AxcutZoomRegion = z.infer;
export type AxcutCameraTrack = z.infer;
+export type AxcutAudioTrack = z.infer;
export type AxcutLegacyEditor = z.infer;
export type AxcutDocument = z.infer;
export type AxcutDocumentInput = z.input;
@@ -977,6 +1018,7 @@ export function createEmptyDocument(
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
});
}
@@ -984,3 +1026,24 @@ export function createEmptyDocument(
export function ensureDocument(value: unknown): AxcutDocument {
return documentSchema.parse(value);
}
+
+/**
+ * Build a timeline audio track for an imported audio asset (issue #350). The
+ * head is placed at `timelineStartSec` (output-timeline seconds) and the track
+ * spans the whole source file until the user trims it. Parsed through the schema
+ * so every default (gain, mute, trim) is applied in one place.
+ */
+export function createAudioTrack(input: {
+ assetId: string;
+ durationSec: number;
+ timelineStartSec?: number;
+ label?: string;
+}): AxcutAudioTrack {
+ return audioTrackSchema.parse({
+ id: createId("audio"),
+ assetId: input.assetId,
+ durationSec: input.durationSec,
+ timelineStartSec: input.timelineStartSec ?? 0,
+ label: input.label ?? "",
+ });
+}
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts
index d7cfdfcf4..ec37d7176 100644
--- a/src/lib/ai-edition/store/editorSettings.test.ts
+++ b/src/lib/ai-edition/store/editorSettings.test.ts
@@ -29,6 +29,7 @@ const baseDoc: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
transcripts: [],
transcript: null,
legacyEditor: null,
diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts
index a46a6b535..84074aad2 100644
--- a/src/lib/ai-edition/store/projectStore.test.ts
+++ b/src/lib/ai-edition/store/projectStore.test.ts
@@ -62,6 +62,7 @@ const sampleDoc = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/store/undo.modalGuard.test.tsx b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
index f54b12389..46e083b0b 100644
--- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx
+++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
@@ -35,6 +35,7 @@ function doc(title: string): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts
index b4063d68c..0cd641328 100644
--- a/src/lib/ai-edition/store/useCaptions.test.ts
+++ b/src/lib/ai-edition/store/useCaptions.test.ts
@@ -71,6 +71,7 @@ const docA: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/store/useEditorSettings.test.ts b/src/lib/ai-edition/store/useEditorSettings.test.ts
index bd47cb452..4122c05bf 100644
--- a/src/lib/ai-edition/store/useEditorSettings.test.ts
+++ b/src/lib/ai-edition/store/useEditorSettings.test.ts
@@ -74,6 +74,7 @@ const docA: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index 78854e817..ef28cd571 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -110,6 +110,7 @@ const sampleDoc: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index e40636dee..9b4ff7c31 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -245,6 +245,7 @@ const base = {
transcripts: [],
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts
index 8ef5989a8..c00f53d51 100644
--- a/src/native/browserShim.ts
+++ b/src/native/browserShim.ts
@@ -386,6 +386,7 @@ function createShimBridgeClient() {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
documentsByProject[doc.project.id] = doc;
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index 4fc088e86..171d2ac4d 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -91,6 +91,7 @@ function makeDoc(
},
annotations: overrides.annotations ?? [],
zoomRanges: overrides.zoomRanges ?? [],
+ audioTracks: overrides.audioTracks ?? [],
legacyEditor: overrides.legacyEditor ?? null,
};
}
From c542fe0261773db43a12bc7d54c82bc263cbe7d6 Mon Sep 17 00:00:00 2001
From: Benjamin Freeman
Date: Mon, 24 Aug 2026 23:27:03 +0200
Subject: [PATCH 002/113] feat(editor): import external audio files as
audio-kind assets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 2 of issue #350. Wires up picking an external audio file (voiceover
/ BGM / SFX) and adding it to a project as a kind:"audio" asset. No
timeline placement, preview, or export yet.
- IPC: open-audio-file-picker mirrors the video picker but approves against
a dedicated audio extension set (mp3/wav/m4a/aac/flac/ogg/opus). Factor the
path approver into a shared approveReadableMediaPath so the audio and video
approvers differ only by their extension gate — an audio picker must not
approve a video path or vice versa.
- document-service.addAsset takes a kind; an audio import validates against
audio extensions and never claims the empty primaryAssetId slot, so a BGM
file dropped into a fresh project can't become its primary (video) asset.
Threaded kind through the bridge chain (contracts, client, nativeBridge,
aiEditionService) and the browser shim.
- projectStore.addAudioAsset imports the file, skips the camera-sidecar
lookup addAsset does, and probes the real duration up front (new
probeAudioDuration, the
) : null}
-
+ {/* No transcribe button here. This pane is reached from the transcript
+ tab, whose empty state carries the one gate — and two buttons for
+ one background pass is what made people believe captions were
+ transcribed separately from the transcript (issue #560). What is
+ worth saying here is whether a run is already going. */}
+ {isTranscribing ? (
+
+
+ {busyLabel ?? t("captions.transcribing")}
+
+ ) : null}
) : (
{title}
-
+
+ {actions}
@@ -1404,6 +1504,7 @@ const TranscriptWord = memo(function TranscriptWord({
onRestore,
onAddTrimRange,
onSetWordText,
+ onRemoveWords,
}: {
cw: ClipWord;
isCue: boolean;
@@ -1414,6 +1515,7 @@ const TranscriptWord = memo(function TranscriptWord({
onRestore: (run: TrimRun) => void;
onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
onSetWordText: (assetId: string, wordId: string, text: string) => void;
+ onRemoveWords: (assetId: string, wordIds: string[]) => void;
}) {
const ts = useScopedT("settings");
const [hover, setHover] = useState(false);
@@ -1441,6 +1543,12 @@ const TranscriptWord = memo(function TranscriptWord({
onSetWordText(target.assetId, cw.word.id, next);
}, [draft, cw.word.text, cw.word.id, onSetWordText, target.assetId]);
+ const inserted = isInsertedWord(cw.word);
+
+ const removeInserted = useCallback(() => {
+ onRemoveWords(target.assetId, [cw.word.id]);
+ }, [onRemoveWords, target.assetId, cw.word.id]);
+
const revert = useCallback(() => {
if (original === undefined) return;
// Writing the original back through the same path is what clears the provenance
@@ -1559,7 +1667,6 @@ const TranscriptWord = memo(function TranscriptWord({
setDraft(null);
}
}}
- onBeforeInput={(event) => event.stopPropagation()}
onPaste={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
style={{
@@ -1581,6 +1688,58 @@ const TranscriptWord = memo(function TranscriptWord({
);
}
+ // A word nobody said. Amber rather than the accent: this one is not a fix to what was
+ // heard, it is text with no sound underneath — the caveat is the point. Double-click
+ // rewrites it like any other word; the cross deletes it, because there is no audio for a
+ // trim to remove.
+ if (inserted) {
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ onDoubleClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ startEditing();
+ }}
+ >
+
+ {cw.word.text}
+
+ {hover ? (
+
+
+
+ ) : null}{" "}
+
+ );
+ }
+
// A word the user emptied. It still owns a span of the media, so it keeps a place in
// the stream: rendered as its own (empty) text it would be a bare space — invisible,
// impossible to click, and therefore impossible to undo.
@@ -1713,6 +1872,27 @@ const TranscriptWord = memo(function TranscriptWord({
* bin on a cut word — same size, same place, the accent rather than the danger colour,
* since reverting a correction restores something instead of removing it. */
function RevertWordButton({ label, onRevert }: { label: string; onRevert: () => void }) {
+ return (
+
+
+
+ );
+}
+
+/** The one hover control shape the word stream uses, in whichever colour says what it does.
+ * `contentEditable={false}` keeps it out of the enclosing editable block, and the click is
+ * stopped so it never reaches the seek handler underneath. */
+function WordChipButton({
+ label,
+ tone,
+ onPress,
+ children,
+}: {
+ label: string;
+ tone: string;
+ onPress: () => void;
+ children: ReactNode;
+}) {
return (
aria-label={label}
onClick={(e) => {
e.stopPropagation();
- onRevert();
+ onPress();
}}
style={{
display: "inline-flex",
@@ -1733,17 +1913,84 @@ function RevertWordButton({ label, onRevert }: { label: string; onRevert: () =>
padding: 0,
border: 0,
borderRadius: 4,
- background: "var(--accent)",
+ background: tone,
color: "white",
cursor: "pointer",
verticalAlign: "middle",
}}
>
-
+ {children}
);
}
+/**
+ * The field a typed character opens between two words. It is not a word yet — nothing is
+ * written until it commits — so it carries no `data-word-id` and no place in `words`.
+ *
+ * Every event it raises is stopped at the field, for the same reason the word editor stops
+ * its own: the block around it reads Backspace as a cut and a click as a seek.
+ */
+function InsertionField({
+ value,
+ label,
+ onChange,
+ onCommit,
+ onCancel,
+ abandonedRef,
+}: {
+ value: string;
+ label: string;
+ onChange: (value: string) => void;
+ onCommit: () => void;
+ onCancel: () => void;
+ abandonedRef: { current: boolean };
+}) {
+ return (
+ onChange(event.target.value)}
+ onBlur={() => {
+ if (abandonedRef.current) {
+ abandonedRef.current = false;
+ return;
+ }
+ onCommit();
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onCommit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ onCancel();
+ }
+ }}
+ onBeforeInput={(event) => event.stopPropagation()}
+ onPaste={(event) => event.stopPropagation()}
+ onPointerUp={(event) => event.stopPropagation()}
+ style={{
+ display: "inline",
+ width: `${Math.max(value.length, 3) + 2}ch`,
+ margin: "0 3px 2px 0",
+ padding: "0 5px",
+ border: "1px solid var(--warn)",
+ borderRadius: 999,
+ background: "var(--warn-soft)",
+ color: "var(--fg)",
+ font: "inherit",
+ outline: "none",
+ }}
+ />
+ );
+}
+
// ─── Caret / selection helpers ────────────────────────────────────
// Ponytail port of axcut's findCollapsedDeletionWordId. The non-collapsed
// path uses findWordId directly (a range selection's endpoints already
@@ -1838,6 +2085,79 @@ function findCollapsedDeletionWordId(
return pool.find((wordNode) => isKept(wordNode.dataset.wordId ?? null))?.dataset.wordId ?? null;
}
+/**
+ * Where a typed character goes: beside the word the caret was resting on, never inside it.
+ *
+ * A caret in the middle of a word anchors AFTER that word rather than splitting it in two —
+ * a split would need two words where the transcript has one, and neither half would own the
+ * audio any more. At the very start of the block there is nothing to sit after, so the
+ * anchor is the first word and the new one lands before it.
+ */
+function findInsertionAnchor(
+ editor: HTMLElement,
+ node: Node | null,
+ offset: number,
+): { clipWordId: string; side: InsertSide } | null {
+ const wordNodes = Array.from(editor.querySelectorAll("[data-word-id]"));
+ if (wordNodes.length === 0 || !node) return null;
+
+ const direct = closestWordElement(node);
+ if (direct?.dataset.wordId) {
+ const atStart = node.nodeType === Node.TEXT_NODE && offset <= 0;
+ return { clipWordId: direct.dataset.wordId, side: atStart ? "before" : "after" };
+ }
+
+ // The caret is between the block's own children, and `offset` is a child index — the
+ // same shape `findCollapsedDeletionWordId` reads when it resolves a cut. Walk back for
+ // the word to sit after; if there is none, the caret is at the head of the stream and
+ // the new word goes before the first word ahead of it.
+ const childNodes = Array.from(node.childNodes);
+ for (const candidate of childNodes.slice(0, clampRangeOffset(node, offset)).reverse()) {
+ const wordId = findWordId(candidate) ?? findDescendantWordId(candidate);
+ if (wordId) return { clipWordId: wordId, side: "after" };
+ }
+ for (const candidate of childNodes.slice(clampRangeOffset(node, offset))) {
+ const wordId = findWordId(candidate) ?? findDescendantWordId(candidate);
+ if (wordId) return { clipWordId: wordId, side: "before" };
+ }
+ const first = wordNodes[0];
+ return first?.dataset.wordId ? { clipWordId: first.dataset.wordId, side: "before" } : null;
+}
+
+/**
+ * Pull the DOM's answer back onto a word the TRANSCRIPT has.
+ *
+ * `[silence]` pills carry a `data-word-id` like everything else in the stream, but they are
+ * pseudo-words `withSilenceGaps` invents per clip — there is nothing in `transcript.words`
+ * for a new word to be inserted next to. So the anchor walks off a silence to the nearest
+ * real word in the direction the caret was already facing, and only crosses to the other
+ * side when that direction runs out of stream.
+ */
+function resolveInsertionAnchor(
+ words: ClipWord[],
+ clipWordId: string,
+ side: InsertSide,
+): { clipWordId: string; side: InsertSide } | null {
+ const from = words.findIndex((w) => w.id === clipWordId);
+ if (from < 0) return null;
+ const real = (index: number) =>
+ index >= 0 && index < words.length && !isSilenceWord(words[index].word);
+ if (side === "after") {
+ for (let i = from; i >= 0; i--) if (real(i)) return { clipWordId: words[i].id, side: "after" };
+ for (let i = 0; i < words.length; i++) {
+ if (real(i)) return { clipWordId: words[i].id, side: "before" };
+ }
+ return null;
+ }
+ for (let i = from; i < words.length; i++) {
+ if (real(i)) return { clipWordId: words[i].id, side: "before" };
+ }
+ for (let i = words.length - 1; i >= 0; i--) {
+ if (real(i)) return { clipWordId: words[i].id, side: "after" };
+ }
+ return null;
+}
+
function findDescendantWordId(node: Node): string | null {
if (node instanceof HTMLElement && node.dataset.wordId) {
return node.dataset.wordId;
diff --git a/src/components/ai-edition/TranscriptPane.gating.test.tsx b/src/components/ai-edition/TranscriptPane.gating.test.tsx
index 9a62d7dc9..cd2397be5 100644
--- a/src/components/ai-edition/TranscriptPane.gating.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.gating.test.tsx
@@ -58,6 +58,8 @@ function renderPane(
onAddTrimRange={vi.fn()}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={overrides.isTranscribing ?? false}
diff --git a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
index af26c2210..4a6091863 100644
--- a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
@@ -86,6 +86,8 @@ function renderPane(
onAddTrimRange={onAddTrimRange}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
@@ -231,6 +233,8 @@ describe("keyboard cut with the caret between words", () => {
}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
diff --git a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
index 53a46aec5..7b553b2eb 100644
--- a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
@@ -80,6 +80,8 @@ function renderPane(onSeek: (sec: number) => void = vi.fn()) {
onAddTrimRange={vi.fn()}
onRemoveTrimRange={vi.fn()}
onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
diff --git a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
index 2514fb23f..3c8fd0b97 100644
--- a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
@@ -65,6 +65,8 @@ function renderPane(words?: AxcutWord[], busyAssetIds: string[] = []) {
onAddTrimRange={onAddTrimRange}
onRemoveTrimRange={vi.fn()}
onSetWordText={onSetWordText}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
diff --git a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
new file mode 100644
index 000000000..7af0cc9db
--- /dev/null
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -0,0 +1,242 @@
+// @vitest-environment jsdom
+// Typing a word into the transcript that nobody said.
+//
+// This is the third gesture on the one word stream, and the one that had to get past a
+// guard: the block used to swallow every keystroke outright, because free text has no
+// `transcript.words` entry to land on. It still never lands in the block — what a typed
+// character opens is a field beside the word the caret was on, and only its commit makes a
+// word. These tests hold that: the DOM never gets ahead of `words`, and Backspace inside
+// the field types instead of cutting the clip out from under it.
+
+import "@testing-library/jest-dom";
+import { cleanup, fireEvent, render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import type { AxcutAsset, AxcutClip, AxcutTranscript, AxcutWord } from "@/lib/ai-edition/schema";
+import { TranscriptPane } from "./RightPanes";
+
+vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
+
+const ASSET: AxcutAsset = {
+ id: "asset_1",
+ kind: "video",
+ label: "recording.mp4",
+ originalPath: "/rec.mp4",
+ durationSec: 3,
+ cameraTrack: null,
+};
+
+const CLIP: AxcutClip = {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 3,
+ timelineStartSec: 0,
+ timelineEndSec: 3,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+};
+
+// Contiguous, so no `[silence]` pill sits between them to shift the caret indices.
+const WORDS: AxcutWord[] = [
+ { id: "w1", segmentId: "s", startSec: 0, endSec: 1, text: "Bonjour" },
+ { id: "w2", segmentId: "s", startSec: 1, endSec: 2, text: "à" },
+ { id: "w3", segmentId: "s", startSec: 2, endSec: 3, text: "tous" },
+];
+
+function renderPane(words: AxcutWord[] = WORDS, busyAssetIds: string[] = []) {
+ const onInsertWord = vi.fn();
+ const onRemoveWords = vi.fn();
+ const onAddTrimRange = vi.fn();
+ const transcript: AxcutTranscript = {
+ assetId: "asset_1",
+ language: "fr",
+ segments: [],
+ words,
+ };
+ const view = render(
+
+
+ ,
+ );
+ const editor = view.container.querySelector('[role="textbox"]');
+ if (!editor) throw new Error("transcript editor not rendered");
+ const field = () => view.container.querySelector("input[data-word-inserter]");
+ const wordEl = (id: string) => {
+ const el = view.container.querySelector(`[data-word-id="clip_1:${id}"]`);
+ if (!el) throw new Error(`word ${id} not rendered`);
+ return el;
+ };
+ return { ...view, editor, field, wordEl, onInsertWord, onRemoveWords, onAddTrimRange };
+}
+
+/** Park the caret between words at editor level, the way `restoreCaretBeforeWord` does. */
+function caretBeforeWordAt(editor: HTMLElement, index: number) {
+ const range = document.createRange();
+ range.setStart(editor, index);
+ range.collapse(true);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+}
+
+/**
+ * A real native `beforeinput`, because that is what the block listens to.
+ *
+ * Not `fireEvent.beforeInput`: React 18 builds its `onBeforeInput` from the legacy
+ * `textInput` event, whose `TextEvent` has no `inputType` — which is exactly why the guard
+ * moved off React and onto the DOM. Driving the synthetic one here would test a path the
+ * browser never takes.
+ */
+function type(editor: HTMLElement, data: string) {
+ // Through `fireEvent` so the state the listener sets is flushed, but with an event
+ // built by hand — `fireEvent.beforeInput` does not exist here, and the point is to
+ // dispatch the real thing.
+ fireEvent(
+ editor,
+ new InputEvent("beforeinput", {
+ data,
+ inputType: "insertText",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ window.getSelection()?.removeAllRanges();
+});
+
+describe("typing between two words", () => {
+ it("opens a field there instead of dropping the keystroke", () => {
+ const view = renderPane();
+ expect(view.field()).toBeNull();
+ caretBeforeWordAt(view.editor, 2); // between "à" and "tous"
+ type(view.editor, "v");
+ expect(view.field()).toHaveValue("v");
+ });
+
+ it("never writes the typed text into the block itself", () => {
+ // The whole reason inserts were blocked: a run of text with no word id behind it
+ // desynchronises the DOM from `words`.
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ expect(view.editor.textContent).not.toContain("v ");
+ expect(view.onInsertWord).not.toHaveBeenCalled();
+ });
+
+ it("commits on Enter, against the word the caret was after", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.change(field, { target: { value: "vraiment" } });
+ fireEvent.keyDown(field, { key: "Enter" });
+ expect(view.onInsertWord).toHaveBeenCalledWith("asset_1", "w2", "after", "vraiment");
+ });
+
+ it("anchors before the first word when the caret is at the very start", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 0);
+ type(view.editor, "E");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.keyDown(field, { key: "Enter" });
+ expect(view.onInsertWord).toHaveBeenCalledWith("asset_1", "w1", "before", "E");
+ });
+
+ it("abandons on Escape without writing anything", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.keyDown(field, { key: "Escape" });
+ fireEvent.blur(field);
+ expect(view.onInsertWord).not.toHaveBeenCalled();
+ expect(view.field()).toBeNull();
+ });
+
+ it("commits on blur", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 1);
+ type(view.editor, "x");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.change(field, { target: { value: "donc" } });
+ fireEvent.blur(field);
+ expect(view.onInsertWord).toHaveBeenCalledWith("asset_1", "w1", "after", "donc");
+ });
+
+ it("does not cut the media when Backspace is pressed inside the field", () => {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ const field = view.field();
+ if (!field) throw new Error("no insertion field");
+ fireEvent.keyDown(field, { key: "Backspace" });
+ expect(view.onAddTrimRange).not.toHaveBeenCalled();
+ });
+
+ it("stays shut while this clip's transcript is being regenerated", () => {
+ const view = renderPane(WORDS, ["asset_1"]);
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ expect(view.field()).toBeNull();
+ });
+});
+
+describe("a word that was inserted", () => {
+ const INSERTED: AxcutWord[] = [
+ WORDS[0],
+ { id: "synth_1", segmentId: "s", startSec: 1, endSec: 1, text: "vraiment", source: "synth" },
+ WORDS[1],
+ WORDS[2],
+ ];
+
+ it("reads as its own thing, not as a transcribed word", () => {
+ const view = renderPane(INSERTED);
+ const el = view.wordEl("synth_1");
+ expect(el).toHaveAttribute("data-inserted", "true");
+ expect(el.textContent).toContain("vraiment");
+ });
+
+ // There is no audio for a trim to remove, so the gesture that makes a spoken word go
+ // away cannot be the one that makes this go away.
+ it("is deleted outright by its own control", () => {
+ const view = renderPane(INSERTED);
+ fireEvent.mouseEnter(view.wordEl("synth_1"));
+ const remove = view.wordEl("synth_1").querySelector("button");
+ if (!remove) throw new Error("no delete control");
+ fireEvent.click(remove);
+ expect(view.onRemoveWords).toHaveBeenCalledWith("asset_1", ["synth_1"]);
+ });
+
+ it("is deleted, not trimmed, when Backspace lands on it alone", () => {
+ const view = renderPane(INSERTED);
+ caretBeforeWordAt(view.editor, 2); // right after the insert
+ fireEvent.keyDown(view.editor, { key: "Backspace" });
+ expect(view.onRemoveWords).toHaveBeenCalledWith("asset_1", ["synth_1"]);
+ expect(view.onAddTrimRange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json
index dc6b1938b..ffe8890d1 100644
--- a/src/i18n/locales/ar/editor.json
+++ b/src/i18n/locales/ar/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}",
"previewCompositorUnavailable": "المعاينة غير متوفرة على هذا الجهاز",
- "wordEditFailed": "تعذّر تغيير هذه الكلمة"
+ "wordEditFailed": "تعذّر تغيير هذه الكلمة",
+ "wordInsertFailed": "تعذّرت إضافة هذه الكلمة",
+ "wordRemoveFailed": "تعذّر حذف هذه الكلمة"
},
"export": {
"canceled": "تم إلغاء التصدير",
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 89af54f3f..a3c142fdb 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "المقطع {{index}}",
- "restoreSilence": "استعادة الصمت ({{duration}} ث)",
- "laneVoiceover": "التعليق الصوتي",
- "editorAria": "نص {{filename}}",
- "noTranscript": "لا يوجد نص بعد",
- "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
- "blankedWord": "مُفرَّغة",
- "laneLabel": "اقرأ النص من",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها: تتبعها الترجمات ولا يتغيّر الفيديو. مرّر المؤشر فوق كلمة معلَّمة لاستعادتها.",
- "noClips": "لا توجد مقاطع بعد",
- "trimSilence": "قص الصمت ({{duration}} ث)",
- "transcribing": "جارٍ التفريغ…",
- "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
- "editWord": "تحرير \"{{word}}\"",
- "restoreWord": "استعادة \"{{word}}\"",
- "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
- "transcribeNow": "فرّغ النص الآن",
- "revertWord": "استعادة \"{{original}}\"",
- "silence": "[صمت {{duration}} ث]",
- "laneRecording": "التسجيل",
- "title": "النص الحالي",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
+ "x": "X (%)",
+ "title": "موضع التركيز"
+ },
+ "threeD": {
+ "preset": {
+ "right": "يمين",
+ "left": "يسار",
+ "iso": "متساوي القياس"
+ },
+ "none": "بلا",
+ "title": "دوران ثلاثي الأبعاد"
+ },
+ "focusMode": {
+ "manual": "يدوي",
+ "title": "وضع التركيز",
+ "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
+ "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
+ "auto": "تلقائي"
+ },
+ "selectRegion": "حدد منطقة التكبير للتعديل",
+ "customScale": "تكبير مخصص",
+ "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
+ "level": "مستوى التكبير",
+ "deleteZoom": "حذف التكبير"
},
- "captions": {
- "showBackground": "إظهار الخلفية",
- "alignLeft": "يسار",
- "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
- "backgroundColor": "لون الخلفية",
- "translating": "جارٍ الترجمة…",
- "backgroundOpacity": "العتامة",
- "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
- "translateFailed": "فشلت الترجمة.",
- "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
- "text": "النص",
- "fontSize": "الحجم",
- "deleteTranslation": "حذف هذه الترجمة",
- "maxWords": "أكثر عدد كلمات في السطر",
- "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
- "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
- "anchorBottom": "أسفل",
- "distanceFromLeft": "المسافة من اليسار",
- "textColor": "لون النص",
- "background": "الخلفية",
- "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
- "minWords": "أقل عدد كلمات في السطر",
- "lineLength": "طول السطر",
- "font": "الخط",
- "original": "الأصل (النص المفرّغ)",
- "bold": "عريض",
- "displayLanguage": "العرض",
- "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
- "alignCenter": "توسيط",
- "distanceFromBottom": "المسافة من الأسفل",
- "distanceFromTop": "المسافة من الأعلى",
- "translate": "ترجمة",
- "position": "الموضع",
- "language": "اللغة",
- "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
- "show": "إظهار الترجمة",
- "anchorTop": "أعلى",
- "alignRight": "يمين",
- "distanceFromRight": "المسافة من اليمين",
- "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك."
+ "audioTrack": {
+ "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "defaultLabel": "مسار صوتي",
+ "fadeOut": "تلاشٍ للخارج",
+ "add": "إضافة مسار صوتي",
+ "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
+ "loop": "تكرار",
+ "remove": "حذف المسار",
+ "fadeIn": "تلاشٍ للداخل",
+ "mute": "كتم",
+ "importFailed": "تعذّر إضافة الصوت"
},
- "effects": {
- "padding": "المسافة البادئة",
- "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
- "motion": "الحركة",
- "title": "التركيب",
- "blurBg": "تمويه الخلفية",
- "fitClip": "ملاءمة",
- "on": "تشغيل",
- "roundness": "الاستدارة",
- "frame": "الإطار",
- "formatOriginal": "الأصلي",
- "shadow": "ظل",
- "fitClipMany": "{{count}} مقاطع",
- "fitClipFew": "{{count}} مقاطع",
- "off": "إيقاف",
- "format": "التنسيق",
+ "cursor": {
+ "clipToBounds": "القص ضمن اللوحة",
+ "size": "الحجم",
"motionBlur": "ضبابية الحركة",
- "fitClipOne": "مقطع واحد"
+ "theme": "نمط المؤشر",
+ "clickBounce": "ارتداد النقر",
+ "title": "المؤشر",
+ "smoothing": "التنعيم",
+ "themeDefault": "افتراضي",
+ "show": "إظهار المؤشر",
+ "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
+ "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك."
},
"annotation": {
- "arrowColor": "لون السهم",
+ "blurIntensity": "كثافة التمويه",
+ "textContent": "محتوى النص",
+ "blurShapeFreehand": "رسم حر",
"active": "نشط",
- "blurTypeMosaic": "فسيفساء",
- "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
- "title": "إعدادات الشروح",
- "blurColorBlack": "أسود",
- "imageUploadSuccess": "تم رفع الصورة بنجاح!",
- "deleteAnnotation": "حذف الشرح",
- "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
- "typeText": "نص",
+ "blurShapeRectangle": "مستطيل",
"customFonts": "خطوط مخصصة",
- "color": "لون",
- "invalidImageType": "نوع ملف غير صالح",
- "type": "النوع",
- "size": "الحجم",
- "blurShapeFreehand": "رسم حر",
- "mosaicBlockSize": "حجم كتلة الفسيفساء",
- "fontStyle": "نمط الخط",
- "blurColorWhite": "أبيض",
- "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
- "textPlaceholder": "أدخل النص هنا...",
+ "title": "إعدادات الشروح",
+ "blurShapeOval": "بيضاوي",
"blurType": "نوع التمويه",
+ "mosaicBlockSize": "حجم كتلة الفسيفساء",
"colorPalette": "لوحة الألوان",
+ "textColor": "لون النص",
+ "colorWheel": "عجلة الألوان",
+ "shortcutsAndTips": "اختصارات ونصائح",
+ "defaultText": "مرحبا",
+ "clearBackground": "مسح الخلفية",
+ "type": "النوع",
"tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
+ "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
"background": "الخلفية",
- "blurShapeRectangle": "مستطيل",
- "typeArrow": "سهم",
- "blurIntensity": "كثافة التمويه",
- "uploadImage": "رفع صورة",
- "blurShapeOval": "بيضاوي",
- "strokeWidth": "عرض الخط: {{width}}px",
+ "blurShape": "شكل التمويه",
+ "arrowColor": "لون السهم",
+ "invalidImageType": "نوع ملف غير صالح",
+ "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
+ "blurColorBlack": "أسود",
+ "blurTypeBlur": "غاوسي",
+ "none": "بدون",
"supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
+ "size": "الحجم",
+ "imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "blurColorWhite": "أبيض",
"arrowDirection": "اتجاه السهم",
- "textContent": "محتوى النص",
+ "typeImage": "صورة",
+ "typeText": "نص",
+ "typeArrow": "سهم",
+ "color": "لون",
"blurColor": "لون التمويه",
"selectStyle": "حدد النمط",
- "colorWheel": "عجلة الألوان",
- "textColor": "لون النص",
- "none": "بدون",
- "defaultText": "مرحبا",
- "blurTypeBlur": "غاوسي",
- "shortcutsAndTips": "اختصارات ونصائح",
- "clearBackground": "مسح الخلفية",
+ "blurTypeMosaic": "فسيفساء",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "textPlaceholder": "أدخل النص هنا...",
+ "uploadImage": "رفع صورة",
+ "strokeWidth": "عرض الخط: {{width}}px",
"typeBlur": "تمويه",
- "typeImage": "صورة",
- "blurShape": "شكل التمويه"
- },
- "textAnimation": {
- "pop": "ظهور",
- "fade": "تلاشي",
- "none": "بدون",
- "selectAnimation": "حدد الحركة",
- "typewriter": "آلة كاتبة",
- "rise": "ارتفاع",
- "slideLeft": "انزلاق لليسار",
- "pulse": "نبض",
- "title": "تحريك النص"
- },
- "audioTrack": {
- "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
- "defaultLabel": "مسار صوتي",
- "mute": "كتم",
- "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
- "loop": "تكرار",
- "add": "إضافة مسار صوتي",
- "fadeIn": "تلاشٍ للداخل",
- "remove": "حذف المسار",
- "importFailed": "تعذّر إضافة الصوت",
- "fadeOut": "تلاشٍ للخارج"
- },
- "customFont": {
- "addingButton": "جاري الإضافة...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
- "failedToAdd": "فشل في إضافة الخط",
- "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
- "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
- "errorEmptyName": "يرجى إدخال اسم الخط",
- "urlLabel": "رابط استيراد خطوط Google",
- "nameLabel": "اسم العرض",
- "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
- "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
- "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
- "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
- "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
- "namePlaceholder": "خطي المخصص",
- "addButton": "إضافة خط",
- "dialogTitle": "إضافة خط Google"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "يمين",
- "iso": "متساوي القياس",
- "left": "يسار"
- },
- "none": "بلا",
- "title": "دوران ثلاثي الأبعاد"
- },
- "position": {
- "title": "موضع التركيز",
- "y": "Y (%)",
- "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
- "x": "X (%)"
- },
- "deleteZoom": "حذف التكبير",
- "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
- "customScale": "تكبير مخصص",
- "focusMode": {
- "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
- "title": "وضع التركيز",
- "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
- "auto": "تلقائي",
- "manual": "يدوي"
- },
- "level": "مستوى التكبير",
- "selectRegion": "حدد منطقة التكبير للتعديل"
- },
- "speed": {
- "selectRegion": "حدد منطقة السرعة للتعديل",
- "customPlaybackSpeed": "سرعة تشغيل مخصصة",
- "playbackSpeed": "سرعة التشغيل",
- "deleteRegion": "حذف منطقة السرعة",
- "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير.",
- "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×"
+ "deleteAnnotation": "حذف الشرح",
+ "fontStyle": "نمط الخط"
},
"layout": {
- "webcamSize": "حجم كاميرا الويب",
- "webcamBlurIntensity": "شدة الضبابية",
+ "webcamCropY": "تحريك عمودي",
+ "reactiveWebcam": "تصغير عند التكبير",
"shapes": {
- "circle": "دائرة",
+ "rectangle": "مستطيل",
"square": "مربع",
"rounded": "زوايا مستديرة",
- "rectangle": "مستطيل"
+ "circle": "دائرة"
},
- "webcamCropY": "تحريك عمودي",
- "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
- "selectPreset": "حدد إعدادًا مسبقًا",
- "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
"preset": "الإعداد المسبق",
- "webcamFraming": "تأطير كاميرا الويب",
- "webcamBackground": "خلفية الكاميرا",
- "webcamShape": "شكل الكاميرا",
- "title": "تخطيط الكاميرا",
+ "dualFrame": "إطار مزدوج",
"bgModes": {
- "blur": "تمويه",
- "transparent": "تفريغ",
"none": "الأصلي",
- "custom": "مخصص"
+ "transparent": "تفريغ",
+ "custom": "مخصص",
+ "blur": "تمويه"
},
- "mirrorWebcam": "عكس كاميرا الويب",
- "noWebcam": "بدون كاميرا",
- "pictureInPicture": "صورة داخل صورة",
- "reactiveWebcam": "تصغير عند التكبير",
"webcamCropZoom": "تكبير الاقتصاص",
- "dualFrame": "إطار مزدوج",
+ "webcamShape": "شكل الكاميرا",
+ "webcamBlurIntensity": "شدة الضبابية",
+ "title": "تخطيط الكاميرا",
"reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
+ "webcamFraming": "تأطير كاميرا الويب",
"webcamCropX": "تحريك أفقي",
+ "selectPreset": "حدد إعدادًا مسبقًا",
+ "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
+ "mirrorWebcam": "عكس كاميرا الويب",
+ "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
+ "webcamBackground": "خلفية الكاميرا",
+ "noWebcam": "بدون كاميرا",
+ "pictureInPicture": "صورة داخل صورة",
+ "webcamSize": "حجم كاميرا الويب",
"verticalStack": "تكدس عمودي"
},
- "project": {
- "load": "تحميل المشروع",
- "save": "حفظ المشروع",
- "new": "مشروع جديد"
- },
- "audio": {
- "outputGain": "ضبط مستوى الإخراج",
- "reset": "إعادة ضبط الصوت",
- "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
- "title": "الصوت"
- },
- "facets": {
- "transcript": "النص",
- "captions": "الترجمة"
- },
- "cursor": {
- "themeDefault": "افتراضي",
- "title": "المؤشر",
- "theme": "نمط المؤشر",
- "clickBounce": "ارتداد النقر",
- "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
- "smoothing": "التنعيم",
- "size": "الحجم",
- "motionBlur": "ضبابية الحركة",
- "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
- "show": "إظهار المؤشر",
- "clipToBounds": "القص ضمن اللوحة"
+ "speed": {
+ "customPlaybackSpeed": "سرعة تشغيل مخصصة",
+ "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير.",
+ "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
+ "playbackSpeed": "سرعة التشغيل",
+ "deleteRegion": "حذف منطقة السرعة",
+ "selectRegion": "حدد منطقة السرعة للتعديل"
},
"imageUpload": {
"failedToUpload": "فشل رفع الصورة",
- "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
- "invalidFileType": "نوع ملف غير صالح",
+ "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
"errorReading": "حدث خطأ أثناء قراءة الملف.",
- "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!"
+ "invalidFileType": "نوع ملف غير صالح",
+ "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG."
},
- "exportFormat": {
- "gifAnimation": "صورة GIF متحركة",
- "mp4Description": "ملف فيديو عالي الجودة",
- "mp4Video": "فيديو MP4",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "صورة متحركة للمشاركة"
+ "transcript": {
+ "blankedWord": "مُفرَّغة",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "editWord": "تحرير \"{{word}}\"",
+ "editorAria": "نص {{filename}}",
+ "noTranscript": "لا يوجد نص بعد",
+ "clipLabel": "المقطع {{index}}",
+ "trimSilence": "قص الصمت ({{duration}} ث)",
+ "laneVoiceover": "التعليق الصوتي",
+ "restoreSilence": "استعادة الصمت ({{duration}} ث)",
+ "transcribeNow": "فرّغ النص الآن",
+ "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
+ "removeInserted": "حذف \"{{word}}\"",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "restoreWord": "استعادة \"{{word}}\"",
+ "silence": "[صمت {{duration}} ث]",
+ "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
+ "laneRecording": "التسجيل",
+ "noClips": "لا توجد مقاطع بعد",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "laneLabel": "اقرأ النص من",
+ "revertWord": "استعادة \"{{original}}\"",
+ "title": "النص الحالي",
+ "transcribing": "جارٍ التفريغ…",
+ "insertAria": "كلمة جديدة",
+ "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد."
+ },
+ "captions": {
+ "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
+ "distanceFromTop": "المسافة من الأعلى",
+ "minWords": "أقل عدد كلمات في السطر",
+ "showBackground": "إظهار الخلفية",
+ "lineLength": "طول السطر",
+ "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
+ "translating": "جارٍ الترجمة…",
+ "distanceFromLeft": "المسافة من اليسار",
+ "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
+ "position": "الموضع",
+ "translateFailed": "فشلت الترجمة.",
+ "backgroundOpacity": "العتامة",
+ "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
+ "original": "الأصل (النص المفرّغ)",
+ "font": "الخط",
+ "distanceFromRight": "المسافة من اليمين",
+ "textColor": "لون النص",
+ "alignCenter": "توسيط",
+ "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "language": "اللغة",
+ "show": "إظهار الترجمة",
+ "anchorTop": "أعلى",
+ "backgroundColor": "لون الخلفية",
+ "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
+ "background": "الخلفية",
+ "bold": "عريض",
+ "distanceFromBottom": "المسافة من الأسفل",
+ "fontSize": "الحجم",
+ "anchorBottom": "أسفل",
+ "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
+ "maxWords": "أكثر عدد كلمات في السطر",
+ "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
+ "translate": "ترجمة",
+ "deleteTranslation": "حذف هذه الترجمة",
+ "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
+ "text": "النص",
+ "displayLanguage": "العرض",
+ "alignRight": "يمين",
+ "alignLeft": "يسار"
+ },
+ "support": {
+ "saveDiagnostics": "حفظ التشخيصات",
+ "reportBug": "الإبلاغ عن خطأ",
+ "starOnGithub": "إعطاء نجمة على GitHub"
+ },
+ "customFont": {
+ "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
+ "urlLabel": "رابط استيراد خطوط Google",
+ "addingButton": "جاري الإضافة...",
+ "namePlaceholder": "خطي المخصص",
+ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
+ "dialogTitle": "إضافة خط Google",
+ "failedToAdd": "فشل في إضافة الخط",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
+ "addButton": "إضافة خط",
+ "errorEmptyName": "يرجى إدخال اسم الخط",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
+ "nameLabel": "اسم العرض",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
+ "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى."
},
"background": {
- "colorLabel": "اللون {{color}}",
- "customWallpaper": "خلفية مخصصة",
- "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
- "gradient": "تدرج لوني",
"presets": "إعدادات مسبقة",
- "color": "لون",
- "custom": "مخصص",
- "colorWheel": "عجلة الألوان",
- "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
"image": "صورة",
- "uploadCustom": "رفع صورة مخصصة",
"title": "الخلفية",
+ "custom": "مخصص",
+ "colorLabel": "اللون {{color}}",
"imageLabel": "الخلفية {{index}}",
+ "customWallpaper": "خلفية مخصصة",
"colorPalette": "لوحة الألوان",
+ "uploadCustom": "رفع صورة مخصصة",
+ "color": "لون",
"imageReadFailed": "تعذّر قراءة ملف الصورة.",
- "gradientLabel": "تدرج لوني {{index}}"
+ "gradientLabel": "تدرج لوني {{index}}",
+ "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
+ "gradient": "تدرج لوني",
+ "colorWheel": "عجلة الألوان",
+ "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص."
+ },
+ "audio": {
+ "reset": "إعادة ضبط الصوت",
+ "outputGain": "ضبط مستوى الإخراج",
+ "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
+ "title": "الصوت"
+ },
+ "textAnimation": {
+ "pulse": "نبض",
+ "selectAnimation": "حدد الحركة",
+ "fade": "تلاشي",
+ "typewriter": "آلة كاتبة",
+ "slideLeft": "انزلاق لليسار",
+ "none": "بدون",
+ "rise": "ارتفاع",
+ "pop": "ظهور",
+ "title": "تحريك النص"
},
"crop": {
"title": "اقتصاص",
+ "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
+ "free": "حر",
"dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
"done": "تم",
- "free": "حر",
- "ratio": "النسبة",
- "cropVideo": "اقتصاص الفيديو",
"lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
- "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
+ "ratio": "النسبة",
+ "cropVideo": "اقتصاص الفيديو"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "دقة التصدير"
+ },
+ "effects": {
+ "off": "إيقاف",
+ "on": "تشغيل",
+ "fitClip": "ملاءمة",
+ "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
+ "fitClipFew": "{{count}} مقاطع",
+ "blurBg": "تمويه الخلفية",
+ "motion": "الحركة",
+ "shadow": "ظل",
+ "fitClipOne": "مقطع واحد",
+ "format": "التنسيق",
+ "roundness": "الاستدارة",
+ "fitClipMany": "{{count}} مقاطع",
+ "formatOriginal": "الأصلي",
+ "frame": "الإطار",
+ "motionBlur": "ضبابية الحركة",
+ "padding": "المسافة البادئة",
+ "title": "التركيب"
+ },
+ "language": {
+ "title": "اللغة"
},
"gifSettings": {
"size": "حجم GIF",
- "frameRate": "معدل إطارات GIF",
- "loop": "تكرار GIF"
+ "loop": "تكرار GIF",
+ "frameRate": "معدل إطارات GIF"
},
- "support": {
- "starOnGithub": "إعطاء نجمة على GitHub",
- "reportBug": "الإبلاغ عن خطأ",
- "saveDiagnostics": "حفظ التشخيصات"
+ "panes": {
+ "help": "مساعدة"
},
"export": {
+ "chooseSaveLocation": "اختيار موقع الحفظ",
"gifButton": "تصدير GIF",
- "videoButton": "تصدير الفيديو",
- "chooseSaveLocation": "اختيار موقع الحفظ"
+ "videoButton": "تصدير الفيديو"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "دقة التصدير",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "فيديو MP4",
+ "mp4Description": "ملف فيديو عالي الجودة",
+ "gifAnimation": "صورة GIF متحركة",
+ "gifDescription": "صورة متحركة للمشاركة",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "اللغة"
+ "project": {
+ "save": "حفظ المشروع",
+ "new": "مشروع جديد",
+ "load": "تحميل المشروع"
+ },
+ "facets": {
+ "captions": "الترجمة",
+ "transcript": "النص"
},
"trim": {
"deleteRegion": "حذف منطقة القص"
- },
- "panes": {
- "help": "مساعدة"
}
}
diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json
index 955049d21..e6f2737aa 100644
--- a/src/i18n/locales/en/editor.json
+++ b/src/i18n/locales/en/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}",
"previewCompositorUnavailable": "Preview unavailable on this machine",
- "wordEditFailed": "Could not change that word"
+ "wordEditFailed": "Could not change that word",
+ "wordInsertFailed": "Could not add that word",
+ "wordRemoveFailed": "Could not delete that word"
},
"export": {
"canceled": "Export canceled",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 9092316bf..1417249f6 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Clip {{index}}",
- "restoreSilence": "Restore silence ({{duration}}s)",
- "laneVoiceover": "Voice-over",
- "editorAria": "Transcript for {{filename}}",
- "noTranscript": "No transcript yet",
- "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
- "blankedWord": "blanked",
- "laneLabel": "Read the transcript from",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text — the captions follow, the film does not move. Hover a marked word to restore it.",
- "noClips": "No clips yet",
- "trimSilence": "Trim silence ({{duration}}s)",
- "transcribing": "Transcribing…",
- "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
- "editWord": "Edit \"{{word}}\"",
- "restoreWord": "Restore \"{{word}}\"",
- "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
- "transcribeNow": "Transcribe now",
- "revertWord": "Restore \"{{original}}\"",
- "silence": "[silence {{duration}}s]",
- "laneRecording": "Recording",
- "title": "Current transcription",
- "noAudio": "This media has no audio track"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
+ "x": "X (%)",
+ "title": "Focus Position"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Right",
+ "left": "Left",
+ "iso": "Iso"
+ },
+ "none": "None",
+ "title": "3D Rotation"
+ },
+ "focusMode": {
+ "manual": "Manual",
+ "title": "Focus Mode",
+ "autoDescription": "Camera follows the recorded cursor position",
+ "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
+ "auto": "Auto"
+ },
+ "selectRegion": "Select a zoom region to adjust",
+ "customScale": "Custom Zoom",
+ "previewHold": "Hold to preview zoom effect",
+ "level": "Zoom Level",
+ "deleteZoom": "Delete Zoom"
},
- "captions": {
- "showBackground": "Show background",
- "alignLeft": "Left",
- "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
- "backgroundColor": "Background color",
- "translating": "Translating…",
- "backgroundOpacity": "Opacity",
- "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
- "translateFailed": "Translation failed.",
- "translateHint": "Translate the transcript with the configured AI provider",
- "text": "Text",
- "fontSize": "Size",
- "deleteTranslation": "Delete this translation",
- "maxWords": "Max words per line",
- "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
- "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
- "anchorBottom": "Bottom",
- "distanceFromLeft": "Distance from left",
- "textColor": "Text color",
- "background": "Background",
- "anchorHintTop": "Long captions grow downward — the top edge stays put.",
- "minWords": "Min words per line",
- "lineLength": "Line length",
- "font": "Font",
- "original": "Original (transcript)",
- "bold": "Bold",
- "displayLanguage": "Display",
- "removeLegacyAnnotations": "Remove old caption annotations",
- "alignCenter": "Center",
- "distanceFromBottom": "Distance from bottom",
- "distanceFromTop": "Distance from top",
- "translate": "Translate",
- "position": "Position",
- "language": "Language",
- "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
- "show": "Show captions",
- "anchorTop": "Top",
- "alignRight": "Right",
- "distanceFromRight": "Distance from right",
- "anchorHintBottom": "Long captions grow upward — the bottom edge stays put."
+ "audioTrack": {
+ "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "defaultLabel": "Audio track",
+ "fadeOut": "Fade out",
+ "add": "Add audio track",
+ "slipHint": "Alt-drag to slide the audio inside it",
+ "loop": "Loop",
+ "remove": "Delete track",
+ "fadeIn": "Fade in",
+ "mute": "Mute",
+ "importFailed": "Could not add audio"
},
- "effects": {
- "padding": "Padding",
- "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
- "motion": "Motion",
- "title": "Composition",
- "blurBg": "Blur BG",
- "fitClip": "Fit",
- "on": "on",
- "roundness": "Roundness",
- "frame": "Frame",
- "formatOriginal": "Original",
- "shadow": "Shadow",
- "fitClipMany": "{{count}} clips",
- "fitClipFew": "{{count}} clips",
- "off": "off",
- "format": "Format",
+ "cursor": {
+ "clipToBounds": "Clip to Canvas",
+ "size": "Size",
"motionBlur": "Motion Blur",
- "fitClipOne": "{{count}} clip"
+ "theme": "Cursor Style",
+ "clickBounce": "Click Bounce",
+ "title": "Cursor",
+ "smoothing": "Smoothing",
+ "themeDefault": "Default",
+ "show": "Show Cursor",
+ "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
+ "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned."
},
"annotation": {
- "arrowColor": "Arrow Color",
+ "blurIntensity": "Blur Intensity",
+ "textContent": "Text Content",
+ "blurShapeFreehand": "Freehand",
"active": "Active",
- "blurTypeMosaic": "Mosaic",
- "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
- "title": "Annotation Settings",
- "blurColorBlack": "Black",
- "imageUploadSuccess": "Image uploaded successfully!",
- "deleteAnnotation": "Delete Annotation",
- "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
- "typeText": "Text",
+ "blurShapeRectangle": "Rectangle",
"customFonts": "Custom Fonts",
- "color": "Color",
- "invalidImageType": "Invalid file type",
- "type": "Type",
- "size": "Size",
- "blurShapeFreehand": "Freehand",
- "mosaicBlockSize": "Mosaic Block Size",
- "fontStyle": "Font Style",
- "blurColorWhite": "White",
- "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
- "textPlaceholder": "Enter your text...",
+ "title": "Annotation Settings",
+ "blurShapeOval": "Oval",
"blurType": "Blur Type",
+ "mosaicBlockSize": "Mosaic Block Size",
"colorPalette": "Color Palette",
+ "textColor": "Text Color",
+ "colorWheel": "Color Wheel",
+ "shortcutsAndTips": "Shortcuts & Tips",
+ "defaultText": "Hello",
+ "clearBackground": "Clear Background",
+ "type": "Type",
"tipTabCycle": "Use Tab to cycle through overlapping items.",
+ "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
"background": "Background",
- "blurShapeRectangle": "Rectangle",
- "typeArrow": "Arrow",
- "blurIntensity": "Blur Intensity",
- "uploadImage": "Upload Image",
- "blurShapeOval": "Oval",
- "strokeWidth": "Stroke Width: {{width}}px",
+ "blurShape": "Blur Shape",
+ "arrowColor": "Arrow Color",
+ "invalidImageType": "Invalid file type",
+ "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
+ "blurColorBlack": "Black",
+ "blurTypeBlur": "Gaussian",
+ "none": "None",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
+ "size": "Size",
+ "imageUploadSuccess": "Image uploaded successfully!",
+ "blurColorWhite": "White",
"arrowDirection": "Arrow Direction",
- "textContent": "Text Content",
+ "typeImage": "Image",
+ "typeText": "Text",
+ "typeArrow": "Arrow",
+ "color": "Color",
"blurColor": "Blur Color",
"selectStyle": "Select style",
- "colorWheel": "Color Wheel",
- "textColor": "Text Color",
- "none": "None",
- "defaultText": "Hello",
- "blurTypeBlur": "Gaussian",
- "shortcutsAndTips": "Shortcuts & Tips",
- "clearBackground": "Clear Background",
+ "blurTypeMosaic": "Mosaic",
+ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
+ "textPlaceholder": "Enter your text...",
+ "uploadImage": "Upload Image",
+ "strokeWidth": "Stroke Width: {{width}}px",
"typeBlur": "Blur",
- "typeImage": "Image",
- "blurShape": "Blur Shape"
- },
- "textAnimation": {
- "pop": "Pop",
- "fade": "Fade",
- "none": "None",
- "selectAnimation": "Select animation",
- "typewriter": "Typewriter",
- "rise": "Rise",
- "slideLeft": "Slide Left",
- "pulse": "Pulse",
- "title": "Text Animation"
- },
- "audioTrack": {
- "slipHint": "Alt-drag to slide the audio inside it",
- "defaultLabel": "Audio track",
- "mute": "Mute",
- "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
- "loop": "Loop",
- "add": "Add audio track",
- "fadeIn": "Fade in",
- "remove": "Delete track",
- "importFailed": "Could not add audio",
- "fadeOut": "Fade out"
- },
- "customFont": {
- "addingButton": "Adding...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Could not extract font family from URL",
- "failedToAdd": "Failed to add font",
- "errorTimeout": "Font took too long to load. Please check the URL and try again.",
- "successMessage": "Font \"{{fontName}}\" added successfully",
- "errorEmptyName": "Please enter a font name",
- "urlLabel": "Google Fonts Import URL",
- "nameLabel": "Display Name",
- "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
- "nameHelp": "This is how the font will appear in the font selector",
- "errorInvalidUrl": "Please enter a valid Google Fonts URL",
- "errorEmptyUrl": "Please enter a Google Fonts import URL",
- "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
- "namePlaceholder": "My Custom Font",
- "addButton": "Add Font",
- "dialogTitle": "Add Google Font"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Right",
- "iso": "Iso",
- "left": "Left"
- },
- "none": "None",
- "title": "3D Rotation"
- },
- "position": {
- "title": "Focus Position",
- "y": "Y (%)",
- "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
- "x": "X (%)"
- },
- "deleteZoom": "Delete Zoom",
- "previewHold": "Hold to preview zoom effect",
- "customScale": "Custom Zoom",
- "focusMode": {
- "autoDescription": "Camera follows the recorded cursor position",
- "title": "Focus Mode",
- "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
- "auto": "Auto",
- "manual": "Manual"
- },
- "level": "Zoom Level",
- "selectRegion": "Select a zoom region to adjust"
- },
- "speed": {
- "selectRegion": "Select a speed region to adjust",
- "customPlaybackSpeed": "Custom Playback Speed",
- "playbackSpeed": "Playback Speed",
- "deleteRegion": "Delete Speed Region",
- "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected.",
- "maxSpeedError": "Speed can't go higher than {{max}}×"
+ "deleteAnnotation": "Delete Annotation",
+ "fontStyle": "Font Style"
},
"layout": {
- "webcamSize": "Webcam Size",
- "webcamBlurIntensity": "Blur Intensity",
+ "webcamCropY": "Pan vertically",
+ "reactiveWebcam": "Shrink on Zoom",
"shapes": {
- "circle": "Circle",
+ "rectangle": "Rect",
"square": "Square",
"rounded": "Rounded",
- "rectangle": "Rect"
+ "circle": "Circle"
},
- "webcamCropY": "Pan vertically",
- "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
- "selectPreset": "Select preset",
- "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
"preset": "Preset",
- "webcamFraming": "Webcam crop",
- "webcamBackground": "Camera Background",
- "webcamShape": "Camera Shape",
- "title": "Camera layout",
+ "dualFrame": "Dual Frame",
"bgModes": {
- "blur": "Blur",
- "transparent": "Cutout",
"none": "Original",
- "custom": "Custom"
+ "transparent": "Cutout",
+ "custom": "Custom",
+ "blur": "Blur"
},
- "mirrorWebcam": "Mirror Webcam",
- "noWebcam": "No Webcam",
- "pictureInPicture": "Picture in Picture",
- "reactiveWebcam": "Shrink on Zoom",
"webcamCropZoom": "Zoom",
- "dualFrame": "Dual Frame",
+ "webcamShape": "Camera Shape",
+ "webcamBlurIntensity": "Blur Intensity",
+ "title": "Camera layout",
"reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
+ "webcamFraming": "Webcam crop",
"webcamCropX": "Pan horizontally",
+ "selectPreset": "Select preset",
+ "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
+ "mirrorWebcam": "Mirror Webcam",
+ "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
+ "webcamBackground": "Camera Background",
+ "noWebcam": "No Webcam",
+ "pictureInPicture": "Picture in Picture",
+ "webcamSize": "Webcam Size",
"verticalStack": "Vertical Stack"
},
- "project": {
- "load": "Load Project",
- "save": "Save Project",
- "new": "New Project"
- },
- "audio": {
- "outputGain": "Output level",
- "reset": "Reset audio",
- "help": "Adjust the audio output level. It applies identically in the preview and the export.",
- "title": "Audio"
- },
- "facets": {
- "transcript": "Transcript",
- "captions": "Captions"
- },
- "cursor": {
- "themeDefault": "Default",
- "title": "Cursor",
- "theme": "Cursor Style",
- "clickBounce": "Click Bounce",
- "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
- "smoothing": "Smoothing",
- "size": "Size",
- "motionBlur": "Motion Blur",
- "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
- "show": "Show Cursor",
- "clipToBounds": "Clip to Canvas"
+ "speed": {
+ "customPlaybackSpeed": "Custom Playback Speed",
+ "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected.",
+ "maxSpeedError": "Speed can't go higher than {{max}}×",
+ "playbackSpeed": "Playback Speed",
+ "deleteRegion": "Delete Speed Region",
+ "selectRegion": "Select a speed region to adjust"
},
"imageUpload": {
"failedToUpload": "Failed to upload image",
- "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
- "invalidFileType": "Invalid file type",
+ "uploadSuccess": "Custom image uploaded successfully!",
"errorReading": "There was an error reading the file.",
- "uploadSuccess": "Custom image uploaded successfully!"
+ "invalidFileType": "Invalid file type",
+ "jpgOnly": "Please upload a JPG, JPEG, or PNG image file."
},
- "exportFormat": {
- "gifAnimation": "GIF Animation",
- "mp4Description": "High quality video file",
- "mp4Video": "MP4 Video",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Animated image for sharing"
+ "transcript": {
+ "blankedWord": "blanked",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Type between two words to add one, in amber: it reaches the captions and leaves the film alone. Hover a marked word to undo it.",
+ "editWord": "Edit \"{{word}}\"",
+ "editorAria": "Transcript for {{filename}}",
+ "noTranscript": "No transcript yet",
+ "clipLabel": "Clip {{index}}",
+ "trimSilence": "Trim silence ({{duration}}s)",
+ "laneVoiceover": "Voice-over",
+ "restoreSilence": "Restore silence ({{duration}}s)",
+ "transcribeNow": "Transcribe now",
+ "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
+ "removeInserted": "Delete \"{{word}}\"",
+ "insertedWord": "Added by you — no audio behind it",
+ "restoreWord": "Restore \"{{word}}\"",
+ "silence": "[silence {{duration}}s]",
+ "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
+ "laneRecording": "Recording",
+ "noClips": "No clips yet",
+ "noAudio": "This media has no audio track",
+ "laneLabel": "Read the transcript from",
+ "revertWord": "Restore \"{{original}}\"",
+ "title": "Current transcription",
+ "transcribing": "Transcribing…",
+ "insertAria": "New word",
+ "noClipTranscript": "No transcript for this clip — open the asset card and regenerate."
+ },
+ "captions": {
+ "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
+ "distanceFromTop": "Distance from top",
+ "minWords": "Min words per line",
+ "showBackground": "Show background",
+ "lineLength": "Line length",
+ "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
+ "translating": "Translating…",
+ "distanceFromLeft": "Distance from left",
+ "anchorHintTop": "Long captions grow downward — the top edge stays put.",
+ "position": "Position",
+ "translateFailed": "Translation failed.",
+ "backgroundOpacity": "Opacity",
+ "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
+ "original": "Original (transcript)",
+ "font": "Font",
+ "distanceFromRight": "Distance from right",
+ "textColor": "Text color",
+ "alignCenter": "Center",
+ "translateHint": "Translate the transcript with the configured AI provider",
+ "language": "Language",
+ "show": "Show captions",
+ "anchorTop": "Top",
+ "backgroundColor": "Background color",
+ "removeLegacyAnnotations": "Remove old caption annotations",
+ "background": "Background",
+ "bold": "Bold",
+ "distanceFromBottom": "Distance from bottom",
+ "fontSize": "Size",
+ "anchorBottom": "Bottom",
+ "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
+ "maxWords": "Max words per line",
+ "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
+ "translate": "Translate",
+ "deleteTranslation": "Delete this translation",
+ "anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
+ "text": "Text",
+ "displayLanguage": "Display",
+ "alignRight": "Right",
+ "alignLeft": "Left"
+ },
+ "support": {
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "Report Bug",
+ "starOnGithub": "Star on GitHub"
+ },
+ "customFont": {
+ "nameHelp": "This is how the font will appear in the font selector",
+ "errorInvalidUrl": "Please enter a valid Google Fonts URL",
+ "urlLabel": "Google Fonts Import URL",
+ "addingButton": "Adding...",
+ "namePlaceholder": "My Custom Font",
+ "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
+ "dialogTitle": "Add Google Font",
+ "failedToAdd": "Failed to add font",
+ "errorEmptyUrl": "Please enter a Google Fonts import URL",
+ "addButton": "Add Font",
+ "errorEmptyName": "Please enter a font name",
+ "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
+ "nameLabel": "Display Name",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "Could not extract font family from URL",
+ "successMessage": "Font \"{{fontName}}\" added successfully",
+ "errorTimeout": "Font took too long to load. Please check the URL and try again."
},
"background": {
- "colorLabel": "Color {{color}}",
- "customWallpaper": "Custom wallpaper",
- "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
- "gradient": "Gradient",
"presets": "Presets",
- "color": "Color",
- "custom": "Custom",
- "colorWheel": "Color Wheel",
- "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
"image": "Image",
- "uploadCustom": "Upload Custom",
"title": "Background",
+ "custom": "Custom",
+ "colorLabel": "Color {{color}}",
"imageLabel": "Background {{index}}",
+ "customWallpaper": "Custom wallpaper",
"colorPalette": "Color Palette",
+ "uploadCustom": "Upload Custom",
+ "color": "Color",
"imageReadFailed": "Could not read that image file.",
- "gradientLabel": "Gradient {{index}}"
+ "gradientLabel": "Gradient {{index}}",
+ "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
+ "gradient": "Gradient",
+ "colorWheel": "Color Wheel",
+ "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk."
+ },
+ "audio": {
+ "reset": "Reset audio",
+ "outputGain": "Output level",
+ "help": "Adjust the audio output level. It applies identically in the preview and the export.",
+ "title": "Audio"
+ },
+ "textAnimation": {
+ "pulse": "Pulse",
+ "selectAnimation": "Select animation",
+ "fade": "Fade",
+ "typewriter": "Typewriter",
+ "slideLeft": "Slide Left",
+ "none": "None",
+ "rise": "Rise",
+ "pop": "Pop",
+ "title": "Text Animation"
},
"crop": {
"title": "Crop",
+ "unlockAspectRatio": "Unlock aspect ratio",
+ "free": "Free",
"dragInstruction": "Drag on each side to adjust the crop area",
"done": "Done",
- "free": "Free",
- "ratio": "Ratio",
- "cropVideo": "Crop Video",
"lockAspectRatio": "Lock aspect ratio",
- "unlockAspectRatio": "Unlock aspect ratio"
+ "ratio": "Ratio",
+ "cropVideo": "Crop Video"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "Export resolution"
+ },
+ "effects": {
+ "off": "off",
+ "on": "on",
+ "fitClip": "Fit",
+ "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
+ "fitClipFew": "{{count}} clips",
+ "blurBg": "Blur BG",
+ "motion": "Motion",
+ "shadow": "Shadow",
+ "fitClipOne": "{{count}} clip",
+ "format": "Format",
+ "roundness": "Roundness",
+ "fitClipMany": "{{count}} clips",
+ "formatOriginal": "Original",
+ "frame": "Frame",
+ "motionBlur": "Motion Blur",
+ "padding": "Padding",
+ "title": "Composition"
+ },
+ "language": {
+ "title": "Language"
},
"gifSettings": {
"size": "GIF Size",
- "frameRate": "GIF Frame Rate",
- "loop": "Loop GIF"
+ "loop": "Loop GIF",
+ "frameRate": "GIF Frame Rate"
},
- "support": {
- "starOnGithub": "Star on GitHub",
- "reportBug": "Report Bug",
- "saveDiagnostics": "Save Diagnostics"
+ "panes": {
+ "help": "Help"
},
"export": {
+ "chooseSaveLocation": "Choose Save Location",
"gifButton": "Export GIF",
- "videoButton": "Export Video",
- "chooseSaveLocation": "Choose Save Location"
+ "videoButton": "Export Video"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "Export resolution",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 Video",
+ "mp4Description": "High quality video file",
+ "gifAnimation": "GIF Animation",
+ "gifDescription": "Animated image for sharing",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Language"
+ "project": {
+ "save": "Save Project",
+ "new": "New Project",
+ "load": "Load Project"
+ },
+ "facets": {
+ "captions": "Captions",
+ "transcript": "Transcript"
},
"trim": {
"deleteRegion": "Delete Trim Region"
- },
- "panes": {
- "help": "Help"
}
}
diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json
index b6bcc2f86..dc4f184f5 100644
--- a/src/i18n/locales/es/editor.json
+++ b/src/i18n/locales/es/editor.json
@@ -13,7 +13,9 @@
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}",
"previewCompositorUnavailable": "Vista previa no disponible en este equipo",
- "wordEditFailed": "No se pudo cambiar esa palabra"
+ "wordEditFailed": "No se pudo cambiar esa palabra",
+ "wordInsertFailed": "No se pudo añadir esa palabra",
+ "wordRemoveFailed": "No se pudo eliminar esa palabra"
},
"export": {
"canceled": "Exportación cancelada",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 7aa6b8d35..788c1934b 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Clip {{index}}",
- "restoreSilence": "Restaurar silencio ({{duration}} s)",
- "laneVoiceover": "Voz en off",
- "editorAria": "Transcripción de {{filename}}",
- "noTranscript": "Aún no hay transcripción",
- "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
- "blankedWord": "vaciada",
- "laneLabel": "Leer la transcripción desde",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto: los subtítulos la siguen, el vídeo no cambia. Pasa el cursor sobre una palabra marcada para restaurarla.",
- "noClips": "Aún no hay clips",
- "trimSilence": "Recortar silencio ({{duration}} s)",
- "transcribing": "Transcribiendo…",
- "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
- "editWord": "Editar «{{word}}»",
- "restoreWord": "Restaurar «{{word}}»",
- "correctedWord": "Corregida: la transcripción decía «{{original}}»",
- "transcribeNow": "Transcribir ahora",
- "revertWord": "Restaurar «{{original}}»",
- "silence": "[silencio {{duration}} s]",
- "laneRecording": "Grabación",
- "title": "Transcripción actual",
- "noAudio": "Este medio no tiene pista de audio"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
+ "x": "X (%)",
+ "title": "Posición de enfoque"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Derecha",
+ "left": "Izquierda",
+ "iso": "Iso"
+ },
+ "none": "Ninguna",
+ "title": "Rotación 3D"
+ },
+ "focusMode": {
+ "manual": "Manual",
+ "title": "Modo de enfoque",
+ "autoDescription": "La cámara sigue la posición del cursor grabado",
+ "lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
+ "auto": "Auto"
+ },
+ "selectRegion": "Selecciona una región de zoom para ajustar",
+ "customScale": "Zoom personalizado",
+ "previewHold": "Mantener para previsualizar el efecto de zoom",
+ "level": "Nivel de zoom",
+ "deleteZoom": "Eliminar zoom"
},
- "captions": {
- "showBackground": "Mostrar fondo",
- "alignLeft": "Izquierda",
- "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
- "backgroundColor": "Color del fondo",
- "translating": "Traduciendo…",
- "backgroundOpacity": "Opacidad",
- "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
- "translateFailed": "La traducción ha fallado.",
- "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
- "text": "Texto",
- "fontSize": "Tamaño",
- "deleteTranslation": "Eliminar esta traducción",
- "maxWords": "Máx. palabras por línea",
- "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
- "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
- "anchorBottom": "Abajo",
- "distanceFromLeft": "Distancia desde la izquierda",
- "textColor": "Color del texto",
- "background": "Fondo",
- "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
- "minWords": "Mín. palabras por línea",
- "lineLength": "Longitud de línea",
- "font": "Fuente",
- "original": "Original (transcripción)",
- "bold": "Negrita",
- "displayLanguage": "Visualización",
- "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
- "alignCenter": "Centro",
- "distanceFromBottom": "Distancia desde abajo",
- "distanceFromTop": "Distancia desde arriba",
- "translate": "Traducir",
- "position": "Posición",
- "language": "Idioma",
- "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
- "show": "Mostrar subtítulos",
- "anchorTop": "Arriba",
- "alignRight": "Derecha",
- "distanceFromRight": "Distancia desde la derecha",
- "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve."
+ "audioTrack": {
+ "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "defaultLabel": "Pista de audio",
+ "fadeOut": "Desvanecido",
+ "add": "Añadir pista de audio",
+ "slipHint": "Alt + arrastrar para desplazar el audio dentro",
+ "loop": "Bucle",
+ "remove": "Eliminar pista",
+ "fadeIn": "Aparición",
+ "mute": "Silenciar",
+ "importFailed": "No se pudo añadir el audio"
},
- "effects": {
- "padding": "Relleno",
- "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
- "motion": "Movimiento",
- "title": "Composición",
- "blurBg": "Desenfocar fondo",
- "fitClip": "Ajustar",
- "on": "activado",
- "roundness": "Redondez",
- "frame": "Marco",
- "formatOriginal": "Original",
- "shadow": "Sombra",
- "fitClipMany": "{{count}} clips",
- "fitClipFew": "{{count}} clips",
- "off": "desactivado",
- "format": "Formato",
+ "cursor": {
+ "clipToBounds": "Recortar al lienzo",
+ "size": "Tamaño",
"motionBlur": "Desenfoque de movimiento",
- "fitClipOne": "{{count}} clip"
+ "theme": "Estilo del cursor",
+ "clickBounce": "Rebote al clic",
+ "title": "Cursor",
+ "smoothing": "Suavizado",
+ "themeDefault": "Predeterminado",
+ "show": "Mostrar cursor",
+ "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
+ "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen."
},
"annotation": {
- "arrowColor": "Color de la flecha",
+ "blurIntensity": "Intensidad del desenfoque",
+ "textContent": "Contenido de texto",
+ "blurShapeFreehand": "Mano alzada",
"active": "Activo",
- "blurTypeMosaic": "Mosaico",
- "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
- "title": "Configuración de anotaciones",
- "blurColorBlack": "Negro",
- "imageUploadSuccess": "¡Imagen subida exitosamente!",
- "deleteAnnotation": "Eliminar anotación",
- "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
- "typeText": "Texto",
+ "blurShapeRectangle": "Rectángulo",
"customFonts": "Fuentes personalizadas",
- "color": "Color",
- "invalidImageType": "Tipo de archivo no válido",
- "type": "Tipo",
- "size": "Tamaño",
- "blurShapeFreehand": "Mano alzada",
- "mosaicBlockSize": "Tamano del bloque mosaico",
- "fontStyle": "Estilo de fuente",
- "blurColorWhite": "Blanco",
- "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
- "textPlaceholder": "Escribe tu texto...",
+ "title": "Configuración de anotaciones",
+ "blurShapeOval": "Óvalo",
"blurType": "Tipo de desenfoque",
+ "mosaicBlockSize": "Tamano del bloque mosaico",
"colorPalette": "Paleta de colores",
+ "textColor": "Color de texto",
+ "colorWheel": "Rueda de colores",
+ "shortcutsAndTips": "Atajos y consejos",
+ "defaultText": "Hola",
+ "clearBackground": "Quitar fondo",
+ "type": "Tipo",
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
+ "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
"background": "Fondo",
- "blurShapeRectangle": "Rectángulo",
- "typeArrow": "Flecha",
- "blurIntensity": "Intensidad del desenfoque",
- "uploadImage": "Subir imagen",
- "blurShapeOval": "Óvalo",
- "strokeWidth": "Grosor del trazo: {{width}}px",
+ "blurShape": "Forma del desenfoque",
+ "arrowColor": "Color de la flecha",
+ "invalidImageType": "Tipo de archivo no válido",
+ "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
+ "blurColorBlack": "Negro",
+ "blurTypeBlur": "Gaussiano",
+ "none": "Ninguno",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
+ "size": "Tamaño",
+ "imageUploadSuccess": "¡Imagen subida exitosamente!",
+ "blurColorWhite": "Blanco",
"arrowDirection": "Dirección de la flecha",
- "textContent": "Contenido de texto",
+ "typeImage": "Imagen",
+ "typeText": "Texto",
+ "typeArrow": "Flecha",
+ "color": "Color",
"blurColor": "Color del desenfoque",
"selectStyle": "Seleccionar estilo",
- "colorWheel": "Rueda de colores",
- "textColor": "Color de texto",
- "none": "Ninguno",
- "defaultText": "Hola",
- "blurTypeBlur": "Gaussiano",
- "shortcutsAndTips": "Atajos y consejos",
- "clearBackground": "Quitar fondo",
+ "blurTypeMosaic": "Mosaico",
+ "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
+ "textPlaceholder": "Escribe tu texto...",
+ "uploadImage": "Subir imagen",
+ "strokeWidth": "Grosor del trazo: {{width}}px",
"typeBlur": "Desenfoque",
- "typeImage": "Imagen",
- "blurShape": "Forma del desenfoque"
- },
- "textAnimation": {
- "pop": "Aparecer",
- "fade": "Desvanecimiento",
- "none": "Ninguna",
- "selectAnimation": "Seleccionar animación",
- "typewriter": "Máquina de escribir",
- "rise": "Ascender",
- "slideLeft": "Deslizar izquierda",
- "pulse": "Pulso",
- "title": "Animación de texto"
- },
- "audioTrack": {
- "slipHint": "Alt + arrastrar para desplazar el audio dentro",
- "defaultLabel": "Pista de audio",
- "mute": "Silenciar",
- "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
- "loop": "Bucle",
- "add": "Añadir pista de audio",
- "fadeIn": "Aparición",
- "remove": "Eliminar pista",
- "importFailed": "No se pudo añadir el audio",
- "fadeOut": "Desvanecido"
- },
- "customFont": {
- "addingButton": "Agregando...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
- "failedToAdd": "Error al agregar la fuente",
- "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
- "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
- "errorEmptyName": "Por favor ingresa un nombre de fuente",
- "urlLabel": "URL de importación de Google Fonts",
- "nameLabel": "Nombre para mostrar",
- "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
- "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
- "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
- "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
- "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
- "namePlaceholder": "Mi fuente personalizada",
- "addButton": "Agregar fuente",
- "dialogTitle": "Agregar fuente de Google"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Derecha",
- "iso": "Iso",
- "left": "Izquierda"
- },
- "none": "Ninguna",
- "title": "Rotación 3D"
- },
- "position": {
- "title": "Posición de enfoque",
- "y": "Y (%)",
- "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
- "x": "X (%)"
- },
- "deleteZoom": "Eliminar zoom",
- "previewHold": "Mantener para previsualizar el efecto de zoom",
- "customScale": "Zoom personalizado",
- "focusMode": {
- "autoDescription": "La cámara sigue la posición del cursor grabado",
- "title": "Modo de enfoque",
- "lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
- "auto": "Auto",
- "manual": "Manual"
- },
- "level": "Nivel de zoom",
- "selectRegion": "Selecciona una región de zoom para ajustar"
- },
- "speed": {
- "selectRegion": "Selecciona una región de velocidad para ajustar",
- "customPlaybackSpeed": "Velocidad personalizada",
- "playbackSpeed": "Velocidad de reproducción",
- "deleteRegion": "Eliminar región de velocidad",
- "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada.",
- "maxSpeedError": "La velocidad no puede superar {{max}}×"
+ "deleteAnnotation": "Eliminar anotación",
+ "fontStyle": "Estilo de fuente"
},
"layout": {
- "webcamSize": "Tamaño de cámara",
- "webcamBlurIntensity": "Intensidad del desenfoque",
+ "webcamCropY": "Desplazamiento vertical",
+ "reactiveWebcam": "Reducir al ampliar",
"shapes": {
- "circle": "Círculo",
+ "rectangle": "Rect.",
"square": "Cuadrado",
"rounded": "Redondeado",
- "rectangle": "Rect."
+ "circle": "Círculo"
},
- "webcamCropY": "Desplazamiento vertical",
- "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
- "selectPreset": "Seleccionar predefinido",
- "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
"preset": "Predefinido",
- "webcamFraming": "Encuadre de cámara",
- "webcamBackground": "Fondo de la cámara",
- "webcamShape": "Forma de cámara",
- "title": "Disposición de cámara",
+ "dualFrame": "Marco dual",
"bgModes": {
- "blur": "Desenfocado",
- "transparent": "Recortado",
"none": "Original",
- "custom": "Personalizado"
+ "transparent": "Recortado",
+ "custom": "Personalizado",
+ "blur": "Desenfocado"
},
- "mirrorWebcam": "Reflejar cámara",
- "noWebcam": "Sin cámara",
- "pictureInPicture": "Imagen en imagen",
- "reactiveWebcam": "Reducir al ampliar",
"webcamCropZoom": "Zoom de recorte",
- "dualFrame": "Marco dual",
+ "webcamShape": "Forma de cámara",
+ "webcamBlurIntensity": "Intensidad del desenfoque",
+ "title": "Disposición de cámara",
"reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
+ "webcamFraming": "Encuadre de cámara",
"webcamCropX": "Desplazamiento horizontal",
+ "selectPreset": "Seleccionar predefinido",
+ "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
+ "mirrorWebcam": "Reflejar cámara",
+ "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
+ "webcamBackground": "Fondo de la cámara",
+ "noWebcam": "Sin cámara",
+ "pictureInPicture": "Imagen en imagen",
+ "webcamSize": "Tamaño de cámara",
"verticalStack": "Apilado vertical"
},
- "project": {
- "load": "Cargar proyecto",
- "save": "Guardar proyecto",
- "new": "Nuevo proyecto"
- },
- "audio": {
- "outputGain": "Ajuste de salida",
- "reset": "Restablecer audio",
- "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
- "title": "Audio"
- },
- "facets": {
- "transcript": "Transcripción",
- "captions": "Subtítulos"
- },
- "cursor": {
- "themeDefault": "Predeterminado",
- "title": "Cursor",
- "theme": "Estilo del cursor",
- "clickBounce": "Rebote al clic",
- "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
- "smoothing": "Suavizado",
- "size": "Tamaño",
- "motionBlur": "Desenfoque de movimiento",
- "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
- "show": "Mostrar cursor",
- "clipToBounds": "Recortar al lienzo"
+ "speed": {
+ "customPlaybackSpeed": "Velocidad personalizada",
+ "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada.",
+ "maxSpeedError": "La velocidad no puede superar {{max}}×",
+ "playbackSpeed": "Velocidad de reproducción",
+ "deleteRegion": "Eliminar región de velocidad",
+ "selectRegion": "Selecciona una región de velocidad para ajustar"
},
"imageUpload": {
"failedToUpload": "Error al subir la imagen",
- "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
- "invalidFileType": "Tipo de archivo no válido",
+ "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"errorReading": "Hubo un error al leer el archivo.",
- "uploadSuccess": "¡Imagen personalizada subida exitosamente!"
+ "invalidFileType": "Tipo de archivo no válido",
+ "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG."
},
- "exportFormat": {
- "gifAnimation": "Animación GIF",
- "mp4Description": "Archivo de video de alta calidad",
- "mp4Video": "Video MP4",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Imagen animada para compartir"
+ "transcript": {
+ "blankedWord": "vaciada",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "editWord": "Editar «{{word}}»",
+ "editorAria": "Transcripción de {{filename}}",
+ "noTranscript": "Aún no hay transcripción",
+ "clipLabel": "Clip {{index}}",
+ "trimSilence": "Recortar silencio ({{duration}} s)",
+ "laneVoiceover": "Voz en off",
+ "restoreSilence": "Restaurar silencio ({{duration}} s)",
+ "transcribeNow": "Transcribir ahora",
+ "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
+ "removeInserted": "Eliminar «{{word}}»",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "restoreWord": "Restaurar «{{word}}»",
+ "silence": "[silencio {{duration}} s]",
+ "correctedWord": "Corregida: la transcripción decía «{{original}}»",
+ "laneRecording": "Grabación",
+ "noClips": "Aún no hay clips",
+ "noAudio": "Este medio no tiene pista de audio",
+ "laneLabel": "Leer la transcripción desde",
+ "revertWord": "Restaurar «{{original}}»",
+ "title": "Transcripción actual",
+ "transcribing": "Transcribiendo…",
+ "insertAria": "Palabra nueva",
+ "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla."
+ },
+ "captions": {
+ "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
+ "distanceFromTop": "Distancia desde arriba",
+ "minWords": "Mín. palabras por línea",
+ "showBackground": "Mostrar fondo",
+ "lineLength": "Longitud de línea",
+ "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
+ "translating": "Traduciendo…",
+ "distanceFromLeft": "Distancia desde la izquierda",
+ "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
+ "position": "Posición",
+ "translateFailed": "La traducción ha fallado.",
+ "backgroundOpacity": "Opacidad",
+ "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
+ "original": "Original (transcripción)",
+ "font": "Fuente",
+ "distanceFromRight": "Distancia desde la derecha",
+ "textColor": "Color del texto",
+ "alignCenter": "Centro",
+ "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
+ "language": "Idioma",
+ "show": "Mostrar subtítulos",
+ "anchorTop": "Arriba",
+ "backgroundColor": "Color del fondo",
+ "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
+ "background": "Fondo",
+ "bold": "Negrita",
+ "distanceFromBottom": "Distancia desde abajo",
+ "fontSize": "Tamaño",
+ "anchorBottom": "Abajo",
+ "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
+ "maxWords": "Máx. palabras por línea",
+ "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
+ "translate": "Traducir",
+ "deleteTranslation": "Eliminar esta traducción",
+ "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
+ "text": "Texto",
+ "displayLanguage": "Visualización",
+ "alignRight": "Derecha",
+ "alignLeft": "Izquierda"
+ },
+ "support": {
+ "saveDiagnostics": "Guardar diagnósticos",
+ "reportBug": "Reportar error",
+ "starOnGithub": "Dar estrella en GitHub"
+ },
+ "customFont": {
+ "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
+ "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
+ "urlLabel": "URL de importación de Google Fonts",
+ "addingButton": "Agregando...",
+ "namePlaceholder": "Mi fuente personalizada",
+ "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
+ "dialogTitle": "Agregar fuente de Google",
+ "failedToAdd": "Error al agregar la fuente",
+ "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
+ "addButton": "Agregar fuente",
+ "errorEmptyName": "Por favor ingresa un nombre de fuente",
+ "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
+ "nameLabel": "Nombre para mostrar",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
+ "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
+ "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo."
},
"background": {
- "colorLabel": "Color {{color}}",
- "customWallpaper": "Fondo personalizado",
- "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
- "gradient": "Degradado",
"presets": "Ajustes preestablecidos",
- "color": "Color",
- "custom": "Personalizado",
- "colorWheel": "Rueda de colores",
- "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
"image": "Imagen",
- "uploadCustom": "Subir personalizado",
"title": "Fondo",
+ "custom": "Personalizado",
+ "colorLabel": "Color {{color}}",
"imageLabel": "Fondo {{index}}",
+ "customWallpaper": "Fondo personalizado",
"colorPalette": "Paleta de colores",
+ "uploadCustom": "Subir personalizado",
+ "color": "Color",
"imageReadFailed": "No se pudo leer ese archivo de imagen.",
- "gradientLabel": "Degradado {{index}}"
+ "gradientLabel": "Degradado {{index}}",
+ "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
+ "gradient": "Degradado",
+ "colorWheel": "Rueda de colores",
+ "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco."
+ },
+ "audio": {
+ "reset": "Restablecer audio",
+ "outputGain": "Ajuste de salida",
+ "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
+ "title": "Audio"
+ },
+ "textAnimation": {
+ "pulse": "Pulso",
+ "selectAnimation": "Seleccionar animación",
+ "fade": "Desvanecimiento",
+ "typewriter": "Máquina de escribir",
+ "slideLeft": "Deslizar izquierda",
+ "none": "Ninguna",
+ "rise": "Ascender",
+ "pop": "Aparecer",
+ "title": "Animación de texto"
},
"crop": {
"title": "Recortar",
+ "unlockAspectRatio": "Desbloquear relación de aspecto",
+ "free": "Libre",
"dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
"done": "Listo",
- "free": "Libre",
- "ratio": "Proporción",
- "cropVideo": "Recortar video",
"lockAspectRatio": "Bloquear relación de aspecto",
- "unlockAspectRatio": "Desbloquear relación de aspecto"
+ "ratio": "Proporción",
+ "cropVideo": "Recortar video"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "Resolución de exportación"
+ },
+ "effects": {
+ "off": "desactivado",
+ "on": "activado",
+ "fitClip": "Ajustar",
+ "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
+ "fitClipFew": "{{count}} clips",
+ "blurBg": "Desenfocar fondo",
+ "motion": "Movimiento",
+ "shadow": "Sombra",
+ "fitClipOne": "{{count}} clip",
+ "format": "Formato",
+ "roundness": "Redondez",
+ "fitClipMany": "{{count}} clips",
+ "formatOriginal": "Original",
+ "frame": "Marco",
+ "motionBlur": "Desenfoque de movimiento",
+ "padding": "Relleno",
+ "title": "Composición"
+ },
+ "language": {
+ "title": "Idioma"
},
"gifSettings": {
"size": "Tamaño del GIF",
- "frameRate": "Velocidad de cuadros del GIF",
- "loop": "Repetir GIF"
+ "loop": "Repetir GIF",
+ "frameRate": "Velocidad de cuadros del GIF"
},
- "support": {
- "starOnGithub": "Dar estrella en GitHub",
- "reportBug": "Reportar error",
- "saveDiagnostics": "Guardar diagnósticos"
+ "panes": {
+ "help": "Ayuda"
},
"export": {
+ "chooseSaveLocation": "Elegir ubicación de guardado",
"gifButton": "Exportar GIF",
- "videoButton": "Exportar video",
- "chooseSaveLocation": "Elegir ubicación de guardado"
+ "videoButton": "Exportar video"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "Resolución de exportación",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "Video MP4",
+ "mp4Description": "Archivo de video de alta calidad",
+ "gifAnimation": "Animación GIF",
+ "gifDescription": "Imagen animada para compartir",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Idioma"
+ "project": {
+ "save": "Guardar proyecto",
+ "new": "Nuevo proyecto",
+ "load": "Cargar proyecto"
+ },
+ "facets": {
+ "captions": "Subtítulos",
+ "transcript": "Transcripción"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
- },
- "panes": {
- "help": "Ayuda"
}
}
diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json
index c99367c04..d58a2f196 100644
--- a/src/i18n/locales/fr/editor.json
+++ b/src/i18n/locales/fr/editor.json
@@ -19,7 +19,9 @@
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}",
"previewCompositorUnavailable": "Aperçu indisponible sur cette machine",
- "wordEditFailed": "Impossible de modifier ce mot"
+ "wordEditFailed": "Impossible de modifier ce mot",
+ "wordInsertFailed": "Impossible d'ajouter ce mot",
+ "wordRemoveFailed": "Impossible de supprimer ce mot"
},
"export": {
"canceled": "Export annulé",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 0eb66599d..1e832e129 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Clip {{index}}",
- "restoreSilence": "Restaurer le silence ({{duration}} s)",
- "laneVoiceover": "Voix off",
- "editorAria": "Transcription de {{filename}}",
- "noTranscript": "Aucune transcription pour l'instant",
- "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
- "blankedWord": "vidé",
- "laneLabel": "Lire la transcription depuis",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte : les sous-titres suivent, le film ne bouge pas. Survolez un mot marqué pour le rétablir.",
- "noClips": "Aucun clip pour l'instant",
- "trimSilence": "Couper le silence ({{duration}} s)",
- "transcribing": "Transcription…",
- "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
- "editWord": "Modifier « {{word}} »",
- "restoreWord": "Restaurer « {{word}} »",
- "correctedWord": "Corrigé — la transcription disait « {{original}} »",
- "transcribeNow": "Transcrire maintenant",
- "revertWord": "Rétablir « {{original}} »",
- "silence": "[silence {{duration}} s]",
- "laneRecording": "Enregistrement",
- "title": "Transcription actuelle",
- "noAudio": "Ce média n'a pas de piste audio"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
+ "x": "X (%)",
+ "title": "Position du focus"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Droite",
+ "left": "Gauche",
+ "iso": "Iso"
+ },
+ "none": "Aucune",
+ "title": "Rotation 3D"
+ },
+ "focusMode": {
+ "manual": "Manuel",
+ "title": "Mode focus",
+ "autoDescription": "La caméra suit la position du curseur enregistré",
+ "lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
+ "auto": "Auto"
+ },
+ "selectRegion": "Sélectionnez une région de zoom à ajuster",
+ "customScale": "Zoom personnalisé",
+ "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
+ "level": "Niveau de zoom",
+ "deleteZoom": "Supprimer le zoom"
},
- "captions": {
- "showBackground": "Afficher le fond",
- "alignLeft": "Gauche",
- "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
- "backgroundColor": "Couleur du fond",
- "translating": "Traduction…",
- "backgroundOpacity": "Opacité",
- "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "translateFailed": "La traduction a échoué.",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
- "text": "Texte",
- "fontSize": "Taille",
- "deleteTranslation": "Supprimer cette traduction",
- "maxWords": "Mots max. par ligne",
- "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
- "anchorBottom": "Bas",
- "distanceFromLeft": "Distance depuis la gauche",
- "textColor": "Couleur du texte",
- "background": "Fond",
- "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
- "minWords": "Mots min. par ligne",
- "lineLength": "Longueur des lignes",
- "font": "Police",
- "original": "Original (transcription)",
- "bold": "Gras",
- "displayLanguage": "Affichage",
- "removeLegacyAnnotations": "Supprimer les anciennes annotations",
- "alignCenter": "Centre",
- "distanceFromBottom": "Distance depuis le bas",
- "distanceFromTop": "Distance depuis le haut",
- "translate": "Traduire",
- "position": "Position",
- "language": "Langue",
- "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
- "show": "Afficher les sous-titres",
- "anchorTop": "Haut",
- "alignRight": "Droite",
- "distanceFromRight": "Distance depuis la droite",
- "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas."
+ "audioTrack": {
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
+ "defaultLabel": "Piste audio",
+ "fadeOut": "Fondu de sortie",
+ "add": "Ajouter une piste audio",
+ "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
+ "loop": "Boucle",
+ "remove": "Supprimer la piste",
+ "fadeIn": "Fondu d'entrée",
+ "mute": "Muet",
+ "importFailed": "Impossible d’ajouter l’audio"
},
- "effects": {
- "padding": "Marge",
- "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
- "motion": "Mouvement",
- "title": "Composition",
- "blurBg": "Flou arrière-plan",
- "fitClip": "Ajuster",
- "on": "activé",
- "roundness": "Arrondi",
- "frame": "Cadre",
- "formatOriginal": "Original",
- "shadow": "Ombre",
- "fitClipMany": "{{count}} clips",
- "fitClipFew": "{{count}} clips",
- "off": "désactivé",
- "format": "Format",
+ "cursor": {
+ "clipToBounds": "Rogner au canevas",
+ "size": "Taille",
"motionBlur": "Flou de mouvement",
- "fitClipOne": "{{count}} clip"
+ "theme": "Style du curseur",
+ "clickBounce": "Rebond au clic",
+ "title": "Curseur",
+ "smoothing": "Lissage",
+ "themeDefault": "Par défaut",
+ "show": "Afficher le curseur",
+ "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
+ "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique."
},
"annotation": {
- "arrowColor": "Couleur de la flèche",
+ "blurIntensity": "Intensité du flou",
+ "textContent": "Contenu du texte",
+ "blurShapeFreehand": "Main levée",
"active": "Actif",
- "blurTypeMosaic": "Mosaïque",
- "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "title": "Paramètres d'annotation",
- "blurColorBlack": "Noir",
- "imageUploadSuccess": "Image téléversée avec succès !",
- "deleteAnnotation": "Supprimer l'annotation",
- "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
- "typeText": "Texte",
+ "blurShapeRectangle": "Rectangle",
"customFonts": "Polices personnalisées",
- "color": "Couleur",
- "invalidImageType": "Type de fichier invalide",
- "type": "Type",
- "size": "Taille",
- "blurShapeFreehand": "Main levée",
- "mosaicBlockSize": "Taille des blocs de mosaique",
- "fontStyle": "Style de police",
- "blurColorWhite": "Blanc",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
- "textPlaceholder": "Saisissez votre texte...",
+ "title": "Paramètres d'annotation",
+ "blurShapeOval": "Ovale",
"blurType": "Type de flou",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
"colorPalette": "Palette de couleurs",
+ "textColor": "Couleur du texte",
+ "colorWheel": "Roue chromatique",
+ "shortcutsAndTips": "Raccourcis & Astuces",
+ "defaultText": "Bonjour",
+ "clearBackground": "Supprimer l'arrière-plan",
+ "type": "Type",
"tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
+ "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
"background": "Arrière-plan",
- "blurShapeRectangle": "Rectangle",
- "typeArrow": "Flèche",
- "blurIntensity": "Intensité du flou",
- "uploadImage": "Téléverser une image",
- "blurShapeOval": "Ovale",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "blurShape": "Forme du flou",
+ "arrowColor": "Couleur de la flèche",
+ "invalidImageType": "Type de fichier invalide",
+ "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+ "blurColorBlack": "Noir",
+ "blurTypeBlur": "Gaussien",
+ "none": "Aucun",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "size": "Taille",
+ "imageUploadSuccess": "Image téléversée avec succès !",
+ "blurColorWhite": "Blanc",
"arrowDirection": "Direction de la flèche",
- "textContent": "Contenu du texte",
+ "typeImage": "Image",
+ "typeText": "Texte",
+ "typeArrow": "Flèche",
+ "color": "Couleur",
"blurColor": "Couleur du flou",
"selectStyle": "Choisir un style",
- "colorWheel": "Roue chromatique",
- "textColor": "Couleur du texte",
- "none": "Aucun",
- "defaultText": "Bonjour",
- "blurTypeBlur": "Gaussien",
- "shortcutsAndTips": "Raccourcis & Astuces",
- "clearBackground": "Supprimer l'arrière-plan",
+ "blurTypeMosaic": "Mosaïque",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "textPlaceholder": "Saisissez votre texte...",
+ "uploadImage": "Téléverser une image",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
"typeBlur": "Flou",
- "typeImage": "Image",
- "blurShape": "Forme du flou"
- },
- "textAnimation": {
- "pop": "Apparition",
- "fade": "Fondu",
- "none": "Aucune",
- "selectAnimation": "Sélectionner une animation",
- "typewriter": "Machine à écrire",
- "rise": "Monter",
- "slideLeft": "Glisser à gauche",
- "pulse": "Pulsation",
- "title": "Animation de texte"
- },
- "audioTrack": {
- "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
- "defaultLabel": "Piste audio",
- "mute": "Muet",
- "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
- "loop": "Boucle",
- "add": "Ajouter une piste audio",
- "fadeIn": "Fondu d'entrée",
- "remove": "Supprimer la piste",
- "importFailed": "Impossible d’ajouter l’audio",
- "fadeOut": "Fondu de sortie"
- },
- "customFont": {
- "addingButton": "Ajout en cours...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
- "failedToAdd": "Échec de l'ajout de la police",
- "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
- "successMessage": "Police « {{fontName}} » ajoutée avec succès",
- "errorEmptyName": "Veuillez saisir un nom de police",
- "urlLabel": "URL d'import Google Fonts",
- "nameLabel": "Nom d'affichage",
- "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
- "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
- "namePlaceholder": "Ma police personnalisée",
- "addButton": "Ajouter la police",
- "dialogTitle": "Ajouter une police Google"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Droite",
- "iso": "Iso",
- "left": "Gauche"
- },
- "none": "Aucune",
- "title": "Rotation 3D"
- },
- "position": {
- "title": "Position du focus",
- "y": "Y (%)",
- "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
- "x": "X (%)"
- },
- "deleteZoom": "Supprimer le zoom",
- "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "customScale": "Zoom personnalisé",
- "focusMode": {
- "autoDescription": "La caméra suit la position du curseur enregistré",
- "title": "Mode focus",
- "lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
- "auto": "Auto",
- "manual": "Manuel"
- },
- "level": "Niveau de zoom",
- "selectRegion": "Sélectionnez une région de zoom à ajuster"
- },
- "speed": {
- "selectRegion": "Sélectionnez une région de vitesse à ajuster",
- "customPlaybackSpeed": "Vitesse de lecture personnalisée",
- "playbackSpeed": "Vitesse de lecture",
- "deleteRegion": "Supprimer la région de vitesse",
- "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
- "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×"
+ "deleteAnnotation": "Supprimer l'annotation",
+ "fontStyle": "Style de police"
},
"layout": {
- "webcamSize": "Taille de la caméra",
- "webcamBlurIntensity": "Intensité du flou",
+ "webcamCropY": "Déplacement vertical",
+ "reactiveWebcam": "Réduire au zoom",
"shapes": {
- "circle": "Cercle",
+ "rectangle": "Rect.",
"square": "Carré",
"rounded": "Arrondi",
- "rectangle": "Rect."
+ "circle": "Cercle"
},
- "webcamCropY": "Déplacement vertical",
- "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
- "selectPreset": "Choisir un préréglage",
- "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
"preset": "Préréglage",
- "webcamFraming": "Cadrage de la webcam",
- "webcamBackground": "Arrière-plan de la caméra",
- "webcamShape": "Forme de la caméra",
- "title": "Disposition caméra",
+ "dualFrame": "Double cadre",
"bgModes": {
- "blur": "Flouté",
- "transparent": "Détouré",
"none": "Original",
- "custom": "Personnalisé"
+ "transparent": "Détouré",
+ "custom": "Personnalisé",
+ "blur": "Flouté"
},
- "mirrorWebcam": "Inverser la webcam",
- "noWebcam": "Sans webcam",
- "pictureInPicture": "Incrustation d'image",
- "reactiveWebcam": "Réduire au zoom",
"webcamCropZoom": "Zoom du recadrage",
- "dualFrame": "Double cadre",
+ "webcamShape": "Forme de la caméra",
+ "webcamBlurIntensity": "Intensité du flou",
+ "title": "Disposition caméra",
"reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
+ "webcamFraming": "Cadrage de la webcam",
"webcamCropX": "Déplacement horizontal",
+ "selectPreset": "Choisir un préréglage",
+ "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
+ "mirrorWebcam": "Inverser la webcam",
+ "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
+ "webcamBackground": "Arrière-plan de la caméra",
+ "noWebcam": "Sans webcam",
+ "pictureInPicture": "Incrustation d'image",
+ "webcamSize": "Taille de la caméra",
"verticalStack": "Empilement vertical"
},
- "project": {
- "load": "Charger un projet",
- "save": "Enregistrer le projet",
- "new": "Nouveau projet"
- },
- "audio": {
- "outputGain": "Niveau de sortie",
- "reset": "Réinitialiser l’audio",
- "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
- "title": "Audio"
- },
- "facets": {
- "transcript": "Transcription",
- "captions": "Sous-titres"
- },
- "cursor": {
- "themeDefault": "Par défaut",
- "title": "Curseur",
- "theme": "Style du curseur",
- "clickBounce": "Rebond au clic",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
- "smoothing": "Lissage",
- "size": "Taille",
- "motionBlur": "Flou de mouvement",
- "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
- "show": "Afficher le curseur",
- "clipToBounds": "Rogner au canevas"
+ "speed": {
+ "customPlaybackSpeed": "Vitesse de lecture personnalisée",
+ "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
+ "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
+ "playbackSpeed": "Vitesse de lecture",
+ "deleteRegion": "Supprimer la région de vitesse",
+ "selectRegion": "Sélectionnez une région de vitesse à ajuster"
},
"imageUpload": {
"failedToUpload": "Échec du téléversement de l'image",
- "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
- "invalidFileType": "Type de fichier invalide",
+ "uploadSuccess": "Image personnalisée téléversée avec succès !",
"errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
- "uploadSuccess": "Image personnalisée téléversée avec succès !"
+ "invalidFileType": "Type de fichier invalide",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG."
},
- "exportFormat": {
- "gifAnimation": "Animation GIF",
- "mp4Description": "Fichier vidéo haute qualité",
- "mp4Video": "Vidéo MP4",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Image animée pour le partage"
+ "transcript": {
+ "blankedWord": "vidé",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film. Survolez un mot marqué pour annuler.",
+ "editWord": "Modifier « {{word}} »",
+ "editorAria": "Transcription de {{filename}}",
+ "noTranscript": "Aucune transcription pour l'instant",
+ "clipLabel": "Clip {{index}}",
+ "trimSilence": "Couper le silence ({{duration}} s)",
+ "laneVoiceover": "Voix off",
+ "restoreSilence": "Restaurer le silence ({{duration}} s)",
+ "transcribeNow": "Transcrire maintenant",
+ "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
+ "removeInserted": "Supprimer « {{word}} »",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "restoreWord": "Restaurer « {{word}} »",
+ "silence": "[silence {{duration}} s]",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
+ "laneRecording": "Enregistrement",
+ "noClips": "Aucun clip pour l'instant",
+ "noAudio": "Ce média n'a pas de piste audio",
+ "laneLabel": "Lire la transcription depuis",
+ "revertWord": "Rétablir « {{original}} »",
+ "title": "Transcription actuelle",
+ "transcribing": "Transcription…",
+ "insertAria": "Nouveau mot",
+ "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération."
+ },
+ "captions": {
+ "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
+ "distanceFromTop": "Distance depuis le haut",
+ "minWords": "Mots min. par ligne",
+ "showBackground": "Afficher le fond",
+ "lineLength": "Longueur des lignes",
+ "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
+ "translating": "Traduction…",
+ "distanceFromLeft": "Distance depuis la gauche",
+ "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
+ "position": "Position",
+ "translateFailed": "La traduction a échoué.",
+ "backgroundOpacity": "Opacité",
+ "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
+ "original": "Original (transcription)",
+ "font": "Police",
+ "distanceFromRight": "Distance depuis la droite",
+ "textColor": "Couleur du texte",
+ "alignCenter": "Centre",
+ "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "language": "Langue",
+ "show": "Afficher les sous-titres",
+ "anchorTop": "Haut",
+ "backgroundColor": "Couleur du fond",
+ "removeLegacyAnnotations": "Supprimer les anciennes annotations",
+ "background": "Fond",
+ "bold": "Gras",
+ "distanceFromBottom": "Distance depuis le bas",
+ "fontSize": "Taille",
+ "anchorBottom": "Bas",
+ "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
+ "maxWords": "Mots max. par ligne",
+ "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
+ "translate": "Traduire",
+ "deleteTranslation": "Supprimer cette traduction",
+ "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
+ "text": "Texte",
+ "displayLanguage": "Affichage",
+ "alignRight": "Droite",
+ "alignLeft": "Gauche"
+ },
+ "support": {
+ "saveDiagnostics": "Enregistrer les diagnostics",
+ "reportBug": "Signaler un bug",
+ "starOnGithub": "Étoile sur GitHub"
+ },
+ "customFont": {
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
+ "urlLabel": "URL d'import Google Fonts",
+ "addingButton": "Ajout en cours...",
+ "namePlaceholder": "Ma police personnalisée",
+ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
+ "dialogTitle": "Ajouter une police Google",
+ "failedToAdd": "Échec de l'ajout de la police",
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "addButton": "Ajouter la police",
+ "errorEmptyName": "Veuillez saisir un nom de police",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
+ "nameLabel": "Nom d'affichage",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
+ "successMessage": "Police « {{fontName}} » ajoutée avec succès",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez."
},
"background": {
- "colorLabel": "Couleur {{color}}",
- "customWallpaper": "Fond personnalisé",
- "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
- "gradient": "Dégradé",
"presets": "Préréglages",
- "color": "Couleur",
- "custom": "Personnalisé",
- "colorWheel": "Roue chromatique",
- "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
"image": "Image",
- "uploadCustom": "Téléverser une image",
"title": "Arrière-plan",
+ "custom": "Personnalisé",
+ "colorLabel": "Couleur {{color}}",
"imageLabel": "Fond {{index}}",
+ "customWallpaper": "Fond personnalisé",
"colorPalette": "Palette de couleurs",
+ "uploadCustom": "Téléverser une image",
+ "color": "Couleur",
"imageReadFailed": "Impossible de lire ce fichier image.",
- "gradientLabel": "Dégradé {{index}}"
+ "gradientLabel": "Dégradé {{index}}",
+ "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
+ "gradient": "Dégradé",
+ "colorWheel": "Roue chromatique",
+ "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque."
+ },
+ "audio": {
+ "reset": "Réinitialiser l’audio",
+ "outputGain": "Niveau de sortie",
+ "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
+ "title": "Audio"
+ },
+ "textAnimation": {
+ "pulse": "Pulsation",
+ "selectAnimation": "Sélectionner une animation",
+ "fade": "Fondu",
+ "typewriter": "Machine à écrire",
+ "slideLeft": "Glisser à gauche",
+ "none": "Aucune",
+ "rise": "Monter",
+ "pop": "Apparition",
+ "title": "Animation de texte"
},
"crop": {
"title": "Recadrage",
+ "unlockAspectRatio": "Déverrouiller le ratio",
+ "free": "Libre",
"dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
"done": "Terminer",
- "free": "Libre",
- "ratio": "Ratio",
- "cropVideo": "Recadrer la vidéo",
"lockAspectRatio": "Verrouiller le ratio",
- "unlockAspectRatio": "Déverrouiller le ratio"
+ "ratio": "Ratio",
+ "cropVideo": "Recadrer la vidéo"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "Résolution d'export"
+ },
+ "effects": {
+ "off": "désactivé",
+ "on": "activé",
+ "fitClip": "Ajuster",
+ "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
+ "fitClipFew": "{{count}} clips",
+ "blurBg": "Flou arrière-plan",
+ "motion": "Mouvement",
+ "shadow": "Ombre",
+ "fitClipOne": "{{count}} clip",
+ "format": "Format",
+ "roundness": "Arrondi",
+ "fitClipMany": "{{count}} clips",
+ "formatOriginal": "Original",
+ "frame": "Cadre",
+ "motionBlur": "Flou de mouvement",
+ "padding": "Marge",
+ "title": "Composition"
+ },
+ "language": {
+ "title": "Langue"
},
"gifSettings": {
"size": "Taille du GIF",
- "frameRate": "Fréquence d'images GIF",
- "loop": "GIF en boucle"
+ "loop": "GIF en boucle",
+ "frameRate": "Fréquence d'images GIF"
},
- "support": {
- "starOnGithub": "Étoile sur GitHub",
- "reportBug": "Signaler un bug",
- "saveDiagnostics": "Enregistrer les diagnostics"
+ "panes": {
+ "help": "Aide"
},
"export": {
+ "chooseSaveLocation": "Choisir l'emplacement d'enregistrement",
"gifButton": "Exporter le GIF",
- "videoButton": "Exporter la vidéo",
- "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
+ "videoButton": "Exporter la vidéo"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "Résolution d'export",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "Vidéo MP4",
+ "mp4Description": "Fichier vidéo haute qualité",
+ "gifAnimation": "Animation GIF",
+ "gifDescription": "Image animée pour le partage",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Langue"
+ "project": {
+ "save": "Enregistrer le projet",
+ "new": "Nouveau projet",
+ "load": "Charger un projet"
+ },
+ "facets": {
+ "captions": "Sous-titres",
+ "transcript": "Transcription"
},
"trim": {
"deleteRegion": "Supprimer la région de coupe"
- },
- "panes": {
- "help": "Aide"
}
}
diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json
index 9fc9f9acf..59dfba3e4 100644
--- a/src/i18n/locales/it/editor.json
+++ b/src/i18n/locales/it/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Impossibile salvare il video esportato",
"failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}",
"previewCompositorUnavailable": "Anteprima non disponibile su questo computer",
- "wordEditFailed": "Impossibile modificare questa parola"
+ "wordEditFailed": "Impossibile modificare questa parola",
+ "wordInsertFailed": "Impossibile aggiungere questa parola",
+ "wordRemoveFailed": "Impossibile eliminare questa parola"
},
"export": {
"canceled": "Esportazione annullata",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 9a79947e9..161ed421e 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Clip {{index}}",
- "restoreSilence": "Ripristina silenzio ({{duration}} s)",
- "laneVoiceover": "Voce fuori campo",
- "editorAria": "Trascrizione di {{filename}}",
- "noTranscript": "Ancora nessuna trascrizione",
- "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
- "blankedWord": "svuotata",
- "laneLabel": "Leggi la trascrizione da",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo: i sottotitoli la seguono, il video non cambia. Passa sopra una parola contrassegnata per ripristinarla.",
- "noClips": "Ancora nessun clip",
- "trimSilence": "Taglia silenzio ({{duration}} s)",
- "transcribing": "Trascrizione…",
- "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
- "editWord": "Modifica «{{word}}»",
- "restoreWord": "Ripristina «{{word}}»",
- "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
- "transcribeNow": "Trascrivi ora",
- "revertWord": "Ripristina «{{original}}»",
- "silence": "[silenzio {{duration}} s]",
- "laneRecording": "Registrazione",
- "title": "Trascrizione corrente",
- "noAudio": "Questo contenuto non ha una traccia audio"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
+ "x": "X (%)",
+ "title": "Posizione messa a fuoco"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Destra",
+ "left": "Sinistra",
+ "iso": "Iso"
+ },
+ "none": "Nessuna",
+ "title": "Rotazione 3D"
+ },
+ "focusMode": {
+ "manual": "Manuale",
+ "title": "Modalità messa a fuoco",
+ "autoDescription": "La fotocamera segue la posizione del cursore registrato",
+ "lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
+ "auto": "Automatico"
+ },
+ "selectRegion": "Seleziona una regione zoom da regolare",
+ "customScale": "Zoom personalizzato",
+ "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
+ "level": "Livello zoom",
+ "deleteZoom": "Elimina zoom"
},
- "captions": {
- "showBackground": "Mostra sfondo",
- "alignLeft": "Sinistra",
- "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
- "backgroundColor": "Colore dello sfondo",
- "translating": "Traduzione…",
- "backgroundOpacity": "Opacità",
- "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
- "translateFailed": "Traduzione non riuscita.",
- "translateHint": "Traduci la trascrizione con il provider IA configurato",
- "text": "Testo",
- "fontSize": "Dimensione",
- "deleteTranslation": "Elimina questa traduzione",
- "maxWords": "Parole max. per riga",
- "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
- "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
- "anchorBottom": "Basso",
- "distanceFromLeft": "Distanza da sinistra",
- "textColor": "Colore del testo",
- "background": "Sfondo",
- "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
- "minWords": "Parole min. per riga",
- "lineLength": "Lunghezza riga",
- "font": "Carattere",
- "original": "Originale (trascrizione)",
- "bold": "Grassetto",
- "displayLanguage": "Visualizzazione",
- "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
- "alignCenter": "Centro",
- "distanceFromBottom": "Distanza dal basso",
- "distanceFromTop": "Distanza dall'alto",
- "translate": "Traduci",
- "position": "Posizione",
- "language": "Lingua",
- "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
- "show": "Mostra sottotitoli",
- "anchorTop": "Alto",
- "alignRight": "Destra",
- "distanceFromRight": "Distanza da destra",
- "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta."
+ "audioTrack": {
+ "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "defaultLabel": "Traccia audio",
+ "fadeOut": "Dissolvenza in uscita",
+ "add": "Aggiungi traccia audio",
+ "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
+ "loop": "Ripeti",
+ "remove": "Elimina traccia",
+ "fadeIn": "Dissolvenza in entrata",
+ "mute": "Muto",
+ "importFailed": "Impossibile aggiungere l’audio"
},
- "effects": {
- "padding": "Spaziatura",
- "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
- "motion": "Movimento",
- "title": "Composizione",
- "blurBg": "Sfuma sfondo",
- "fitClip": "Adatta",
- "on": "acceso",
- "roundness": "Arrotondamento",
- "frame": "Cornice",
- "formatOriginal": "Originale",
- "shadow": "Ombra",
- "fitClipMany": "{{count}} clip",
- "fitClipFew": "{{count}} clip",
- "off": "spento",
- "format": "Formato",
+ "cursor": {
+ "clipToBounds": "Ritaglia al canvas",
+ "size": "Dimensione",
"motionBlur": "Sfocatura movimento",
- "fitClipOne": "{{count}} clip"
+ "theme": "Stile del cursore",
+ "clickBounce": "Rimbalzo clic",
+ "title": "Cursore",
+ "smoothing": "Smussatura",
+ "themeDefault": "Predefinito",
+ "show": "Mostra cursore",
+ "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
+ "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica."
},
"annotation": {
- "arrowColor": "Colore freccia",
+ "blurIntensity": "Intensità sfocatura",
+ "textContent": "Contenuto testo",
+ "blurShapeFreehand": "A mano libera",
"active": "Attivo",
- "blurTypeMosaic": "Mosaico",
- "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
- "title": "Impostazioni annotazione",
- "blurColorBlack": "Nero",
- "imageUploadSuccess": "Immagine caricata con successo!",
- "deleteAnnotation": "Elimina annotazione",
- "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
- "typeText": "Testo",
+ "blurShapeRectangle": "Rettangolo",
"customFonts": "Caratteri personalizzati",
- "color": "Colore",
- "invalidImageType": "Tipo di file non valido",
- "type": "Tipo",
- "size": "Dimensione",
- "blurShapeFreehand": "A mano libera",
- "mosaicBlockSize": "Dimensione blocco mosaico",
- "fontStyle": "Stile carattere",
- "blurColorWhite": "Bianco",
- "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
- "textPlaceholder": "Inserisci il tuo testo...",
+ "title": "Impostazioni annotazione",
+ "blurShapeOval": "Ovale",
"blurType": "Tipo sfocatura",
+ "mosaicBlockSize": "Dimensione blocco mosaico",
"colorPalette": "Tavolozza dei colori",
+ "textColor": "Colore testo",
+ "colorWheel": "Ruota dei colori",
+ "shortcutsAndTips": "Scorciatoie e suggerimenti",
+ "defaultText": "Ciao",
+ "clearBackground": "Rimuovi sfondo",
+ "type": "Tipo",
"tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
+ "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
"background": "Sfondo",
- "blurShapeRectangle": "Rettangolo",
- "typeArrow": "Freccia",
- "blurIntensity": "Intensità sfocatura",
- "uploadImage": "Carica immagine",
- "blurShapeOval": "Ovale",
- "strokeWidth": "Larghezza tratto: {{width}}px",
+ "blurShape": "Forma sfocatura",
+ "arrowColor": "Colore freccia",
+ "invalidImageType": "Tipo di file non valido",
+ "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
+ "blurColorBlack": "Nero",
+ "blurTypeBlur": "Gaussiano",
+ "none": "Nessuno",
"supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
+ "size": "Dimensione",
+ "imageUploadSuccess": "Immagine caricata con successo!",
+ "blurColorWhite": "Bianco",
"arrowDirection": "Direzione freccia",
- "textContent": "Contenuto testo",
+ "typeImage": "Immagine",
+ "typeText": "Testo",
+ "typeArrow": "Freccia",
+ "color": "Colore",
"blurColor": "Colore sfocatura",
"selectStyle": "Seleziona stile",
- "colorWheel": "Ruota dei colori",
- "textColor": "Colore testo",
- "none": "Nessuno",
- "defaultText": "Ciao",
- "blurTypeBlur": "Gaussiano",
- "shortcutsAndTips": "Scorciatoie e suggerimenti",
- "clearBackground": "Rimuovi sfondo",
+ "blurTypeMosaic": "Mosaico",
+ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
+ "textPlaceholder": "Inserisci il tuo testo...",
+ "uploadImage": "Carica immagine",
+ "strokeWidth": "Larghezza tratto: {{width}}px",
"typeBlur": "Sfocatura",
- "typeImage": "Immagine",
- "blurShape": "Forma sfocatura"
- },
- "textAnimation": {
- "pop": "Apparizione",
- "fade": "Dissolvenza",
- "none": "Nessuna",
- "selectAnimation": "Seleziona animazione",
- "typewriter": "Macchina da scrivere",
- "rise": "Ascesa",
- "slideLeft": "Scivola a sinistra",
- "pulse": "Pulsazione",
- "title": "Animazione testo"
- },
- "audioTrack": {
- "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
- "defaultLabel": "Traccia audio",
- "mute": "Muto",
- "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
- "loop": "Ripeti",
- "add": "Aggiungi traccia audio",
- "fadeIn": "Dissolvenza in entrata",
- "remove": "Elimina traccia",
- "importFailed": "Impossibile aggiungere l’audio",
- "fadeOut": "Dissolvenza in uscita"
- },
- "customFont": {
- "addingButton": "Aggiunta in corso...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
- "failedToAdd": "Impossibile aggiungere il font",
- "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
- "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
- "errorEmptyName": "Inserisci un nome per il font",
- "urlLabel": "URL importazione Google Fonts",
- "nameLabel": "Nome visualizzato",
- "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
- "nameHelp": "Così apparirà il font nel selettore",
- "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
- "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
- "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
- "namePlaceholder": "Il mio font personalizzato",
- "addButton": "Aggiungi font",
- "dialogTitle": "Aggiungi font Google"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Destra",
- "iso": "Iso",
- "left": "Sinistra"
- },
- "none": "Nessuna",
- "title": "Rotazione 3D"
- },
- "position": {
- "title": "Posizione messa a fuoco",
- "y": "Y (%)",
- "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
- "x": "X (%)"
- },
- "deleteZoom": "Elimina zoom",
- "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
- "customScale": "Zoom personalizzato",
- "focusMode": {
- "autoDescription": "La fotocamera segue la posizione del cursore registrato",
- "title": "Modalità messa a fuoco",
- "lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
- "auto": "Automatico",
- "manual": "Manuale"
- },
- "level": "Livello zoom",
- "selectRegion": "Seleziona una regione zoom da regolare"
- },
- "speed": {
- "selectRegion": "Seleziona una regione velocità da regolare",
- "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
- "playbackSpeed": "Velocità di riproduzione",
- "deleteRegion": "Elimina regione velocità",
- "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata.",
- "maxSpeedError": "La velocità non può superare {{max}}×"
+ "deleteAnnotation": "Elimina annotazione",
+ "fontStyle": "Stile carattere"
},
"layout": {
- "webcamSize": "Dimensione webcam",
- "webcamBlurIntensity": "Intensità sfocatura",
+ "webcamCropY": "Spostamento verticale",
+ "reactiveWebcam": "Riduci con lo zoom",
"shapes": {
- "circle": "Cerchio",
+ "rectangle": "Rett.",
"square": "Quadrato",
"rounded": "Arrotondato",
- "rectangle": "Rett."
+ "circle": "Cerchio"
},
- "webcamCropY": "Spostamento verticale",
- "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
- "selectPreset": "Seleziona predefinito",
- "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
"preset": "Predefinito",
- "webcamFraming": "Inquadratura webcam",
- "webcamBackground": "Sfondo della fotocamera",
- "webcamShape": "Forma fotocamera",
- "title": "Disposizione camera",
+ "dualFrame": "Doppio frame",
"bgModes": {
- "blur": "Sfocato",
- "transparent": "Scontornato",
"none": "Originale",
- "custom": "Personalizzato"
+ "transparent": "Scontornato",
+ "custom": "Personalizzato",
+ "blur": "Sfocato"
},
- "mirrorWebcam": "Specchia webcam",
- "noWebcam": "Nessuna webcam",
- "pictureInPicture": "Immagine nell'immagine",
- "reactiveWebcam": "Riduci con lo zoom",
"webcamCropZoom": "Zoom ritaglio",
- "dualFrame": "Doppio frame",
+ "webcamShape": "Forma fotocamera",
+ "webcamBlurIntensity": "Intensità sfocatura",
+ "title": "Disposizione camera",
"reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
+ "webcamFraming": "Inquadratura webcam",
"webcamCropX": "Spostamento orizzontale",
+ "selectPreset": "Seleziona predefinito",
+ "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
+ "mirrorWebcam": "Specchia webcam",
+ "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
+ "webcamBackground": "Sfondo della fotocamera",
+ "noWebcam": "Nessuna webcam",
+ "pictureInPicture": "Immagine nell'immagine",
+ "webcamSize": "Dimensione webcam",
"verticalStack": "Pila verticale"
},
- "project": {
- "load": "Carica progetto",
- "save": "Salva progetto",
- "new": "Nuovo progetto"
- },
- "audio": {
- "outputGain": "Livello di uscita",
- "reset": "Reimposta audio",
- "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
- "title": "Audio"
- },
- "facets": {
- "transcript": "Trascrizione",
- "captions": "Sottotitoli"
- },
- "cursor": {
- "themeDefault": "Predefinito",
- "title": "Cursore",
- "theme": "Stile del cursore",
- "clickBounce": "Rimbalzo clic",
- "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
- "smoothing": "Smussatura",
- "size": "Dimensione",
- "motionBlur": "Sfocatura movimento",
- "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
- "show": "Mostra cursore",
- "clipToBounds": "Ritaglia al canvas"
+ "speed": {
+ "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
+ "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata.",
+ "maxSpeedError": "La velocità non può superare {{max}}×",
+ "playbackSpeed": "Velocità di riproduzione",
+ "deleteRegion": "Elimina regione velocità",
+ "selectRegion": "Seleziona una regione velocità da regolare"
},
"imageUpload": {
"failedToUpload": "Impossibile caricare l'immagine",
- "jpgOnly": "Carica un file immagine JPG o JPEG.",
- "invalidFileType": "Tipo di file non valido",
+ "uploadSuccess": "Immagine personalizzata caricata con successo!",
"errorReading": "Si è verificato un errore durante la lettura del file.",
- "uploadSuccess": "Immagine personalizzata caricata con successo!"
+ "invalidFileType": "Tipo di file non valido",
+ "jpgOnly": "Carica un file immagine JPG o JPEG."
},
- "exportFormat": {
- "gifAnimation": "Animazione GIF",
- "mp4Description": "File video di alta qualità",
- "mp4Video": "Video MP4",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Immagine animata per la condivisione"
+ "transcript": {
+ "blankedWord": "svuotata",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video. Passa sopra una parola contrassegnata per annullare.",
+ "editWord": "Modifica «{{word}}»",
+ "editorAria": "Trascrizione di {{filename}}",
+ "noTranscript": "Ancora nessuna trascrizione",
+ "clipLabel": "Clip {{index}}",
+ "trimSilence": "Taglia silenzio ({{duration}} s)",
+ "laneVoiceover": "Voce fuori campo",
+ "restoreSilence": "Ripristina silenzio ({{duration}} s)",
+ "transcribeNow": "Trascrivi ora",
+ "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
+ "removeInserted": "Elimina «{{word}}»",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "restoreWord": "Ripristina «{{word}}»",
+ "silence": "[silenzio {{duration}} s]",
+ "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
+ "laneRecording": "Registrazione",
+ "noClips": "Ancora nessun clip",
+ "noAudio": "Questo contenuto non ha una traccia audio",
+ "laneLabel": "Leggi la trascrizione da",
+ "revertWord": "Ripristina «{{original}}»",
+ "title": "Trascrizione corrente",
+ "transcribing": "Trascrizione…",
+ "insertAria": "Nuova parola",
+ "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala."
+ },
+ "captions": {
+ "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
+ "distanceFromTop": "Distanza dall'alto",
+ "minWords": "Parole min. per riga",
+ "showBackground": "Mostra sfondo",
+ "lineLength": "Lunghezza riga",
+ "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
+ "translating": "Traduzione…",
+ "distanceFromLeft": "Distanza da sinistra",
+ "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
+ "position": "Posizione",
+ "translateFailed": "Traduzione non riuscita.",
+ "backgroundOpacity": "Opacità",
+ "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
+ "original": "Originale (trascrizione)",
+ "font": "Carattere",
+ "distanceFromRight": "Distanza da destra",
+ "textColor": "Colore del testo",
+ "alignCenter": "Centro",
+ "translateHint": "Traduci la trascrizione con il provider IA configurato",
+ "language": "Lingua",
+ "show": "Mostra sottotitoli",
+ "anchorTop": "Alto",
+ "backgroundColor": "Colore dello sfondo",
+ "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
+ "background": "Sfondo",
+ "bold": "Grassetto",
+ "distanceFromBottom": "Distanza dal basso",
+ "fontSize": "Dimensione",
+ "anchorBottom": "Basso",
+ "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
+ "maxWords": "Parole max. per riga",
+ "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
+ "translate": "Traduci",
+ "deleteTranslation": "Elimina questa traduzione",
+ "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
+ "text": "Testo",
+ "displayLanguage": "Visualizzazione",
+ "alignRight": "Destra",
+ "alignLeft": "Sinistra"
+ },
+ "support": {
+ "saveDiagnostics": "Salva dati diagnostici",
+ "reportBug": "Segnala bug",
+ "starOnGithub": "Metti stella su GitHub"
+ },
+ "customFont": {
+ "nameHelp": "Così apparirà il font nel selettore",
+ "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
+ "urlLabel": "URL importazione Google Fonts",
+ "addingButton": "Aggiunta in corso...",
+ "namePlaceholder": "Il mio font personalizzato",
+ "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
+ "dialogTitle": "Aggiungi font Google",
+ "failedToAdd": "Impossibile aggiungere il font",
+ "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
+ "addButton": "Aggiungi font",
+ "errorEmptyName": "Inserisci un nome per il font",
+ "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
+ "nameLabel": "Nome visualizzato",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
+ "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
+ "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova."
},
"background": {
- "colorLabel": "Colore {{color}}",
- "customWallpaper": "Sfondo personalizzato",
- "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
- "gradient": "Sfumatura",
"presets": "Predefiniti",
- "color": "Colore",
- "custom": "Personalizzato",
- "colorWheel": "Ruota dei colori",
- "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
"image": "Immagine",
- "uploadCustom": "Carica personalizzato",
"title": "Sfondo",
+ "custom": "Personalizzato",
+ "colorLabel": "Colore {{color}}",
"imageLabel": "Sfondo {{index}}",
+ "customWallpaper": "Sfondo personalizzato",
"colorPalette": "Tavolozza dei colori",
+ "uploadCustom": "Carica personalizzato",
+ "color": "Colore",
"imageReadFailed": "Impossibile leggere quel file immagine.",
- "gradientLabel": "Sfumatura {{index}}"
+ "gradientLabel": "Sfumatura {{index}}",
+ "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
+ "gradient": "Sfumatura",
+ "colorWheel": "Ruota dei colori",
+ "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco."
+ },
+ "audio": {
+ "reset": "Reimposta audio",
+ "outputGain": "Livello di uscita",
+ "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
+ "title": "Audio"
+ },
+ "textAnimation": {
+ "pulse": "Pulsazione",
+ "selectAnimation": "Seleziona animazione",
+ "fade": "Dissolvenza",
+ "typewriter": "Macchina da scrivere",
+ "slideLeft": "Scivola a sinistra",
+ "none": "Nessuna",
+ "rise": "Ascesa",
+ "pop": "Apparizione",
+ "title": "Animazione testo"
},
"crop": {
"title": "Ritaglia",
+ "unlockAspectRatio": "Sblocca proporzioni",
+ "free": "Libero",
"dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
"done": "Fatto",
- "free": "Libero",
- "ratio": "Proporzioni",
- "cropVideo": "Ritaglia video",
"lockAspectRatio": "Blocca proporzioni",
- "unlockAspectRatio": "Sblocca proporzioni"
+ "ratio": "Proporzioni",
+ "cropVideo": "Ritaglia video"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Originale",
+ "title": "Risoluzione esportazione"
+ },
+ "effects": {
+ "off": "spento",
+ "on": "acceso",
+ "fitClip": "Adatta",
+ "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
+ "fitClipFew": "{{count}} clip",
+ "blurBg": "Sfuma sfondo",
+ "motion": "Movimento",
+ "shadow": "Ombra",
+ "fitClipOne": "{{count}} clip",
+ "format": "Formato",
+ "roundness": "Arrotondamento",
+ "fitClipMany": "{{count}} clip",
+ "formatOriginal": "Originale",
+ "frame": "Cornice",
+ "motionBlur": "Sfocatura movimento",
+ "padding": "Spaziatura",
+ "title": "Composizione"
+ },
+ "language": {
+ "title": "Lingua"
},
"gifSettings": {
"size": "Dimensione GIF",
- "frameRate": "Frequenza fotogrammi GIF",
- "loop": "GIF in loop"
+ "loop": "GIF in loop",
+ "frameRate": "Frequenza fotogrammi GIF"
},
- "support": {
- "starOnGithub": "Metti stella su GitHub",
- "reportBug": "Segnala bug",
- "saveDiagnostics": "Salva dati diagnostici"
+ "panes": {
+ "help": "Aiuto"
},
"export": {
+ "chooseSaveLocation": "Scegli posizione di salvataggio",
"gifButton": "Esporta GIF",
- "videoButton": "Esporta video",
- "chooseSaveLocation": "Scegli posizione di salvataggio"
+ "videoButton": "Esporta video"
},
- "exportQuality": {
- "high": "Originale",
- "medium": "1080p",
- "title": "Risoluzione esportazione",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "Video MP4",
+ "mp4Description": "File video di alta qualità",
+ "gifAnimation": "Animazione GIF",
+ "gifDescription": "Immagine animata per la condivisione",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Lingua"
+ "project": {
+ "save": "Salva progetto",
+ "new": "Nuovo progetto",
+ "load": "Carica progetto"
+ },
+ "facets": {
+ "captions": "Sottotitoli",
+ "transcript": "Trascrizione"
},
"trim": {
"deleteRegion": "Elimina regione taglio"
- },
- "panes": {
- "help": "Aiuto"
}
}
diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json
index b5c3240ed..68a0c513f 100644
--- a/src/i18n/locales/ja-JP/editor.json
+++ b/src/i18n/locales/ja-JP/editor.json
@@ -21,7 +21,9 @@
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
"exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})",
"previewCompositorUnavailable": "このマシンではプレビューを表示できません",
- "wordEditFailed": "この単語を変更できませんでした"
+ "wordEditFailed": "この単語を変更できませんでした",
+ "wordInsertFailed": "この単語を追加できませんでした",
+ "wordRemoveFailed": "この単語を削除できませんでした"
},
"export": {
"canceled": "エクスポートがキャンセルされました",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 62247aede..cea7e016e 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "クリップ {{index}}",
- "restoreSilence": "無音を元に戻す({{duration}} 秒)",
- "laneVoiceover": "ナレーション",
- "editorAria": "{{filename}} の文字起こし",
- "noTranscript": "文字起こしがまだありません",
- "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
- "blankedWord": "空欄",
- "laneLabel": "文字起こしの読み込み元",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕は追従し、映像は変わりません。印の付いた単語にカーソルを合わせると元に戻せます。",
- "noClips": "クリップがまだありません",
- "trimSilence": "無音をトリム({{duration}} 秒)",
- "transcribing": "文字起こし中…",
- "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
- "editWord": "「{{word}}」を編集",
- "restoreWord": "「{{word}}」を元に戻す",
- "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
- "transcribeNow": "今すぐ文字起こし",
- "revertWord": "「{{original}}」に戻す",
- "silence": "[無音 {{duration}} 秒]",
- "laneRecording": "録画",
- "title": "現在の文字起こし",
- "noAudio": "このメディアには音声トラックがありません"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
+ "x": "X (%)",
+ "title": "フォーカス位置"
+ },
+ "threeD": {
+ "preset": {
+ "right": "右",
+ "left": "左",
+ "iso": "Iso"
+ },
+ "none": "なし",
+ "title": "3D回転"
+ },
+ "focusMode": {
+ "manual": "手動",
+ "title": "フォーカスモード",
+ "autoDescription": "表示範囲が録画中のカーソル位置に追従します",
+ "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
+ "auto": "自動"
+ },
+ "selectRegion": "ズーム範囲を選択して調整",
+ "customScale": "カスタムズーム",
+ "previewHold": "押している間ズーム効果をプレビュー",
+ "level": "ズーム倍率",
+ "deleteZoom": "ズームを削除"
},
- "captions": {
- "showBackground": "背景を表示",
- "alignLeft": "左",
- "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
- "backgroundColor": "背景色",
- "translating": "翻訳中…",
- "backgroundOpacity": "不透明度",
- "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
- "translateFailed": "翻訳に失敗しました。",
- "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
- "text": "テキスト",
- "fontSize": "サイズ",
- "deleteTranslation": "この翻訳を削除",
- "maxWords": "1 行の最大単語数",
- "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
- "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
- "anchorBottom": "下",
- "distanceFromLeft": "左端からの距離",
- "textColor": "文字色",
- "background": "背景",
- "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
- "minWords": "1 行の最小単語数",
- "lineLength": "行の長さ",
- "font": "フォント",
- "original": "オリジナル(文字起こし)",
- "bold": "太字",
- "displayLanguage": "表示",
- "removeLegacyAnnotations": "古い字幕の注釈を削除",
- "alignCenter": "中央",
- "distanceFromBottom": "下端からの距離",
- "distanceFromTop": "上端からの距離",
- "translate": "翻訳",
- "position": "位置",
- "language": "言語",
- "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
- "show": "字幕を表示",
- "anchorTop": "上",
- "alignRight": "右",
- "distanceFromRight": "右端からの距離",
- "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。"
+ "audioTrack": {
+ "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "defaultLabel": "オーディオトラック",
+ "fadeOut": "フェードアウト",
+ "add": "オーディオトラックを追加",
+ "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
+ "loop": "ループ",
+ "remove": "トラックを削除",
+ "fadeIn": "フェードイン",
+ "mute": "ミュート",
+ "importFailed": "オーディオを追加できませんでした"
},
- "effects": {
- "padding": "余白",
- "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
- "motion": "モーション",
- "title": "コンポジション",
- "blurBg": "背景をぼかす",
- "fitClip": "合わせる",
- "on": "オン",
- "roundness": "丸み",
- "frame": "フレーム",
- "formatOriginal": "元のサイズ",
- "shadow": "影",
- "fitClipMany": "{{count}} クリップ",
- "fitClipFew": "{{count}} クリップ",
- "off": "オフ",
- "format": "フォーマット",
+ "cursor": {
+ "clipToBounds": "キャンバスにクリップ",
+ "size": "サイズ",
"motionBlur": "モーションブラー",
- "fitClipOne": "{{count}} クリップ"
+ "theme": "カーソルのスタイル",
+ "clickBounce": "クリックバウンス",
+ "title": "カーソル",
+ "smoothing": "スムージング",
+ "themeDefault": "デフォルト",
+ "show": "カーソルを表示",
+ "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
+ "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。"
},
"annotation": {
- "arrowColor": "矢印の色",
+ "blurIntensity": "ぼかしの強さ",
+ "textContent": "テキスト内容",
+ "blurShapeFreehand": "自由形状",
"active": "アクティブ",
- "blurTypeMosaic": "モザイク",
- "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
- "title": "注釈設定",
- "blurColorBlack": "黒",
- "imageUploadSuccess": "画像を読み込みました。",
- "deleteAnnotation": "注釈を削除",
- "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "typeText": "テキスト",
+ "blurShapeRectangle": "長方形",
"customFonts": "カスタムフォント",
- "color": "色",
- "invalidImageType": "無効なファイル形式",
- "type": "種類",
- "size": "サイズ",
- "blurShapeFreehand": "自由形状",
- "mosaicBlockSize": "モザイクブロックのサイズ",
- "fontStyle": "フォントスタイル",
- "blurColorWhite": "白",
- "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
- "textPlaceholder": "テキストを入力してください...",
+ "title": "注釈設定",
+ "blurShapeOval": "楕円",
"blurType": "ぼかしの種類",
+ "mosaicBlockSize": "モザイクブロックのサイズ",
"colorPalette": "カラーパレット",
+ "textColor": "文字色",
+ "colorWheel": "カラーホイール",
+ "shortcutsAndTips": "ショートカットとヒント",
+ "defaultText": "こんにちは",
+ "clearBackground": "背景をクリア",
+ "type": "種類",
"tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
+ "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
"background": "背景",
- "blurShapeRectangle": "長方形",
- "typeArrow": "矢印",
- "blurIntensity": "ぼかしの強さ",
- "uploadImage": "画像を読み込む",
- "blurShapeOval": "楕円",
- "strokeWidth": "線の太さ: {{width}}px",
+ "blurShape": "ぼかしの形状",
+ "arrowColor": "矢印の色",
+ "invalidImageType": "無効なファイル形式",
+ "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
+ "blurColorBlack": "黒",
+ "blurTypeBlur": "ガウス",
+ "none": "なし",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
+ "size": "サイズ",
+ "imageUploadSuccess": "画像を読み込みました。",
+ "blurColorWhite": "白",
"arrowDirection": "矢印の方向",
- "textContent": "テキスト内容",
+ "typeImage": "画像",
+ "typeText": "テキスト",
+ "typeArrow": "矢印",
+ "color": "色",
"blurColor": "ぼかしの色",
"selectStyle": "スタイルを選択",
- "colorWheel": "カラーホイール",
- "textColor": "文字色",
- "none": "なし",
- "defaultText": "こんにちは",
- "blurTypeBlur": "ガウス",
- "shortcutsAndTips": "ショートカットとヒント",
- "clearBackground": "背景をクリア",
+ "blurTypeMosaic": "モザイク",
+ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
+ "textPlaceholder": "テキストを入力してください...",
+ "uploadImage": "画像を読み込む",
+ "strokeWidth": "線の太さ: {{width}}px",
"typeBlur": "ぼかし",
- "typeImage": "画像",
- "blurShape": "ぼかしの形状"
- },
- "textAnimation": {
- "pop": "ポップ",
- "fade": "フェード",
- "none": "なし",
- "selectAnimation": "アニメーションを選択",
- "typewriter": "タイプライター",
- "rise": "上昇",
- "slideLeft": "左へスライド",
- "pulse": "パルス",
- "title": "テキストアニメーション"
- },
- "audioTrack": {
- "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
- "defaultLabel": "オーディオトラック",
- "mute": "ミュート",
- "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
- "loop": "ループ",
- "add": "オーディオトラックを追加",
- "fadeIn": "フェードイン",
- "remove": "トラックを削除",
- "importFailed": "オーディオを追加できませんでした",
- "fadeOut": "フェードアウト"
- },
- "customFont": {
- "addingButton": "追加中...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
- "failedToAdd": "フォントの追加に失敗しました",
- "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
- "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
- "errorEmptyName": "フォント名を入力してください",
- "urlLabel": "GoogleフォントのインポートURL",
- "nameLabel": "表示名",
- "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
- "nameHelp": "フォントセレクターに表示される名前です",
- "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
- "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
- "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
- "namePlaceholder": "マイカスタムフォント",
- "addButton": "フォントを追加",
- "dialogTitle": "Googleフォントを追加"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "右",
- "iso": "Iso",
- "left": "左"
- },
- "none": "なし",
- "title": "3D回転"
- },
- "position": {
- "title": "フォーカス位置",
- "y": "Y (%)",
- "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
- "x": "X (%)"
- },
- "deleteZoom": "ズームを削除",
- "previewHold": "押している間ズーム効果をプレビュー",
- "customScale": "カスタムズーム",
- "focusMode": {
- "autoDescription": "表示範囲が録画中のカーソル位置に追従します",
- "title": "フォーカスモード",
- "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
- "auto": "自動",
- "manual": "手動"
- },
- "level": "ズーム倍率",
- "selectRegion": "ズーム範囲を選択して調整"
- },
- "speed": {
- "selectRegion": "再生速度の範囲を選択して調整",
- "customPlaybackSpeed": "カスタム再生速度",
- "playbackSpeed": "再生速度",
- "deleteRegion": "再生速度の範囲を削除",
- "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。",
- "maxSpeedError": "速度は{{max}}×を超えることはできません"
+ "deleteAnnotation": "注釈を削除",
+ "fontStyle": "フォントスタイル"
},
"layout": {
- "webcamSize": "カメラのサイズ",
- "webcamBlurIntensity": "ぼかしの強さ",
+ "webcamCropY": "垂直方向に移動",
+ "reactiveWebcam": "ズーム時に縮小",
"shapes": {
- "circle": "円",
+ "rectangle": "長方形",
"square": "正方形",
"rounded": "角丸",
- "rectangle": "長方形"
+ "circle": "円"
},
- "webcamCropY": "垂直方向に移動",
- "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
- "selectPreset": "プリセットを選択",
- "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
"preset": "プリセット",
- "webcamFraming": "ウェブカメラの構図",
- "webcamBackground": "カメラ背景",
- "webcamShape": "カメラの形状",
- "title": "カメラレイアウト",
+ "dualFrame": "デュアルフレーム",
"bgModes": {
- "blur": "ぼかし",
- "transparent": "切り抜き",
"none": "オリジナル",
- "custom": "カスタム"
+ "transparent": "切り抜き",
+ "custom": "カスタム",
+ "blur": "ぼかし"
},
- "mirrorWebcam": "Webカメラを反転",
- "noWebcam": "Webカメラなし",
- "pictureInPicture": "ピクチャーインピクチャ",
- "reactiveWebcam": "ズーム時に縮小",
"webcamCropZoom": "クロップのズーム",
- "dualFrame": "デュアルフレーム",
+ "webcamShape": "カメラの形状",
+ "webcamBlurIntensity": "ぼかしの強さ",
+ "title": "カメラレイアウト",
"reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
+ "webcamFraming": "ウェブカメラの構図",
"webcamCropX": "水平方向に移動",
+ "selectPreset": "プリセットを選択",
+ "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
+ "mirrorWebcam": "Webカメラを反転",
+ "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
+ "webcamBackground": "カメラ背景",
+ "noWebcam": "Webカメラなし",
+ "pictureInPicture": "ピクチャーインピクチャ",
+ "webcamSize": "カメラのサイズ",
"verticalStack": "縦並び"
},
- "project": {
- "load": "プロジェクトを読み込む",
- "save": "プロジェクトを保存",
- "new": "新規プロジェクト"
- },
- "audio": {
- "outputGain": "出力レベル",
- "reset": "オーディオをリセット",
- "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
- "title": "オーディオ"
- },
- "facets": {
- "transcript": "文字起こし",
- "captions": "字幕"
- },
- "cursor": {
- "themeDefault": "デフォルト",
- "title": "カーソル",
- "theme": "カーソルのスタイル",
- "clickBounce": "クリックバウンス",
- "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
- "smoothing": "スムージング",
- "size": "サイズ",
- "motionBlur": "モーションブラー",
- "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
- "show": "カーソルを表示",
- "clipToBounds": "キャンバスにクリップ"
+ "speed": {
+ "customPlaybackSpeed": "カスタム再生速度",
+ "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。",
+ "maxSpeedError": "速度は{{max}}×を超えることはできません",
+ "playbackSpeed": "再生速度",
+ "deleteRegion": "再生速度の範囲を削除",
+ "selectRegion": "再生速度の範囲を選択して調整"
},
"imageUpload": {
"failedToUpload": "画像の読み込みに失敗しました",
- "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "invalidFileType": "無効なファイル形式",
+ "uploadSuccess": "カスタム画像を読み込みました。",
"errorReading": "ファイルの読み取り中にエラーが発生しました。",
- "uploadSuccess": "カスタム画像を読み込みました。"
+ "invalidFileType": "無効なファイル形式",
+ "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。"
},
- "exportFormat": {
- "gifAnimation": "GIF アニメーション",
- "mp4Description": "高品質の動画ファイル",
- "mp4Video": "MP4 動画",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "共有用のアニメーション画像"
+ "transcript": {
+ "blankedWord": "空欄",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "editWord": "「{{word}}」を編集",
+ "editorAria": "{{filename}} の文字起こし",
+ "noTranscript": "文字起こしがまだありません",
+ "clipLabel": "クリップ {{index}}",
+ "trimSilence": "無音をトリム({{duration}} 秒)",
+ "laneVoiceover": "ナレーション",
+ "restoreSilence": "無音を元に戻す({{duration}} 秒)",
+ "transcribeNow": "今すぐ文字起こし",
+ "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
+ "removeInserted": "「{{word}}」を削除",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "restoreWord": "「{{word}}」を元に戻す",
+ "silence": "[無音 {{duration}} 秒]",
+ "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
+ "laneRecording": "録画",
+ "noClips": "クリップがまだありません",
+ "noAudio": "このメディアには音声トラックがありません",
+ "laneLabel": "文字起こしの読み込み元",
+ "revertWord": "「{{original}}」に戻す",
+ "title": "現在の文字起こし",
+ "transcribing": "文字起こし中…",
+ "insertAria": "新しい単語",
+ "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。"
+ },
+ "captions": {
+ "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
+ "distanceFromTop": "上端からの距離",
+ "minWords": "1 行の最小単語数",
+ "showBackground": "背景を表示",
+ "lineLength": "行の長さ",
+ "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
+ "translating": "翻訳中…",
+ "distanceFromLeft": "左端からの距離",
+ "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
+ "position": "位置",
+ "translateFailed": "翻訳に失敗しました。",
+ "backgroundOpacity": "不透明度",
+ "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
+ "original": "オリジナル(文字起こし)",
+ "font": "フォント",
+ "distanceFromRight": "右端からの距離",
+ "textColor": "文字色",
+ "alignCenter": "中央",
+ "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "language": "言語",
+ "show": "字幕を表示",
+ "anchorTop": "上",
+ "backgroundColor": "背景色",
+ "removeLegacyAnnotations": "古い字幕の注釈を削除",
+ "background": "背景",
+ "bold": "太字",
+ "distanceFromBottom": "下端からの距離",
+ "fontSize": "サイズ",
+ "anchorBottom": "下",
+ "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
+ "maxWords": "1 行の最大単語数",
+ "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
+ "translate": "翻訳",
+ "deleteTranslation": "この翻訳を削除",
+ "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
+ "text": "テキスト",
+ "displayLanguage": "表示",
+ "alignRight": "右",
+ "alignLeft": "左"
+ },
+ "support": {
+ "saveDiagnostics": "診断情報を保存",
+ "reportBug": "バグを報告",
+ "starOnGithub": "GitHub でスターを付ける"
+ },
+ "customFont": {
+ "nameHelp": "フォントセレクターに表示される名前です",
+ "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
+ "urlLabel": "GoogleフォントのインポートURL",
+ "addingButton": "追加中...",
+ "namePlaceholder": "マイカスタムフォント",
+ "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
+ "dialogTitle": "Googleフォントを追加",
+ "failedToAdd": "フォントの追加に失敗しました",
+ "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
+ "addButton": "フォントを追加",
+ "errorEmptyName": "フォント名を入力してください",
+ "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
+ "nameLabel": "表示名",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
+ "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
+ "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。"
},
"background": {
- "colorLabel": "色 {{color}}",
- "customWallpaper": "カスタム壁紙",
- "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
- "gradient": "グラデーション",
"presets": "プリセット",
- "color": "色",
- "custom": "カスタム",
- "colorWheel": "カラーホイール",
- "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
"image": "画像",
- "uploadCustom": "カスタム画像を読み込む",
"title": "背景",
+ "custom": "カスタム",
+ "colorLabel": "色 {{color}}",
"imageLabel": "背景 {{index}}",
+ "customWallpaper": "カスタム壁紙",
"colorPalette": "カラーパレット",
+ "uploadCustom": "カスタム画像を読み込む",
+ "color": "色",
"imageReadFailed": "この画像ファイルを読み込めませんでした。",
- "gradientLabel": "グラデーション {{index}}"
+ "gradientLabel": "グラデーション {{index}}",
+ "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
+ "gradient": "グラデーション",
+ "colorWheel": "カラーホイール",
+ "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。"
+ },
+ "audio": {
+ "reset": "オーディオをリセット",
+ "outputGain": "出力レベル",
+ "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
+ "title": "オーディオ"
+ },
+ "textAnimation": {
+ "pulse": "パルス",
+ "selectAnimation": "アニメーションを選択",
+ "fade": "フェード",
+ "typewriter": "タイプライター",
+ "slideLeft": "左へスライド",
+ "none": "なし",
+ "rise": "上昇",
+ "pop": "ポップ",
+ "title": "テキストアニメーション"
},
"crop": {
"title": "クロップ",
+ "unlockAspectRatio": "アスペクト比の固定を解除",
+ "free": "自由",
"dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
"done": "完了",
- "free": "自由",
- "ratio": "比率",
- "cropVideo": "動画をクロップ",
"lockAspectRatio": "アスペクト比を固定",
- "unlockAspectRatio": "アスペクト比の固定を解除"
+ "ratio": "比率",
+ "cropVideo": "動画をクロップ"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "書き出し解像度"
+ },
+ "effects": {
+ "off": "オフ",
+ "on": "オン",
+ "fitClip": "合わせる",
+ "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
+ "fitClipFew": "{{count}} クリップ",
+ "blurBg": "背景をぼかす",
+ "motion": "モーション",
+ "shadow": "影",
+ "fitClipOne": "{{count}} クリップ",
+ "format": "フォーマット",
+ "roundness": "丸み",
+ "fitClipMany": "{{count}} クリップ",
+ "formatOriginal": "元のサイズ",
+ "frame": "フレーム",
+ "motionBlur": "モーションブラー",
+ "padding": "余白",
+ "title": "コンポジション"
+ },
+ "language": {
+ "title": "言語"
},
"gifSettings": {
"size": "GIF サイズ",
- "frameRate": "GIF フレームレート",
- "loop": "GIF をループする"
+ "loop": "GIF をループする",
+ "frameRate": "GIF フレームレート"
},
- "support": {
- "starOnGithub": "GitHub でスターを付ける",
- "reportBug": "バグを報告",
- "saveDiagnostics": "診断情報を保存"
+ "panes": {
+ "help": "ヘルプ"
},
"export": {
+ "chooseSaveLocation": "保存場所を選択",
"gifButton": "GIF をエクスポート",
- "videoButton": "動画をエクスポート",
- "chooseSaveLocation": "保存場所を選択"
+ "videoButton": "動画をエクスポート"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "書き出し解像度",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 動画",
+ "mp4Description": "高品質の動画ファイル",
+ "gifAnimation": "GIF アニメーション",
+ "gifDescription": "共有用のアニメーション画像",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "言語"
+ "project": {
+ "save": "プロジェクトを保存",
+ "new": "新規プロジェクト",
+ "load": "プロジェクトを読み込む"
+ },
+ "facets": {
+ "captions": "字幕",
+ "transcript": "文字起こし"
},
"trim": {
"deleteRegion": "トリム範囲を削除"
- },
- "panes": {
- "help": "ヘルプ"
}
}
diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json
index f6f051bd4..b8828d084 100644
--- a/src/i18n/locales/ko-KR/editor.json
+++ b/src/i18n/locales/ko-KR/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
"failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}",
"previewCompositorUnavailable": "이 컴퓨터에서는 미리보기를 사용할 수 없습니다",
- "wordEditFailed": "이 단어를 변경할 수 없습니다"
+ "wordEditFailed": "이 단어를 변경할 수 없습니다",
+ "wordInsertFailed": "이 단어를 추가할 수 없습니다",
+ "wordRemoveFailed": "이 단어를 삭제할 수 없습니다"
},
"export": {
"canceled": "내보내기가 취소되었습니다",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index fdba19cda..6bc890083 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "클립 {{index}}",
- "restoreSilence": "무음 복원 ({{duration}}초)",
- "laneVoiceover": "내레이션",
- "editorAria": "{{filename}}의 전사",
- "noTranscript": "아직 전사가 없습니다",
- "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "blankedWord": "비움",
- "laneLabel": "전사본을 읽어올 소스",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막은 따라가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
- "noClips": "아직 클립이 없습니다",
- "trimSilence": "무음 자르기 ({{duration}}초)",
- "transcribing": "전사 중…",
- "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
- "editWord": "\"{{word}}\" 편집",
- "restoreWord": "\"{{word}}\" 복원",
- "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
- "transcribeNow": "지금 전사하기",
- "revertWord": "\"{{original}}\"(으)로 되돌리기",
- "silence": "[무음 {{duration}}초]",
- "laneRecording": "녹화",
- "title": "현재 전사",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
+ "x": "X (%)",
+ "title": "포커스 위치"
+ },
+ "threeD": {
+ "preset": {
+ "right": "오른쪽",
+ "left": "왼쪽",
+ "iso": "Iso"
+ },
+ "none": "없음",
+ "title": "3D 회전"
+ },
+ "focusMode": {
+ "manual": "수동",
+ "title": "포커스 모드",
+ "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
+ "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
+ "auto": "자동"
+ },
+ "selectRegion": "조정할 줌 구간을 선택하세요",
+ "customScale": "커스텀 줌",
+ "previewHold": "누르고 있으면 줌 효과 미리보기",
+ "level": "줌 레벨",
+ "deleteZoom": "줌 삭제"
},
- "captions": {
- "showBackground": "배경 표시",
- "alignLeft": "왼쪽",
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "backgroundColor": "배경 색",
- "translating": "번역 중…",
- "backgroundOpacity": "불투명도",
- "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "translateFailed": "번역에 실패했습니다.",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
- "text": "텍스트",
- "fontSize": "크기",
- "deleteTranslation": "이 번역 삭제",
- "maxWords": "줄당 최대 단어 수",
- "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
- "anchorBottom": "아래",
- "distanceFromLeft": "왼쪽에서의 거리",
- "textColor": "글자 색",
- "background": "배경",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
- "minWords": "줄당 최소 단어 수",
- "lineLength": "줄 길이",
- "font": "글꼴",
- "original": "원본 (전사)",
- "bold": "굵게",
- "displayLanguage": "표시",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "alignCenter": "가운데",
- "distanceFromBottom": "아래에서의 거리",
- "distanceFromTop": "위에서의 거리",
- "translate": "번역",
- "position": "위치",
- "language": "언어",
- "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
- "show": "자막 표시",
- "anchorTop": "위",
- "alignRight": "오른쪽",
- "distanceFromRight": "오른쪽에서의 거리",
- "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다."
+ "audioTrack": {
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "defaultLabel": "오디오 트랙",
+ "fadeOut": "페이드 아웃",
+ "add": "오디오 트랙 추가",
+ "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
+ "loop": "반복",
+ "remove": "트랙 삭제",
+ "fadeIn": "페이드 인",
+ "mute": "음소거",
+ "importFailed": "오디오를 추가할 수 없습니다"
},
- "effects": {
- "padding": "여백",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
- "motion": "모션",
- "title": "컴포지션",
- "blurBg": "배경 흐림",
- "fitClip": "맞추기",
- "on": "켜기",
- "roundness": "모서리 둥글기",
- "frame": "프레임",
- "formatOriginal": "원본",
- "shadow": "그림자",
- "fitClipMany": "{{count}}개 클립",
- "fitClipFew": "{{count}}개 클립",
- "off": "끄기",
- "format": "형식",
+ "cursor": {
+ "clipToBounds": "캔버스에 맞춰 자르기",
+ "size": "크기",
"motionBlur": "모션 블러",
- "fitClipOne": "{{count}}개 클립"
+ "theme": "커서 스타일",
+ "clickBounce": "클릭 바운스",
+ "title": "커서",
+ "smoothing": "부드러움",
+ "themeDefault": "기본",
+ "show": "커서 표시",
+ "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
+ "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다."
},
"annotation": {
- "arrowColor": "화살표 색상",
+ "blurIntensity": "블러 강도",
+ "textContent": "텍스트 내용",
+ "blurShapeFreehand": "자유 곡선",
"active": "활성",
- "blurTypeMosaic": "모자이크",
- "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "title": "주석 설정",
- "blurColorBlack": "검정",
- "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
- "deleteAnnotation": "주석 삭제",
- "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
- "typeText": "텍스트",
+ "blurShapeRectangle": "사각형",
"customFonts": "커스텀 폰트",
- "color": "색상",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "type": "유형",
- "size": "크기",
- "blurShapeFreehand": "자유 곡선",
- "mosaicBlockSize": "모자이크 블록 크기",
- "fontStyle": "폰트 스타일",
- "blurColorWhite": "흰색",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
- "textPlaceholder": "텍스트를 입력하세요...",
+ "title": "주석 설정",
+ "blurShapeOval": "타원",
"blurType": "블러 종류",
+ "mosaicBlockSize": "모자이크 블록 크기",
"colorPalette": "색상 팔레트",
+ "textColor": "텍스트 색상",
+ "colorWheel": "색상 휠",
+ "shortcutsAndTips": "단축키 및 팁",
+ "defaultText": "안녕하세요",
+ "clearBackground": "배경 지우기",
+ "type": "유형",
"tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
"background": "배경",
- "blurShapeRectangle": "사각형",
- "typeArrow": "화살표",
- "blurIntensity": "블러 강도",
- "uploadImage": "이미지 업로드",
- "blurShapeOval": "타원",
- "strokeWidth": "선 두께: {{width}}px",
+ "blurShape": "블러 모양",
+ "arrowColor": "화살표 색상",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
+ "blurColorBlack": "검정",
+ "blurTypeBlur": "가우시안",
+ "none": "없음",
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "size": "크기",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "blurColorWhite": "흰색",
"arrowDirection": "화살표 방향",
- "textContent": "텍스트 내용",
+ "typeImage": "이미지",
+ "typeText": "텍스트",
+ "typeArrow": "화살표",
+ "color": "색상",
"blurColor": "블러 색상",
"selectStyle": "스타일 선택",
- "colorWheel": "색상 휠",
- "textColor": "텍스트 색상",
- "none": "없음",
- "defaultText": "안녕하세요",
- "blurTypeBlur": "가우시안",
- "shortcutsAndTips": "단축키 및 팁",
- "clearBackground": "배경 지우기",
+ "blurTypeMosaic": "모자이크",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "uploadImage": "이미지 업로드",
+ "strokeWidth": "선 두께: {{width}}px",
"typeBlur": "블러",
- "typeImage": "이미지",
- "blurShape": "블러 모양"
- },
- "textAnimation": {
- "pop": "팝",
- "fade": "페이드",
- "none": "없음",
- "selectAnimation": "애니메이션 선택",
- "typewriter": "타자기",
- "rise": "상승",
- "slideLeft": "왼쪽 슬라이드",
- "pulse": "펄스",
- "title": "텍스트 애니메이션"
- },
- "audioTrack": {
- "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
- "defaultLabel": "오디오 트랙",
- "mute": "음소거",
- "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
- "loop": "반복",
- "add": "오디오 트랙 추가",
- "fadeIn": "페이드 인",
- "remove": "트랙 삭제",
- "importFailed": "오디오를 추가할 수 없습니다",
- "fadeOut": "페이드 아웃"
- },
- "customFont": {
- "addingButton": "추가 중...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
- "failedToAdd": "폰트 추가에 실패했습니다",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
- "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
- "errorEmptyName": "폰트 이름을 입력해 주세요",
- "urlLabel": "Google Fonts 가져오기 URL",
- "nameLabel": "표시 이름",
- "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
- "nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
- "namePlaceholder": "내 커스텀 폰트",
- "addButton": "폰트 추가",
- "dialogTitle": "Google 폰트 추가"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "오른쪽",
- "iso": "Iso",
- "left": "왼쪽"
- },
- "none": "없음",
- "title": "3D 회전"
- },
- "position": {
- "title": "포커스 위치",
- "y": "Y (%)",
- "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
- "x": "X (%)"
- },
- "deleteZoom": "줌 삭제",
- "previewHold": "누르고 있으면 줌 효과 미리보기",
- "customScale": "커스텀 줌",
- "focusMode": {
- "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
- "title": "포커스 모드",
- "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
- "auto": "자동",
- "manual": "수동"
- },
- "level": "줌 레벨",
- "selectRegion": "조정할 줌 구간을 선택하세요"
- },
- "speed": {
- "selectRegion": "조정할 속도 구간을 선택하세요",
- "customPlaybackSpeed": "재생 속도 직접 입력",
- "playbackSpeed": "재생 속도",
- "deleteRegion": "속도 구간 삭제",
- "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
- "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다"
+ "deleteAnnotation": "주석 삭제",
+ "fontStyle": "폰트 스타일"
},
"layout": {
- "webcamSize": "웹캠 크기",
- "webcamBlurIntensity": "블러 강도",
+ "webcamCropY": "세로 이동",
+ "reactiveWebcam": "확대 시 축소",
"shapes": {
- "circle": "원형",
+ "rectangle": "직사각형",
"square": "정사각형",
"rounded": "둥근 모서리",
- "rectangle": "직사각형"
+ "circle": "원형"
},
- "webcamCropY": "세로 이동",
- "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
- "selectPreset": "프리셋 선택",
- "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
"preset": "프리셋",
- "webcamFraming": "웹캠 구도",
- "webcamBackground": "카메라 배경",
- "webcamShape": "카메라 모양",
- "title": "카메라 레이아웃",
+ "dualFrame": "듀얼 프레임",
"bgModes": {
- "blur": "블러",
- "transparent": "누끼",
"none": "원본",
- "custom": "사용자 지정"
+ "transparent": "누끼",
+ "custom": "사용자 지정",
+ "blur": "블러"
},
- "mirrorWebcam": "웹캠 미러링",
- "noWebcam": "웹캠 없음",
- "pictureInPicture": "화면 속 화면",
- "reactiveWebcam": "확대 시 축소",
"webcamCropZoom": "자르기 확대",
- "dualFrame": "듀얼 프레임",
+ "webcamShape": "카메라 모양",
+ "webcamBlurIntensity": "블러 강도",
+ "title": "카메라 레이아웃",
"reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
+ "webcamFraming": "웹캠 구도",
"webcamCropX": "가로 이동",
+ "selectPreset": "프리셋 선택",
+ "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
+ "mirrorWebcam": "웹캠 미러링",
+ "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
+ "webcamBackground": "카메라 배경",
+ "noWebcam": "웹캠 없음",
+ "pictureInPicture": "화면 속 화면",
+ "webcamSize": "웹캠 크기",
"verticalStack": "세로 배치"
},
- "project": {
- "load": "프로젝트 불러오기",
- "save": "프로젝트 저장",
- "new": "새 프로젝트"
- },
- "audio": {
- "outputGain": "출력 레벨",
- "reset": "오디오 재설정",
- "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
- "title": "오디오"
- },
- "facets": {
- "transcript": "대본",
- "captions": "자막"
- },
- "cursor": {
- "themeDefault": "기본",
- "title": "커서",
- "theme": "커서 스타일",
- "clickBounce": "클릭 바운스",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
- "smoothing": "부드러움",
- "size": "크기",
- "motionBlur": "모션 블러",
- "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
- "show": "커서 표시",
- "clipToBounds": "캔버스에 맞춰 자르기"
+ "speed": {
+ "customPlaybackSpeed": "재생 속도 직접 입력",
+ "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
+ "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
+ "playbackSpeed": "재생 속도",
+ "deleteRegion": "속도 구간 삭제",
+ "selectRegion": "조정할 속도 구간을 선택하세요"
},
"imageUpload": {
"failedToUpload": "이미지 업로드에 실패했습니다",
- "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
- "invalidFileType": "지원하지 않는 파일 형식입니다",
+ "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
"errorReading": "파일을 읽는 중 오류가 발생했습니다.",
- "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!"
+ "invalidFileType": "지원하지 않는 파일 형식입니다",
+ "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요."
},
- "exportFormat": {
- "gifAnimation": "GIF 애니메이션",
- "mp4Description": "고화질 비디오 파일",
- "mp4Video": "MP4 비디오",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "공유용 애니메이션 이미지"
+ "transcript": {
+ "blankedWord": "비움",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 두 단어 사이에 입력하면 호박색 단어가 추가됩니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "editWord": "\"{{word}}\" 편집",
+ "editorAria": "{{filename}}의 전사",
+ "noTranscript": "아직 전사가 없습니다",
+ "clipLabel": "클립 {{index}}",
+ "trimSilence": "무음 자르기 ({{duration}}초)",
+ "laneVoiceover": "내레이션",
+ "restoreSilence": "무음 복원 ({{duration}}초)",
+ "transcribeNow": "지금 전사하기",
+ "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
+ "removeInserted": "\"{{word}}\" 삭제",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "restoreWord": "\"{{word}}\" 복원",
+ "silence": "[무음 {{duration}}초]",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
+ "laneRecording": "녹화",
+ "noClips": "아직 클립이 없습니다",
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "laneLabel": "전사본을 읽어올 소스",
+ "revertWord": "\"{{original}}\"(으)로 되돌리기",
+ "title": "현재 전사",
+ "transcribing": "전사 중…",
+ "insertAria": "새 단어",
+ "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요."
+ },
+ "captions": {
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "distanceFromTop": "위에서의 거리",
+ "minWords": "줄당 최소 단어 수",
+ "showBackground": "배경 표시",
+ "lineLength": "줄 길이",
+ "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
+ "translating": "번역 중…",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
+ "position": "위치",
+ "translateFailed": "번역에 실패했습니다.",
+ "backgroundOpacity": "불투명도",
+ "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "original": "원본 (전사)",
+ "font": "글꼴",
+ "distanceFromRight": "오른쪽에서의 거리",
+ "textColor": "글자 색",
+ "alignCenter": "가운데",
+ "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "language": "언어",
+ "show": "자막 표시",
+ "anchorTop": "위",
+ "backgroundColor": "배경 색",
+ "removeLegacyAnnotations": "이전 자막 주석 제거",
+ "background": "배경",
+ "bold": "굵게",
+ "distanceFromBottom": "아래에서의 거리",
+ "fontSize": "크기",
+ "anchorBottom": "아래",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
+ "maxWords": "줄당 최대 단어 수",
+ "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
+ "translate": "번역",
+ "deleteTranslation": "이 번역 삭제",
+ "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
+ "text": "텍스트",
+ "displayLanguage": "표시",
+ "alignRight": "오른쪽",
+ "alignLeft": "왼쪽"
+ },
+ "support": {
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "버그 신고",
+ "starOnGithub": "GitHub에 Star 남기기"
+ },
+ "customFont": {
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "addingButton": "추가 중...",
+ "namePlaceholder": "내 커스텀 폰트",
+ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
+ "dialogTitle": "Google 폰트 추가",
+ "failedToAdd": "폰트 추가에 실패했습니다",
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "addButton": "폰트 추가",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "nameLabel": "표시 이름",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
+ "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요."
},
"background": {
- "colorLabel": "색상 {{color}}",
- "customWallpaper": "사용자 배경",
- "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
- "gradient": "그라디언트",
"presets": "프리셋",
- "color": "색상",
- "custom": "사용자 지정",
- "colorWheel": "색상 휠",
- "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
"image": "이미지",
- "uploadCustom": "직접 업로드",
"title": "배경",
+ "custom": "사용자 지정",
+ "colorLabel": "색상 {{color}}",
"imageLabel": "배경 {{index}}",
+ "customWallpaper": "사용자 배경",
"colorPalette": "색상 팔레트",
+ "uploadCustom": "직접 업로드",
+ "color": "색상",
"imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "gradientLabel": "그라디언트 {{index}}"
+ "gradientLabel": "그라디언트 {{index}}",
+ "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
+ "gradient": "그라디언트",
+ "colorWheel": "색상 휠",
+ "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다."
+ },
+ "audio": {
+ "reset": "오디오 재설정",
+ "outputGain": "출력 레벨",
+ "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
+ "title": "오디오"
+ },
+ "textAnimation": {
+ "pulse": "펄스",
+ "selectAnimation": "애니메이션 선택",
+ "fade": "페이드",
+ "typewriter": "타자기",
+ "slideLeft": "왼쪽 슬라이드",
+ "none": "없음",
+ "rise": "상승",
+ "pop": "팝",
+ "title": "텍스트 애니메이션"
},
"crop": {
"title": "자르기",
+ "unlockAspectRatio": "화면 비율 해제",
+ "free": "자유",
"dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
"done": "완료",
- "free": "자유",
- "ratio": "비율",
- "cropVideo": "비디오 자르기",
"lockAspectRatio": "화면 비율 고정",
- "unlockAspectRatio": "화면 비율 해제"
+ "ratio": "비율",
+ "cropVideo": "비디오 자르기"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "내보내기 해상도"
+ },
+ "effects": {
+ "off": "끄기",
+ "on": "켜기",
+ "fitClip": "맞추기",
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "fitClipFew": "{{count}}개 클립",
+ "blurBg": "배경 흐림",
+ "motion": "모션",
+ "shadow": "그림자",
+ "fitClipOne": "{{count}}개 클립",
+ "format": "형식",
+ "roundness": "모서리 둥글기",
+ "fitClipMany": "{{count}}개 클립",
+ "formatOriginal": "원본",
+ "frame": "프레임",
+ "motionBlur": "모션 블러",
+ "padding": "여백",
+ "title": "컴포지션"
+ },
+ "language": {
+ "title": "언어"
},
"gifSettings": {
"size": "GIF 크기",
- "frameRate": "GIF 프레임 속도",
- "loop": "GIF 반복"
+ "loop": "GIF 반복",
+ "frameRate": "GIF 프레임 속도"
},
- "support": {
- "starOnGithub": "GitHub에 Star 남기기",
- "reportBug": "버그 신고",
- "saveDiagnostics": "Save Diagnostics"
+ "panes": {
+ "help": "도움말"
},
"export": {
+ "chooseSaveLocation": "저장 위치 선택",
"gifButton": "GIF 내보내기",
- "videoButton": "비디오 내보내기",
- "chooseSaveLocation": "저장 위치 선택"
+ "videoButton": "비디오 내보내기"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "내보내기 해상도",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 비디오",
+ "mp4Description": "고화질 비디오 파일",
+ "gifAnimation": "GIF 애니메이션",
+ "gifDescription": "공유용 애니메이션 이미지",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "언어"
+ "project": {
+ "save": "프로젝트 저장",
+ "new": "새 프로젝트",
+ "load": "프로젝트 불러오기"
+ },
+ "facets": {
+ "captions": "자막",
+ "transcript": "대본"
},
"trim": {
"deleteRegion": "트림 구간 삭제"
- },
- "panes": {
- "help": "도움말"
}
}
diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json
index d88355954..e3534483b 100644
--- a/src/i18n/locales/pt-BR/editor.json
+++ b/src/i18n/locales/pt-BR/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Falha ao salvar vídeo exportado",
"failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}",
"previewCompositorUnavailable": "Pré-visualização indisponível neste computador",
- "wordEditFailed": "Não foi possível alterar essa palavra"
+ "wordEditFailed": "Não foi possível alterar essa palavra",
+ "wordInsertFailed": "Não foi possível adicionar essa palavra",
+ "wordRemoveFailed": "Não foi possível excluir essa palavra"
},
"export": {
"canceled": "Exportação cancelada",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 482cccbab..a71c7bcd5 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Clipe {{index}}",
- "restoreSilence": "Restaurar silêncio ({{duration}} s)",
- "laneVoiceover": "Narração",
- "editorAria": "Transcrição de {{filename}}",
- "noTranscript": "Nenhuma transcrição ainda",
- "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
- "blankedWord": "apagada",
- "laneLabel": "Ler a transcrição de",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto: as legendas acompanham, o vídeo não muda. Passe o mouse sobre uma palavra marcada para restaurá-la.",
- "noClips": "Nenhum clipe ainda",
- "trimSilence": "Cortar silêncio ({{duration}} s)",
- "transcribing": "Transcrevendo…",
- "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
- "editWord": "Editar \"{{word}}\"",
- "restoreWord": "Restaurar \"{{word}}\"",
- "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
- "transcribeNow": "Transcrever agora",
- "revertWord": "Restaurar \"{{original}}\"",
- "silence": "[silêncio {{duration}} s]",
- "laneRecording": "Gravação",
- "title": "Transcrição atual",
- "noAudio": "Esta mídia não tem faixa de áudio"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
+ "x": "X (%)",
+ "title": "Posição do Foco"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Direita",
+ "left": "Esquerda",
+ "iso": "Iso"
+ },
+ "none": "Nenhuma",
+ "title": "Rotação 3D"
+ },
+ "focusMode": {
+ "manual": "Manual",
+ "title": "Modo de Foco",
+ "autoDescription": "A câmera segue a posição do cursor gravado",
+ "lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
+ "auto": "Automático"
+ },
+ "selectRegion": "Selecione uma região de zoom para ajustar",
+ "customScale": "Zoom Personalizado",
+ "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
+ "level": "Nível de Zoom",
+ "deleteZoom": "Excluir Zoom"
},
- "captions": {
- "showBackground": "Mostrar fundo",
- "alignLeft": "Esquerda",
- "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
- "backgroundColor": "Cor do fundo",
- "translating": "Traduzindo…",
- "backgroundOpacity": "Opacidade",
- "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
- "translateFailed": "A tradução falhou.",
- "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
- "text": "Texto",
- "fontSize": "Tamanho",
- "deleteTranslation": "Excluir esta tradução",
- "maxWords": "Máx. de palavras por linha",
- "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
- "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
- "anchorBottom": "Base",
- "distanceFromLeft": "Distância da esquerda",
- "textColor": "Cor do texto",
- "background": "Fundo",
- "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
- "minWords": "Mín. de palavras por linha",
- "lineLength": "Comprimento da linha",
- "font": "Fonte",
- "original": "Original (transcrição)",
- "bold": "Negrito",
- "displayLanguage": "Exibição",
- "removeLegacyAnnotations": "Remover anotações de legenda antigas",
- "alignCenter": "Centro",
- "distanceFromBottom": "Distância da base",
- "distanceFromTop": "Distância do topo",
- "translate": "Traduzir",
- "position": "Posição",
- "language": "Idioma",
- "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
- "show": "Mostrar legendas",
- "anchorTop": "Topo",
- "alignRight": "Direita",
- "distanceFromRight": "Distância da direita",
- "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move."
+ "audioTrack": {
+ "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "defaultLabel": "Faixa de áudio",
+ "fadeOut": "Fade out",
+ "add": "Adicionar faixa de áudio",
+ "slipHint": "Alt + arrastar para deslizar o áudio dentro",
+ "loop": "Repetir",
+ "remove": "Excluir faixa",
+ "fadeIn": "Fade in",
+ "mute": "Silenciar",
+ "importFailed": "Não foi possível adicionar o áudio"
},
- "effects": {
- "padding": "Espaçamento",
- "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
- "motion": "Movimento",
- "title": "Composição",
- "blurBg": "Desfocar Fundo",
- "fitClip": "Ajustar",
- "on": "ativado",
- "roundness": "Arredondamento",
- "frame": "Moldura",
- "formatOriginal": "Original",
- "shadow": "Sombra",
- "fitClipMany": "{{count}} clipes",
- "fitClipFew": "{{count}} clipes",
- "off": "desativado",
- "format": "Formato",
- "motionBlur": "Desfoque de Movimento",
- "fitClipOne": "{{count}} clipe"
+ "cursor": {
+ "clipToBounds": "Recortar à tela",
+ "size": "Tamanho",
+ "motionBlur": "Desfoque de movimento",
+ "theme": "Estilo do cursor",
+ "clickBounce": "Rebote ao clicar",
+ "title": "Cursor",
+ "smoothing": "Suavização",
+ "themeDefault": "Padrão",
+ "show": "Mostrar cursor",
+ "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
+ "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar."
},
"annotation": {
- "arrowColor": "Cor da Seta",
+ "blurIntensity": "Intensidade do Desfoque",
+ "textContent": "Conteúdo do Texto",
+ "blurShapeFreehand": "Mão Livre",
"active": "Ativo",
- "blurTypeMosaic": "Mosaico",
- "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
- "title": "Configurações de Anotação",
- "blurColorBlack": "Preto",
- "imageUploadSuccess": "Imagem enviada com sucesso!",
- "deleteAnnotation": "Excluir Anotação",
- "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
- "typeText": "Texto",
+ "blurShapeRectangle": "Retângulo",
"customFonts": "Fontes Personalizadas",
- "color": "Cor",
- "invalidImageType": "Tipo de imagem inválido",
- "type": "Tipo",
- "size": "Tamanho",
- "blurShapeFreehand": "Mão Livre",
- "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
- "fontStyle": "Estilo da Fonte",
- "blurColorWhite": "Branco",
- "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
- "textPlaceholder": "Digite seu texto...",
+ "title": "Configurações de Anotação",
+ "blurShapeOval": "Oval",
"blurType": "Tipo de Desfoque",
+ "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
"colorPalette": "Paleta de Cores",
+ "textColor": "Cor do Texto",
+ "colorWheel": "Roda de Cores",
+ "shortcutsAndTips": "Atalhos e Dicas",
+ "defaultText": "Olá",
+ "clearBackground": "Limpar Fundo",
+ "type": "Tipo",
"tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
+ "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
"background": "Fundo",
- "blurShapeRectangle": "Retângulo",
- "typeArrow": "Seta",
- "blurIntensity": "Intensidade do Desfoque",
- "uploadImage": "Enviar Imagem",
- "blurShapeOval": "Oval",
- "strokeWidth": "Largura do Traço: {{width}}px",
+ "blurShape": "Formato do Desfoque",
+ "arrowColor": "Cor da Seta",
+ "invalidImageType": "Tipo de imagem inválido",
+ "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
+ "blurColorBlack": "Preto",
+ "blurTypeBlur": "Gaussiano",
+ "none": "Nenhum",
"supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
+ "size": "Tamanho",
+ "imageUploadSuccess": "Imagem enviada com sucesso!",
+ "blurColorWhite": "Branco",
"arrowDirection": "Direção da Seta",
- "textContent": "Conteúdo do Texto",
+ "typeImage": "Imagem",
+ "typeText": "Texto",
+ "typeArrow": "Seta",
+ "color": "Cor",
"blurColor": "Cor do Desfoque",
"selectStyle": "Selecionar estilo",
- "colorWheel": "Roda de Cores",
- "textColor": "Cor do Texto",
- "none": "Nenhum",
- "defaultText": "Olá",
- "blurTypeBlur": "Gaussiano",
- "shortcutsAndTips": "Atalhos e Dicas",
- "clearBackground": "Limpar Fundo",
+ "blurTypeMosaic": "Mosaico",
+ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
+ "textPlaceholder": "Digite seu texto...",
+ "uploadImage": "Enviar Imagem",
+ "strokeWidth": "Largura do Traço: {{width}}px",
"typeBlur": "Desfoque",
- "typeImage": "Imagem",
- "blurShape": "Formato do Desfoque"
- },
- "textAnimation": {
- "pop": "Aparecer",
- "fade": "Esmaecer",
- "none": "Nenhuma",
- "selectAnimation": "Selecionar animação",
- "typewriter": "Máquina de Escrever",
- "rise": "Subir",
- "slideLeft": "Deslizar à Esquerda",
- "pulse": "Pulsar",
- "title": "Animação de Texto"
- },
- "audioTrack": {
- "slipHint": "Alt + arrastar para deslizar o áudio dentro",
- "defaultLabel": "Faixa de áudio",
- "mute": "Silenciar",
- "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
- "loop": "Repetir",
- "add": "Adicionar faixa de áudio",
- "fadeIn": "Fade in",
- "remove": "Excluir faixa",
- "importFailed": "Não foi possível adicionar o áudio",
- "fadeOut": "Fade out"
- },
- "customFont": {
- "addingButton": "Adicionando...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
- "failedToAdd": "Falha ao adicionar fonte",
- "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
- "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
- "errorEmptyName": "Por favor, insira um nome para a fonte",
- "urlLabel": "URL de Importação do Google Fonts",
- "nameLabel": "Nome de Exibição",
- "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
- "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
- "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
- "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
- "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
- "namePlaceholder": "Minha Fonte Personalizada",
- "addButton": "Adicionar Fonte",
- "dialogTitle": "Adicionar Google Font"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Direita",
- "iso": "Iso",
- "left": "Esquerda"
- },
- "none": "Nenhuma",
- "title": "Rotação 3D"
- },
- "position": {
- "title": "Posição do Foco",
- "y": "Y (%)",
- "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
- "x": "X (%)"
- },
- "deleteZoom": "Excluir Zoom",
- "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
- "customScale": "Zoom Personalizado",
- "focusMode": {
- "autoDescription": "A câmera segue a posição do cursor gravado",
- "title": "Modo de Foco",
- "lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
- "auto": "Automático",
- "manual": "Manual"
- },
- "level": "Nível de Zoom",
- "selectRegion": "Selecione uma região de zoom para ajustar"
- },
- "speed": {
- "selectRegion": "Selecione uma região de velocidade para ajustar",
- "customPlaybackSpeed": "Velocidade Personalizada",
- "playbackSpeed": "Velocidade de Reprodução",
- "deleteRegion": "Excluir Região de Velocidade",
- "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada.",
- "maxSpeedError": "A velocidade não pode ser superior a {{max}}×"
+ "deleteAnnotation": "Excluir Anotação",
+ "fontStyle": "Estilo da Fonte"
},
"layout": {
- "webcamSize": "Tamanho da Webcam",
- "webcamBlurIntensity": "Intensidade do desfoque",
+ "webcamCropY": "Deslocamento vertical",
+ "reactiveWebcam": "Encolher ao ampliar",
"shapes": {
- "circle": "Círculo",
+ "rectangle": "Ret.",
"square": "Quadrado",
"rounded": "Arredondado",
- "rectangle": "Ret."
+ "circle": "Círculo"
},
- "webcamCropY": "Deslocamento vertical",
- "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
- "selectPreset": "Selecionar predefinição",
- "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
"preset": "Predefinição",
- "webcamFraming": "Enquadramento da webcam",
- "webcamBackground": "Plano de fundo da câmera",
- "webcamShape": "Formato da Câmera",
- "title": "Layout da câmera",
+ "dualFrame": "Quadro Duplo",
"bgModes": {
- "blur": "Desfocado",
- "transparent": "Recorte",
"none": "Original",
- "custom": "Personalizado"
+ "transparent": "Recorte",
+ "custom": "Personalizado",
+ "blur": "Desfocado"
},
- "mirrorWebcam": "Espelhar Webcam",
- "noWebcam": "Sem Webcam",
- "pictureInPicture": "Picture in Picture",
- "reactiveWebcam": "Encolher ao ampliar",
"webcamCropZoom": "Zoom do recorte",
- "dualFrame": "Quadro Duplo",
+ "webcamShape": "Formato da Câmera",
+ "webcamBlurIntensity": "Intensidade do desfoque",
+ "title": "Layout da câmera",
"reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
+ "webcamFraming": "Enquadramento da webcam",
"webcamCropX": "Deslocamento horizontal",
+ "selectPreset": "Selecionar predefinição",
+ "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
+ "mirrorWebcam": "Espelhar Webcam",
+ "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
+ "webcamBackground": "Plano de fundo da câmera",
+ "noWebcam": "Sem Webcam",
+ "pictureInPicture": "Picture in Picture",
+ "webcamSize": "Tamanho da Webcam",
"verticalStack": "Empilhamento Vertical"
},
- "project": {
- "load": "Carregar Projeto",
- "save": "Salvar Projeto",
- "new": "Novo Projeto"
- },
- "audio": {
- "outputGain": "Nível de saída",
- "reset": "Redefinir áudio",
- "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
- "title": "Áudio"
- },
- "facets": {
- "transcript": "Transcrição",
- "captions": "Legendas"
- },
- "cursor": {
- "themeDefault": "Padrão",
- "title": "Cursor",
- "theme": "Estilo do cursor",
- "clickBounce": "Rebote ao clicar",
- "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
- "smoothing": "Suavização",
- "size": "Tamanho",
- "motionBlur": "Desfoque de movimento",
- "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
- "show": "Mostrar cursor",
- "clipToBounds": "Recortar à tela"
+ "speed": {
+ "customPlaybackSpeed": "Velocidade Personalizada",
+ "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada.",
+ "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
+ "playbackSpeed": "Velocidade de Reprodução",
+ "deleteRegion": "Excluir Região de Velocidade",
+ "selectRegion": "Selecione uma região de velocidade para ajustar"
},
"imageUpload": {
"failedToUpload": "Falha ao enviar imagem",
- "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
- "invalidFileType": "Tipo de arquivo inválido",
+ "uploadSuccess": "Imagem personalizada enviada com sucesso!",
"errorReading": "Ocorreu um erro ao ler o arquivo.",
- "uploadSuccess": "Imagem personalizada enviada com sucesso!"
+ "invalidFileType": "Tipo de arquivo inválido",
+ "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG."
},
- "exportFormat": {
- "gifAnimation": "Animação GIF",
- "mp4Description": "Arquivo de vídeo de alta qualidade",
- "mp4Video": "Vídeo MP4",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Imagem animada para compartilhamento"
+ "transcript": {
+ "blankedWord": "apagada",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "editWord": "Editar \"{{word}}\"",
+ "editorAria": "Transcrição de {{filename}}",
+ "noTranscript": "Nenhuma transcrição ainda",
+ "clipLabel": "Clipe {{index}}",
+ "trimSilence": "Cortar silêncio ({{duration}} s)",
+ "laneVoiceover": "Narração",
+ "restoreSilence": "Restaurar silêncio ({{duration}} s)",
+ "transcribeNow": "Transcrever agora",
+ "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
+ "removeInserted": "Excluir \"{{word}}\"",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "restoreWord": "Restaurar \"{{word}}\"",
+ "silence": "[silêncio {{duration}} s]",
+ "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
+ "laneRecording": "Gravação",
+ "noClips": "Nenhum clipe ainda",
+ "noAudio": "Esta mídia não tem faixa de áudio",
+ "laneLabel": "Ler a transcrição de",
+ "revertWord": "Restaurar \"{{original}}\"",
+ "title": "Transcrição atual",
+ "transcribing": "Transcrevendo…",
+ "insertAria": "Nova palavra",
+ "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente."
+ },
+ "captions": {
+ "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
+ "distanceFromTop": "Distância do topo",
+ "minWords": "Mín. de palavras por linha",
+ "showBackground": "Mostrar fundo",
+ "lineLength": "Comprimento da linha",
+ "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
+ "translating": "Traduzindo…",
+ "distanceFromLeft": "Distância da esquerda",
+ "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
+ "position": "Posição",
+ "translateFailed": "A tradução falhou.",
+ "backgroundOpacity": "Opacidade",
+ "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
+ "original": "Original (transcrição)",
+ "font": "Fonte",
+ "distanceFromRight": "Distância da direita",
+ "textColor": "Cor do texto",
+ "alignCenter": "Centro",
+ "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
+ "language": "Idioma",
+ "show": "Mostrar legendas",
+ "anchorTop": "Topo",
+ "backgroundColor": "Cor do fundo",
+ "removeLegacyAnnotations": "Remover anotações de legenda antigas",
+ "background": "Fundo",
+ "bold": "Negrito",
+ "distanceFromBottom": "Distância da base",
+ "fontSize": "Tamanho",
+ "anchorBottom": "Base",
+ "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
+ "maxWords": "Máx. de palavras por linha",
+ "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
+ "translate": "Traduzir",
+ "deleteTranslation": "Excluir esta tradução",
+ "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
+ "text": "Texto",
+ "displayLanguage": "Exibição",
+ "alignRight": "Direita",
+ "alignLeft": "Esquerda"
+ },
+ "support": {
+ "saveDiagnostics": "Salvar Diagnósticos",
+ "reportBug": "Relatar Bug",
+ "starOnGithub": "Dar Estrela no GitHub"
+ },
+ "customFont": {
+ "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
+ "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
+ "urlLabel": "URL de Importação do Google Fonts",
+ "addingButton": "Adicionando...",
+ "namePlaceholder": "Minha Fonte Personalizada",
+ "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
+ "dialogTitle": "Adicionar Google Font",
+ "failedToAdd": "Falha ao adicionar fonte",
+ "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
+ "addButton": "Adicionar Fonte",
+ "errorEmptyName": "Por favor, insira um nome para a fonte",
+ "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
+ "nameLabel": "Nome de Exibição",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
+ "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
+ "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente."
},
"background": {
- "colorLabel": "Cor {{color}}",
- "customWallpaper": "Papel de parede personalizado",
- "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
- "gradient": "Gradiente",
"presets": "Predefinições",
- "color": "Cor",
- "custom": "Personalizado",
- "colorWheel": "Roda de Cores",
- "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
"image": "Imagem",
- "uploadCustom": "Enviar Personalizada",
"title": "Fundo",
+ "custom": "Personalizado",
+ "colorLabel": "Cor {{color}}",
"imageLabel": "Fundo {{index}}",
+ "customWallpaper": "Papel de parede personalizado",
"colorPalette": "Paleta de Cores",
+ "uploadCustom": "Enviar Personalizada",
+ "color": "Cor",
"imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
- "gradientLabel": "Gradiente {{index}}"
+ "gradientLabel": "Gradiente {{index}}",
+ "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
+ "gradient": "Gradiente",
+ "colorWheel": "Roda de Cores",
+ "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco."
+ },
+ "audio": {
+ "reset": "Redefinir áudio",
+ "outputGain": "Nível de saída",
+ "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
+ "title": "Áudio"
+ },
+ "textAnimation": {
+ "pulse": "Pulsar",
+ "selectAnimation": "Selecionar animação",
+ "fade": "Esmaecer",
+ "typewriter": "Máquina de Escrever",
+ "slideLeft": "Deslizar à Esquerda",
+ "none": "Nenhuma",
+ "rise": "Subir",
+ "pop": "Aparecer",
+ "title": "Animação de Texto"
},
"crop": {
"title": "Cortar",
+ "unlockAspectRatio": "Desbloquear proporção",
+ "free": "Livre",
"dragInstruction": "Arraste cada lado para ajustar a área de corte",
"done": "Concluir",
- "free": "Livre",
- "ratio": "Proporção",
- "cropVideo": "Cortar Vídeo",
"lockAspectRatio": "Bloquear proporção",
- "unlockAspectRatio": "Desbloquear proporção"
+ "ratio": "Proporção",
+ "cropVideo": "Cortar Vídeo"
+ },
+ "exportQuality": {
+ "low": "Baixa",
+ "medium": "Média",
+ "high": "Alta",
+ "title": "Qualidade de Exportação"
+ },
+ "effects": {
+ "off": "desativado",
+ "on": "ativado",
+ "fitClip": "Ajustar",
+ "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
+ "fitClipFew": "{{count}} clipes",
+ "blurBg": "Desfocar Fundo",
+ "motion": "Movimento",
+ "shadow": "Sombra",
+ "fitClipOne": "{{count}} clipe",
+ "format": "Formato",
+ "roundness": "Arredondamento",
+ "fitClipMany": "{{count}} clipes",
+ "formatOriginal": "Original",
+ "frame": "Moldura",
+ "motionBlur": "Desfoque de Movimento",
+ "padding": "Espaçamento",
+ "title": "Composição"
+ },
+ "language": {
+ "title": "Idioma"
},
"gifSettings": {
"size": "Tamanho do GIF",
- "frameRate": "Taxa de Quadros do GIF",
- "loop": "Loop no GIF"
+ "loop": "Loop no GIF",
+ "frameRate": "Taxa de Quadros do GIF"
},
- "support": {
- "starOnGithub": "Dar Estrela no GitHub",
- "reportBug": "Relatar Bug",
- "saveDiagnostics": "Salvar Diagnósticos"
+ "panes": {
+ "help": "Ajuda"
},
"export": {
+ "chooseSaveLocation": "Escolher Local para Salvar",
"gifButton": "Exportar GIF",
- "videoButton": "Exportar Vídeo",
- "chooseSaveLocation": "Escolher Local para Salvar"
+ "videoButton": "Exportar Vídeo"
},
- "exportQuality": {
- "high": "Alta",
- "medium": "Média",
- "title": "Qualidade de Exportação",
- "low": "Baixa"
+ "exportFormat": {
+ "mp4Video": "Vídeo MP4",
+ "mp4Description": "Arquivo de vídeo de alta qualidade",
+ "gifAnimation": "Animação GIF",
+ "gifDescription": "Imagem animada para compartilhamento",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Idioma"
+ "project": {
+ "save": "Salvar Projeto",
+ "new": "Novo Projeto",
+ "load": "Carregar Projeto"
+ },
+ "facets": {
+ "captions": "Legendas",
+ "transcript": "Transcrição"
},
"trim": {
"deleteRegion": "Excluir Região de Recorte"
- },
- "panes": {
- "help": "Ajuda"
}
}
diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json
index 67707d1fe..db4127bc3 100644
--- a/src/i18n/locales/ru/editor.json
+++ b/src/i18n/locales/ru/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео",
"failedToRevealInFolder": "Ошибка при показе в папке: {{error}}",
"previewCompositorUnavailable": "Предпросмотр недоступен на этом компьютере",
- "wordEditFailed": "Не удалось изменить это слово"
+ "wordEditFailed": "Не удалось изменить это слово",
+ "wordInsertFailed": "Не удалось добавить слово",
+ "wordRemoveFailed": "Не удалось удалить слово"
},
"export": {
"canceled": "Экспорт отменён",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index f62453568..cfb6fb425 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Клип {{index}}",
- "restoreSilence": "Вернуть тишину ({{duration}} с)",
- "laneVoiceover": "Закадровый голос",
- "editorAria": "Расшифровка «{{filename}}»",
- "noTranscript": "Расшифровки пока нет",
- "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
- "blankedWord": "очищено",
- "laneLabel": "Читать расшифровку из",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст: субтитры следуют за ним, видео не меняется. Наведите курсор на отмеченное слово, чтобы вернуть его.",
- "noClips": "Клипов пока нет",
- "trimSilence": "Вырезать тишину ({{duration}} с)",
- "transcribing": "Расшифровка…",
- "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
- "editWord": "Изменить «{{word}}»",
- "restoreWord": "Вернуть «{{word}}»",
- "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
- "transcribeNow": "Расшифровать сейчас",
- "revertWord": "Вернуть «{{original}}»",
- "silence": "[тишина {{duration}} с]",
- "laneRecording": "Запись",
- "title": "Текущая расшифровка",
- "noAudio": "В этом медиафайле нет аудиодорожки"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = край слева / сверху, 100 = край справа / снизу",
+ "x": "X (%)",
+ "title": "Положение фокуса"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Справа",
+ "left": "Слева",
+ "iso": "Изометрия"
+ },
+ "none": "Нет",
+ "title": "3D вращение"
+ },
+ "focusMode": {
+ "manual": "Ручной",
+ "title": "Режим фокуса",
+ "autoDescription": "Камера следует за записанной позицией курсора",
+ "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
+ "auto": "Авто"
+ },
+ "selectRegion": "Выберите область масштабирования для настройки",
+ "customScale": "Пользовательский масштаб",
+ "previewHold": "Удерживайте для предпросмотра эффекта зума",
+ "level": "Уровень масштабирования",
+ "deleteZoom": "Удалить масштабирование"
},
- "captions": {
- "showBackground": "Показывать фон",
- "alignLeft": "Слева",
- "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
- "backgroundColor": "Цвет фона",
- "translating": "Перевод…",
- "backgroundOpacity": "Непрозрачность",
- "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
- "translateFailed": "Не удалось перевести.",
- "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
- "text": "Текст",
- "fontSize": "Размер",
- "deleteTranslation": "Удалить этот перевод",
- "maxWords": "Макс. слов в строке",
- "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
- "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
- "anchorBottom": "Снизу",
- "distanceFromLeft": "Отступ слева",
- "textColor": "Цвет текста",
- "background": "Фон",
- "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
- "minWords": "Мин. слов в строке",
- "lineLength": "Длина строки",
- "font": "Шрифт",
- "original": "Оригинал (расшифровка)",
- "bold": "Полужирный",
- "displayLanguage": "Отображение",
- "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
- "alignCenter": "По центру",
- "distanceFromBottom": "Отступ снизу",
- "distanceFromTop": "Отступ сверху",
- "translate": "Перевести",
- "position": "Положение",
- "language": "Язык",
- "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
- "show": "Показывать субтитры",
- "anchorTop": "Сверху",
- "alignRight": "Справа",
- "distanceFromRight": "Отступ справа",
- "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте."
+ "audioTrack": {
+ "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "defaultLabel": "Аудиодорожка",
+ "fadeOut": "Затухание",
+ "add": "Добавить аудиодорожку",
+ "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
+ "loop": "Повтор",
+ "remove": "Удалить дорожку",
+ "fadeIn": "Нарастание",
+ "mute": "Без звука",
+ "importFailed": "Не удалось добавить аудио"
},
- "effects": {
- "padding": "Отступ",
- "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
- "motion": "Движение",
- "title": "Композиция",
- "blurBg": "Размытие фона",
- "fitClip": "Подогнать",
- "on": "вкл",
- "roundness": "Скругление",
- "frame": "Рамка",
- "formatOriginal": "Исходный",
- "shadow": "Тень",
- "fitClipMany": "{{count}} клипов",
- "fitClipFew": "{{count}} клипа",
- "off": "выкл",
- "format": "Формат",
+ "cursor": {
+ "clipToBounds": "Обрезать по холсту",
+ "size": "Размер",
"motionBlur": "Размытие движения",
- "fitClipOne": "{{count}} клип"
+ "theme": "Стиль курсора",
+ "clickBounce": "Отскок при клике",
+ "title": "Курсор",
+ "smoothing": "Сглаживание",
+ "themeDefault": "По умолчанию",
+ "show": "Показывать курсор",
+ "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
+ "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании."
},
"annotation": {
- "arrowColor": "Цвет стрелки",
+ "blurIntensity": "Интенсивность размытия",
+ "textContent": "Содержание текста",
+ "blurShapeFreehand": "От руки",
"active": "Активно",
- "blurTypeMosaic": "Мозаика",
- "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
- "title": "Настройки аннотаций",
- "blurColorBlack": "Чёрный",
- "imageUploadSuccess": "Изображение успешно загружено!",
- "deleteAnnotation": "Удалить аннотацию",
- "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
- "typeText": "Текст",
+ "blurShapeRectangle": "Прямоугольник",
"customFonts": "Пользовательские шрифты",
- "color": "Цвет",
- "invalidImageType": "Неверный тип файла",
- "type": "Тип",
- "size": "Размер",
- "blurShapeFreehand": "От руки",
- "mosaicBlockSize": "Размер блока мозаики",
- "fontStyle": "Стиль шрифта",
- "blurColorWhite": "Белый",
- "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
- "textPlaceholder": "Введите ваш текст...",
+ "title": "Настройки аннотаций",
+ "blurShapeOval": "Овал",
"blurType": "Тип размытия",
+ "mosaicBlockSize": "Размер блока мозаики",
"colorPalette": "Палитра цветов",
+ "textColor": "Цвет текста",
+ "colorWheel": "Цветовой круг",
+ "shortcutsAndTips": "Горячие клавиши и советы",
+ "defaultText": "Привет",
+ "clearBackground": "Очистить фон",
+ "type": "Тип",
"tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
+ "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
"background": "Фон",
- "blurShapeRectangle": "Прямоугольник",
- "typeArrow": "Стрелка",
- "blurIntensity": "Интенсивность размытия",
- "uploadImage": "Загрузить изображение",
- "blurShapeOval": "Овал",
- "strokeWidth": "Толщина линии: {{width}}px",
+ "blurShape": "Форма размытия",
+ "arrowColor": "Цвет стрелки",
+ "invalidImageType": "Неверный тип файла",
+ "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
+ "blurColorBlack": "Чёрный",
+ "blurTypeBlur": "Гауссово",
+ "none": "Нет",
"supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
+ "size": "Размер",
+ "imageUploadSuccess": "Изображение успешно загружено!",
+ "blurColorWhite": "Белый",
"arrowDirection": "Направление стрелки",
- "textContent": "Содержание текста",
+ "typeImage": "Изображение",
+ "typeText": "Текст",
+ "typeArrow": "Стрелка",
+ "color": "Цвет",
"blurColor": "Цвет размытия",
"selectStyle": "Выбрать стиль",
- "colorWheel": "Цветовой круг",
- "textColor": "Цвет текста",
- "none": "Нет",
- "defaultText": "Привет",
- "blurTypeBlur": "Гауссово",
- "shortcutsAndTips": "Горячие клавиши и советы",
- "clearBackground": "Очистить фон",
+ "blurTypeMosaic": "Мозаика",
+ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
+ "textPlaceholder": "Введите ваш текст...",
+ "uploadImage": "Загрузить изображение",
+ "strokeWidth": "Толщина линии: {{width}}px",
"typeBlur": "Размытие",
- "typeImage": "Изображение",
- "blurShape": "Форма размытия"
- },
- "textAnimation": {
- "pop": "Всплытие",
- "fade": "Затухание",
- "none": "Нет",
- "selectAnimation": "Выбрать анимацию",
- "typewriter": "Пишущая машинка",
- "rise": "Подъем",
- "slideLeft": "Скольжение влево",
- "pulse": "Импульс",
- "title": "Анимация текста"
- },
- "audioTrack": {
- "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
- "defaultLabel": "Аудиодорожка",
- "mute": "Без звука",
- "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
- "loop": "Повтор",
- "add": "Добавить аудиодорожку",
- "fadeIn": "Нарастание",
- "remove": "Удалить дорожку",
- "importFailed": "Не удалось добавить аудио",
- "fadeOut": "Затухание"
- },
- "customFont": {
- "addingButton": "Добавление...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
- "failedToAdd": "Не удалось добавить шрифт",
- "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
- "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
- "errorEmptyName": "Пожалуйста, введите имя шрифта",
- "urlLabel": "URL импорта Google Fonts",
- "nameLabel": "Отображаемое имя",
- "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
- "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
- "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
- "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
- "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
- "namePlaceholder": "Мой пользовательский шрифт",
- "addButton": "Добавить шрифт",
- "dialogTitle": "Добавить шрифт Google"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Справа",
- "iso": "Изометрия",
- "left": "Слева"
- },
- "none": "Нет",
- "title": "3D вращение"
- },
- "position": {
- "title": "Положение фокуса",
- "y": "Y (%)",
- "hint": "0 = край слева / сверху, 100 = край справа / снизу",
- "x": "X (%)"
- },
- "deleteZoom": "Удалить масштабирование",
- "previewHold": "Удерживайте для предпросмотра эффекта зума",
- "customScale": "Пользовательский масштаб",
- "focusMode": {
- "autoDescription": "Камера следует за записанной позицией курсора",
- "title": "Режим фокуса",
- "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
- "auto": "Авто",
- "manual": "Ручной"
- },
- "level": "Уровень масштабирования",
- "selectRegion": "Выберите область масштабирования для настройки"
- },
- "speed": {
- "selectRegion": "Выберите область скорости для настройки",
- "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
- "playbackSpeed": "Скорость воспроизведения",
- "deleteRegion": "Удалить область скорости",
- "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет.",
- "maxSpeedError": "Скорость не может быть выше {{max}}×"
+ "deleteAnnotation": "Удалить аннотацию",
+ "fontStyle": "Стиль шрифта"
},
"layout": {
- "webcamSize": "Размер веб-камеры",
- "webcamBlurIntensity": "Интенсивность размытия",
+ "webcamCropY": "Смещение по вертикали",
+ "reactiveWebcam": "Уменьшать при зуме",
"shapes": {
- "circle": "Круг",
+ "rectangle": "Прямоуг.",
"square": "Квадрат",
"rounded": "Скруглённый",
- "rectangle": "Прямоуг."
+ "circle": "Круг"
},
- "webcamCropY": "Смещение по вертикали",
- "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
- "selectPreset": "Выбрать пресет",
- "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
"preset": "Пресет",
- "webcamFraming": "Кадрирование веб-камеры",
- "webcamBackground": "Фон камеры",
- "webcamShape": "Форма камеры",
- "title": "Расположение камеры",
+ "dualFrame": "Двойной кадр",
"bgModes": {
- "blur": "Размытие",
- "transparent": "Вырезка",
"none": "Оригинал",
- "custom": "Пользовательский"
+ "transparent": "Вырезка",
+ "custom": "Пользовательский",
+ "blur": "Размытие"
},
- "mirrorWebcam": "Зеркалить веб-камеру",
- "noWebcam": "Без веб-камеры",
- "pictureInPicture": "Картинка в картинке",
- "reactiveWebcam": "Уменьшать при зуме",
"webcamCropZoom": "Масштаб обрезки",
- "dualFrame": "Двойной кадр",
+ "webcamShape": "Форма камеры",
+ "webcamBlurIntensity": "Интенсивность размытия",
+ "title": "Расположение камеры",
"reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
+ "webcamFraming": "Кадрирование веб-камеры",
"webcamCropX": "Смещение по горизонтали",
+ "selectPreset": "Выбрать пресет",
+ "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
+ "mirrorWebcam": "Зеркалить веб-камеру",
+ "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
+ "webcamBackground": "Фон камеры",
+ "noWebcam": "Без веб-камеры",
+ "pictureInPicture": "Картинка в картинке",
+ "webcamSize": "Размер веб-камеры",
"verticalStack": "Вертикальный стек"
},
- "project": {
- "load": "Загрузить проект",
- "save": "Сохранить проект",
- "new": "Новый проект"
- },
- "audio": {
- "outputGain": "Уровень выхода",
- "reset": "Сбросить аудио",
- "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
- "title": "Аудио"
- },
- "facets": {
- "transcript": "Транскрипт",
- "captions": "Субтитры"
- },
- "cursor": {
- "themeDefault": "По умолчанию",
- "title": "Курсор",
- "theme": "Стиль курсора",
- "clickBounce": "Отскок при клике",
- "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
- "smoothing": "Сглаживание",
- "size": "Размер",
- "motionBlur": "Размытие движения",
- "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
- "show": "Показывать курсор",
- "clipToBounds": "Обрезать по холсту"
+ "speed": {
+ "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
+ "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет.",
+ "maxSpeedError": "Скорость не может быть выше {{max}}×",
+ "playbackSpeed": "Скорость воспроизведения",
+ "deleteRegion": "Удалить область скорости",
+ "selectRegion": "Выберите область скорости для настройки"
},
"imageUpload": {
"failedToUpload": "Не удалось загрузить изображение",
- "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
- "invalidFileType": "Неверный тип файла",
+ "uploadSuccess": "Пользовательское изображение успешно загружено!",
"errorReading": "Произошла ошибка при чтении файла.",
- "uploadSuccess": "Пользовательское изображение успешно загружено!"
+ "invalidFileType": "Неверный тип файла",
+ "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG."
},
- "exportFormat": {
- "gifAnimation": "GIF анимация",
- "mp4Description": "Видеофайл высокого качества",
- "mp4Video": "MP4 видео",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Анимированное изображение для обмена"
+ "transcript": {
+ "blankedWord": "очищено",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "editWord": "Изменить «{{word}}»",
+ "editorAria": "Расшифровка «{{filename}}»",
+ "noTranscript": "Расшифровки пока нет",
+ "clipLabel": "Клип {{index}}",
+ "trimSilence": "Вырезать тишину ({{duration}} с)",
+ "laneVoiceover": "Закадровый голос",
+ "restoreSilence": "Вернуть тишину ({{duration}} с)",
+ "transcribeNow": "Расшифровать сейчас",
+ "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
+ "removeInserted": "Удалить «{{word}}»",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "restoreWord": "Вернуть «{{word}}»",
+ "silence": "[тишина {{duration}} с]",
+ "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
+ "laneRecording": "Запись",
+ "noClips": "Клипов пока нет",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "laneLabel": "Читать расшифровку из",
+ "revertWord": "Вернуть «{{original}}»",
+ "title": "Текущая расшифровка",
+ "transcribing": "Расшифровка…",
+ "insertAria": "Новое слово",
+ "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново."
+ },
+ "captions": {
+ "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
+ "distanceFromTop": "Отступ сверху",
+ "minWords": "Мин. слов в строке",
+ "showBackground": "Показывать фон",
+ "lineLength": "Длина строки",
+ "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
+ "translating": "Перевод…",
+ "distanceFromLeft": "Отступ слева",
+ "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
+ "position": "Положение",
+ "translateFailed": "Не удалось перевести.",
+ "backgroundOpacity": "Непрозрачность",
+ "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
+ "original": "Оригинал (расшифровка)",
+ "font": "Шрифт",
+ "distanceFromRight": "Отступ справа",
+ "textColor": "Цвет текста",
+ "alignCenter": "По центру",
+ "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "language": "Язык",
+ "show": "Показывать субтитры",
+ "anchorTop": "Сверху",
+ "backgroundColor": "Цвет фона",
+ "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
+ "background": "Фон",
+ "bold": "Полужирный",
+ "distanceFromBottom": "Отступ снизу",
+ "fontSize": "Размер",
+ "anchorBottom": "Снизу",
+ "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
+ "maxWords": "Макс. слов в строке",
+ "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
+ "translate": "Перевести",
+ "deleteTranslation": "Удалить этот перевод",
+ "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
+ "text": "Текст",
+ "displayLanguage": "Отображение",
+ "alignRight": "Справа",
+ "alignLeft": "Слева"
+ },
+ "support": {
+ "saveDiagnostics": "Сохранить диагностику",
+ "reportBug": "Сообщить об ошибке",
+ "starOnGithub": "Звезда на GitHub"
+ },
+ "customFont": {
+ "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
+ "urlLabel": "URL импорта Google Fonts",
+ "addingButton": "Добавление...",
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
+ "dialogTitle": "Добавить шрифт Google",
+ "failedToAdd": "Не удалось добавить шрифт",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
+ "addButton": "Добавить шрифт",
+ "errorEmptyName": "Пожалуйста, введите имя шрифта",
+ "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "nameLabel": "Отображаемое имя",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
+ "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
+ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова."
},
"background": {
- "colorLabel": "Цвет {{color}}",
- "customWallpaper": "Свои обои",
- "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
- "gradient": "Градиент",
"presets": "Пресеты",
- "color": "Цвет",
- "custom": "Свой",
- "colorWheel": "Цветовой круг",
- "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
"image": "Изображение",
- "uploadCustom": "Загрузить свой",
"title": "Фон",
+ "custom": "Свой",
+ "colorLabel": "Цвет {{color}}",
"imageLabel": "Фон {{index}}",
+ "customWallpaper": "Свои обои",
"colorPalette": "Палитра цветов",
+ "uploadCustom": "Загрузить свой",
+ "color": "Цвет",
"imageReadFailed": "Не удалось прочитать этот файл изображения.",
- "gradientLabel": "Градиент {{index}}"
+ "gradientLabel": "Градиент {{index}}",
+ "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
+ "gradient": "Градиент",
+ "colorWheel": "Цветовой круг",
+ "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска."
+ },
+ "audio": {
+ "reset": "Сбросить аудио",
+ "outputGain": "Уровень выхода",
+ "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
+ "title": "Аудио"
+ },
+ "textAnimation": {
+ "pulse": "Импульс",
+ "selectAnimation": "Выбрать анимацию",
+ "fade": "Затухание",
+ "typewriter": "Пишущая машинка",
+ "slideLeft": "Скольжение влево",
+ "none": "Нет",
+ "rise": "Подъем",
+ "pop": "Всплытие",
+ "title": "Анимация текста"
},
"crop": {
"title": "Обрезка",
+ "unlockAspectRatio": "Разблокировать соотношение сторон",
+ "free": "Свободно",
"dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
"done": "Готово",
- "free": "Свободно",
- "ratio": "Соотношение сторон",
- "cropVideo": "Обрезать видео",
"lockAspectRatio": "Заблокировать соотношение сторон",
- "unlockAspectRatio": "Разблокировать соотношение сторон"
+ "ratio": "Соотношение сторон",
+ "cropVideo": "Обрезать видео"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "Разрешение экспорта"
+ },
+ "effects": {
+ "off": "выкл",
+ "on": "вкл",
+ "fitClip": "Подогнать",
+ "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
+ "fitClipFew": "{{count}} клипа",
+ "blurBg": "Размытие фона",
+ "motion": "Движение",
+ "shadow": "Тень",
+ "fitClipOne": "{{count}} клип",
+ "format": "Формат",
+ "roundness": "Скругление",
+ "fitClipMany": "{{count}} клипов",
+ "formatOriginal": "Исходный",
+ "frame": "Рамка",
+ "motionBlur": "Размытие движения",
+ "padding": "Отступ",
+ "title": "Композиция"
+ },
+ "language": {
+ "title": "Язык"
},
"gifSettings": {
"size": "Размер GIF",
- "frameRate": "Частота кадров GIF",
- "loop": "Зациклить GIF"
+ "loop": "Зациклить GIF",
+ "frameRate": "Частота кадров GIF"
},
- "support": {
- "starOnGithub": "Звезда на GitHub",
- "reportBug": "Сообщить об ошибке",
- "saveDiagnostics": "Сохранить диагностику"
+ "panes": {
+ "help": "Справка"
},
"export": {
+ "chooseSaveLocation": "Выбрать место сохранения",
"gifButton": "Экспорт GIF",
- "videoButton": "Экспорт видео",
- "chooseSaveLocation": "Выбрать место сохранения"
+ "videoButton": "Экспорт видео"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "Разрешение экспорта",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 видео",
+ "mp4Description": "Видеофайл высокого качества",
+ "gifAnimation": "GIF анимация",
+ "gifDescription": "Анимированное изображение для обмена",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Язык"
+ "project": {
+ "save": "Сохранить проект",
+ "new": "Новый проект",
+ "load": "Загрузить проект"
+ },
+ "facets": {
+ "captions": "Субтитры",
+ "transcript": "Транскрипт"
},
"trim": {
"deleteRegion": "Удалить область обрезки"
- },
- "panes": {
- "help": "Справка"
}
}
diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json
index 55491f675..a682d46a3 100644
--- a/src/i18n/locales/tr/editor.json
+++ b/src/i18n/locales/tr/editor.json
@@ -13,7 +13,9 @@
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}",
"previewCompositorUnavailable": "Bu makinede önizleme kullanılamıyor",
- "wordEditFailed": "Bu kelime değiştirilemedi"
+ "wordEditFailed": "Bu kelime değiştirilemedi",
+ "wordInsertFailed": "Bu kelime eklenemedi",
+ "wordRemoveFailed": "Bu kelime silinemedi"
},
"export": {
"canceled": "Dışa aktarım iptal edildi",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 7fdca94bb..387671419 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Klip {{index}}",
- "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
- "laneVoiceover": "Dış ses",
- "editorAria": "{{filename}} dökümü",
- "noTranscript": "Henüz döküm yok",
- "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
- "blankedWord": "boşaltıldı",
- "laneLabel": "Deşifreyi şuradan oku",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz: altyazılar buna uyar, video değişmez. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
- "noClips": "Henüz klip yok",
- "trimSilence": "Sessizliği kırp ({{duration}} sn)",
- "transcribing": "Döküm çıkarılıyor…",
- "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
- "editWord": "\"{{word}}\" kelimesini düzenle",
- "restoreWord": "\"{{word}}\" kelimesini geri al",
- "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
- "transcribeNow": "Şimdi dökümünü çıkar",
- "revertWord": "\"{{original}}\" haline getir",
- "silence": "[sessizlik {{duration}} sn]",
- "laneRecording": "Kayıt",
- "title": "Geçerli döküm",
- "noAudio": "Bu medyada ses parçası yok"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
+ "x": "X (%)",
+ "title": "Odak Konumu"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Sağ",
+ "left": "Sol",
+ "iso": "Iso"
+ },
+ "none": "Yok",
+ "title": "3D Döndürme"
+ },
+ "focusMode": {
+ "manual": "Manuel",
+ "title": "Odak Modu",
+ "autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
+ "lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
+ "auto": "Otomatik"
+ },
+ "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
+ "customScale": "Özel Yakınlaştırma",
+ "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
+ "level": "Yakınlaştırma Seviyesi",
+ "deleteZoom": "Yakınlaştırmayı Sil"
},
- "captions": {
- "showBackground": "Arka planı göster",
- "alignLeft": "Sol",
- "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
- "backgroundColor": "Arka plan rengi",
- "translating": "Çevriliyor…",
- "backgroundOpacity": "Saydamlık",
- "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
- "translateFailed": "Çeviri başarısız oldu.",
- "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
- "text": "Metin",
- "fontSize": "Boyut",
- "deleteTranslation": "Bu çeviriyi sil",
- "maxWords": "Satır başına en çok kelime",
- "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
- "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
- "anchorBottom": "Alt",
- "distanceFromLeft": "Soldan uzaklık",
- "textColor": "Metin rengi",
- "background": "Arka plan",
- "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
- "minWords": "Satır başına en az kelime",
- "lineLength": "Satır uzunluğu",
- "font": "Yazı tipi",
- "original": "Özgün (döküm)",
- "bold": "Kalın",
- "displayLanguage": "Görüntüleme",
- "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
- "alignCenter": "Orta",
- "distanceFromBottom": "Alttan uzaklık",
- "distanceFromTop": "Üstten uzaklık",
- "translate": "Çevir",
- "position": "Konum",
- "language": "Dil",
- "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
- "show": "Altyazıları göster",
- "anchorTop": "Üst",
- "alignRight": "Sağ",
- "distanceFromRight": "Sağdan uzaklık",
- "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır."
+ "audioTrack": {
+ "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "defaultLabel": "Ses parçası",
+ "fadeOut": "Kararma",
+ "add": "Ses parçası ekle",
+ "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
+ "loop": "Döngü",
+ "remove": "Parçayı sil",
+ "fadeIn": "Açılma",
+ "mute": "Sessiz",
+ "importFailed": "Ses eklenemedi"
},
- "effects": {
- "padding": "Dolgu",
- "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
- "motion": "Hareket",
- "title": "Kompozisyon",
- "blurBg": "Arka Planı Bulanıklaştır",
- "fitClip": "Sığdır",
- "on": "açık",
- "roundness": "Yuvarlaklık",
- "frame": "Çerçeve",
- "formatOriginal": "Orijinal",
- "shadow": "Gölge",
- "fitClipMany": "{{count}} klip",
- "fitClipFew": "{{count}} klip",
- "off": "kapalı",
- "format": "Biçim",
+ "cursor": {
+ "clipToBounds": "Tuvale Kırp",
+ "size": "Boyut",
"motionBlur": "Hareket Bulanıklığı",
- "fitClipOne": "{{count}} klip"
+ "theme": "İmleç Stili",
+ "clickBounce": "Tıklama Sıçraması",
+ "title": "İmleç",
+ "smoothing": "Yumuşatma",
+ "themeDefault": "Varsayılan",
+ "show": "İmleci Göster",
+ "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
+ "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır."
},
"annotation": {
- "arrowColor": "Ok Rengi",
+ "blurIntensity": "Bulanıklık Yoğunluğu",
+ "textContent": "Metin İçeriği",
+ "blurShapeFreehand": "Serbest",
"active": "Aktif",
- "blurTypeMosaic": "Mozaik",
- "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
- "title": "Açıklama Ayarları",
- "blurColorBlack": "Siyah",
- "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
- "deleteAnnotation": "Açıklamayı Sil",
- "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
- "typeText": "Metin",
+ "blurShapeRectangle": "Dikdörtgen",
"customFonts": "Özel Yazı Tipleri",
- "color": "Renk",
- "invalidImageType": "Geçersiz dosya türü",
- "type": "Tür",
- "size": "Boyut",
- "blurShapeFreehand": "Serbest",
- "mosaicBlockSize": "Mozaik Blok Boyutu",
- "fontStyle": "Yazı Tipi Stili",
- "blurColorWhite": "Beyaz",
- "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
- "textPlaceholder": "Metninizi girin...",
+ "title": "Açıklama Ayarları",
+ "blurShapeOval": "Oval",
"blurType": "Bulanıklık Türü",
+ "mosaicBlockSize": "Mozaik Blok Boyutu",
"colorPalette": "Renk paleti",
+ "textColor": "Metin Rengi",
+ "colorWheel": "Renk çarkı",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
+ "defaultText": "Merhaba",
+ "clearBackground": "Arka Planı Temizle",
+ "type": "Tür",
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
+ "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
"background": "Arka Plan",
- "blurShapeRectangle": "Dikdörtgen",
- "typeArrow": "Ok",
- "blurIntensity": "Bulanıklık Yoğunluğu",
- "uploadImage": "Görüntü Yükle",
- "blurShapeOval": "Oval",
- "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "blurShape": "Bulanık Şekli",
+ "arrowColor": "Ok Rengi",
+ "invalidImageType": "Geçersiz dosya türü",
+ "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "blurColorBlack": "Siyah",
+ "blurTypeBlur": "Gauss",
+ "none": "Yok",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
+ "size": "Boyut",
+ "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
+ "blurColorWhite": "Beyaz",
"arrowDirection": "Ok Yönü",
- "textContent": "Metin İçeriği",
+ "typeImage": "Görüntü",
+ "typeText": "Metin",
+ "typeArrow": "Ok",
+ "color": "Renk",
"blurColor": "Bulanıklık Rengi",
"selectStyle": "Stil seçin",
- "colorWheel": "Renk çarkı",
- "textColor": "Metin Rengi",
- "none": "Yok",
- "defaultText": "Merhaba",
- "blurTypeBlur": "Gauss",
- "shortcutsAndTips": "Kısayollar ve İpuçları",
- "clearBackground": "Arka Planı Temizle",
+ "blurTypeMosaic": "Mozaik",
+ "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
+ "textPlaceholder": "Metninizi girin...",
+ "uploadImage": "Görüntü Yükle",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
"typeBlur": "Bulanık",
- "typeImage": "Görüntü",
- "blurShape": "Bulanık Şekli"
- },
- "textAnimation": {
- "pop": "Fırlama",
- "fade": "Belirme",
- "none": "Yok",
- "selectAnimation": "Animasyon seçin",
- "typewriter": "Daktilo",
- "rise": "Yükselme",
- "slideLeft": "Sola Kaydırma",
- "pulse": "Nabız",
- "title": "Metin Animasyonu"
- },
- "audioTrack": {
- "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
- "defaultLabel": "Ses parçası",
- "mute": "Sessiz",
- "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
- "loop": "Döngü",
- "add": "Ses parçası ekle",
- "fadeIn": "Açılma",
- "remove": "Parçayı sil",
- "importFailed": "Ses eklenemedi",
- "fadeOut": "Kararma"
- },
- "customFont": {
- "addingButton": "Ekleniyor...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
- "failedToAdd": "Yazı tipi eklenemedi",
- "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
- "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
- "errorEmptyName": "Lütfen bir yazı tipi adı girin",
- "urlLabel": "Google Fonts İçe Aktarım URL'si",
- "nameLabel": "Görünen Ad",
- "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
- "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
- "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
- "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
- "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
- "namePlaceholder": "Özel Yazı Tipim",
- "addButton": "Yazı Tipi Ekle",
- "dialogTitle": "Google Yazı Tipi Ekle"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Sağ",
- "iso": "Iso",
- "left": "Sol"
- },
- "none": "Yok",
- "title": "3D Döndürme"
- },
- "position": {
- "title": "Odak Konumu",
- "y": "Y (%)",
- "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
- "x": "X (%)"
- },
- "deleteZoom": "Yakınlaştırmayı Sil",
- "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
- "customScale": "Özel Yakınlaştırma",
- "focusMode": {
- "autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
- "title": "Odak Modu",
- "lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
- "auto": "Otomatik",
- "manual": "Manuel"
- },
- "level": "Yakınlaştırma Seviyesi",
- "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin"
- },
- "speed": {
- "selectRegion": "Ayarlamak için bir hız bölgesi seçin",
- "customPlaybackSpeed": "Özel Oynatma Hızı",
- "playbackSpeed": "Oynatma Hızı",
- "deleteRegion": "Hız Bölgesini Sil",
- "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez.",
- "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz"
+ "deleteAnnotation": "Açıklamayı Sil",
+ "fontStyle": "Yazı Tipi Stili"
},
"layout": {
- "webcamSize": "Webcam Boyutu",
- "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "webcamCropY": "Dikey kaydırma",
+ "reactiveWebcam": "Yakınlaştırınca küçült",
"shapes": {
- "circle": "Daire",
+ "rectangle": "Dikdörtgen",
"square": "Kare",
"rounded": "Yuvarlatılmış",
- "rectangle": "Dikdörtgen"
+ "circle": "Daire"
},
- "webcamCropY": "Dikey kaydırma",
- "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
- "selectPreset": "Ön ayar seçin",
- "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
"preset": "Ön Ayar",
- "webcamFraming": "Webcam kadrajı",
- "webcamBackground": "Kamera Arka Planı",
- "webcamShape": "Kamera Şekli",
- "title": "Kamera düzeni",
+ "dualFrame": "Çift Kare",
"bgModes": {
- "blur": "Bulanık",
- "transparent": "Kesme",
"none": "Orijinal",
- "custom": "Özel"
+ "transparent": "Kesme",
+ "custom": "Özel",
+ "blur": "Bulanık"
},
- "mirrorWebcam": "Web kamerasını aynala",
- "noWebcam": "Web kamerası yok",
- "pictureInPicture": "Resim İçinde Resim",
- "reactiveWebcam": "Yakınlaştırınca küçült",
"webcamCropZoom": "Kırpma yakınlaştırması",
- "dualFrame": "Çift Kare",
+ "webcamShape": "Kamera Şekli",
+ "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "title": "Kamera düzeni",
"reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
+ "webcamFraming": "Webcam kadrajı",
"webcamCropX": "Yatay kaydırma",
+ "selectPreset": "Ön ayar seçin",
+ "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
+ "mirrorWebcam": "Web kamerasını aynala",
+ "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
+ "webcamBackground": "Kamera Arka Planı",
+ "noWebcam": "Web kamerası yok",
+ "pictureInPicture": "Resim İçinde Resim",
+ "webcamSize": "Webcam Boyutu",
"verticalStack": "Dikey Yığın"
},
- "project": {
- "load": "Proje Yükle",
- "save": "Projeyi Kaydet",
- "new": "Yeni Proje"
- },
- "audio": {
- "outputGain": "Çıkış seviyesi",
- "reset": "Sesi sıfırla",
- "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
- "title": "Ses"
- },
- "facets": {
- "transcript": "Metin Dökümü",
- "captions": "Altyazılar"
- },
- "cursor": {
- "themeDefault": "Varsayılan",
- "title": "İmleç",
- "theme": "İmleç Stili",
- "clickBounce": "Tıklama Sıçraması",
- "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
- "smoothing": "Yumuşatma",
- "size": "Boyut",
- "motionBlur": "Hareket Bulanıklığı",
- "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
- "show": "İmleci Göster",
- "clipToBounds": "Tuvale Kırp"
+ "speed": {
+ "customPlaybackSpeed": "Özel Oynatma Hızı",
+ "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez.",
+ "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
+ "playbackSpeed": "Oynatma Hızı",
+ "deleteRegion": "Hız Bölgesini Sil",
+ "selectRegion": "Ayarlamak için bir hız bölgesi seçin"
},
"imageUpload": {
"failedToUpload": "Görüntü yüklenemedi",
- "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.",
- "invalidFileType": "Geçersiz dosya türü",
+ "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
"errorReading": "Dosya okunurken bir hata oluştu.",
- "uploadSuccess": "Özel görüntü başarıyla yüklendi!"
+ "invalidFileType": "Geçersiz dosya türü",
+ "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin."
},
- "exportFormat": {
- "gifAnimation": "GIF Animasyon",
- "mp4Description": "Yüksek kaliteli video dosyası",
- "mp4Video": "MP4 Video",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Paylaşım için hareketli görüntü"
+ "transcript": {
+ "blankedWord": "boşaltıldı",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "editWord": "\"{{word}}\" kelimesini düzenle",
+ "editorAria": "{{filename}} dökümü",
+ "noTranscript": "Henüz döküm yok",
+ "clipLabel": "Klip {{index}}",
+ "trimSilence": "Sessizliği kırp ({{duration}} sn)",
+ "laneVoiceover": "Dış ses",
+ "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
+ "transcribeNow": "Şimdi dökümünü çıkar",
+ "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "restoreWord": "\"{{word}}\" kelimesini geri al",
+ "silence": "[sessizlik {{duration}} sn]",
+ "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
+ "laneRecording": "Kayıt",
+ "noClips": "Henüz klip yok",
+ "noAudio": "Bu medyada ses parçası yok",
+ "laneLabel": "Deşifreyi şuradan oku",
+ "revertWord": "\"{{original}}\" haline getir",
+ "title": "Geçerli döküm",
+ "transcribing": "Döküm çıkarılıyor…",
+ "insertAria": "Yeni kelime",
+ "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun."
+ },
+ "captions": {
+ "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
+ "distanceFromTop": "Üstten uzaklık",
+ "minWords": "Satır başına en az kelime",
+ "showBackground": "Arka planı göster",
+ "lineLength": "Satır uzunluğu",
+ "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
+ "translating": "Çevriliyor…",
+ "distanceFromLeft": "Soldan uzaklık",
+ "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
+ "position": "Konum",
+ "translateFailed": "Çeviri başarısız oldu.",
+ "backgroundOpacity": "Saydamlık",
+ "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
+ "original": "Özgün (döküm)",
+ "font": "Yazı tipi",
+ "distanceFromRight": "Sağdan uzaklık",
+ "textColor": "Metin rengi",
+ "alignCenter": "Orta",
+ "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
+ "language": "Dil",
+ "show": "Altyazıları göster",
+ "anchorTop": "Üst",
+ "backgroundColor": "Arka plan rengi",
+ "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
+ "background": "Arka plan",
+ "bold": "Kalın",
+ "distanceFromBottom": "Alttan uzaklık",
+ "fontSize": "Boyut",
+ "anchorBottom": "Alt",
+ "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
+ "maxWords": "Satır başına en çok kelime",
+ "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
+ "translate": "Çevir",
+ "deleteTranslation": "Bu çeviriyi sil",
+ "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
+ "text": "Metin",
+ "displayLanguage": "Görüntüleme",
+ "alignRight": "Sağ",
+ "alignLeft": "Sol"
+ },
+ "support": {
+ "saveDiagnostics": "Teşhis Verilerini Kaydet",
+ "reportBug": "Hata Bildir",
+ "starOnGithub": "GitHub'da Yıldızla"
+ },
+ "customFont": {
+ "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
+ "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
+ "urlLabel": "Google Fonts İçe Aktarım URL'si",
+ "addingButton": "Ekleniyor...",
+ "namePlaceholder": "Özel Yazı Tipim",
+ "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
+ "dialogTitle": "Google Yazı Tipi Ekle",
+ "failedToAdd": "Yazı tipi eklenemedi",
+ "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
+ "addButton": "Yazı Tipi Ekle",
+ "errorEmptyName": "Lütfen bir yazı tipi adı girin",
+ "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
+ "nameLabel": "Görünen Ad",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
+ "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
+ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin."
},
"background": {
- "colorLabel": "Renk {{color}}",
- "customWallpaper": "Özel duvar kâğıdı",
- "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
- "gradient": "Gradyan",
"presets": "Ön ayarlar",
- "color": "Renk",
- "custom": "Özel",
- "colorWheel": "Renk çarkı",
- "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
"image": "Görüntü",
- "uploadCustom": "Özel Yükle",
"title": "Arka Plan",
+ "custom": "Özel",
+ "colorLabel": "Renk {{color}}",
"imageLabel": "Arka plan {{index}}",
+ "customWallpaper": "Özel duvar kâğıdı",
"colorPalette": "Renk paleti",
+ "uploadCustom": "Özel Yükle",
+ "color": "Renk",
"imageReadFailed": "Bu görsel dosyası okunamadı.",
- "gradientLabel": "Gradyan {{index}}"
+ "gradientLabel": "Gradyan {{index}}",
+ "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
+ "gradient": "Gradyan",
+ "colorWheel": "Renk çarkı",
+ "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel."
+ },
+ "audio": {
+ "reset": "Sesi sıfırla",
+ "outputGain": "Çıkış seviyesi",
+ "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
+ "title": "Ses"
+ },
+ "textAnimation": {
+ "pulse": "Nabız",
+ "selectAnimation": "Animasyon seçin",
+ "fade": "Belirme",
+ "typewriter": "Daktilo",
+ "slideLeft": "Sola Kaydırma",
+ "none": "Yok",
+ "rise": "Yükselme",
+ "pop": "Fırlama",
+ "title": "Metin Animasyonu"
},
"crop": {
"title": "Kırpma",
+ "unlockAspectRatio": "En boy oranının kilidini aç",
+ "free": "Serbest",
"dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
"done": "Tamam",
- "free": "Serbest",
- "ratio": "Oran",
- "cropVideo": "Videoyu Kırp",
"lockAspectRatio": "En boy oranını kilitle",
- "unlockAspectRatio": "En boy oranının kilidini aç"
+ "ratio": "Oran",
+ "cropVideo": "Videoyu Kırp"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "Dışa aktarma çözünürlüğü"
+ },
+ "effects": {
+ "off": "kapalı",
+ "on": "açık",
+ "fitClip": "Sığdır",
+ "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
+ "fitClipFew": "{{count}} klip",
+ "blurBg": "Arka Planı Bulanıklaştır",
+ "motion": "Hareket",
+ "shadow": "Gölge",
+ "fitClipOne": "{{count}} klip",
+ "format": "Biçim",
+ "roundness": "Yuvarlaklık",
+ "fitClipMany": "{{count}} klip",
+ "formatOriginal": "Orijinal",
+ "frame": "Çerçeve",
+ "motionBlur": "Hareket Bulanıklığı",
+ "padding": "Dolgu",
+ "title": "Kompozisyon"
+ },
+ "language": {
+ "title": "Dil"
},
"gifSettings": {
"size": "GIF Boyutu",
- "frameRate": "GIF Kare Hızı",
- "loop": "GIF Döngüsü"
+ "loop": "GIF Döngüsü",
+ "frameRate": "GIF Kare Hızı"
},
- "support": {
- "starOnGithub": "GitHub'da Yıldızla",
- "reportBug": "Hata Bildir",
- "saveDiagnostics": "Teşhis Verilerini Kaydet"
+ "panes": {
+ "help": "Yardım"
},
"export": {
+ "chooseSaveLocation": "Kayıt Konumu Seç",
"gifButton": "GIF Olarak Dışa Aktar",
- "videoButton": "Videoyu Dışa Aktar",
- "chooseSaveLocation": "Kayıt Konumu Seç"
+ "videoButton": "Videoyu Dışa Aktar"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "Dışa aktarma çözünürlüğü",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 Video",
+ "mp4Description": "Yüksek kaliteli video dosyası",
+ "gifAnimation": "GIF Animasyon",
+ "gifDescription": "Paylaşım için hareketli görüntü",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Dil"
+ "project": {
+ "save": "Projeyi Kaydet",
+ "new": "Yeni Proje",
+ "load": "Proje Yükle"
+ },
+ "facets": {
+ "captions": "Altyazılar",
+ "transcript": "Metin Dökümü"
},
"trim": {
"deleteRegion": "Kırpma Bölgesini Sil"
- },
- "panes": {
- "help": "Yardım"
}
}
diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json
index bfa4c53cf..3ba80c56d 100644
--- a/src/i18n/locales/vi/editor.json
+++ b/src/i18n/locales/vi/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "Không thể lưu video đã xuất",
"failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}",
"previewCompositorUnavailable": "Không thể xem trước trên máy này",
- "wordEditFailed": "Không thể thay đổi từ này"
+ "wordEditFailed": "Không thể thay đổi từ này",
+ "wordInsertFailed": "Không thể thêm từ này",
+ "wordRemoveFailed": "Không thể xoá từ này"
},
"export": {
"canceled": "Đã hủy xuất",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index ebfb90ada..c88886729 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "Clip {{index}}",
- "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
- "laneVoiceover": "Lời thuyết minh",
- "editorAria": "Bản chép lời của {{filename}}",
- "noTranscript": "Chưa có bản chép lời",
- "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
- "blankedWord": "đã xoá",
- "laneLabel": "Đọc bản chép lời từ",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản: phụ đề đi theo, video không đổi. Di chuột lên từ được đánh dấu để khôi phục.",
- "noClips": "Chưa có clip nào",
- "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
- "transcribing": "Đang chép lời…",
- "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
- "editWord": "Sửa \"{{word}}\"",
- "restoreWord": "Khôi phục \"{{word}}\"",
- "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
- "transcribeNow": "Chép lời ngay",
- "revertWord": "Khôi phục \"{{original}}\"",
- "silence": "[khoảng lặng {{duration}} giây]",
- "laneRecording": "Bản ghi",
- "title": "Bản chép lời hiện tại",
- "noAudio": "Media này không có bản âm thanh"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
+ "x": "X (%)",
+ "title": "Vị trí tiêu điểm"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Phải",
+ "left": "Trái",
+ "iso": "Đẳng phối"
+ },
+ "none": "Không",
+ "title": "Xoay 3D"
+ },
+ "focusMode": {
+ "manual": "Thủ công",
+ "title": "Chế độ lấy nét",
+ "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
+ "lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
+ "auto": "Tự động"
+ },
+ "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
+ "customScale": "Thu phóng tùy chỉnh",
+ "previewHold": "Giữ để xem trước hiệu ứng phóng to",
+ "level": "Mức độ thu phóng",
+ "deleteZoom": "Xóa thu phóng"
},
- "captions": {
- "showBackground": "Hiện nền",
- "alignLeft": "Trái",
- "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
- "backgroundColor": "Màu nền",
- "translating": "Đang dịch…",
- "backgroundOpacity": "Độ mờ",
- "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
- "translateFailed": "Dịch thất bại.",
- "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
- "text": "Văn bản",
- "fontSize": "Cỡ chữ",
- "deleteTranslation": "Xóa bản dịch này",
- "maxWords": "Số từ tối đa mỗi dòng",
- "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
- "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
- "anchorBottom": "Dưới",
- "distanceFromLeft": "Khoảng cách từ trái",
- "textColor": "Màu chữ",
- "background": "Nền",
- "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
- "minWords": "Số từ tối thiểu mỗi dòng",
- "lineLength": "Độ dài dòng",
- "font": "Phông chữ",
- "original": "Gốc (bản chép lời)",
- "bold": "Đậm",
- "displayLanguage": "Hiển thị",
- "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
- "alignCenter": "Giữa",
- "distanceFromBottom": "Khoảng cách từ dưới",
- "distanceFromTop": "Khoảng cách từ trên",
- "translate": "Dịch",
- "position": "Vị trí",
- "language": "Ngôn ngữ",
- "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
- "show": "Hiện phụ đề",
- "anchorTop": "Trên",
- "alignRight": "Phải",
- "distanceFromRight": "Khoảng cách từ phải",
- "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi."
+ "audioTrack": {
+ "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "defaultLabel": "Bản âm thanh",
+ "fadeOut": "Mờ ra",
+ "add": "Thêm bản âm thanh",
+ "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
+ "loop": "Lặp",
+ "remove": "Xóa bản nhạc",
+ "fadeIn": "Mờ vào",
+ "mute": "Tắt tiếng",
+ "importFailed": "Không thể thêm âm thanh"
},
- "effects": {
- "padding": "Phần đệm",
- "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
- "motion": "Chuyển động",
- "title": "Bố cục hình ảnh",
- "blurBg": "Làm mờ nền",
- "fitClip": "Vừa khít",
- "on": "bật",
- "roundness": "Độ bo tròn",
- "frame": "Khung",
- "formatOriginal": "Gốc",
- "shadow": "Bóng đổ",
- "fitClipMany": "{{count}} clip",
- "fitClipFew": "{{count}} clip",
- "off": "tắt",
- "format": "Định dạng",
+ "cursor": {
+ "clipToBounds": "Cắt theo khung",
+ "size": "Kích thước",
"motionBlur": "Làm mờ chuyển động",
- "fitClipOne": "{{count}} clip"
+ "theme": "Kiểu con trỏ",
+ "clickBounce": "Nảy khi nhấp",
+ "title": "Con trỏ",
+ "smoothing": "Làm mượt",
+ "themeDefault": "Mặc định",
+ "show": "Hiện con trỏ",
+ "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
+ "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy."
},
"annotation": {
- "arrowColor": "Màu mũi tên",
+ "blurIntensity": "Cường độ làm mờ",
+ "textContent": "Nội dung văn bản",
+ "blurShapeFreehand": "Vẽ tự do",
"active": "Hoạt động",
- "blurTypeMosaic": "Khảm",
- "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
- "title": "Cài đặt chú thích",
- "blurColorBlack": "Đen",
- "imageUploadSuccess": "Tải lên hình ảnh thành công!",
- "deleteAnnotation": "Xóa chú thích",
- "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
- "typeText": "Văn bản",
+ "blurShapeRectangle": "Chữ nhật",
"customFonts": "Phông chữ tùy chỉnh",
- "color": "Màu sắc",
- "invalidImageType": "Loại tệp không hợp lệ",
- "type": "Loại",
- "size": "Kích thước",
- "blurShapeFreehand": "Vẽ tự do",
- "mosaicBlockSize": "Kích thước khối khảm",
- "fontStyle": "Kiểu phông chữ",
- "blurColorWhite": "Trắng",
- "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
- "textPlaceholder": "Nhập văn bản của bạn...",
+ "title": "Cài đặt chú thích",
+ "blurShapeOval": "Bầu dục",
"blurType": "Loại làm mờ",
+ "mosaicBlockSize": "Kích thước khối khảm",
"colorPalette": "Bảng màu",
+ "textColor": "Màu văn bản",
+ "colorWheel": "Vòng màu",
+ "shortcutsAndTips": "Phím tắt & Mẹo",
+ "defaultText": "Xin chào",
+ "clearBackground": "Xóa nền",
+ "type": "Loại",
"tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
+ "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
"background": "Nền",
- "blurShapeRectangle": "Chữ nhật",
- "typeArrow": "Mũi tên",
- "blurIntensity": "Cường độ làm mờ",
- "uploadImage": "Tải lên hình ảnh",
- "blurShapeOval": "Bầu dục",
- "strokeWidth": "Độ dày nét: {{width}}px",
+ "blurShape": "Hình dạng làm mờ",
+ "arrowColor": "Màu mũi tên",
+ "invalidImageType": "Loại tệp không hợp lệ",
+ "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
+ "blurColorBlack": "Đen",
+ "blurTypeBlur": "Gaussian",
+ "none": "Không có",
"supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
+ "size": "Kích thước",
+ "imageUploadSuccess": "Tải lên hình ảnh thành công!",
+ "blurColorWhite": "Trắng",
"arrowDirection": "Hướng mũi tên",
- "textContent": "Nội dung văn bản",
+ "typeImage": "Hình ảnh",
+ "typeText": "Văn bản",
+ "typeArrow": "Mũi tên",
+ "color": "Màu sắc",
"blurColor": "Màu làm mờ",
"selectStyle": "Chọn kiểu",
- "colorWheel": "Vòng màu",
- "textColor": "Màu văn bản",
- "none": "Không có",
- "defaultText": "Xin chào",
- "blurTypeBlur": "Gaussian",
- "shortcutsAndTips": "Phím tắt & Mẹo",
- "clearBackground": "Xóa nền",
+ "blurTypeMosaic": "Khảm",
+ "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
+ "textPlaceholder": "Nhập văn bản của bạn...",
+ "uploadImage": "Tải lên hình ảnh",
+ "strokeWidth": "Độ dày nét: {{width}}px",
"typeBlur": "Làm mờ",
- "typeImage": "Hình ảnh",
- "blurShape": "Hình dạng làm mờ"
- },
- "textAnimation": {
- "pop": "Bật lên",
- "fade": "Mờ dần",
- "none": "Không có",
- "selectAnimation": "Chọn hoạt ảnh",
- "typewriter": "Máy đánh chữ",
- "rise": "Trồi lên",
- "slideLeft": "Trượt sang trái",
- "pulse": "Nhấp nháy",
- "title": "Hoạt ảnh văn bản"
- },
- "audioTrack": {
- "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
- "defaultLabel": "Bản âm thanh",
- "mute": "Tắt tiếng",
- "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
- "loop": "Lặp",
- "add": "Thêm bản âm thanh",
- "fadeIn": "Mờ vào",
- "remove": "Xóa bản nhạc",
- "importFailed": "Không thể thêm âm thanh",
- "fadeOut": "Mờ ra"
- },
- "customFont": {
- "addingButton": "Đang thêm...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
- "failedToAdd": "Thêm phông chữ thất bại",
- "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
- "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
- "errorEmptyName": "Vui lòng nhập tên phông chữ",
- "urlLabel": "URL nhập Google Fonts",
- "nameLabel": "Tên hiển thị",
- "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
- "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
- "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
- "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
- "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
- "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
- "addButton": "Thêm phông chữ",
- "dialogTitle": "Thêm Google Font"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "Phải",
- "iso": "Đẳng phối",
- "left": "Trái"
- },
- "none": "Không",
- "title": "Xoay 3D"
- },
- "position": {
- "title": "Vị trí tiêu điểm",
- "y": "Y (%)",
- "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
- "x": "X (%)"
- },
- "deleteZoom": "Xóa thu phóng",
- "previewHold": "Giữ để xem trước hiệu ứng phóng to",
- "customScale": "Thu phóng tùy chỉnh",
- "focusMode": {
- "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
- "title": "Chế độ lấy nét",
- "lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
- "auto": "Tự động",
- "manual": "Thủ công"
- },
- "level": "Mức độ thu phóng",
- "selectRegion": "Chọn vùng thu phóng để điều chỉnh"
- },
- "speed": {
- "selectRegion": "Chọn vùng tốc độ để điều chỉnh",
- "customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
- "playbackSpeed": "Tốc độ phát",
- "deleteRegion": "Xóa vùng tốc độ",
- "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng.",
- "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×"
+ "deleteAnnotation": "Xóa chú thích",
+ "fontStyle": "Kiểu phông chữ"
},
"layout": {
- "webcamSize": "Kích thước Webcam",
- "webcamBlurIntensity": "Độ mờ",
+ "webcamCropY": "Dịch chuyển dọc",
+ "reactiveWebcam": "Thu nhỏ khi phóng to",
"shapes": {
- "circle": "Tròn",
+ "rectangle": "Chữ nhật",
"square": "Vuông",
"rounded": "Bo góc",
- "rectangle": "Chữ nhật"
+ "circle": "Tròn"
},
- "webcamCropY": "Dịch chuyển dọc",
- "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
- "selectPreset": "Chọn cài đặt sẵn",
- "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
"preset": "Cài đặt sẵn",
- "webcamFraming": "Khung hình webcam",
- "webcamBackground": "Nền máy ảnh",
- "webcamShape": "Hình dạng máy ảnh",
- "title": "Bố cục camera",
+ "dualFrame": "Khung kép",
"bgModes": {
- "blur": "Làm mờ",
- "transparent": "Tách nền",
"none": "Gốc",
- "custom": "Tùy chỉnh"
+ "transparent": "Tách nền",
+ "custom": "Tùy chỉnh",
+ "blur": "Làm mờ"
},
- "mirrorWebcam": "Lật webcam",
- "noWebcam": "Không có webcam",
- "pictureInPicture": "Hình trong hình",
- "reactiveWebcam": "Thu nhỏ khi phóng to",
"webcamCropZoom": "Thu phóng vùng cắt",
- "dualFrame": "Khung kép",
+ "webcamShape": "Hình dạng máy ảnh",
+ "webcamBlurIntensity": "Độ mờ",
+ "title": "Bố cục camera",
"reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
+ "webcamFraming": "Khung hình webcam",
"webcamCropX": "Dịch chuyển ngang",
+ "selectPreset": "Chọn cài đặt sẵn",
+ "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
+ "mirrorWebcam": "Lật webcam",
+ "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
+ "webcamBackground": "Nền máy ảnh",
+ "noWebcam": "Không có webcam",
+ "pictureInPicture": "Hình trong hình",
+ "webcamSize": "Kích thước Webcam",
"verticalStack": "Xếp chồng dọc"
},
- "project": {
- "load": "Tải dự án",
- "save": "Lưu dự án",
- "new": "Dự án mới"
- },
- "audio": {
- "outputGain": "Mức đầu ra",
- "reset": "Đặt lại âm thanh",
- "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
- "title": "Âm thanh"
- },
- "facets": {
- "transcript": "Bản ghi lời thoại",
- "captions": "Phụ đề"
- },
- "cursor": {
- "themeDefault": "Mặc định",
- "title": "Con trỏ",
- "theme": "Kiểu con trỏ",
- "clickBounce": "Nảy khi nhấp",
- "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
- "smoothing": "Làm mượt",
- "size": "Kích thước",
- "motionBlur": "Làm mờ chuyển động",
- "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
- "show": "Hiện con trỏ",
- "clipToBounds": "Cắt theo khung"
+ "speed": {
+ "customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
+ "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng.",
+ "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
+ "playbackSpeed": "Tốc độ phát",
+ "deleteRegion": "Xóa vùng tốc độ",
+ "selectRegion": "Chọn vùng tốc độ để điều chỉnh"
},
"imageUpload": {
"failedToUpload": "Tải lên hình ảnh thất bại",
- "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
- "invalidFileType": "Loại tệp không hợp lệ",
+ "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
"errorReading": "Đã xảy ra lỗi khi đọc tệp.",
- "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!"
+ "invalidFileType": "Loại tệp không hợp lệ",
+ "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG."
},
- "exportFormat": {
- "gifAnimation": "Ảnh động GIF",
- "mp4Description": "Tệp video chất lượng cao",
- "mp4Video": "Video MP4",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "Hình ảnh động để chia sẻ"
+ "transcript": {
+ "blankedWord": "đã xoá",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "editWord": "Sửa \"{{word}}\"",
+ "editorAria": "Bản chép lời của {{filename}}",
+ "noTranscript": "Chưa có bản chép lời",
+ "clipLabel": "Clip {{index}}",
+ "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
+ "laneVoiceover": "Lời thuyết minh",
+ "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
+ "transcribeNow": "Chép lời ngay",
+ "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
+ "removeInserted": "Xoá \"{{word}}\"",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "restoreWord": "Khôi phục \"{{word}}\"",
+ "silence": "[khoảng lặng {{duration}} giây]",
+ "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
+ "laneRecording": "Bản ghi",
+ "noClips": "Chưa có clip nào",
+ "noAudio": "Media này không có bản âm thanh",
+ "laneLabel": "Đọc bản chép lời từ",
+ "revertWord": "Khôi phục \"{{original}}\"",
+ "title": "Bản chép lời hiện tại",
+ "transcribing": "Đang chép lời…",
+ "insertAria": "Từ mới",
+ "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại."
+ },
+ "captions": {
+ "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
+ "distanceFromTop": "Khoảng cách từ trên",
+ "minWords": "Số từ tối thiểu mỗi dòng",
+ "showBackground": "Hiện nền",
+ "lineLength": "Độ dài dòng",
+ "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
+ "translating": "Đang dịch…",
+ "distanceFromLeft": "Khoảng cách từ trái",
+ "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
+ "position": "Vị trí",
+ "translateFailed": "Dịch thất bại.",
+ "backgroundOpacity": "Độ mờ",
+ "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
+ "original": "Gốc (bản chép lời)",
+ "font": "Phông chữ",
+ "distanceFromRight": "Khoảng cách từ phải",
+ "textColor": "Màu chữ",
+ "alignCenter": "Giữa",
+ "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
+ "language": "Ngôn ngữ",
+ "show": "Hiện phụ đề",
+ "anchorTop": "Trên",
+ "backgroundColor": "Màu nền",
+ "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
+ "background": "Nền",
+ "bold": "Đậm",
+ "distanceFromBottom": "Khoảng cách từ dưới",
+ "fontSize": "Cỡ chữ",
+ "anchorBottom": "Dưới",
+ "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
+ "maxWords": "Số từ tối đa mỗi dòng",
+ "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
+ "translate": "Dịch",
+ "deleteTranslation": "Xóa bản dịch này",
+ "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
+ "text": "Văn bản",
+ "displayLanguage": "Hiển thị",
+ "alignRight": "Phải",
+ "alignLeft": "Trái"
+ },
+ "support": {
+ "saveDiagnostics": "Lưu thông tin chẩn đoán",
+ "reportBug": "Báo cáo lỗi",
+ "starOnGithub": "Đánh giá sao trên GitHub"
+ },
+ "customFont": {
+ "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
+ "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
+ "urlLabel": "URL nhập Google Fonts",
+ "addingButton": "Đang thêm...",
+ "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
+ "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
+ "dialogTitle": "Thêm Google Font",
+ "failedToAdd": "Thêm phông chữ thất bại",
+ "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
+ "addButton": "Thêm phông chữ",
+ "errorEmptyName": "Vui lòng nhập tên phông chữ",
+ "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
+ "nameLabel": "Tên hiển thị",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
+ "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
+ "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại."
},
"background": {
- "colorLabel": "Màu {{color}}",
- "customWallpaper": "Ảnh nền tùy chỉnh",
- "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
- "gradient": "Dải màu",
"presets": "Có sẵn",
- "color": "Màu sắc",
- "custom": "Tùy chỉnh",
- "colorWheel": "Vòng màu",
- "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
"image": "Hình ảnh",
- "uploadCustom": "Tải lên tùy chỉnh",
"title": "Nền",
+ "custom": "Tùy chỉnh",
+ "colorLabel": "Màu {{color}}",
"imageLabel": "Nền {{index}}",
+ "customWallpaper": "Ảnh nền tùy chỉnh",
"colorPalette": "Bảng màu",
+ "uploadCustom": "Tải lên tùy chỉnh",
+ "color": "Màu sắc",
"imageReadFailed": "Không thể đọc tệp ảnh này.",
- "gradientLabel": "Dải màu {{index}}"
+ "gradientLabel": "Dải màu {{index}}",
+ "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
+ "gradient": "Dải màu",
+ "colorWheel": "Vòng màu",
+ "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa."
+ },
+ "audio": {
+ "reset": "Đặt lại âm thanh",
+ "outputGain": "Mức đầu ra",
+ "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
+ "title": "Âm thanh"
+ },
+ "textAnimation": {
+ "pulse": "Nhấp nháy",
+ "selectAnimation": "Chọn hoạt ảnh",
+ "fade": "Mờ dần",
+ "typewriter": "Máy đánh chữ",
+ "slideLeft": "Trượt sang trái",
+ "none": "Không có",
+ "rise": "Trồi lên",
+ "pop": "Bật lên",
+ "title": "Hoạt ảnh văn bản"
},
"crop": {
"title": "Cắt xén",
+ "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
+ "free": "Tự do",
"dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
"done": "Hoàn tất",
- "free": "Tự do",
- "ratio": "Tỷ lệ",
- "cropVideo": "Cắt xén video",
"lockAspectRatio": "Khóa tỷ lệ khung hình",
- "unlockAspectRatio": "Mở khóa tỷ lệ khung hình"
+ "ratio": "Tỷ lệ",
+ "cropVideo": "Cắt xén video"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "Độ phân giải xuất"
+ },
+ "effects": {
+ "off": "tắt",
+ "on": "bật",
+ "fitClip": "Vừa khít",
+ "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
+ "fitClipFew": "{{count}} clip",
+ "blurBg": "Làm mờ nền",
+ "motion": "Chuyển động",
+ "shadow": "Bóng đổ",
+ "fitClipOne": "{{count}} clip",
+ "format": "Định dạng",
+ "roundness": "Độ bo tròn",
+ "fitClipMany": "{{count}} clip",
+ "formatOriginal": "Gốc",
+ "frame": "Khung",
+ "motionBlur": "Làm mờ chuyển động",
+ "padding": "Phần đệm",
+ "title": "Bố cục hình ảnh"
+ },
+ "language": {
+ "title": "Ngôn ngữ"
},
"gifSettings": {
"size": "Kích thước GIF",
- "frameRate": "Tốc độ khung hình GIF",
- "loop": "Lặp lại GIF"
+ "loop": "Lặp lại GIF",
+ "frameRate": "Tốc độ khung hình GIF"
},
- "support": {
- "starOnGithub": "Đánh giá sao trên GitHub",
- "reportBug": "Báo cáo lỗi",
- "saveDiagnostics": "Lưu thông tin chẩn đoán"
+ "panes": {
+ "help": "Trợ giúp"
},
"export": {
+ "chooseSaveLocation": "Chọn vị trí lưu",
"gifButton": "Xuất GIF",
- "videoButton": "Xuất Video",
- "chooseSaveLocation": "Chọn vị trí lưu"
+ "videoButton": "Xuất Video"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "Độ phân giải xuất",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "Video MP4",
+ "mp4Description": "Tệp video chất lượng cao",
+ "gifAnimation": "Ảnh động GIF",
+ "gifDescription": "Hình ảnh động để chia sẻ",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "Ngôn ngữ"
+ "project": {
+ "save": "Lưu dự án",
+ "new": "Dự án mới",
+ "load": "Tải dự án"
+ },
+ "facets": {
+ "captions": "Phụ đề",
+ "transcript": "Bản ghi lời thoại"
},
"trim": {
"deleteRegion": "Xóa vùng cắt"
- },
- "panes": {
- "help": "Trợ giúp"
}
}
diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json
index ec0cec3c1..b13c664f1 100644
--- a/src/i18n/locales/zh-CN/editor.json
+++ b/src/i18n/locales/zh-CN/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "保存导出的视频失败",
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}",
"previewCompositorUnavailable": "此设备无法使用预览",
- "wordEditFailed": "无法修改该词"
+ "wordEditFailed": "无法修改该词",
+ "wordInsertFailed": "无法添加该词",
+ "wordRemoveFailed": "无法删除该词"
},
"export": {
"canceled": "导出已取消",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 7eace9b79..effa4a2d7 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "片段 {{index}}",
- "restoreSilence": "恢复静音({{duration}} 秒)",
- "laneVoiceover": "配音",
- "editorAria": "{{filename}} 的转录",
- "noTranscript": "暂无转录",
- "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
- "blankedWord": "已清空",
- "laneLabel": "转写文本读取自",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字:字幕随之更新,画面不变。将鼠标悬停在带标记的词上可还原。",
- "noClips": "暂无片段",
- "trimSilence": "修剪静音({{duration}} 秒)",
- "transcribing": "转录中…",
- "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
- "editWord": "编辑“{{word}}”",
- "restoreWord": "恢复“{{word}}”",
- "correctedWord": "已更正 — 转录原文为“{{original}}”",
- "transcribeNow": "立即转录",
- "revertWord": "还原为“{{original}}”",
- "silence": "[静音 {{duration}} 秒]",
- "laneRecording": "录制",
- "title": "当前转录",
- "noAudio": "此媒体没有音频轨道"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "x": "X (%)",
+ "title": "焦点位置"
+ },
+ "threeD": {
+ "preset": {
+ "right": "右",
+ "left": "左",
+ "iso": "Iso"
+ },
+ "none": "无",
+ "title": "3D 旋转"
+ },
+ "focusMode": {
+ "manual": "手动",
+ "title": "对焦模式",
+ "autoDescription": "摄像头跟随录制时的光标位置",
+ "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
+ "auto": "自动"
+ },
+ "selectRegion": "选择要调整的缩放区域",
+ "customScale": "自定义缩放",
+ "previewHold": "按住预览放大效果",
+ "level": "缩放级别",
+ "deleteZoom": "删除缩放"
},
- "captions": {
- "showBackground": "显示背景",
- "alignLeft": "左对齐",
- "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
- "backgroundColor": "背景颜色",
- "translating": "翻译中…",
- "backgroundOpacity": "不透明度",
- "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
- "translateFailed": "翻译失败。",
- "translateHint": "使用已配置的 AI 提供方翻译转录",
- "text": "文本",
- "fontSize": "字号",
- "deleteTranslation": "删除此翻译",
- "maxWords": "每行最多词数",
- "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
- "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
- "anchorBottom": "底部",
- "distanceFromLeft": "距左侧",
- "textColor": "文字颜色",
- "background": "背景",
- "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
- "minWords": "每行最少词数",
- "lineLength": "行长",
- "font": "字体",
- "original": "原文(转录)",
- "bold": "粗体",
- "displayLanguage": "显示",
- "removeLegacyAnnotations": "移除旧的字幕批注",
- "alignCenter": "居中",
- "distanceFromBottom": "距底部",
- "distanceFromTop": "距顶部",
- "translate": "翻译",
- "position": "位置",
- "language": "语言",
- "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
- "show": "显示字幕",
- "anchorTop": "顶部",
- "alignRight": "右对齐",
- "distanceFromRight": "距右侧",
- "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。"
+ "audioTrack": {
+ "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "defaultLabel": "音频轨道",
+ "fadeOut": "淡出",
+ "add": "添加音频轨道",
+ "slipHint": "按住 Alt 拖动可在其中滑动音频",
+ "loop": "循环",
+ "remove": "删除轨道",
+ "fadeIn": "淡入",
+ "mute": "静音",
+ "importFailed": "无法添加音频"
},
- "effects": {
- "padding": "内边距",
- "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
- "motion": "运动",
- "title": "画面合成",
- "blurBg": "模糊背景",
- "fitClip": "适配",
- "on": "开",
- "roundness": "圆角",
- "frame": "画框",
- "formatOriginal": "原始",
- "shadow": "阴影",
- "fitClipMany": "{{count}} 个片段",
- "fitClipFew": "{{count}} 个片段",
- "off": "关",
- "format": "格式",
+ "cursor": {
+ "clipToBounds": "裁剪到画布",
+ "size": "大小",
"motionBlur": "运动模糊",
- "fitClipOne": "{{count}} 个片段"
+ "theme": "光标样式",
+ "clickBounce": "点击弹跳",
+ "title": "光标",
+ "smoothing": "平滑",
+ "themeDefault": "默认",
+ "show": "显示光标",
+ "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
+ "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。"
},
"annotation": {
- "arrowColor": "箭头颜色",
+ "blurIntensity": "模糊强度",
+ "textContent": "文本内容",
+ "blurShapeFreehand": "自由手绘",
"active": "活动",
- "blurTypeMosaic": "马赛克",
- "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
- "title": "标注设置",
- "blurColorBlack": "黑色",
- "imageUploadSuccess": "图片上传成功!",
- "deleteAnnotation": "删除标注",
- "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
- "typeText": "文本",
+ "blurShapeRectangle": "矩形",
"customFonts": "自定义字体",
- "color": "颜色",
- "invalidImageType": "无效的文件类型",
- "type": "类型",
- "size": "大小",
- "blurShapeFreehand": "自由手绘",
- "mosaicBlockSize": "马赛克块大小",
- "fontStyle": "字体样式",
- "blurColorWhite": "白色",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
- "textPlaceholder": "输入您的文本...",
+ "title": "标注设置",
+ "blurShapeOval": "椭圆",
"blurType": "模糊类型",
+ "mosaicBlockSize": "马赛克块大小",
"colorPalette": "颜色调色板",
+ "textColor": "文本颜色",
+ "colorWheel": "颜色轮",
+ "shortcutsAndTips": "快捷键与提示",
+ "defaultText": "你好",
+ "clearBackground": "清除背景",
+ "type": "类型",
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
+ "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
"background": "背景",
- "blurShapeRectangle": "矩形",
- "typeArrow": "箭头",
- "blurIntensity": "模糊强度",
- "uploadImage": "上传图片",
- "blurShapeOval": "椭圆",
- "strokeWidth": "描边宽度:{{width}}px",
+ "blurShape": "模糊形状",
+ "arrowColor": "箭头颜色",
+ "invalidImageType": "无效的文件类型",
+ "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+ "blurColorBlack": "黑色",
+ "blurTypeBlur": "高斯",
+ "none": "无",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
+ "size": "大小",
+ "imageUploadSuccess": "图片上传成功!",
+ "blurColorWhite": "白色",
"arrowDirection": "箭头方向",
- "textContent": "文本内容",
+ "typeImage": "图片",
+ "typeText": "文本",
+ "typeArrow": "箭头",
+ "color": "颜色",
"blurColor": "模糊颜色",
"selectStyle": "选择样式",
- "colorWheel": "颜色轮",
- "textColor": "文本颜色",
- "none": "无",
- "defaultText": "你好",
- "blurTypeBlur": "高斯",
- "shortcutsAndTips": "快捷键与提示",
- "clearBackground": "清除背景",
+ "blurTypeMosaic": "马赛克",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
+ "textPlaceholder": "输入您的文本...",
+ "uploadImage": "上传图片",
+ "strokeWidth": "描边宽度:{{width}}px",
"typeBlur": "模糊",
- "typeImage": "图片",
- "blurShape": "模糊形状"
- },
- "textAnimation": {
- "pop": "弹出",
- "fade": "淡入淡出",
- "none": "无",
- "selectAnimation": "选择动画",
- "typewriter": "打字机",
- "rise": "上升",
- "slideLeft": "向左滑动",
- "pulse": "脉动",
- "title": "文本动画"
- },
- "audioTrack": {
- "slipHint": "按住 Alt 拖动可在其中滑动音频",
- "defaultLabel": "音频轨道",
- "mute": "静音",
- "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
- "loop": "循环",
- "add": "添加音频轨道",
- "fadeIn": "淡入",
- "remove": "删除轨道",
- "importFailed": "无法添加音频",
- "fadeOut": "淡出"
- },
- "customFont": {
- "addingButton": "添加中...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "无法从 URL 中提取字体系列",
- "failedToAdd": "添加字体失败",
- "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
- "successMessage": "字体 \"{{fontName}}\" 添加成功",
- "errorEmptyName": "请输入字体名称",
- "urlLabel": "Google Fonts 导入 URL",
- "nameLabel": "显示名称",
- "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
- "nameHelp": "这是字体在字体选择器中显示的名称",
- "errorInvalidUrl": "请输入有效的 Google Fonts URL",
- "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
- "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
- "namePlaceholder": "我的自定义字体",
- "addButton": "添加字体",
- "dialogTitle": "添加 Google 字体"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "右",
- "iso": "Iso",
- "left": "左"
- },
- "none": "无",
- "title": "3D 旋转"
- },
- "position": {
- "title": "焦点位置",
- "y": "Y (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "x": "X (%)"
- },
- "deleteZoom": "删除缩放",
- "previewHold": "按住预览放大效果",
- "customScale": "自定义缩放",
- "focusMode": {
- "autoDescription": "摄像头跟随录制时的光标位置",
- "title": "对焦模式",
- "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
- "auto": "自动",
- "manual": "手动"
- },
- "level": "缩放级别",
- "selectRegion": "选择要调整的缩放区域"
- },
- "speed": {
- "selectRegion": "选择要调整的速度区域",
- "customPlaybackSpeed": "自定义播放速度",
- "playbackSpeed": "播放速度",
- "deleteRegion": "删除速度区域",
- "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。",
- "maxSpeedError": "速度不能超过 {{max}}×"
+ "deleteAnnotation": "删除标注",
+ "fontStyle": "字体样式"
},
"layout": {
- "webcamSize": "摄像头大小",
- "webcamBlurIntensity": "模糊强度",
+ "webcamCropY": "垂直移动",
+ "reactiveWebcam": "缩放时缩小",
"shapes": {
- "circle": "圆形",
+ "rectangle": "矩形",
"square": "正方形",
"rounded": "圆角",
- "rectangle": "矩形"
+ "circle": "圆形"
},
- "webcamCropY": "垂直移动",
- "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
- "selectPreset": "选择预设",
- "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
"preset": "预设",
- "webcamFraming": "摄像头构图",
- "webcamBackground": "摄像头背景",
- "webcamShape": "摄像头形状",
- "title": "摄像头布局",
+ "dualFrame": "双画框",
"bgModes": {
- "blur": "模糊",
- "transparent": "抠图",
"none": "原画",
- "custom": "自定义"
+ "transparent": "抠图",
+ "custom": "自定义",
+ "blur": "模糊"
},
- "mirrorWebcam": "镜像摄像头",
- "noWebcam": "无摄像头",
- "pictureInPicture": "画中画",
- "reactiveWebcam": "缩放时缩小",
"webcamCropZoom": "裁剪缩放",
- "dualFrame": "双画框",
+ "webcamShape": "摄像头形状",
+ "webcamBlurIntensity": "模糊强度",
+ "title": "摄像头布局",
"reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
+ "webcamFraming": "摄像头构图",
"webcamCropX": "水平移动",
+ "selectPreset": "选择预设",
+ "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
+ "mirrorWebcam": "镜像摄像头",
+ "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
+ "webcamBackground": "摄像头背景",
+ "noWebcam": "无摄像头",
+ "pictureInPicture": "画中画",
+ "webcamSize": "摄像头大小",
"verticalStack": "垂直堆叠"
},
- "project": {
- "load": "加载项目",
- "save": "保存项目",
- "new": "新建项目"
- },
- "audio": {
- "outputGain": "输出电平",
- "reset": "重置音频",
- "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
- "title": "音频"
- },
- "facets": {
- "transcript": "转录文本",
- "captions": "字幕"
- },
- "cursor": {
- "themeDefault": "默认",
- "title": "光标",
- "theme": "光标样式",
- "clickBounce": "点击弹跳",
- "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
- "smoothing": "平滑",
- "size": "大小",
- "motionBlur": "运动模糊",
- "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
- "show": "显示光标",
- "clipToBounds": "裁剪到画布"
+ "speed": {
+ "customPlaybackSpeed": "自定义播放速度",
+ "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。",
+ "maxSpeedError": "速度不能超过 {{max}}×",
+ "playbackSpeed": "播放速度",
+ "deleteRegion": "删除速度区域",
+ "selectRegion": "选择要调整的速度区域"
},
"imageUpload": {
"failedToUpload": "上传图片失败",
- "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。",
- "invalidFileType": "无效的文件类型",
+ "uploadSuccess": "自定义图片上传成功!",
"errorReading": "读取文件时出错。",
- "uploadSuccess": "自定义图片上传成功!"
+ "invalidFileType": "无效的文件类型",
+ "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。"
},
- "exportFormat": {
- "gifAnimation": "GIF 动画",
- "mp4Description": "高质量视频文件",
- "mp4Video": "MP4 视频",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "可分享的动态图片"
+ "transcript": {
+ "blankedWord": "已清空",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。将鼠标悬停在带标记的词上可撤销。",
+ "editWord": "编辑“{{word}}”",
+ "editorAria": "{{filename}} 的转录",
+ "noTranscript": "暂无转录",
+ "clipLabel": "片段 {{index}}",
+ "trimSilence": "修剪静音({{duration}} 秒)",
+ "laneVoiceover": "配音",
+ "restoreSilence": "恢复静音({{duration}} 秒)",
+ "transcribeNow": "立即转录",
+ "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
+ "removeInserted": "删除“{{word}}”",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "restoreWord": "恢复“{{word}}”",
+ "silence": "[静音 {{duration}} 秒]",
+ "correctedWord": "已更正 — 转录原文为“{{original}}”",
+ "laneRecording": "录制",
+ "noClips": "暂无片段",
+ "noAudio": "此媒体没有音频轨道",
+ "laneLabel": "转写文本读取自",
+ "revertWord": "还原为“{{original}}”",
+ "title": "当前转录",
+ "transcribing": "转录中…",
+ "insertAria": "新词",
+ "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。"
+ },
+ "captions": {
+ "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
+ "distanceFromTop": "距顶部",
+ "minWords": "每行最少词数",
+ "showBackground": "显示背景",
+ "lineLength": "行长",
+ "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
+ "translating": "翻译中…",
+ "distanceFromLeft": "距左侧",
+ "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
+ "position": "位置",
+ "translateFailed": "翻译失败。",
+ "backgroundOpacity": "不透明度",
+ "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
+ "original": "原文(转录)",
+ "font": "字体",
+ "distanceFromRight": "距右侧",
+ "textColor": "文字颜色",
+ "alignCenter": "居中",
+ "translateHint": "使用已配置的 AI 提供方翻译转录",
+ "language": "语言",
+ "show": "显示字幕",
+ "anchorTop": "顶部",
+ "backgroundColor": "背景颜色",
+ "removeLegacyAnnotations": "移除旧的字幕批注",
+ "background": "背景",
+ "bold": "粗体",
+ "distanceFromBottom": "距底部",
+ "fontSize": "字号",
+ "anchorBottom": "底部",
+ "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
+ "maxWords": "每行最多词数",
+ "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
+ "translate": "翻译",
+ "deleteTranslation": "删除此翻译",
+ "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
+ "text": "文本",
+ "displayLanguage": "显示",
+ "alignRight": "右对齐",
+ "alignLeft": "左对齐"
+ },
+ "support": {
+ "saveDiagnostics": "保存诊断信息",
+ "reportBug": "报告错误",
+ "starOnGithub": "在 GitHub 上加星"
+ },
+ "customFont": {
+ "nameHelp": "这是字体在字体选择器中显示的名称",
+ "errorInvalidUrl": "请输入有效的 Google Fonts URL",
+ "urlLabel": "Google Fonts 导入 URL",
+ "addingButton": "添加中...",
+ "namePlaceholder": "我的自定义字体",
+ "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
+ "dialogTitle": "添加 Google 字体",
+ "failedToAdd": "添加字体失败",
+ "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
+ "addButton": "添加字体",
+ "errorEmptyName": "请输入字体名称",
+ "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
+ "nameLabel": "显示名称",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "无法从 URL 中提取字体系列",
+ "successMessage": "字体 \"{{fontName}}\" 添加成功",
+ "errorTimeout": "字体加载时间过长。请检查 URL 并重试。"
},
"background": {
- "colorLabel": "颜色 {{color}}",
- "customWallpaper": "自定义壁纸",
- "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
- "gradient": "渐变",
"presets": "预设",
- "color": "颜色",
- "custom": "自定义",
- "colorWheel": "颜色轮",
- "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
"image": "图片",
- "uploadCustom": "上传自定义",
"title": "背景",
+ "custom": "自定义",
+ "colorLabel": "颜色 {{color}}",
"imageLabel": "背景 {{index}}",
+ "customWallpaper": "自定义壁纸",
"colorPalette": "颜色调色板",
+ "uploadCustom": "上传自定义",
+ "color": "颜色",
"imageReadFailed": "无法读取该图片文件。",
- "gradientLabel": "渐变 {{index}}"
+ "gradientLabel": "渐变 {{index}}",
+ "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
+ "gradient": "渐变",
+ "colorWheel": "颜色轮",
+ "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。"
+ },
+ "audio": {
+ "reset": "重置音频",
+ "outputGain": "输出电平",
+ "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
+ "title": "音频"
+ },
+ "textAnimation": {
+ "pulse": "脉动",
+ "selectAnimation": "选择动画",
+ "fade": "淡入淡出",
+ "typewriter": "打字机",
+ "slideLeft": "向左滑动",
+ "none": "无",
+ "rise": "上升",
+ "pop": "弹出",
+ "title": "文本动画"
},
"crop": {
"title": "裁剪",
+ "unlockAspectRatio": "解锁宽高比",
+ "free": "自由",
"dragInstruction": "拖动每一侧来调整裁剪区域",
"done": "完成",
- "free": "自由",
- "ratio": "比例",
- "cropVideo": "裁剪视频",
"lockAspectRatio": "锁定宽高比",
- "unlockAspectRatio": "解锁宽高比"
+ "ratio": "比例",
+ "cropVideo": "裁剪视频"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "导出分辨率"
+ },
+ "effects": {
+ "off": "关",
+ "on": "开",
+ "fitClip": "适配",
+ "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
+ "fitClipFew": "{{count}} 个片段",
+ "blurBg": "模糊背景",
+ "motion": "运动",
+ "shadow": "阴影",
+ "fitClipOne": "{{count}} 个片段",
+ "format": "格式",
+ "roundness": "圆角",
+ "fitClipMany": "{{count}} 个片段",
+ "formatOriginal": "原始",
+ "frame": "画框",
+ "motionBlur": "运动模糊",
+ "padding": "内边距",
+ "title": "画面合成"
+ },
+ "language": {
+ "title": "语言"
},
"gifSettings": {
"size": "GIF 尺寸",
- "frameRate": "GIF 帧率",
- "loop": "循环 GIF"
+ "loop": "循环 GIF",
+ "frameRate": "GIF 帧率"
},
- "support": {
- "starOnGithub": "在 GitHub 上加星",
- "reportBug": "报告错误",
- "saveDiagnostics": "保存诊断信息"
+ "panes": {
+ "help": "帮助"
},
"export": {
+ "chooseSaveLocation": "选择保存位置",
"gifButton": "导出 GIF",
- "videoButton": "导出视频",
- "chooseSaveLocation": "选择保存位置"
+ "videoButton": "导出视频"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "导出分辨率",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 视频",
+ "mp4Description": "高质量视频文件",
+ "gifAnimation": "GIF 动画",
+ "gifDescription": "可分享的动态图片",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "语言"
+ "project": {
+ "save": "保存项目",
+ "new": "新建项目",
+ "load": "加载项目"
+ },
+ "facets": {
+ "captions": "字幕",
+ "transcript": "转录文本"
},
"trim": {
"deleteRegion": "删除剪辑区域"
- },
- "panes": {
- "help": "帮助"
}
}
diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json
index 831ae920e..a5cbe8c56 100644
--- a/src/i18n/locales/zh-TW/editor.json
+++ b/src/i18n/locales/zh-TW/editor.json
@@ -21,7 +21,9 @@
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}",
"previewCompositorUnavailable": "此裝置無法使用預覽",
- "wordEditFailed": "無法修改這個字"
+ "wordEditFailed": "無法修改這個字",
+ "wordInsertFailed": "無法加入這個字詞",
+ "wordRemoveFailed": "無法刪除這個字詞"
},
"export": {
"canceled": "匯出已取消",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 2ec9f029d..912e93a72 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -1,348 +1,351 @@
{
- "transcript": {
- "clipLabel": "片段 {{index}}",
- "restoreSilence": "還原靜音({{duration}} 秒)",
- "laneVoiceover": "旁白",
- "editorAria": "{{filename}} 的逐字稿",
- "noTranscript": "尚無逐字稿",
- "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
- "blankedWord": "已清空",
- "laneLabel": "轉錄文字讀取自",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字:字幕隨之更新,影片不變。將滑鼠移到有標記的字上即可還原。",
- "noClips": "尚無片段",
- "trimSilence": "修剪靜音({{duration}} 秒)",
- "transcribing": "轉錄中…",
- "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
- "editWord": "編輯「{{word}}」",
- "restoreWord": "還原「{{word}}」",
- "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
- "transcribeNow": "立即產生逐字稿",
- "revertWord": "還原為「{{original}}」",
- "silence": "[靜音 {{duration}} 秒]",
- "laneRecording": "錄影",
- "title": "目前的逐字稿",
- "noAudio": "此媒體沒有音訊軌道"
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "x": "X (%)",
+ "title": "焦點位置"
+ },
+ "threeD": {
+ "preset": {
+ "right": "右",
+ "left": "左",
+ "iso": "Iso"
+ },
+ "none": "無",
+ "title": "3D 旋轉"
+ },
+ "focusMode": {
+ "manual": "手動",
+ "title": "對焦模式",
+ "autoDescription": "攝影機跟隨錄製時的游標位置",
+ "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
+ "auto": "自動"
+ },
+ "selectRegion": "選擇要調整的縮放區域",
+ "customScale": "自訂縮放",
+ "previewHold": "按住預覽放大效果",
+ "level": "縮放級別",
+ "deleteZoom": "刪除縮放"
},
- "captions": {
- "showBackground": "顯示背景",
- "alignLeft": "靠左",
- "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
- "backgroundColor": "背景顏色",
- "translating": "翻譯中…",
- "backgroundOpacity": "不透明度",
- "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
- "translateFailed": "翻譯失敗。",
- "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
- "text": "文字",
- "fontSize": "大小",
- "deleteTranslation": "刪除這個翻譯",
- "maxWords": "每行最多字數",
- "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
- "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
- "anchorBottom": "下",
- "distanceFromLeft": "距左緣",
- "textColor": "文字顏色",
- "background": "背景",
- "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
- "minWords": "每行最少字數",
- "lineLength": "行長",
- "font": "字型",
- "original": "原文(逐字稿)",
- "bold": "粗體",
- "displayLanguage": "顯示",
- "removeLegacyAnnotations": "移除舊的字幕註解",
- "alignCenter": "置中",
- "distanceFromBottom": "距下緣",
- "distanceFromTop": "距上緣",
- "translate": "翻譯",
- "position": "位置",
- "language": "語言",
- "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
- "show": "顯示字幕",
- "anchorTop": "上",
- "alignRight": "靠右",
- "distanceFromRight": "距右緣",
- "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。"
+ "audioTrack": {
+ "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "defaultLabel": "音訊軌道",
+ "fadeOut": "淡出",
+ "add": "新增音訊軌道",
+ "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
+ "loop": "循環",
+ "remove": "刪除軌道",
+ "fadeIn": "淡入",
+ "mute": "靜音",
+ "importFailed": "無法新增音訊"
},
- "effects": {
- "padding": "內邊距",
- "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
- "motion": "動態",
- "title": "畫面合成",
- "blurBg": "模糊背景",
- "fitClip": "符合",
- "on": "開",
- "roundness": "圓角",
- "frame": "外框",
- "formatOriginal": "原始",
- "shadow": "陰影",
- "fitClipMany": "{{count}} 個片段",
- "fitClipFew": "{{count}} 個片段",
- "off": "關",
- "format": "格式",
+ "cursor": {
+ "clipToBounds": "裁切至畫布",
+ "size": "大小",
"motionBlur": "動態模糊",
- "fitClipOne": "{{count}} 個片段"
+ "theme": "游標樣式",
+ "clickBounce": "點擊彈跳",
+ "title": "游標",
+ "smoothing": "平滑",
+ "themeDefault": "預設",
+ "show": "顯示游標",
+ "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
+ "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。"
},
"annotation": {
- "arrowColor": "箭頭顏色",
+ "blurIntensity": "模糊強度",
+ "textContent": "文字內容",
+ "blurShapeFreehand": "自由手繪",
"active": "啟用",
- "blurTypeMosaic": "馬賽克",
- "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
- "title": "標註設定",
- "blurColorBlack": "黑色",
- "imageUploadSuccess": "圖片上傳成功!",
- "deleteAnnotation": "刪除標註",
- "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
- "typeText": "文字",
+ "blurShapeRectangle": "矩形",
"customFonts": "自訂字體",
- "color": "顏色",
- "invalidImageType": "無效的檔案類型",
- "type": "類型",
- "size": "大小",
- "blurShapeFreehand": "自由手繪",
- "mosaicBlockSize": "馬賽克區塊大小",
- "fontStyle": "字體樣式",
- "blurColorWhite": "白色",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
- "textPlaceholder": "輸入您的文字...",
+ "title": "標註設定",
+ "blurShapeOval": "橢圓",
"blurType": "模糊類型",
+ "mosaicBlockSize": "馬賽克區塊大小",
"colorPalette": "調色盤",
+ "textColor": "文字顏色",
+ "colorWheel": "色輪",
+ "shortcutsAndTips": "快捷鍵與提示",
+ "defaultText": "你好",
+ "clearBackground": "清除背景",
+ "type": "類型",
"tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
+ "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
"background": "背景",
- "blurShapeRectangle": "矩形",
- "typeArrow": "箭頭",
- "blurIntensity": "模糊強度",
- "uploadImage": "上傳圖片",
- "blurShapeOval": "橢圓",
- "strokeWidth": "描邊寬度:{{width}}px",
+ "blurShape": "模糊形狀",
+ "arrowColor": "箭頭顏色",
+ "invalidImageType": "無效的檔案類型",
+ "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "blurColorBlack": "黑色",
+ "blurTypeBlur": "高斯",
+ "none": "無",
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
+ "size": "大小",
+ "imageUploadSuccess": "圖片上傳成功!",
+ "blurColorWhite": "白色",
"arrowDirection": "箭頭方向",
- "textContent": "文字內容",
+ "typeImage": "圖片",
+ "typeText": "文字",
+ "typeArrow": "箭頭",
+ "color": "顏色",
"blurColor": "模糊顏色",
"selectStyle": "選擇樣式",
- "colorWheel": "色輪",
- "textColor": "文字顏色",
- "none": "無",
- "defaultText": "你好",
- "blurTypeBlur": "高斯",
- "shortcutsAndTips": "快捷鍵與提示",
- "clearBackground": "清除背景",
+ "blurTypeMosaic": "馬賽克",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "textPlaceholder": "輸入您的文字...",
+ "uploadImage": "上傳圖片",
+ "strokeWidth": "描邊寬度:{{width}}px",
"typeBlur": "模糊",
- "typeImage": "圖片",
- "blurShape": "模糊形狀"
- },
- "textAnimation": {
- "pop": "彈出",
- "fade": "淡入淡出",
- "none": "無",
- "selectAnimation": "選擇動畫",
- "typewriter": "打字機",
- "rise": "上升",
- "slideLeft": "向左滑動",
- "pulse": "脈動",
- "title": "文字動畫"
- },
- "audioTrack": {
- "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
- "defaultLabel": "音訊軌道",
- "mute": "靜音",
- "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
- "loop": "循環",
- "add": "新增音訊軌道",
- "fadeIn": "淡入",
- "remove": "刪除軌道",
- "importFailed": "無法新增音訊",
- "fadeOut": "淡出"
- },
- "customFont": {
- "addingButton": "新增中...",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "無法從 URL 中提取字體系列",
- "failedToAdd": "新增字體失敗",
- "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
- "successMessage": "字體 \"{{fontName}}\" 新增成功",
- "errorEmptyName": "請輸入字體名稱",
- "urlLabel": "Google Fonts 匯入 URL",
- "nameLabel": "顯示名稱",
- "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
- "nameHelp": "這是字體在字體選擇器中顯示的名稱",
- "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
- "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
- "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
- "namePlaceholder": "我的自訂字體",
- "addButton": "新增字體",
- "dialogTitle": "新增 Google 字體"
- },
- "zoom": {
- "threeD": {
- "preset": {
- "right": "右",
- "iso": "Iso",
- "left": "左"
- },
- "none": "無",
- "title": "3D 旋轉"
- },
- "position": {
- "title": "焦點位置",
- "y": "Y (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "x": "X (%)"
- },
- "deleteZoom": "刪除縮放",
- "previewHold": "按住預覽放大效果",
- "customScale": "自訂縮放",
- "focusMode": {
- "autoDescription": "攝影機跟隨錄製時的游標位置",
- "title": "對焦模式",
- "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
- "auto": "自動",
- "manual": "手動"
- },
- "level": "縮放級別",
- "selectRegion": "選擇要調整的縮放區域"
- },
- "speed": {
- "selectRegion": "選擇要調整的速度區域",
- "customPlaybackSpeed": "自訂播放速度",
- "playbackSpeed": "播放速度",
- "deleteRegion": "刪除速度區域",
- "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。",
- "maxSpeedError": "速度不能超過 {{max}}×"
+ "deleteAnnotation": "刪除標註",
+ "fontStyle": "字體樣式"
},
"layout": {
- "webcamSize": "攝影機大小",
- "webcamBlurIntensity": "模糊強度",
+ "webcamCropY": "垂直移動",
+ "reactiveWebcam": "縮放時縮小",
"shapes": {
- "circle": "圓形",
+ "rectangle": "矩形",
"square": "正方形",
"rounded": "圓角",
- "rectangle": "矩形"
+ "circle": "圓形"
},
- "webcamCropY": "垂直移動",
- "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
- "selectPreset": "選擇預設",
- "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
"preset": "預設",
- "webcamFraming": "攝影機構圖",
- "webcamBackground": "攝影機背景",
- "webcamShape": "攝影機形狀",
- "title": "攝影機版面",
+ "dualFrame": "雙畫框",
"bgModes": {
- "blur": "模糊",
- "transparent": "去背",
"none": "原畫",
- "custom": "自訂"
+ "transparent": "去背",
+ "custom": "自訂",
+ "blur": "模糊"
},
- "mirrorWebcam": "鏡像攝影機",
- "noWebcam": "無網路攝影機",
- "pictureInPicture": "子母畫面",
- "reactiveWebcam": "縮放時縮小",
"webcamCropZoom": "裁切縮放",
- "dualFrame": "雙畫框",
+ "webcamShape": "攝影機形狀",
+ "webcamBlurIntensity": "模糊強度",
+ "title": "攝影機版面",
"reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
+ "webcamFraming": "攝影機構圖",
"webcamCropX": "水平移動",
+ "selectPreset": "選擇預設",
+ "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
+ "mirrorWebcam": "鏡像攝影機",
+ "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
+ "webcamBackground": "攝影機背景",
+ "noWebcam": "無網路攝影機",
+ "pictureInPicture": "子母畫面",
+ "webcamSize": "攝影機大小",
"verticalStack": "垂直堆疊"
},
- "project": {
- "load": "載入專案",
- "save": "儲存專案",
- "new": "新增專案"
- },
- "audio": {
- "outputGain": "輸出音量",
- "reset": "重設音訊",
- "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
- "title": "音訊"
- },
- "facets": {
- "transcript": "逐字稿",
- "captions": "字幕"
- },
- "cursor": {
- "themeDefault": "預設",
- "title": "游標",
- "theme": "游標樣式",
- "clickBounce": "點擊彈跳",
- "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
- "smoothing": "平滑",
- "size": "大小",
- "motionBlur": "動態模糊",
- "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
- "show": "顯示游標",
- "clipToBounds": "裁切至畫布"
+ "speed": {
+ "customPlaybackSpeed": "自訂播放速度",
+ "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。",
+ "maxSpeedError": "速度不能超過 {{max}}×",
+ "playbackSpeed": "播放速度",
+ "deleteRegion": "刪除速度區域",
+ "selectRegion": "選擇要調整的速度區域"
},
"imageUpload": {
"failedToUpload": "上傳圖片失敗",
- "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。",
- "invalidFileType": "無效的檔案類型",
+ "uploadSuccess": "自訂圖片上傳成功!",
"errorReading": "讀取檔案時出錯。",
- "uploadSuccess": "自訂圖片上傳成功!"
+ "invalidFileType": "無效的檔案類型",
+ "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。"
},
- "exportFormat": {
- "gifAnimation": "GIF 動畫",
- "mp4Description": "高品質影片檔案",
- "mp4Video": "MP4 影片",
- "mp4": "MP4",
- "gif": "GIF",
- "gifDescription": "可分享的動態圖片"
+ "transcript": {
+ "blankedWord": "已清空",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。將滑鼠移到有標記的字上即可復原。",
+ "editWord": "編輯「{{word}}」",
+ "editorAria": "{{filename}} 的逐字稿",
+ "noTranscript": "尚無逐字稿",
+ "clipLabel": "片段 {{index}}",
+ "trimSilence": "修剪靜音({{duration}} 秒)",
+ "laneVoiceover": "旁白",
+ "restoreSilence": "還原靜音({{duration}} 秒)",
+ "transcribeNow": "立即產生逐字稿",
+ "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
+ "removeInserted": "刪除「{{word}}」",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "restoreWord": "還原「{{word}}」",
+ "silence": "[靜音 {{duration}} 秒]",
+ "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
+ "laneRecording": "錄影",
+ "noClips": "尚無片段",
+ "noAudio": "此媒體沒有音訊軌道",
+ "laneLabel": "轉錄文字讀取自",
+ "revertWord": "還原為「{{original}}」",
+ "title": "目前的逐字稿",
+ "transcribing": "轉錄中…",
+ "insertAria": "新字詞",
+ "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。"
+ },
+ "captions": {
+ "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
+ "distanceFromTop": "距上緣",
+ "minWords": "每行最少字數",
+ "showBackground": "顯示背景",
+ "lineLength": "行長",
+ "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
+ "translating": "翻譯中…",
+ "distanceFromLeft": "距左緣",
+ "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
+ "position": "位置",
+ "translateFailed": "翻譯失敗。",
+ "backgroundOpacity": "不透明度",
+ "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
+ "original": "原文(逐字稿)",
+ "font": "字型",
+ "distanceFromRight": "距右緣",
+ "textColor": "文字顏色",
+ "alignCenter": "置中",
+ "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
+ "language": "語言",
+ "show": "顯示字幕",
+ "anchorTop": "上",
+ "backgroundColor": "背景顏色",
+ "removeLegacyAnnotations": "移除舊的字幕註解",
+ "background": "背景",
+ "bold": "粗體",
+ "distanceFromBottom": "距下緣",
+ "fontSize": "大小",
+ "anchorBottom": "下",
+ "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
+ "maxWords": "每行最多字數",
+ "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
+ "translate": "翻譯",
+ "deleteTranslation": "刪除這個翻譯",
+ "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
+ "text": "文字",
+ "displayLanguage": "顯示",
+ "alignRight": "靠右",
+ "alignLeft": "靠左"
+ },
+ "support": {
+ "saveDiagnostics": "儲存診斷資料",
+ "reportBug": "回報錯誤",
+ "starOnGithub": "在 GitHub 上加星"
+ },
+ "customFont": {
+ "nameHelp": "這是字體在字體選擇器中顯示的名稱",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
+ "urlLabel": "Google Fonts 匯入 URL",
+ "addingButton": "新增中...",
+ "namePlaceholder": "我的自訂字體",
+ "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
+ "dialogTitle": "新增 Google 字體",
+ "failedToAdd": "新增字體失敗",
+ "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
+ "addButton": "新增字體",
+ "errorEmptyName": "請輸入字體名稱",
+ "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "nameLabel": "顯示名稱",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "errorExtractFailed": "無法從 URL 中提取字體系列",
+ "successMessage": "字體 \"{{fontName}}\" 新增成功",
+ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。"
},
"background": {
- "colorLabel": "顏色 {{color}}",
- "customWallpaper": "自訂桌布",
- "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
- "gradient": "漸層",
"presets": "預設",
- "color": "顏色",
- "custom": "自訂",
- "colorWheel": "色輪",
- "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
"image": "圖片",
- "uploadCustom": "上傳自訂",
"title": "背景",
+ "custom": "自訂",
+ "colorLabel": "顏色 {{color}}",
"imageLabel": "背景 {{index}}",
+ "customWallpaper": "自訂桌布",
"colorPalette": "調色盤",
+ "uploadCustom": "上傳自訂",
+ "color": "顏色",
"imageReadFailed": "無法讀取該圖片檔案。",
- "gradientLabel": "漸層 {{index}}"
+ "gradientLabel": "漸層 {{index}}",
+ "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
+ "gradient": "漸層",
+ "colorWheel": "色輪",
+ "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。"
+ },
+ "audio": {
+ "reset": "重設音訊",
+ "outputGain": "輸出音量",
+ "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
+ "title": "音訊"
+ },
+ "textAnimation": {
+ "pulse": "脈動",
+ "selectAnimation": "選擇動畫",
+ "fade": "淡入淡出",
+ "typewriter": "打字機",
+ "slideLeft": "向左滑動",
+ "none": "無",
+ "rise": "上升",
+ "pop": "彈出",
+ "title": "文字動畫"
},
"crop": {
"title": "裁剪",
+ "unlockAspectRatio": "解鎖長寬比",
+ "free": "自由",
"dragInstruction": "拖動每一側來調整裁剪區域",
"done": "完成",
- "free": "自由",
- "ratio": "比例",
- "cropVideo": "裁剪影片",
"lockAspectRatio": "鎖定長寬比",
- "unlockAspectRatio": "解鎖長寬比"
+ "ratio": "比例",
+ "cropVideo": "裁剪影片"
+ },
+ "exportQuality": {
+ "low": "720p",
+ "medium": "1080p",
+ "high": "Source",
+ "title": "匯出解析度"
+ },
+ "effects": {
+ "off": "關",
+ "on": "開",
+ "fitClip": "符合",
+ "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
+ "fitClipFew": "{{count}} 個片段",
+ "blurBg": "模糊背景",
+ "motion": "動態",
+ "shadow": "陰影",
+ "fitClipOne": "{{count}} 個片段",
+ "format": "格式",
+ "roundness": "圓角",
+ "fitClipMany": "{{count}} 個片段",
+ "formatOriginal": "原始",
+ "frame": "外框",
+ "motionBlur": "動態模糊",
+ "padding": "內邊距",
+ "title": "畫面合成"
+ },
+ "language": {
+ "title": "語言"
},
"gifSettings": {
"size": "GIF 尺寸",
- "frameRate": "GIF 影格率",
- "loop": "循環 GIF"
+ "loop": "循環 GIF",
+ "frameRate": "GIF 影格率"
},
- "support": {
- "starOnGithub": "在 GitHub 上加星",
- "reportBug": "回報錯誤",
- "saveDiagnostics": "儲存診斷資料"
+ "panes": {
+ "help": "說明"
},
"export": {
+ "chooseSaveLocation": "選擇儲存位置",
"gifButton": "匯出 GIF",
- "videoButton": "匯出影片",
- "chooseSaveLocation": "選擇儲存位置"
+ "videoButton": "匯出影片"
},
- "exportQuality": {
- "high": "Source",
- "medium": "1080p",
- "title": "匯出解析度",
- "low": "720p"
+ "exportFormat": {
+ "mp4Video": "MP4 影片",
+ "mp4Description": "高品質影片檔案",
+ "gifAnimation": "GIF 動畫",
+ "gifDescription": "可分享的動態圖片",
+ "mp4": "MP4",
+ "gif": "GIF"
},
- "language": {
- "title": "語言"
+ "project": {
+ "save": "儲存專案",
+ "new": "新增專案",
+ "load": "載入專案"
+ },
+ "facets": {
+ "captions": "字幕",
+ "transcript": "逐字稿"
},
"trim": {
"deleteRegion": "刪除剪輯區域"
- },
- "panes": {
- "help": "說明"
}
}
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 1bc1f5451..9e8ace067 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import { type AxcutTranscript, createEmptyDocument } from "../schema";
-import { carryOverWordEdits, setDocumentWordText, setWordText, withTranscript } from "./transcript";
+import {
+ carryOverWordEdits,
+ insertDocumentWord,
+ insertWord,
+ removeDocumentWords,
+ removeWord,
+ setDocumentWordText,
+ setWordText,
+ withTranscript,
+} from "./transcript";
function fixture(language = "en"): AxcutTranscript {
return {
@@ -483,3 +492,178 @@ describe("carryOverWordEdits", () => {
expect(carryOverWordEdits(null, next).transcript).toBe(next);
});
});
+
+// ─── Inserting a word nobody said ────────────────────────────────
+// The word carries no audio, so what it may occupy is the silence around it and nothing
+// else. These pin that boundary: never over a spoken word, never a duration invented out
+// of nothing when there is no pause to take.
+
+describe("insertWord", () => {
+ // "I"(1–2) "use"(2–3) "OpenScreen"(3–4), then a gap, then segment 2 at 5.
+ it("takes the silence after the word it follows, up to what its text needs", () => {
+ const result = insertWord(fixture(), "word_3", "after", "everywhere");
+ const inserted = result.words.find((w) => w.source === "synth");
+ expect(inserted?.startSec).toBe(4);
+ // 10 characters at 15/s = 0.67s, and the next word is a full second away.
+ expect(inserted?.endSec).toBeCloseTo(4 + 10 / 15, 5);
+ });
+
+ it("never runs over the word that comes next", () => {
+ // "use" ends at 3 and "OpenScreen" starts there: a long word gets no room at all.
+ const inserted = insertWord(fixture(), "word_2", "after", "a very long addition").words.find(
+ (w) => w.source === "synth",
+ );
+ expect(inserted).toMatchObject({ startSec: 3, endSec: 3 });
+ });
+
+ it("borrows backwards when it goes before the first word", () => {
+ const inserted = insertWord(fixture(), "word_1", "before", "Well").words.find(
+ (w) => w.source === "synth",
+ );
+ // "word_1" starts at 1, and nothing precedes it — the floor is the media's own start.
+ expect(inserted?.endSec).toBe(1);
+ expect(inserted?.startSec).toBeCloseTo(1 - 0.4, 5);
+ });
+
+ it("marks it synthesized, with an id no transcription run can reuse", () => {
+ const inserted = insertWord(fixture(), "word_3", "after", "indeed").words.find(
+ (w) => w.source === "synth",
+ );
+ expect(inserted).toMatchObject({ text: "indeed", source: "synth", segmentId: "segment_1" });
+ expect(inserted?.id).toMatch(/^synth_\d+$/);
+ expect(inserted).not.toHaveProperty("originalText");
+ });
+
+ it("numbers past the inserts already there", () => {
+ const once = insertWord(fixture(), "word_3", "after", "one");
+ const twice = insertWord(once, "word_3", "after", "two");
+ const ids = twice.words.filter((w) => w.source === "synth").map((w) => w.id);
+ expect(new Set(ids).size).toBe(2);
+ expect(ids).toContain("synth_2");
+ });
+
+ it("lands in the segment's reading order, and rebuilds its text", () => {
+ const transcript = fixture();
+ const result = insertWord(transcript, "word_2", "after", "really");
+ const segment = result.segments.find((seg) => seg.id === "segment_1");
+ expect(segment?.wordIds).toEqual(["word_1", "word_2", "synth_1", "word_3"]);
+ expect(segment?.text).toBe("I use really OpenScreen");
+ // The segment the insert did not land in is carried over untouched, not rebuilt.
+ expect(result.segments[1]).toBe(transcript.segments[1]);
+ });
+
+ it("sits beside its anchor in the words array, which is what orders a zero-length insert", () => {
+ const result = insertWord(fixture(), "word_2", "after", "really");
+ const ids = result.words.map((w) => w.id);
+ expect(ids.indexOf("synth_1")).toBe(ids.indexOf("word_2") + 1);
+ });
+
+ it("refuses empty text and unknown anchors", () => {
+ expect(() => insertWord(fixture(), "word_2", "after", " ")).toThrow(/empty/);
+ expect(() => insertWord(fixture(), "nope", "after", "x")).toThrow(/missing/);
+ });
+
+ it("keeps the input transcript untouched", () => {
+ const transcript = fixture();
+ const before = JSON.stringify(transcript);
+ insertWord(transcript, "word_2", "after", "really");
+ expect(JSON.stringify(transcript)).toBe(before);
+ });
+});
+
+describe("removeWord", () => {
+ const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
+
+ it("takes the word out of the array, the segment, and its text", () => {
+ const result = removeWord(withInsert(), "synth_1");
+ expect(result.words.some((w) => w.id === "synth_1")).toBe(false);
+ const segment = result.segments.find((seg) => seg.id === "segment_1");
+ expect(segment?.wordIds).toEqual(["word_1", "word_2", "word_3"]);
+ expect(segment?.text).toBe("I use OpenScreen");
+ });
+
+ // Deleting a transcribed word would leave the film saying something the transcript
+ // denies. The operation for making a spoken word go away is a trim.
+ it("refuses a word that was actually spoken", () => {
+ expect(() => removeWord(fixture(), "word_2")).toThrow(/Refusing to remove transcribed word/);
+ });
+
+ it("refuses a word that is not there", () => {
+ expect(() => removeWord(fixture(), "nope")).toThrow(/missing/);
+ });
+});
+
+describe("insertDocumentWord / removeDocumentWords", () => {
+ it("writes both the per-asset transcript and the legacy mirror", () => {
+ const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
+ expect(result.transcript?.words.some((w) => w.id === "synth_1")).toBe(true);
+ expect(result.transcript).toBe(result.transcripts.find((t) => t.assetId === "asset_1"));
+ });
+
+ // One save for the whole set: a Backspace over three inserted words must be one Ctrl+Z.
+ it("removes several inserted words in a single document", () => {
+ let doc = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "one");
+ doc = insertDocumentWord(doc, "asset_1", "word_3", "after", "two");
+ const result = removeDocumentWords(doc, "asset_1", ["synth_1", "synth_2"]);
+ expect(result.transcripts[0].words.some((w) => w.source === "synth")).toBe(false);
+ });
+
+ it("rejects an asset with no transcript", () => {
+ expect(() => insertDocumentWord(makeDoc(), "nope", "word_2", "after", "x")).toThrow(
+ /no transcript/,
+ );
+ });
+});
+
+describe("carryOverWordEdits with inserted words", () => {
+ const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
+
+ it("puts an insert back after whatever the new run now ends last before it", () => {
+ // The insert sits at 3s. The new transcript says "I"(1–2) "used"(2–3) "it"(3.5–4).
+ const next = retranscribed([
+ ["n1", "I", 1, 2],
+ ["n2", "used", 2, 3],
+ ["n3", "it", 3.5, 4],
+ ]);
+ const result = carryOverWordEdits(withInsert(), next);
+ expect(result).toMatchObject({ carried: 1, dropped: 0 });
+ const ids = result.transcript.words.map((w) => w.id);
+ expect(ids.indexOf("synth_1")).toBe(ids.indexOf("n2") + 1);
+ expect(result.transcript.words.find((w) => w.id === "synth_1")).toMatchObject({
+ text: "really",
+ source: "synth",
+ });
+ });
+
+ it("puts it at the head when the new run has nothing before it", () => {
+ const carried = carryOverWordEdits(
+ insertWord(fixture(), "word_1", "before", "Well"),
+ retranscribed([["n1", "I", 1, 2]]),
+ );
+ expect(carried.carried).toBe(1);
+ expect(carried.transcript.words[0].text).toBe("Well");
+ });
+
+ it("counts an insert it could not place, rather than losing it quietly", () => {
+ const empty: AxcutTranscript = { assetId: "asset_1", language: "en", segments: [], words: [] };
+ expect(carryOverWordEdits(withInsert(), empty)).toMatchObject({ carried: 0, dropped: 1 });
+ });
+
+ it("carries corrections and inserts together", () => {
+ const both = insertWord(
+ setWordText(fixture(), "word_3", "OpenScreenApp"),
+ "word_2",
+ "after",
+ "really",
+ );
+ const next = retranscribed([
+ ["n1", "I", 1, 2],
+ ["n2", "use", 2, 3],
+ ["n3", "OpenScreen", 3, 4],
+ ]);
+ const result = carryOverWordEdits(both, next);
+ expect(result).toMatchObject({ carried: 2, dropped: 0 });
+ expect(result.transcript.words.find((w) => w.id === "n3")?.text).toBe("OpenScreenApp");
+ expect(result.transcript.words.some((w) => w.text === "really")).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 6728575b1..dec291474 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -147,12 +147,200 @@ export function setDocumentWordText(
return withTranscript(document, setWordText(transcript, wordId, text));
}
+/** Where a new word goes relative to the word the caret was resting on. */
+export type InsertSide = "before" | "after";
+
+/**
+ * How long an inserted word needs to be readable on screen. Subtitle practice is roughly
+ * fifteen characters a second, with a floor so a one-letter word is not a single frame.
+ * It is only ever a REQUEST — `insertWord` gives the word whatever silence is actually
+ * free, and no more.
+ */
+function readingSeconds(text: string): number {
+ return Math.max(0.4, text.trim().length / 15);
+}
+
+/** `synth_N`, numbered past every id already in the transcript.
+ *
+ * The prefix buys uniqueness, not meaning: a transcription run regenerates `word_N` from
+ * 1, so a synthesized word holding one of those ids would be overwritten by the next run.
+ * What the word IS lives in `source`, which is what every reader checks. */
+function nextSynthWordId(transcript: AxcutTranscript): string {
+ let highest = 0;
+ for (const word of transcript.words) {
+ const match = /^synth_(\d+)$/.exec(word.id);
+ if (match) highest = Math.max(highest, Number(match[1]));
+ }
+ return `synth_${highest + 1}`;
+}
+
+/**
+ * Insert a word that no one said.
+ *
+ * It carries no audio, so it takes the SILENCE it is dropped into and nothing else: from
+ * the word it follows up to what its text needs to be read, and never past the word that
+ * comes next. Dropped between two words that run straight into each other it has no
+ * duration at all and simply rides their caption line — which is where it reads correctly
+ * anyway, since there is no pause on screen to fill.
+ *
+ * That is the whole of what an inserted word can do today: it reaches the captions and
+ * stops there. When a voice can be synthesized for it, `source: "synth"` is what marks the
+ * words that need speaking, and the span computed here is the slot that audio has to fit.
+ */
+export function insertWord(
+ transcript: AxcutTranscript,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutTranscript {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) {
+ throw new Error("Cannot insert an empty word");
+ }
+ const anchorIndex = transcript.words.findIndex((word) => word.id === anchorWordId);
+ if (anchorIndex < 0) {
+ throw new Error(`Cannot insert next to missing transcript word "${anchorWordId}"`);
+ }
+ const anchor = transcript.words[anchorIndex];
+ const segment = transcript.segments.find((seg) => seg.id === anchor.segmentId);
+ if (!segment) {
+ throw new Error(
+ `Transcript word "${anchorWordId}" references missing segment "${anchor.segmentId}"`,
+ );
+ }
+ const anchorSlot = segment.wordIds.indexOf(anchorWordId);
+ if (anchorSlot < 0) {
+ throw new Error(`Segment "${segment.id}" does not reference anchor word "${anchorWordId}"`);
+ }
+
+ const wanted = readingSeconds(trimmed);
+ let startSec: number;
+ let endSec: number;
+ if (side === "after") {
+ startSec = anchor.endSec;
+ // The next word IN TIME, which is not necessarily the next one in the array — the
+ // array is insertion order, and only time decides what the new word may overlap.
+ const nextStart = transcript.words
+ .filter((word) => word.startSec >= startSec && word.id !== anchorWordId)
+ .reduce(
+ (soonest, word) => (soonest === null ? word.startSec : Math.min(soonest, word.startSec)),
+ null,
+ );
+ endSec = nextStart === null ? startSec + wanted : Math.min(startSec + wanted, nextStart);
+ } else {
+ endSec = anchor.startSec;
+ const previousEnd = transcript.words
+ .filter((word) => word.endSec <= endSec && word.id !== anchorWordId)
+ .reduce(
+ (latest, word) => (latest === null ? word.endSec : Math.max(latest, word.endSec)),
+ null,
+ );
+ const floor = previousEnd === null ? 0 : previousEnd;
+ startSec = Math.max(floor, endSec - wanted);
+ }
+
+ const inserted: AxcutWord = {
+ id: nextSynthWordId(transcript),
+ segmentId: segment.id,
+ startSec,
+ endSec: Math.max(startSec, endSec),
+ text: trimmed,
+ source: "synth",
+ };
+
+ // Position in `words` matters as well as the timings: a zero-length insert shares its
+ // start with the word it sits against, and the reading order of that tie is the array
+ // order (see `withSilenceGaps`).
+ const at = side === "after" ? anchorIndex + 1 : anchorIndex;
+ const words = [...transcript.words.slice(0, at), inserted, ...transcript.words.slice(at)];
+ const slot = side === "after" ? anchorSlot + 1 : anchorSlot;
+ const wordIds = [...segment.wordIds.slice(0, slot), inserted.id, ...segment.wordIds.slice(slot)];
+ const byId = new Map(words.map((word) => [word.id, word]));
+ const segmentText = joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? ""));
+
+ return {
+ ...transcript,
+ words,
+ segments: transcript.segments.map((seg) =>
+ seg.id === segment.id ? { ...seg, wordIds, text: segmentText } : seg,
+ ),
+ };
+}
+
+/**
+ * Delete an inserted word.
+ *
+ * Only a synthesized one: a transcribed word is the label on a piece of audio, and the
+ * operation for making that go away is a trim, which removes the sound with it. Deleting
+ * the label alone would leave the film saying a word the transcript denies.
+ */
+export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTranscript {
+ const target = transcript.words.find((word) => word.id === wordId);
+ if (!target) {
+ throw new Error(`Cannot remove missing transcript word "${wordId}"`);
+ }
+ if (target.source !== "synth") {
+ throw new Error(
+ `Refusing to remove transcribed word "${wordId}": cut it with a trim, or blank its text`,
+ );
+ }
+ const words = transcript.words.filter((word) => word.id !== wordId);
+ const byId = new Map(words.map((word) => [word.id, word]));
+ return {
+ ...transcript,
+ words,
+ segments: transcript.segments.map((segment) => {
+ if (!segment.wordIds.includes(wordId)) return segment;
+ const wordIds = segment.wordIds.filter((id) => id !== wordId);
+ return {
+ ...segment,
+ wordIds,
+ text: joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? "")),
+ };
+ }),
+ };
+}
+
+/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
+ * reason {@link setDocumentWordText} does. */
+export function insertDocumentWord(
+ document: AxcutDocument,
+ assetId: string,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ if (!transcript) {
+ throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
+ }
+ return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+}
+
+/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
+ * over several inserted words has to be ONE write, or undoing it takes as many presses as
+ * there were words. */
+export function removeDocumentWords(
+ document: AxcutDocument,
+ assetId: string,
+ wordIds: readonly string[],
+): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ if (!transcript) {
+ throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
+ }
+ return withTranscript(
+ document,
+ wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ );
+}
+
/** What {@link carryOverWordEdits} managed to save from the previous transcript. */
export interface WordEditCarryOver {
transcript: AxcutTranscript;
- /** Corrections re-applied to the new transcript. */
+ /** Corrections and insertions re-applied to the new transcript. */
carried: number;
- /** Corrections the new transcript left no place for. These are lost. */
+ /** Edits the new transcript left no place for. These are lost. */
dropped: number;
}
@@ -176,7 +364,12 @@ export function carryOverWordEdits(
const edits = (previous?.words ?? []).filter(
(word) => word.source === "user" && word.originalText !== undefined,
);
- if (edits.length === 0) return { transcript: next, carried: 0, dropped: 0 };
+ const inserts = (previous?.words ?? [])
+ .filter((word) => word.source === "synth")
+ .sort((a, b) => a.startSec - b.startSec);
+ if (edits.length === 0 && inserts.length === 0) {
+ return { transcript: next, carried: 0, dropped: 0 };
+ }
// Candidates are read from `next` throughout, never from the transcript being
// built up: a word already rewritten by an earlier correction no longer carries
@@ -198,5 +391,24 @@ export function carryOverWordEdits(
transcript = setWordText(transcript, match.id, edit.text);
carried += 1;
}
- return { transcript, carried, dropped: edits.length - carried };
+
+ // An inserted word has no original text to recognise, so time is what places it: the
+ // audio did not change between runs, only how it was heard. Each one goes back after
+ // whatever the new transcript now ends last before it — including a word re-inserted a
+ // moment ago, which is what keeps two inserts at the same spot in their old order.
+ for (const insert of inserts) {
+ const before = transcript.words
+ .filter((word) => word.endSec <= insert.startSec)
+ .reduce(
+ (latest, word) => (latest === null || word.endSec >= latest.endSec ? word : latest),
+ null,
+ );
+ const head = transcript.words[0];
+ const target = before ?? head ?? null;
+ if (!target) continue;
+ transcript = insertWord(transcript, target.id, before ? "after" : "before", insert.text);
+ carried += 1;
+ }
+
+ return { transcript, carried, dropped: edits.length + inserts.length - carried };
}
diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts
index ca4d635f2..ad6906307 100644
--- a/src/lib/ai-edition/store/documentWriteAudit.test.ts
+++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts
@@ -131,6 +131,10 @@ const DECLARED: WritePath[] = [
w("src/components/ai-edition/NewEditorShell.tsx", "handleRenameProject", "save", "gesture"),
// Ctrl+S / File > Save.
w("src/components/ai-edition/NewEditorShell.tsx", "handleSave", "save", "gesture"),
+ // A word typed into the transcript pane, and the deletion of one. Both are the user's
+ // own edits to the transcript; neither touches the timeline.
+ w("src/components/ai-edition/NewEditorShell.tsx", "handleInsertWord", "save", "gesture"),
+ w("src/components/ai-edition/NewEditorShell.tsx", "handleRemoveWords", "save", "gesture"),
// A word rewritten in the transcript pane. A correction, not a cut: it writes
// `transcript.words[].text` and leaves the timeline alone.
w("src/components/ai-edition/NewEditorShell.tsx", "handleSetWordText", "save", "gesture"),
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index fdac2ff7b..9b59f2bd6 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -61,6 +61,13 @@ export function isSilenceWord(word: AxcutWord): boolean {
return word.id.startsWith("silence_");
}
+/** True for a word the user typed in, which no one said and nothing in the media carries.
+ * Keyed on `source`, never on the id: the id shape is only there to stop a transcription
+ * run from reusing it. */
+export function isInsertedWord(word: AxcutWord): boolean {
+ return word.source === "synth";
+}
+
/**
* Insert a synthetic `[silence]` pseudo-word into every gap of at least
* `SILENCE_THRESHOLD_SEC` between consecutive words (and at the clip's
@@ -74,7 +81,14 @@ function withSilenceGaps(
clipStartSec: number,
clipEndSec: number | undefined,
): AxcutWord[] {
- const sorted = [...words].sort((a, b) => a.startSec - b.startSec);
+ // Sorted by time, ties broken by the order the transcript stores them in. The tie is
+ // not hypothetical: a word inserted between two contiguous words has no duration and
+ // therefore shares its start with the one it sits against, and only the array says
+ // which of the two the reader sees first.
+ const order = new Map(words.map((word, index) => [word.id, index]));
+ const sorted = [...words].sort(
+ (a, b) => a.startSec - b.startSec || (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0),
+ );
const result: AxcutWord[] = [];
let cursor = clipStartSec;
let n = 0;
@@ -151,7 +165,15 @@ export interface ClipSection {
}
function wordsInRange(transcript: AxcutTranscript, startSec: number, endSec: number): AxcutWord[] {
- return transcript.words.filter((w) => w.endSec > startSec && w.startSec < endSec);
+ return transcript.words.filter((w) =>
+ // An inserted word dropped between two words that run into each other has NO
+ // duration, and an overlap test excludes a point at either edge of the range —
+ // which silently lost every word inserted at the very start of a clip. A word with
+ // no span is in the clip when its moment is.
+ w.endSec > w.startSec
+ ? w.endSec > startSec && w.startSec < endSec
+ : w.startSec >= startSec && w.startSec < endSec,
+ );
}
/** Find the trim range covering this word's center (returns the deepest match). */
From 4860af48524d5ef7c4539d95332c115c465fccf3 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 22:19:19 +0200
Subject: [PATCH 057/113] feat(editor): an added word buys itself time, and the
film holds its frame
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adding a word only borrowed whatever silence happened to be free, so a word
dropped between two words that run into each other got no time at all. It now
creates the time it needs: the clip splits at the word's edge and a held-frame
clip carries the deficit, so the timeline grows and everything downstream
shifts. Screen and webcam freeze together — both are derived from the one asset
source clock the freeze stops advancing — and the decoder is paused for the
duration rather than free-running past the held frame into what comes after.
That created span is the slot a synthesized voice will speak in.
The gesture is gated to dev builds until there is a voice to put in it. A silent
freeze frame is not a feature, and captions are not always on, so a release
build offers no way to add a word at all — the pane does not even advertise it.
Drop the gate in `openInsertion` when TTS lands.
Three things the split broke, found by running it rather than by reading it:
The captions went DARK over the pause. A line straddling the split was
ventilated once per half, each half carrying the whole line, so the caption
played, blinked out for exactly the pause the word exists for, then played again
from the top. A line covering the held moment now covers the pause too, and
spans that meet on the ruler coalesce — measured before and after: two cues of
"Bonjour on va parler de vraiment Kubernetes" became one, 0→3.9s.
The word rendered TWICE in the pane. The freeze claims it and the half that
starts at the same moment matched it as well. The freeze owns it: it is the
section the playhead is inside while the pause plays.
And one word turned one recording into three headed blocks, each announcing the
same filename over a sliver of timecode ("Clip 2 · 0:02.5—0:02.5"). Sections
that continue the one before — same media, meeting on both clocks — now flow
inline under a single header spanning the whole run. Two clips over one media
still get a header each, which is the case the header exists for.
The pane also says what its gestures are now: double-click corrects, Backspace
cuts, and (in dev) typing between two words adds one. They were invisible until
tried.
Tests cover the freeze end to end: the document split, playback keeping the
created time, the source clock held still through it, the pane showing the word
once, the header run, and the caption playing once straight through.
---
src/components/ai-edition/RightPanes.tsx | 272 ++++++---
.../TranscriptPane.sharedMedia.test.tsx | 82 +++
.../TranscriptPane.wordEdit.test.tsx | 9 +
.../TranscriptPane.wordInsert.test.tsx | 17 +
src/i18n/locales/ar/settings.json | 555 +++++++++---------
src/i18n/locales/en/settings.json | 555 +++++++++---------
src/i18n/locales/es/settings.json | 555 +++++++++---------
src/i18n/locales/fr/settings.json | 555 +++++++++---------
src/i18n/locales/it/settings.json | 555 +++++++++---------
src/i18n/locales/ja-JP/settings.json | 555 +++++++++---------
src/i18n/locales/ko-KR/settings.json | 555 +++++++++---------
src/i18n/locales/pt-BR/settings.json | 555 +++++++++---------
src/i18n/locales/ru/settings.json | 555 +++++++++---------
src/i18n/locales/tr/settings.json | 555 +++++++++---------
src/i18n/locales/vi/settings.json | 555 +++++++++---------
src/i18n/locales/zh-CN/settings.json | 555 +++++++++---------
src/i18n/locales/zh-TW/settings.json | 555 +++++++++---------
src/lib/ai-edition/captions/captions.test.ts | 115 ++++
src/lib/ai-edition/captions/cues.ts | 30 +-
src/lib/ai-edition/document/timeline.ts | 72 ++-
.../ai-edition/document/transcript.test.ts | 59 ++
src/lib/ai-edition/document/transcript.ts | 34 +-
src/lib/ai-edition/schema/index.ts | 8 +
.../timeline/aggregated-transcript.test.ts | 91 +++
.../timeline/aggregated-transcript.ts | 45 +-
.../ai-edition/timeline/timelineMap.test.ts | 61 ++
src/lib/ai-edition/timeline/timelineMap.ts | 22 +-
src/native/useNativePlaybackSync.ts | 28 +-
28 files changed, 4468 insertions(+), 3692 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index e3a0f0047..bc2bce966 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -68,6 +68,7 @@ import {
findCueWordId,
isInsertedWord,
isSilenceWord,
+ placementRawExtent,
type TranscriptLane,
type TrimRun,
voiceoverPlacements,
@@ -915,12 +916,20 @@ export function TranscriptPane({
transcriptionLabel,
);
+ // The insert gesture is dev-only until TTS (see openInsertion), so the copy follows
+ // the same gate: release builds must not advertise a dead gesture.
+ const helpText =
+ ts("transcript.help") + (import.meta.env.DEV ? ` ${ts("transcript.helpInsert")}` : "");
+ const editingHint = ts(
+ import.meta.env.DEV ? "transcript.editingHintDev" : "transcript.editingHint",
+ );
+
if (placements.length === 0 || !hasAnyTranscript) {
return (
}
- helpText={ts("transcript.help")}
+ helpText={helpText}
actions={}
>
{laneSwitch}
@@ -967,39 +976,95 @@ export function TranscriptPane({
}
return (
-
+ }
+ helpText={helpText}
+ actions={}
+ >
+ {laneSwitch}
+ {/* The gestures are invisible until tried: nothing on a plain word stream says
+ * that double-click corrects and Backspace cuts. One muted line names them; the
+ * ? popover above carries the long version (amber inserts, hover-bin restore). */}
+
+ {editingHint}
+
+ {sections.map((section, idx) => (
+
+ ))}
+
);
}
+/**
+ * Whether this section merely continues the previous one: same media, and the previous
+ * clip ends exactly where this one starts, on the source clock and on the ruler alike.
+ *
+ * Inserting a word SPLITS the clip it lands in — [before · freeze · after] — so one word
+ * turned one recording into three headed blocks, each announcing the same filename and a
+ * sliver of timecode. They are one continuous read and now render as one: the header
+ * appears on the first section of the run, the rest flow straight on from it. Two clips
+ * over the same media that are NOT contiguous still get a header each, which is the case
+ * the header exists for.
+ */
+function continuesPreviousSection(
+ previous: ClipSection | undefined,
+ section: ClipSection,
+): boolean {
+ if (!previous || previous.clip.assetId !== section.clip.assetId) return false;
+ const EPSILON_SEC = 0.001;
+ const sourceMeets =
+ Math.abs((previous.clip.sourceEndSec ?? Number.NaN) - section.clip.sourceStartSec) <
+ EPSILON_SEC;
+ // A placement's ruler end is derived, not stored: the voiceover lane has no clip behind
+ // it to read a `timelineEndSec` from.
+ const previousEnd = placementRawExtent(previous.clip)?.endSec;
+ const rulerMeets =
+ previousEnd !== undefined &&
+ Math.abs(previousEnd - section.clip.timelineStartSec) < EPSILON_SEC;
+ return sourceMeets && rulerMeets;
+}
+
+/** The source range the whole run covers, for the one header that fronts it. */
+function runLabelFor(sections: ClipSection[], index: number): { start: number; end: number } {
+ let last = index;
+ while (
+ last + 1 < sections.length &&
+ continuesPreviousSection(sections[last], sections[last + 1])
+ ) {
+ last += 1;
+ }
+ return {
+ start: sections[index].clip.sourceStartSec,
+ end: sections[last].clip.sourceEndSec ?? sections[last].clip.sourceStartSec,
+ };
+}
+
// One contentEditable block per clip — header (vignette + filename +
// range) and a flowing word stream. The stream contains every transcript
// word inside the clip's source range, color-coded by whether the word
@@ -1015,6 +1080,8 @@ export function TranscriptPane({
const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
+ continuation,
+ runLabel,
busy,
busyLabel,
cueWordId,
@@ -1027,6 +1094,10 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}: {
index: number;
section: ClipSection;
+ /** This section reads straight on from the one above — no header, no gap. */
+ continuation: boolean;
+ /** Source range of the whole contiguous run this section fronts. */
+ runLabel: { start: number; end: number };
busy: boolean;
busyLabel?: string;
cueWordId: string | null;
@@ -1046,10 +1117,9 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
[clip.assetId, clip.id],
);
const filename = asset?.label ?? clip.assetId;
- const sourceRangeLabel =
- clip.sourceEndSec !== undefined
- ? `${formatMs(clip.sourceStartSec * 1000)}—${formatMs(clip.sourceEndSec * 1000)}`
- : `${formatMs(clip.sourceStartSec * 1000)}—`;
+ // The run's range, not this clip's: a split clip's own sliver would read as a
+ // 0:02.5—0:02.5 recording.
+ const sourceRangeLabel = `${formatMs(runLabel.start * 1000)}—${formatMs(runLabel.end * 1000)}`;
const editorRef = useRef(null);
const pendingCaretWordIdRef = useRef(null);
@@ -1204,6 +1274,11 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
const openInsertion = useCallback(
(seed: string) => {
+ // ponytail: word insertion ships dev-only until a voice can be synthesized for
+ // the word — without one it only borrows free silence, and once it creates
+ // timeline time (the pause gesture) it is a silent freeze frame. Drop this gate
+ // when TTS lands.
+ if (!import.meta.env.DEV) return;
if (busy || !seed.trim()) return;
const editor = editorRef.current;
const selection = globalThis.getSelection();
@@ -1321,79 +1396,85 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
return (
-
+ {continuation ? null : (
0 ? 16 : 0,
+ marginBottom: 6,
}}
>
- {index + 1}
-
-
-
- {filename}
-
-
- {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
-
-
- {/* A block whose transcript is being regenerated is read-only — say it,
- rather than letting the word stream look live and drop the edits. */}
- {busy ? (
-
- {busyLabel ?? ts("transcript.transcribing")}
+ {index + 1}
- ) : null}
-
+
+
+ {filename}
+
+
+ {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
+
+
+ {/* A block whose transcript is being regenerated is read-only — say it,
+ rather than letting the word stream look live and drop the edits. */}
+ {busy ? (
+
+
+ {ts("transcript.transcribing")}
+
+ ) : null}
+
+ )}
{words.length === 0 ? (
{
).toEqual(["clip_2:w2"]);
});
});
+
+// ─── Headers on a clip an inserted word split ────────────────────
+// Inserting a word splits the clip it lands in — [before · freeze · after] — so one word
+// turned one recording into three blocks, each announcing the same filename and a sliver
+// of timecode ("Clip 2 · 0:02.5—0:02.5"). They are one continuous read: one header, and
+// the words flow straight on. The two-copies case above must keep its two headers, which
+// is what tells the split apart from a media genuinely placed twice.
+
+const SPLIT_CLIPS: AxcutClip[] = [
+ {
+ id: "clip_1_fzA",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "clip_1_fz",
+ assetId: "asset_1",
+ sourceStartSec: 6,
+ sourceEndSec: 6,
+ timelineStartSec: 6,
+ timelineEndSec: 6.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "Inserted word — held frame",
+ frozenSec: 0.5,
+ },
+ {
+ id: "clip_1_fzB",
+ assetId: "asset_1",
+ sourceStartSec: 6,
+ sourceEndSec: 12,
+ timelineStartSec: 6.5,
+ timelineEndSec: 12.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+function renderClips(clips: AxcutClip[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("clip headers", () => {
+ it("fronts a split clip with one header covering the whole run", () => {
+ const view = renderClips(SPLIT_CLIPS);
+ const headers = view.container.querySelectorAll("[data-clip-header]");
+ expect(headers).toHaveLength(1);
+ // The run's range, not the first piece's — and not the freeze's 0:06.0—0:06.0.
+ expect(headers[0].textContent).toContain("0:00.0—0:12.0");
+ });
+
+ it("still gives two headers to one media placed twice", () => {
+ const view = renderClips(CLIPS);
+ expect(view.container.querySelectorAll("[data-clip-header]")).toHaveLength(2);
+ });
+});
diff --git a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
index 3c8fd0b97..74d2c021f 100644
--- a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
@@ -84,6 +84,15 @@ function renderPane(words?: AxcutWord[], busyAssetIds: string[] = []) {
afterEach(cleanup);
+describe("telling the user the gestures exist", () => {
+ it("shows the editing hint line and the ? help when a transcript is on screen", () => {
+ // The gestures are invisible until tried — the pane must name them itself.
+ const view = renderPane();
+ expect(view.getByText(/Double-click a word to correct it/)).toBeInTheDocument();
+ expect(view.getByRole("button", { name: "Help" })).toBeInTheDocument();
+ });
+});
+
describe("correcting a word", () => {
it("opens an editing field on the word a double-click lands on", () => {
const view = renderPane();
diff --git a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
index 7af0cc9db..caed55d61 100644
--- a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -134,6 +134,23 @@ describe("typing between two words", () => {
expect(view.field()).toHaveValue("v");
});
+ it("stays inert outside dev builds — the gesture waits for TTS", () => {
+ // An inserted word with no voice only borrows free silence, so the gesture ships
+ // dev-only (see openInsertion). Release builds must drop the keystroke silently,
+ // the same way they did before the feature existed.
+ vi.stubEnv("DEV", false);
+ try {
+ const view = renderPane();
+ caretBeforeWordAt(view.editor, 2);
+ type(view.editor, "v");
+ expect(view.field()).toBeNull();
+ expect(view.onInsertWord).not.toHaveBeenCalled();
+ expect(view.editor.textContent).not.toContain("v ");
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
it("never writes the typed text into the block itself", () => {
// The whole reason inserts were blocked: a run of text with no word id behind it
// desynchronises the DOM from `words`.
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index a3c142fdb..6a583fafe 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
- "x": "X (%)",
- "title": "موضع التركيز"
- },
- "threeD": {
- "preset": {
- "right": "يمين",
- "left": "يسار",
- "iso": "متساوي القياس"
- },
- "none": "بلا",
- "title": "دوران ثلاثي الأبعاد"
- },
- "focusMode": {
- "manual": "يدوي",
- "title": "وضع التركيز",
- "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
- "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
- "auto": "تلقائي"
- },
- "selectRegion": "حدد منطقة التكبير للتعديل",
- "customScale": "تكبير مخصص",
- "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
- "level": "مستوى التكبير",
- "deleteZoom": "حذف التكبير"
- },
- "audioTrack": {
- "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
- "defaultLabel": "مسار صوتي",
- "fadeOut": "تلاشٍ للخارج",
- "add": "إضافة مسار صوتي",
- "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
- "loop": "تكرار",
- "remove": "حذف المسار",
- "fadeIn": "تلاشٍ للداخل",
- "mute": "كتم",
- "importFailed": "تعذّر إضافة الصوت"
- },
- "cursor": {
- "clipToBounds": "القص ضمن اللوحة",
- "size": "الحجم",
- "motionBlur": "ضبابية الحركة",
- "theme": "نمط المؤشر",
- "clickBounce": "ارتداد النقر",
- "title": "المؤشر",
- "smoothing": "التنعيم",
- "themeDefault": "افتراضي",
- "show": "إظهار المؤشر",
- "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
- "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك."
- },
"annotation": {
- "blurIntensity": "كثافة التمويه",
+ "blurTypeBlur": "غاوسي",
+ "textPlaceholder": "أدخل النص هنا...",
+ "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
+ "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
+ "blurColorBlack": "أسود",
"textContent": "محتوى النص",
- "blurShapeFreehand": "رسم حر",
- "active": "نشط",
- "blurShapeRectangle": "مستطيل",
"customFonts": "خطوط مخصصة",
+ "clearBackground": "مسح الخلفية",
"title": "إعدادات الشروح",
+ "typeBlur": "تمويه",
+ "color": "لون",
"blurShapeOval": "بيضاوي",
- "blurType": "نوع التمويه",
- "mosaicBlockSize": "حجم كتلة الفسيفساء",
- "colorPalette": "لوحة الألوان",
- "textColor": "لون النص",
- "colorWheel": "عجلة الألوان",
- "shortcutsAndTips": "اختصارات ونصائح",
"defaultText": "مرحبا",
- "clearBackground": "مسح الخلفية",
- "type": "النوع",
- "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
- "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
+ "blurColor": "لون التمويه",
+ "blurShapeFreehand": "رسم حر",
+ "typeImage": "صورة",
+ "size": "الحجم",
+ "textColor": "لون النص",
"background": "الخلفية",
- "blurShape": "شكل التمويه",
"arrowColor": "لون السهم",
- "invalidImageType": "نوع ملف غير صالح",
- "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
- "blurColorBlack": "أسود",
- "blurTypeBlur": "غاوسي",
- "none": "بدون",
+ "arrowDirection": "اتجاه السهم",
+ "blurTypeMosaic": "فسيفساء",
"supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
- "size": "الحجم",
+ "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
+ "blurType": "نوع التمويه",
+ "deleteAnnotation": "حذف الشرح",
+ "fontStyle": "نمط الخط",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "selectStyle": "حدد النمط",
+ "typeText": "نص",
+ "type": "النوع",
+ "strokeWidth": "عرض الخط: {{width}}px",
+ "shortcutsAndTips": "اختصارات ونصائح",
"imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "mosaicBlockSize": "حجم كتلة الفسيفساء",
+ "blurShape": "شكل التمويه",
+ "none": "بدون",
"blurColorWhite": "أبيض",
- "arrowDirection": "اتجاه السهم",
- "typeImage": "صورة",
- "typeText": "نص",
+ "blurShapeRectangle": "مستطيل",
+ "colorWheel": "عجلة الألوان",
"typeArrow": "سهم",
- "color": "لون",
- "blurColor": "لون التمويه",
- "selectStyle": "حدد النمط",
- "blurTypeMosaic": "فسيفساء",
- "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
- "textPlaceholder": "أدخل النص هنا...",
+ "colorPalette": "لوحة الألوان",
"uploadImage": "رفع صورة",
- "strokeWidth": "عرض الخط: {{width}}px",
- "typeBlur": "تمويه",
- "deleteAnnotation": "حذف الشرح",
- "fontStyle": "نمط الخط"
+ "invalidImageType": "نوع ملف غير صالح",
+ "blurIntensity": "كثافة التمويه",
+ "active": "نشط"
+ },
+ "background": {
+ "colorPalette": "لوحة الألوان",
+ "custom": "مخصص",
+ "colorLabel": "اللون {{color}}",
+ "imageLabel": "الخلفية {{index}}",
+ "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
+ "colorWheel": "عجلة الألوان",
+ "presets": "إعدادات مسبقة",
+ "gradient": "تدرج لوني",
+ "uploadCustom": "رفع صورة مخصصة",
+ "customWallpaper": "خلفية مخصصة",
+ "gradientLabel": "تدرج لوني {{index}}",
+ "title": "الخلفية",
+ "color": "لون",
+ "image": "صورة",
+ "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
+ "imageReadFailed": "تعذّر قراءة ملف الصورة."
+ },
+ "customFont": {
+ "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
+ "errorEmptyName": "يرجى إدخال اسم الخط",
+ "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "إضافة خط Google",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
+ "failedToAdd": "فشل في إضافة الخط",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
+ "namePlaceholder": "خطي المخصص",
+ "nameLabel": "اسم العرض",
+ "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
+ "addButton": "إضافة خط",
+ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
+ "urlLabel": "رابط استيراد خطوط Google",
+ "addingButton": "جاري الإضافة..."
},
"layout": {
- "webcamCropY": "تحريك عمودي",
- "reactiveWebcam": "تصغير عند التكبير",
- "shapes": {
- "rectangle": "مستطيل",
- "square": "مربع",
- "rounded": "زوايا مستديرة",
- "circle": "دائرة"
- },
- "preset": "الإعداد المسبق",
- "dualFrame": "إطار مزدوج",
"bgModes": {
- "none": "الأصلي",
"transparent": "تفريغ",
"custom": "مخصص",
- "blur": "تمويه"
+ "blur": "تمويه",
+ "none": "الأصلي"
},
- "webcamCropZoom": "تكبير الاقتصاص",
- "webcamShape": "شكل الكاميرا",
- "webcamBlurIntensity": "شدة الضبابية",
+ "reactiveWebcam": "تصغير عند التكبير",
+ "webcamCropX": "تحريك أفقي",
+ "webcamBackground": "خلفية الكاميرا",
+ "mirrorWebcam": "عكس كاميرا الويب",
+ "webcamFraming": "تأطير كاميرا الويب",
"title": "تخطيط الكاميرا",
"reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
- "webcamFraming": "تأطير كاميرا الويب",
- "webcamCropX": "تحريك أفقي",
"selectPreset": "حدد إعدادًا مسبقًا",
+ "noWebcam": "بدون كاميرا",
+ "webcamShape": "شكل الكاميرا",
"helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
- "mirrorWebcam": "عكس كاميرا الويب",
"help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
- "webcamBackground": "خلفية الكاميرا",
- "noWebcam": "بدون كاميرا",
- "pictureInPicture": "صورة داخل صورة",
+ "shapes": {
+ "circle": "دائرة",
+ "square": "مربع",
+ "rectangle": "مستطيل",
+ "rounded": "زوايا مستديرة"
+ },
"webcamSize": "حجم كاميرا الويب",
- "verticalStack": "تكدس عمودي"
+ "verticalStack": "تكدس عمودي",
+ "webcamBlurIntensity": "شدة الضبابية",
+ "dualFrame": "إطار مزدوج",
+ "webcamCropY": "تحريك عمودي",
+ "webcamCropZoom": "تكبير الاقتصاص",
+ "pictureInPicture": "صورة داخل صورة",
+ "preset": "الإعداد المسبق"
},
- "speed": {
- "customPlaybackSpeed": "سرعة تشغيل مخصصة",
- "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير.",
- "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
- "playbackSpeed": "سرعة التشغيل",
- "deleteRegion": "حذف منطقة السرعة",
- "selectRegion": "حدد منطقة السرعة للتعديل"
+ "export": {
+ "gifButton": "تصدير GIF",
+ "chooseSaveLocation": "اختيار موقع الحفظ",
+ "videoButton": "تصدير الفيديو"
},
"imageUpload": {
- "failedToUpload": "فشل رفع الصورة",
+ "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
"uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
"errorReading": "حدث خطأ أثناء قراءة الملف.",
- "invalidFileType": "نوع ملف غير صالح",
- "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG."
+ "failedToUpload": "فشل رفع الصورة",
+ "invalidFileType": "نوع ملف غير صالح"
+ },
+ "effects": {
+ "shadow": "ظل",
+ "motion": "الحركة",
+ "off": "إيقاف",
+ "on": "تشغيل",
+ "format": "التنسيق",
+ "fitClip": "ملاءمة",
+ "fitClipMany": "{{count}} مقاطع",
+ "fitClipFew": "{{count}} مقاطع",
+ "fitClipOne": "مقطع واحد",
+ "frame": "الإطار",
+ "padding": "المسافة البادئة",
+ "title": "التركيب",
+ "blurBg": "تمويه الخلفية",
+ "formatOriginal": "الأصلي",
+ "roundness": "الاستدارة",
+ "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
+ "motionBlur": "ضبابية الحركة"
+ },
+ "audioTrack": {
+ "fadeOut": "تلاشٍ للخارج",
+ "importFailed": "تعذّر إضافة الصوت",
+ "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
+ "loop": "تكرار",
+ "remove": "حذف المسار",
+ "mute": "كتم",
+ "defaultLabel": "مسار صوتي",
+ "add": "إضافة مسار صوتي",
+ "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "fadeIn": "تلاشٍ للداخل"
},
"transcript": {
- "blankedWord": "مُفرَّغة",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "insertAria": "كلمة جديدة",
+ "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
+ "laneRecording": "التسجيل",
"editWord": "تحرير \"{{word}}\"",
+ "laneLabel": "اقرأ النص من",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
"editorAria": "نص {{filename}}",
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "silence": "[صمت {{duration}} ث]",
+ "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
"noTranscript": "لا يوجد نص بعد",
+ "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
+ "blankedWord": "مُفرَّغة",
+ "removeInserted": "حذف \"{{word}}\"",
"clipLabel": "المقطع {{index}}",
- "trimSilence": "قص الصمت ({{duration}} ث)",
- "laneVoiceover": "التعليق الصوتي",
- "restoreSilence": "استعادة الصمت ({{duration}} ث)",
- "transcribeNow": "فرّغ النص الآن",
+ "transcribing": "جارٍ التفريغ…",
"whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
- "removeInserted": "حذف \"{{word}}\"",
- "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
- "restoreWord": "استعادة \"{{word}}\"",
- "silence": "[صمت {{duration}} ث]",
- "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
- "laneRecording": "التسجيل",
- "noClips": "لا توجد مقاطع بعد",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
- "laneLabel": "اقرأ النص من",
+ "restoreSilence": "استعادة الصمت ({{duration}} ث)",
"revertWord": "استعادة \"{{original}}\"",
+ "helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
+ "laneVoiceover": "التعليق الصوتي",
+ "noClips": "لا توجد مقاطع بعد",
+ "trimSilence": "قص الصمت ({{duration}} ث)",
+ "restoreWord": "استعادة \"{{word}}\"",
"title": "النص الحالي",
- "transcribing": "جارٍ التفريغ…",
- "insertAria": "كلمة جديدة",
- "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد."
+ "transcribeNow": "فرّغ النص الآن"
+ },
+ "cursor": {
+ "title": "المؤشر",
+ "show": "إظهار المؤشر",
+ "clipToBounds": "القص ضمن اللوحة",
+ "motionBlur": "ضبابية الحركة",
+ "smoothing": "التنعيم",
+ "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
+ "themeDefault": "افتراضي",
+ "clickBounce": "ارتداد النقر",
+ "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
+ "theme": "نمط المؤشر",
+ "size": "الحجم"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
+ "title": "موضع التركيز",
+ "y": "Y (%)"
+ },
+ "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
+ "focusMode": {
+ "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
+ "manual": "يدوي",
+ "auto": "تلقائي",
+ "title": "وضع التركيز",
+ "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
+ },
+ "threeD": {
+ "preset": {
+ "right": "يمين",
+ "iso": "متساوي القياس",
+ "left": "يسار"
+ },
+ "none": "بلا",
+ "title": "دوران ثلاثي الأبعاد"
+ },
+ "selectRegion": "حدد منطقة التكبير للتعديل",
+ "deleteZoom": "حذف التكبير",
+ "customScale": "تكبير مخصص",
+ "level": "مستوى التكبير"
+ },
+ "project": {
+ "save": "حفظ المشروع",
+ "load": "تحميل المشروع",
+ "new": "مشروع جديد"
},
"captions": {
- "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
- "distanceFromTop": "المسافة من الأعلى",
- "minWords": "أقل عدد كلمات في السطر",
- "showBackground": "إظهار الخلفية",
- "lineLength": "طول السطر",
- "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
- "translating": "جارٍ الترجمة…",
- "distanceFromLeft": "المسافة من اليسار",
- "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
- "position": "الموضع",
- "translateFailed": "فشلت الترجمة.",
- "backgroundOpacity": "العتامة",
- "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
- "original": "الأصل (النص المفرّغ)",
- "font": "الخط",
+ "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
"distanceFromRight": "المسافة من اليمين",
+ "distanceFromBottom": "المسافة من الأسفل",
+ "font": "الخط",
+ "distanceFromTop": "المسافة من الأعلى",
+ "alignLeft": "يسار",
+ "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
"textColor": "لون النص",
- "alignCenter": "توسيط",
- "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "text": "النص",
+ "anchorTop": "أعلى",
"language": "اللغة",
+ "translate": "ترجمة",
"show": "إظهار الترجمة",
- "anchorTop": "أعلى",
- "backgroundColor": "لون الخلفية",
- "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
- "background": "الخلفية",
- "bold": "عريض",
- "distanceFromBottom": "المسافة من الأسفل",
"fontSize": "الحجم",
+ "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "backgroundOpacity": "العتامة",
+ "translating": "جارٍ الترجمة…",
"anchorBottom": "أسفل",
- "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
- "maxWords": "أكثر عدد كلمات في السطر",
- "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
- "translate": "ترجمة",
"deleteTranslation": "حذف هذه الترجمة",
+ "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
+ "backgroundColor": "لون الخلفية",
+ "translateFailed": "فشلت الترجمة.",
"anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
- "text": "النص",
- "displayLanguage": "العرض",
+ "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
+ "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
"alignRight": "يمين",
- "alignLeft": "يسار"
+ "displayLanguage": "العرض",
+ "distanceFromLeft": "المسافة من اليسار",
+ "minWords": "أقل عدد كلمات في السطر",
+ "showBackground": "إظهار الخلفية",
+ "maxWords": "أكثر عدد كلمات في السطر",
+ "bold": "عريض",
+ "background": "الخلفية",
+ "alignCenter": "توسيط",
+ "original": "الأصل (النص المفرّغ)",
+ "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
+ "lineLength": "طول السطر",
+ "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
+ "position": "الموضع"
},
- "support": {
- "saveDiagnostics": "حفظ التشخيصات",
- "reportBug": "الإبلاغ عن خطأ",
- "starOnGithub": "إعطاء نجمة على GitHub"
+ "textAnimation": {
+ "none": "بدون",
+ "title": "تحريك النص",
+ "pop": "ظهور",
+ "slideLeft": "انزلاق لليسار",
+ "selectAnimation": "حدد الحركة",
+ "fade": "تلاشي",
+ "rise": "ارتفاع",
+ "typewriter": "آلة كاتبة",
+ "pulse": "نبض"
},
- "customFont": {
- "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
- "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
- "urlLabel": "رابط استيراد خطوط Google",
- "addingButton": "جاري الإضافة...",
- "namePlaceholder": "خطي المخصص",
- "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
- "dialogTitle": "إضافة خط Google",
- "failedToAdd": "فشل في إضافة الخط",
- "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
- "addButton": "إضافة خط",
- "errorEmptyName": "يرجى إدخال اسم الخط",
- "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
- "nameLabel": "اسم العرض",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
- "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
- "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى."
+ "gifSettings": {
+ "loop": "تكرار GIF",
+ "frameRate": "معدل إطارات GIF",
+ "size": "حجم GIF"
},
- "background": {
- "presets": "إعدادات مسبقة",
- "image": "صورة",
- "title": "الخلفية",
- "custom": "مخصص",
- "colorLabel": "اللون {{color}}",
- "imageLabel": "الخلفية {{index}}",
- "customWallpaper": "خلفية مخصصة",
- "colorPalette": "لوحة الألوان",
- "uploadCustom": "رفع صورة مخصصة",
- "color": "لون",
- "imageReadFailed": "تعذّر قراءة ملف الصورة.",
- "gradientLabel": "تدرج لوني {{index}}",
- "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
- "gradient": "تدرج لوني",
- "colorWheel": "عجلة الألوان",
- "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص."
+ "exportFormat": {
+ "gifDescription": "صورة متحركة للمشاركة",
+ "mp4Description": "ملف فيديو عالي الجودة",
+ "mp4Video": "فيديو MP4",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "صورة GIF متحركة"
},
"audio": {
+ "title": "الصوت",
"reset": "إعادة ضبط الصوت",
- "outputGain": "ضبط مستوى الإخراج",
"help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
- "title": "الصوت"
+ "outputGain": "ضبط مستوى الإخراج"
},
- "textAnimation": {
- "pulse": "نبض",
- "selectAnimation": "حدد الحركة",
- "fade": "تلاشي",
- "typewriter": "آلة كاتبة",
- "slideLeft": "انزلاق لليسار",
- "none": "بدون",
- "rise": "ارتفاع",
- "pop": "ظهور",
- "title": "تحريك النص"
+ "language": {
+ "title": "اللغة"
+ },
+ "support": {
+ "saveDiagnostics": "حفظ التشخيصات",
+ "reportBug": "الإبلاغ عن خطأ",
+ "starOnGithub": "إعطاء نجمة على GitHub"
},
"crop": {
- "title": "اقتصاص",
- "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
+ "cropVideo": "اقتصاص الفيديو",
"free": "حر",
- "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
- "done": "تم",
"lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
- "ratio": "النسبة",
- "cropVideo": "اقتصاص الفيديو"
+ "done": "تم",
+ "title": "اقتصاص",
+ "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
+ "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
+ "ratio": "النسبة"
+ },
+ "speed": {
+ "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
+ "playbackSpeed": "سرعة التشغيل",
+ "customPlaybackSpeed": "سرعة تشغيل مخصصة",
+ "deleteRegion": "حذف منطقة السرعة",
+ "selectRegion": "حدد منطقة السرعة للتعديل",
+ "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير."
+ },
+ "trim": {
+ "deleteRegion": "حذف منطقة القص"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "دقة التصدير"
},
- "effects": {
- "off": "إيقاف",
- "on": "تشغيل",
- "fitClip": "ملاءمة",
- "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
- "fitClipFew": "{{count}} مقاطع",
- "blurBg": "تمويه الخلفية",
- "motion": "الحركة",
- "shadow": "ظل",
- "fitClipOne": "مقطع واحد",
- "format": "التنسيق",
- "roundness": "الاستدارة",
- "fitClipMany": "{{count}} مقاطع",
- "formatOriginal": "الأصلي",
- "frame": "الإطار",
- "motionBlur": "ضبابية الحركة",
- "padding": "المسافة البادئة",
- "title": "التركيب"
- },
- "language": {
- "title": "اللغة"
- },
- "gifSettings": {
- "size": "حجم GIF",
- "loop": "تكرار GIF",
- "frameRate": "معدل إطارات GIF"
+ "facets": {
+ "transcript": "النص",
+ "captions": "الترجمة"
},
"panes": {
"help": "مساعدة"
- },
- "export": {
- "chooseSaveLocation": "اختيار موقع الحفظ",
- "gifButton": "تصدير GIF",
- "videoButton": "تصدير الفيديو"
- },
- "exportFormat": {
- "mp4Video": "فيديو MP4",
- "mp4Description": "ملف فيديو عالي الجودة",
- "gifAnimation": "صورة GIF متحركة",
- "gifDescription": "صورة متحركة للمشاركة",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "حفظ المشروع",
- "new": "مشروع جديد",
- "load": "تحميل المشروع"
- },
- "facets": {
- "captions": "الترجمة",
- "transcript": "النص"
- },
- "trim": {
- "deleteRegion": "حذف منطقة القص"
}
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 1417249f6..f427e0aa8 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
- "x": "X (%)",
- "title": "Focus Position"
- },
- "threeD": {
- "preset": {
- "right": "Right",
- "left": "Left",
- "iso": "Iso"
- },
- "none": "None",
- "title": "3D Rotation"
- },
- "focusMode": {
- "manual": "Manual",
- "title": "Focus Mode",
- "autoDescription": "Camera follows the recorded cursor position",
- "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
- "auto": "Auto"
- },
- "selectRegion": "Select a zoom region to adjust",
- "customScale": "Custom Zoom",
- "previewHold": "Hold to preview zoom effect",
- "level": "Zoom Level",
- "deleteZoom": "Delete Zoom"
- },
- "audioTrack": {
- "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
- "defaultLabel": "Audio track",
- "fadeOut": "Fade out",
- "add": "Add audio track",
- "slipHint": "Alt-drag to slide the audio inside it",
- "loop": "Loop",
- "remove": "Delete track",
- "fadeIn": "Fade in",
- "mute": "Mute",
- "importFailed": "Could not add audio"
- },
- "cursor": {
- "clipToBounds": "Clip to Canvas",
- "size": "Size",
- "motionBlur": "Motion Blur",
- "theme": "Cursor Style",
- "clickBounce": "Click Bounce",
- "title": "Cursor",
- "smoothing": "Smoothing",
- "themeDefault": "Default",
- "show": "Show Cursor",
- "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
- "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned."
- },
"annotation": {
- "blurIntensity": "Blur Intensity",
+ "blurTypeBlur": "Gaussian",
+ "textPlaceholder": "Enter your text...",
+ "tipTabCycle": "Use Tab to cycle through overlapping items.",
+ "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
+ "blurColorBlack": "Black",
"textContent": "Text Content",
- "blurShapeFreehand": "Freehand",
- "active": "Active",
- "blurShapeRectangle": "Rectangle",
"customFonts": "Custom Fonts",
+ "clearBackground": "Clear Background",
"title": "Annotation Settings",
+ "typeBlur": "Blur",
+ "color": "Color",
"blurShapeOval": "Oval",
- "blurType": "Blur Type",
- "mosaicBlockSize": "Mosaic Block Size",
- "colorPalette": "Color Palette",
- "textColor": "Text Color",
- "colorWheel": "Color Wheel",
- "shortcutsAndTips": "Shortcuts & Tips",
"defaultText": "Hello",
- "clearBackground": "Clear Background",
- "type": "Type",
- "tipTabCycle": "Use Tab to cycle through overlapping items.",
- "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
+ "blurColor": "Blur Color",
+ "blurShapeFreehand": "Freehand",
+ "typeImage": "Image",
+ "size": "Size",
+ "textColor": "Text Color",
"background": "Background",
- "blurShape": "Blur Shape",
"arrowColor": "Arrow Color",
- "invalidImageType": "Invalid file type",
- "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
- "blurColorBlack": "Black",
- "blurTypeBlur": "Gaussian",
- "none": "None",
+ "arrowDirection": "Arrow Direction",
+ "blurTypeMosaic": "Mosaic",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
- "size": "Size",
+ "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
+ "blurType": "Blur Type",
+ "deleteAnnotation": "Delete Annotation",
+ "fontStyle": "Font Style",
+ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
+ "selectStyle": "Select style",
+ "typeText": "Text",
+ "type": "Type",
+ "strokeWidth": "Stroke Width: {{width}}px",
+ "shortcutsAndTips": "Shortcuts & Tips",
"imageUploadSuccess": "Image uploaded successfully!",
+ "mosaicBlockSize": "Mosaic Block Size",
+ "blurShape": "Blur Shape",
+ "none": "None",
"blurColorWhite": "White",
- "arrowDirection": "Arrow Direction",
- "typeImage": "Image",
- "typeText": "Text",
+ "blurShapeRectangle": "Rectangle",
+ "colorWheel": "Color Wheel",
"typeArrow": "Arrow",
- "color": "Color",
- "blurColor": "Blur Color",
- "selectStyle": "Select style",
- "blurTypeMosaic": "Mosaic",
- "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
- "textPlaceholder": "Enter your text...",
+ "colorPalette": "Color Palette",
"uploadImage": "Upload Image",
- "strokeWidth": "Stroke Width: {{width}}px",
- "typeBlur": "Blur",
- "deleteAnnotation": "Delete Annotation",
- "fontStyle": "Font Style"
+ "invalidImageType": "Invalid file type",
+ "blurIntensity": "Blur Intensity",
+ "active": "Active"
+ },
+ "background": {
+ "colorPalette": "Color Palette",
+ "custom": "Custom",
+ "colorLabel": "Color {{color}}",
+ "imageLabel": "Background {{index}}",
+ "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
+ "colorWheel": "Color Wheel",
+ "presets": "Presets",
+ "gradient": "Gradient",
+ "uploadCustom": "Upload Custom",
+ "customWallpaper": "Custom wallpaper",
+ "gradientLabel": "Gradient {{index}}",
+ "title": "Background",
+ "color": "Color",
+ "image": "Image",
+ "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
+ "imageReadFailed": "Could not read that image file."
+ },
+ "customFont": {
+ "nameHelp": "This is how the font will appear in the font selector",
+ "errorEmptyName": "Please enter a font name",
+ "errorExtractFailed": "Could not extract font family from URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Add Google Font",
+ "errorInvalidUrl": "Please enter a valid Google Fonts URL",
+ "errorEmptyUrl": "Please enter a Google Fonts import URL",
+ "errorTimeout": "Font took too long to load. Please check the URL and try again.",
+ "failedToAdd": "Failed to add font",
+ "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
+ "namePlaceholder": "My Custom Font",
+ "nameLabel": "Display Name",
+ "successMessage": "Font \"{{fontName}}\" added successfully",
+ "addButton": "Add Font",
+ "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
+ "urlLabel": "Google Fonts Import URL",
+ "addingButton": "Adding..."
},
"layout": {
- "webcamCropY": "Pan vertically",
- "reactiveWebcam": "Shrink on Zoom",
- "shapes": {
- "rectangle": "Rect",
- "square": "Square",
- "rounded": "Rounded",
- "circle": "Circle"
- },
- "preset": "Preset",
- "dualFrame": "Dual Frame",
"bgModes": {
- "none": "Original",
"transparent": "Cutout",
"custom": "Custom",
- "blur": "Blur"
+ "blur": "Blur",
+ "none": "Original"
},
- "webcamCropZoom": "Zoom",
- "webcamShape": "Camera Shape",
- "webcamBlurIntensity": "Blur Intensity",
+ "reactiveWebcam": "Shrink on Zoom",
+ "webcamCropX": "Pan horizontally",
+ "webcamBackground": "Camera Background",
+ "mirrorWebcam": "Mirror Webcam",
+ "webcamFraming": "Webcam crop",
"title": "Camera layout",
"reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
- "webcamFraming": "Webcam crop",
- "webcamCropX": "Pan horizontally",
"selectPreset": "Select preset",
+ "noWebcam": "No Webcam",
+ "webcamShape": "Camera Shape",
"helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
- "mirrorWebcam": "Mirror Webcam",
"help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
- "webcamBackground": "Camera Background",
- "noWebcam": "No Webcam",
- "pictureInPicture": "Picture in Picture",
+ "shapes": {
+ "circle": "Circle",
+ "square": "Square",
+ "rectangle": "Rect",
+ "rounded": "Rounded"
+ },
"webcamSize": "Webcam Size",
- "verticalStack": "Vertical Stack"
+ "verticalStack": "Vertical Stack",
+ "webcamBlurIntensity": "Blur Intensity",
+ "dualFrame": "Dual Frame",
+ "webcamCropY": "Pan vertically",
+ "webcamCropZoom": "Zoom",
+ "pictureInPicture": "Picture in Picture",
+ "preset": "Preset"
},
- "speed": {
- "customPlaybackSpeed": "Custom Playback Speed",
- "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected.",
- "maxSpeedError": "Speed can't go higher than {{max}}×",
- "playbackSpeed": "Playback Speed",
- "deleteRegion": "Delete Speed Region",
- "selectRegion": "Select a speed region to adjust"
+ "export": {
+ "gifButton": "Export GIF",
+ "chooseSaveLocation": "Choose Save Location",
+ "videoButton": "Export Video"
},
"imageUpload": {
- "failedToUpload": "Failed to upload image",
+ "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
"uploadSuccess": "Custom image uploaded successfully!",
"errorReading": "There was an error reading the file.",
- "invalidFileType": "Invalid file type",
- "jpgOnly": "Please upload a JPG, JPEG, or PNG image file."
+ "failedToUpload": "Failed to upload image",
+ "invalidFileType": "Invalid file type"
+ },
+ "effects": {
+ "shadow": "Shadow",
+ "motion": "Motion",
+ "off": "off",
+ "on": "on",
+ "format": "Format",
+ "fitClip": "Fit",
+ "fitClipMany": "{{count}} clips",
+ "fitClipFew": "{{count}} clips",
+ "fitClipOne": "{{count}} clip",
+ "frame": "Frame",
+ "padding": "Padding",
+ "title": "Composition",
+ "blurBg": "Blur BG",
+ "formatOriginal": "Original",
+ "roundness": "Roundness",
+ "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
+ "motionBlur": "Motion Blur"
+ },
+ "audioTrack": {
+ "fadeOut": "Fade out",
+ "importFailed": "Could not add audio",
+ "slipHint": "Alt-drag to slide the audio inside it",
+ "loop": "Loop",
+ "remove": "Delete track",
+ "mute": "Mute",
+ "defaultLabel": "Audio track",
+ "add": "Add audio track",
+ "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "fadeIn": "Fade in"
},
"transcript": {
- "blankedWord": "blanked",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Type between two words to add one, in amber: it reaches the captions and leaves the film alone. Hover a marked word to undo it.",
+ "noAudio": "This media has no audio track",
+ "insertAria": "New word",
+ "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
+ "laneRecording": "Recording",
"editWord": "Edit \"{{word}}\"",
+ "laneLabel": "Read the transcript from",
+ "insertedWord": "Added by you — no audio behind it",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
"editorAria": "Transcript for {{filename}}",
+ "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
+ "silence": "[silence {{duration}}s]",
+ "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
"noTranscript": "No transcript yet",
+ "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
+ "blankedWord": "blanked",
+ "removeInserted": "Delete \"{{word}}\"",
"clipLabel": "Clip {{index}}",
- "trimSilence": "Trim silence ({{duration}}s)",
- "laneVoiceover": "Voice-over",
- "restoreSilence": "Restore silence ({{duration}}s)",
- "transcribeNow": "Transcribe now",
+ "transcribing": "Transcribing…",
"whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
- "removeInserted": "Delete \"{{word}}\"",
- "insertedWord": "Added by you — no audio behind it",
- "restoreWord": "Restore \"{{word}}\"",
- "silence": "[silence {{duration}}s]",
- "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
- "laneRecording": "Recording",
- "noClips": "No clips yet",
- "noAudio": "This media has no audio track",
- "laneLabel": "Read the transcript from",
+ "restoreSilence": "Restore silence ({{duration}}s)",
"revertWord": "Restore \"{{original}}\"",
+ "helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
+ "laneVoiceover": "Voice-over",
+ "noClips": "No clips yet",
+ "trimSilence": "Trim silence ({{duration}}s)",
+ "restoreWord": "Restore \"{{word}}\"",
"title": "Current transcription",
- "transcribing": "Transcribing…",
- "insertAria": "New word",
- "noClipTranscript": "No transcript for this clip — open the asset card and regenerate."
+ "transcribeNow": "Transcribe now"
+ },
+ "cursor": {
+ "title": "Cursor",
+ "show": "Show Cursor",
+ "clipToBounds": "Clip to Canvas",
+ "motionBlur": "Motion Blur",
+ "smoothing": "Smoothing",
+ "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
+ "themeDefault": "Default",
+ "clickBounce": "Click Bounce",
+ "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
+ "theme": "Cursor Style",
+ "size": "Size"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
+ "title": "Focus Position",
+ "y": "Y (%)"
+ },
+ "previewHold": "Hold to preview zoom effect",
+ "focusMode": {
+ "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
+ "manual": "Manual",
+ "auto": "Auto",
+ "title": "Focus Mode",
+ "autoDescription": "Camera follows the recorded cursor position"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Right",
+ "iso": "Iso",
+ "left": "Left"
+ },
+ "none": "None",
+ "title": "3D Rotation"
+ },
+ "selectRegion": "Select a zoom region to adjust",
+ "deleteZoom": "Delete Zoom",
+ "customScale": "Custom Zoom",
+ "level": "Zoom Level"
+ },
+ "project": {
+ "save": "Save Project",
+ "load": "Load Project",
+ "new": "New Project"
},
"captions": {
- "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
- "distanceFromTop": "Distance from top",
- "minWords": "Min words per line",
- "showBackground": "Show background",
- "lineLength": "Line length",
- "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
- "translating": "Translating…",
- "distanceFromLeft": "Distance from left",
- "anchorHintTop": "Long captions grow downward — the top edge stays put.",
- "position": "Position",
- "translateFailed": "Translation failed.",
- "backgroundOpacity": "Opacity",
- "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
- "original": "Original (transcript)",
- "font": "Font",
+ "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
"distanceFromRight": "Distance from right",
+ "distanceFromBottom": "Distance from bottom",
+ "font": "Font",
+ "distanceFromTop": "Distance from top",
+ "alignLeft": "Left",
+ "removeLegacyAnnotations": "Remove old caption annotations",
"textColor": "Text color",
- "alignCenter": "Center",
- "translateHint": "Translate the transcript with the configured AI provider",
+ "text": "Text",
+ "anchorTop": "Top",
"language": "Language",
+ "translate": "Translate",
"show": "Show captions",
- "anchorTop": "Top",
- "backgroundColor": "Background color",
- "removeLegacyAnnotations": "Remove old caption annotations",
- "background": "Background",
- "bold": "Bold",
- "distanceFromBottom": "Distance from bottom",
"fontSize": "Size",
+ "translateHint": "Translate the transcript with the configured AI provider",
+ "backgroundOpacity": "Opacity",
+ "translating": "Translating…",
"anchorBottom": "Bottom",
- "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
- "maxWords": "Max words per line",
- "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
- "translate": "Translate",
"deleteTranslation": "Delete this translation",
+ "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
+ "backgroundColor": "Background color",
+ "translateFailed": "Translation failed.",
"anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
- "text": "Text",
- "displayLanguage": "Display",
+ "anchorHintTop": "Long captions grow downward — the top edge stays put.",
+ "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
"alignRight": "Right",
- "alignLeft": "Left"
+ "displayLanguage": "Display",
+ "distanceFromLeft": "Distance from left",
+ "minWords": "Min words per line",
+ "showBackground": "Show background",
+ "maxWords": "Max words per line",
+ "bold": "Bold",
+ "background": "Background",
+ "alignCenter": "Center",
+ "original": "Original (transcript)",
+ "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
+ "lineLength": "Line length",
+ "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
+ "position": "Position"
},
- "support": {
- "saveDiagnostics": "Save Diagnostics",
- "reportBug": "Report Bug",
- "starOnGithub": "Star on GitHub"
+ "textAnimation": {
+ "none": "None",
+ "title": "Text Animation",
+ "pop": "Pop",
+ "slideLeft": "Slide Left",
+ "selectAnimation": "Select animation",
+ "fade": "Fade",
+ "rise": "Rise",
+ "typewriter": "Typewriter",
+ "pulse": "Pulse"
},
- "customFont": {
- "nameHelp": "This is how the font will appear in the font selector",
- "errorInvalidUrl": "Please enter a valid Google Fonts URL",
- "urlLabel": "Google Fonts Import URL",
- "addingButton": "Adding...",
- "namePlaceholder": "My Custom Font",
- "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
- "dialogTitle": "Add Google Font",
- "failedToAdd": "Failed to add font",
- "errorEmptyUrl": "Please enter a Google Fonts import URL",
- "addButton": "Add Font",
- "errorEmptyName": "Please enter a font name",
- "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
- "nameLabel": "Display Name",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Could not extract font family from URL",
- "successMessage": "Font \"{{fontName}}\" added successfully",
- "errorTimeout": "Font took too long to load. Please check the URL and try again."
+ "gifSettings": {
+ "loop": "Loop GIF",
+ "frameRate": "GIF Frame Rate",
+ "size": "GIF Size"
},
- "background": {
- "presets": "Presets",
- "image": "Image",
- "title": "Background",
- "custom": "Custom",
- "colorLabel": "Color {{color}}",
- "imageLabel": "Background {{index}}",
- "customWallpaper": "Custom wallpaper",
- "colorPalette": "Color Palette",
- "uploadCustom": "Upload Custom",
- "color": "Color",
- "imageReadFailed": "Could not read that image file.",
- "gradientLabel": "Gradient {{index}}",
- "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
- "gradient": "Gradient",
- "colorWheel": "Color Wheel",
- "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk."
+ "exportFormat": {
+ "gifDescription": "Animated image for sharing",
+ "mp4Description": "High quality video file",
+ "mp4Video": "MP4 Video",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF Animation"
},
"audio": {
+ "title": "Audio",
"reset": "Reset audio",
- "outputGain": "Output level",
"help": "Adjust the audio output level. It applies identically in the preview and the export.",
- "title": "Audio"
+ "outputGain": "Output level"
},
- "textAnimation": {
- "pulse": "Pulse",
- "selectAnimation": "Select animation",
- "fade": "Fade",
- "typewriter": "Typewriter",
- "slideLeft": "Slide Left",
- "none": "None",
- "rise": "Rise",
- "pop": "Pop",
- "title": "Text Animation"
+ "language": {
+ "title": "Language"
+ },
+ "support": {
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "Report Bug",
+ "starOnGithub": "Star on GitHub"
},
"crop": {
- "title": "Crop",
- "unlockAspectRatio": "Unlock aspect ratio",
+ "cropVideo": "Crop Video",
"free": "Free",
- "dragInstruction": "Drag on each side to adjust the crop area",
- "done": "Done",
"lockAspectRatio": "Lock aspect ratio",
- "ratio": "Ratio",
- "cropVideo": "Crop Video"
+ "done": "Done",
+ "title": "Crop",
+ "dragInstruction": "Drag on each side to adjust the crop area",
+ "unlockAspectRatio": "Unlock aspect ratio",
+ "ratio": "Ratio"
+ },
+ "speed": {
+ "maxSpeedError": "Speed can't go higher than {{max}}×",
+ "playbackSpeed": "Playback Speed",
+ "customPlaybackSpeed": "Custom Playback Speed",
+ "deleteRegion": "Delete Speed Region",
+ "selectRegion": "Select a speed region to adjust",
+ "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected."
+ },
+ "trim": {
+ "deleteRegion": "Delete Trim Region"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "Export resolution"
},
- "effects": {
- "off": "off",
- "on": "on",
- "fitClip": "Fit",
- "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
- "fitClipFew": "{{count}} clips",
- "blurBg": "Blur BG",
- "motion": "Motion",
- "shadow": "Shadow",
- "fitClipOne": "{{count}} clip",
- "format": "Format",
- "roundness": "Roundness",
- "fitClipMany": "{{count}} clips",
- "formatOriginal": "Original",
- "frame": "Frame",
- "motionBlur": "Motion Blur",
- "padding": "Padding",
- "title": "Composition"
- },
- "language": {
- "title": "Language"
- },
- "gifSettings": {
- "size": "GIF Size",
- "loop": "Loop GIF",
- "frameRate": "GIF Frame Rate"
+ "facets": {
+ "transcript": "Transcript",
+ "captions": "Captions"
},
"panes": {
"help": "Help"
- },
- "export": {
- "chooseSaveLocation": "Choose Save Location",
- "gifButton": "Export GIF",
- "videoButton": "Export Video"
- },
- "exportFormat": {
- "mp4Video": "MP4 Video",
- "mp4Description": "High quality video file",
- "gifAnimation": "GIF Animation",
- "gifDescription": "Animated image for sharing",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Save Project",
- "new": "New Project",
- "load": "Load Project"
- },
- "facets": {
- "captions": "Captions",
- "transcript": "Transcript"
- },
- "trim": {
- "deleteRegion": "Delete Trim Region"
}
}
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 788c1934b..43ed8bbb4 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
- "x": "X (%)",
- "title": "Posición de enfoque"
- },
- "threeD": {
- "preset": {
- "right": "Derecha",
- "left": "Izquierda",
- "iso": "Iso"
- },
- "none": "Ninguna",
- "title": "Rotación 3D"
- },
- "focusMode": {
- "manual": "Manual",
- "title": "Modo de enfoque",
- "autoDescription": "La cámara sigue la posición del cursor grabado",
- "lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
- "auto": "Auto"
- },
- "selectRegion": "Selecciona una región de zoom para ajustar",
- "customScale": "Zoom personalizado",
- "previewHold": "Mantener para previsualizar el efecto de zoom",
- "level": "Nivel de zoom",
- "deleteZoom": "Eliminar zoom"
- },
- "audioTrack": {
- "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
- "defaultLabel": "Pista de audio",
- "fadeOut": "Desvanecido",
- "add": "Añadir pista de audio",
- "slipHint": "Alt + arrastrar para desplazar el audio dentro",
- "loop": "Bucle",
- "remove": "Eliminar pista",
- "fadeIn": "Aparición",
- "mute": "Silenciar",
- "importFailed": "No se pudo añadir el audio"
- },
- "cursor": {
- "clipToBounds": "Recortar al lienzo",
- "size": "Tamaño",
- "motionBlur": "Desenfoque de movimiento",
- "theme": "Estilo del cursor",
- "clickBounce": "Rebote al clic",
- "title": "Cursor",
- "smoothing": "Suavizado",
- "themeDefault": "Predeterminado",
- "show": "Mostrar cursor",
- "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
- "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen."
- },
"annotation": {
- "blurIntensity": "Intensidad del desenfoque",
+ "blurTypeBlur": "Gaussiano",
+ "textPlaceholder": "Escribe tu texto...",
+ "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
+ "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
+ "blurColorBlack": "Negro",
"textContent": "Contenido de texto",
- "blurShapeFreehand": "Mano alzada",
- "active": "Activo",
- "blurShapeRectangle": "Rectángulo",
"customFonts": "Fuentes personalizadas",
+ "clearBackground": "Quitar fondo",
"title": "Configuración de anotaciones",
+ "typeBlur": "Desenfoque",
+ "color": "Color",
"blurShapeOval": "Óvalo",
- "blurType": "Tipo de desenfoque",
- "mosaicBlockSize": "Tamano del bloque mosaico",
- "colorPalette": "Paleta de colores",
- "textColor": "Color de texto",
- "colorWheel": "Rueda de colores",
- "shortcutsAndTips": "Atajos y consejos",
"defaultText": "Hola",
- "clearBackground": "Quitar fondo",
- "type": "Tipo",
- "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
- "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
+ "blurColor": "Color del desenfoque",
+ "blurShapeFreehand": "Mano alzada",
+ "typeImage": "Imagen",
+ "size": "Tamaño",
+ "textColor": "Color de texto",
"background": "Fondo",
- "blurShape": "Forma del desenfoque",
"arrowColor": "Color de la flecha",
- "invalidImageType": "Tipo de archivo no válido",
- "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
- "blurColorBlack": "Negro",
- "blurTypeBlur": "Gaussiano",
- "none": "Ninguno",
+ "arrowDirection": "Dirección de la flecha",
+ "blurTypeMosaic": "Mosaico",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
- "size": "Tamaño",
+ "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
+ "blurType": "Tipo de desenfoque",
+ "deleteAnnotation": "Eliminar anotación",
+ "fontStyle": "Estilo de fuente",
+ "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
+ "selectStyle": "Seleccionar estilo",
+ "typeText": "Texto",
+ "type": "Tipo",
+ "strokeWidth": "Grosor del trazo: {{width}}px",
+ "shortcutsAndTips": "Atajos y consejos",
"imageUploadSuccess": "¡Imagen subida exitosamente!",
+ "mosaicBlockSize": "Tamano del bloque mosaico",
+ "blurShape": "Forma del desenfoque",
+ "none": "Ninguno",
"blurColorWhite": "Blanco",
- "arrowDirection": "Dirección de la flecha",
- "typeImage": "Imagen",
- "typeText": "Texto",
+ "blurShapeRectangle": "Rectángulo",
+ "colorWheel": "Rueda de colores",
"typeArrow": "Flecha",
- "color": "Color",
- "blurColor": "Color del desenfoque",
- "selectStyle": "Seleccionar estilo",
- "blurTypeMosaic": "Mosaico",
- "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
- "textPlaceholder": "Escribe tu texto...",
+ "colorPalette": "Paleta de colores",
"uploadImage": "Subir imagen",
- "strokeWidth": "Grosor del trazo: {{width}}px",
- "typeBlur": "Desenfoque",
- "deleteAnnotation": "Eliminar anotación",
- "fontStyle": "Estilo de fuente"
+ "invalidImageType": "Tipo de archivo no válido",
+ "blurIntensity": "Intensidad del desenfoque",
+ "active": "Activo"
+ },
+ "background": {
+ "colorPalette": "Paleta de colores",
+ "custom": "Personalizado",
+ "colorLabel": "Color {{color}}",
+ "imageLabel": "Fondo {{index}}",
+ "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
+ "colorWheel": "Rueda de colores",
+ "presets": "Ajustes preestablecidos",
+ "gradient": "Degradado",
+ "uploadCustom": "Subir personalizado",
+ "customWallpaper": "Fondo personalizado",
+ "gradientLabel": "Degradado {{index}}",
+ "title": "Fondo",
+ "color": "Color",
+ "image": "Imagen",
+ "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
+ "imageReadFailed": "No se pudo leer ese archivo de imagen."
+ },
+ "customFont": {
+ "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
+ "errorEmptyName": "Por favor ingresa un nombre de fuente",
+ "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Agregar fuente de Google",
+ "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
+ "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
+ "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
+ "failedToAdd": "Error al agregar la fuente",
+ "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
+ "namePlaceholder": "Mi fuente personalizada",
+ "nameLabel": "Nombre para mostrar",
+ "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
+ "addButton": "Agregar fuente",
+ "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
+ "urlLabel": "URL de importación de Google Fonts",
+ "addingButton": "Agregando..."
},
"layout": {
- "webcamCropY": "Desplazamiento vertical",
- "reactiveWebcam": "Reducir al ampliar",
- "shapes": {
- "rectangle": "Rect.",
- "square": "Cuadrado",
- "rounded": "Redondeado",
- "circle": "Círculo"
- },
- "preset": "Predefinido",
- "dualFrame": "Marco dual",
"bgModes": {
- "none": "Original",
"transparent": "Recortado",
"custom": "Personalizado",
- "blur": "Desenfocado"
+ "blur": "Desenfocado",
+ "none": "Original"
},
- "webcamCropZoom": "Zoom de recorte",
- "webcamShape": "Forma de cámara",
- "webcamBlurIntensity": "Intensidad del desenfoque",
+ "reactiveWebcam": "Reducir al ampliar",
+ "webcamCropX": "Desplazamiento horizontal",
+ "webcamBackground": "Fondo de la cámara",
+ "mirrorWebcam": "Reflejar cámara",
+ "webcamFraming": "Encuadre de cámara",
"title": "Disposición de cámara",
"reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
- "webcamFraming": "Encuadre de cámara",
- "webcamCropX": "Desplazamiento horizontal",
"selectPreset": "Seleccionar predefinido",
+ "noWebcam": "Sin cámara",
+ "webcamShape": "Forma de cámara",
"helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
- "mirrorWebcam": "Reflejar cámara",
"help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
- "webcamBackground": "Fondo de la cámara",
- "noWebcam": "Sin cámara",
- "pictureInPicture": "Imagen en imagen",
+ "shapes": {
+ "circle": "Círculo",
+ "square": "Cuadrado",
+ "rectangle": "Rect.",
+ "rounded": "Redondeado"
+ },
"webcamSize": "Tamaño de cámara",
- "verticalStack": "Apilado vertical"
+ "verticalStack": "Apilado vertical",
+ "webcamBlurIntensity": "Intensidad del desenfoque",
+ "dualFrame": "Marco dual",
+ "webcamCropY": "Desplazamiento vertical",
+ "webcamCropZoom": "Zoom de recorte",
+ "pictureInPicture": "Imagen en imagen",
+ "preset": "Predefinido"
},
- "speed": {
- "customPlaybackSpeed": "Velocidad personalizada",
- "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada.",
- "maxSpeedError": "La velocidad no puede superar {{max}}×",
- "playbackSpeed": "Velocidad de reproducción",
- "deleteRegion": "Eliminar región de velocidad",
- "selectRegion": "Selecciona una región de velocidad para ajustar"
+ "export": {
+ "gifButton": "Exportar GIF",
+ "chooseSaveLocation": "Elegir ubicación de guardado",
+ "videoButton": "Exportar video"
},
"imageUpload": {
- "failedToUpload": "Error al subir la imagen",
+ "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"errorReading": "Hubo un error al leer el archivo.",
- "invalidFileType": "Tipo de archivo no válido",
- "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG."
+ "failedToUpload": "Error al subir la imagen",
+ "invalidFileType": "Tipo de archivo no válido"
+ },
+ "effects": {
+ "shadow": "Sombra",
+ "motion": "Movimiento",
+ "off": "desactivado",
+ "on": "activado",
+ "format": "Formato",
+ "fitClip": "Ajustar",
+ "fitClipMany": "{{count}} clips",
+ "fitClipFew": "{{count}} clips",
+ "fitClipOne": "{{count}} clip",
+ "frame": "Marco",
+ "padding": "Relleno",
+ "title": "Composición",
+ "blurBg": "Desenfocar fondo",
+ "formatOriginal": "Original",
+ "roundness": "Redondez",
+ "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
+ "motionBlur": "Desenfoque de movimiento"
+ },
+ "audioTrack": {
+ "fadeOut": "Desvanecido",
+ "importFailed": "No se pudo añadir el audio",
+ "slipHint": "Alt + arrastrar para desplazar el audio dentro",
+ "loop": "Bucle",
+ "remove": "Eliminar pista",
+ "mute": "Silenciar",
+ "defaultLabel": "Pista de audio",
+ "add": "Añadir pista de audio",
+ "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "fadeIn": "Aparición"
},
"transcript": {
- "blankedWord": "vaciada",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "noAudio": "Este medio no tiene pista de audio",
+ "insertAria": "Palabra nueva",
+ "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
+ "laneRecording": "Grabación",
"editWord": "Editar «{{word}}»",
+ "laneLabel": "Leer la transcripción desde",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
"editorAria": "Transcripción de {{filename}}",
+ "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
+ "silence": "[silencio {{duration}} s]",
+ "correctedWord": "Corregida: la transcripción decía «{{original}}»",
"noTranscript": "Aún no hay transcripción",
+ "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
+ "blankedWord": "vaciada",
+ "removeInserted": "Eliminar «{{word}}»",
"clipLabel": "Clip {{index}}",
- "trimSilence": "Recortar silencio ({{duration}} s)",
- "laneVoiceover": "Voz en off",
- "restoreSilence": "Restaurar silencio ({{duration}} s)",
- "transcribeNow": "Transcribir ahora",
+ "transcribing": "Transcribiendo…",
"whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
- "removeInserted": "Eliminar «{{word}}»",
- "insertedWord": "Añadida por ti: no hay audio detrás",
- "restoreWord": "Restaurar «{{word}}»",
- "silence": "[silencio {{duration}} s]",
- "correctedWord": "Corregida: la transcripción decía «{{original}}»",
- "laneRecording": "Grabación",
- "noClips": "Aún no hay clips",
- "noAudio": "Este medio no tiene pista de audio",
- "laneLabel": "Leer la transcripción desde",
+ "restoreSilence": "Restaurar silencio ({{duration}} s)",
"revertWord": "Restaurar «{{original}}»",
+ "helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
+ "laneVoiceover": "Voz en off",
+ "noClips": "Aún no hay clips",
+ "trimSilence": "Recortar silencio ({{duration}} s)",
+ "restoreWord": "Restaurar «{{word}}»",
"title": "Transcripción actual",
- "transcribing": "Transcribiendo…",
- "insertAria": "Palabra nueva",
- "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla."
+ "transcribeNow": "Transcribir ahora"
+ },
+ "cursor": {
+ "title": "Cursor",
+ "show": "Mostrar cursor",
+ "clipToBounds": "Recortar al lienzo",
+ "motionBlur": "Desenfoque de movimiento",
+ "smoothing": "Suavizado",
+ "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
+ "themeDefault": "Predeterminado",
+ "clickBounce": "Rebote al clic",
+ "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
+ "theme": "Estilo del cursor",
+ "size": "Tamaño"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
+ "title": "Posición de enfoque",
+ "y": "Y (%)"
+ },
+ "previewHold": "Mantener para previsualizar el efecto de zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
+ "manual": "Manual",
+ "auto": "Auto",
+ "title": "Modo de enfoque",
+ "autoDescription": "La cámara sigue la posición del cursor grabado"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Derecha",
+ "iso": "Iso",
+ "left": "Izquierda"
+ },
+ "none": "Ninguna",
+ "title": "Rotación 3D"
+ },
+ "selectRegion": "Selecciona una región de zoom para ajustar",
+ "deleteZoom": "Eliminar zoom",
+ "customScale": "Zoom personalizado",
+ "level": "Nivel de zoom"
+ },
+ "project": {
+ "save": "Guardar proyecto",
+ "load": "Cargar proyecto",
+ "new": "Nuevo proyecto"
},
"captions": {
- "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
- "distanceFromTop": "Distancia desde arriba",
- "minWords": "Mín. palabras por línea",
- "showBackground": "Mostrar fondo",
- "lineLength": "Longitud de línea",
- "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
- "translating": "Traduciendo…",
- "distanceFromLeft": "Distancia desde la izquierda",
- "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
- "position": "Posición",
- "translateFailed": "La traducción ha fallado.",
- "backgroundOpacity": "Opacidad",
- "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
- "original": "Original (transcripción)",
- "font": "Fuente",
+ "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
"distanceFromRight": "Distancia desde la derecha",
+ "distanceFromBottom": "Distancia desde abajo",
+ "font": "Fuente",
+ "distanceFromTop": "Distancia desde arriba",
+ "alignLeft": "Izquierda",
+ "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
"textColor": "Color del texto",
- "alignCenter": "Centro",
- "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
+ "text": "Texto",
+ "anchorTop": "Arriba",
"language": "Idioma",
+ "translate": "Traducir",
"show": "Mostrar subtítulos",
- "anchorTop": "Arriba",
- "backgroundColor": "Color del fondo",
- "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
- "background": "Fondo",
- "bold": "Negrita",
- "distanceFromBottom": "Distancia desde abajo",
"fontSize": "Tamaño",
+ "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
+ "backgroundOpacity": "Opacidad",
+ "translating": "Traduciendo…",
"anchorBottom": "Abajo",
- "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
- "maxWords": "Máx. palabras por línea",
- "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
- "translate": "Traducir",
"deleteTranslation": "Eliminar esta traducción",
+ "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
+ "backgroundColor": "Color del fondo",
+ "translateFailed": "La traducción ha fallado.",
"anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
- "text": "Texto",
- "displayLanguage": "Visualización",
+ "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
+ "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
"alignRight": "Derecha",
- "alignLeft": "Izquierda"
+ "displayLanguage": "Visualización",
+ "distanceFromLeft": "Distancia desde la izquierda",
+ "minWords": "Mín. palabras por línea",
+ "showBackground": "Mostrar fondo",
+ "maxWords": "Máx. palabras por línea",
+ "bold": "Negrita",
+ "background": "Fondo",
+ "alignCenter": "Centro",
+ "original": "Original (transcripción)",
+ "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
+ "lineLength": "Longitud de línea",
+ "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
+ "position": "Posición"
},
- "support": {
- "saveDiagnostics": "Guardar diagnósticos",
- "reportBug": "Reportar error",
- "starOnGithub": "Dar estrella en GitHub"
+ "textAnimation": {
+ "none": "Ninguna",
+ "title": "Animación de texto",
+ "pop": "Aparecer",
+ "slideLeft": "Deslizar izquierda",
+ "selectAnimation": "Seleccionar animación",
+ "fade": "Desvanecimiento",
+ "rise": "Ascender",
+ "typewriter": "Máquina de escribir",
+ "pulse": "Pulso"
},
- "customFont": {
- "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
- "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
- "urlLabel": "URL de importación de Google Fonts",
- "addingButton": "Agregando...",
- "namePlaceholder": "Mi fuente personalizada",
- "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
- "dialogTitle": "Agregar fuente de Google",
- "failedToAdd": "Error al agregar la fuente",
- "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
- "addButton": "Agregar fuente",
- "errorEmptyName": "Por favor ingresa un nombre de fuente",
- "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
- "nameLabel": "Nombre para mostrar",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
- "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
- "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo."
+ "gifSettings": {
+ "loop": "Repetir GIF",
+ "frameRate": "Velocidad de cuadros del GIF",
+ "size": "Tamaño del GIF"
},
- "background": {
- "presets": "Ajustes preestablecidos",
- "image": "Imagen",
- "title": "Fondo",
- "custom": "Personalizado",
- "colorLabel": "Color {{color}}",
- "imageLabel": "Fondo {{index}}",
- "customWallpaper": "Fondo personalizado",
- "colorPalette": "Paleta de colores",
- "uploadCustom": "Subir personalizado",
- "color": "Color",
- "imageReadFailed": "No se pudo leer ese archivo de imagen.",
- "gradientLabel": "Degradado {{index}}",
- "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
- "gradient": "Degradado",
- "colorWheel": "Rueda de colores",
- "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco."
+ "exportFormat": {
+ "gifDescription": "Imagen animada para compartir",
+ "mp4Description": "Archivo de video de alta calidad",
+ "mp4Video": "Video MP4",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animación GIF"
},
"audio": {
+ "title": "Audio",
"reset": "Restablecer audio",
- "outputGain": "Ajuste de salida",
"help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
- "title": "Audio"
+ "outputGain": "Ajuste de salida"
},
- "textAnimation": {
- "pulse": "Pulso",
- "selectAnimation": "Seleccionar animación",
- "fade": "Desvanecimiento",
- "typewriter": "Máquina de escribir",
- "slideLeft": "Deslizar izquierda",
- "none": "Ninguna",
- "rise": "Ascender",
- "pop": "Aparecer",
- "title": "Animación de texto"
+ "language": {
+ "title": "Idioma"
+ },
+ "support": {
+ "saveDiagnostics": "Guardar diagnósticos",
+ "reportBug": "Reportar error",
+ "starOnGithub": "Dar estrella en GitHub"
},
"crop": {
- "title": "Recortar",
- "unlockAspectRatio": "Desbloquear relación de aspecto",
+ "cropVideo": "Recortar video",
"free": "Libre",
- "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
- "done": "Listo",
"lockAspectRatio": "Bloquear relación de aspecto",
- "ratio": "Proporción",
- "cropVideo": "Recortar video"
+ "done": "Listo",
+ "title": "Recortar",
+ "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
+ "unlockAspectRatio": "Desbloquear relación de aspecto",
+ "ratio": "Proporción"
+ },
+ "speed": {
+ "maxSpeedError": "La velocidad no puede superar {{max}}×",
+ "playbackSpeed": "Velocidad de reproducción",
+ "customPlaybackSpeed": "Velocidad personalizada",
+ "deleteRegion": "Eliminar región de velocidad",
+ "selectRegion": "Selecciona una región de velocidad para ajustar",
+ "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada."
+ },
+ "trim": {
+ "deleteRegion": "Eliminar región de recorte"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "Resolución de exportación"
},
- "effects": {
- "off": "desactivado",
- "on": "activado",
- "fitClip": "Ajustar",
- "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
- "fitClipFew": "{{count}} clips",
- "blurBg": "Desenfocar fondo",
- "motion": "Movimiento",
- "shadow": "Sombra",
- "fitClipOne": "{{count}} clip",
- "format": "Formato",
- "roundness": "Redondez",
- "fitClipMany": "{{count}} clips",
- "formatOriginal": "Original",
- "frame": "Marco",
- "motionBlur": "Desenfoque de movimiento",
- "padding": "Relleno",
- "title": "Composición"
- },
- "language": {
- "title": "Idioma"
- },
- "gifSettings": {
- "size": "Tamaño del GIF",
- "loop": "Repetir GIF",
- "frameRate": "Velocidad de cuadros del GIF"
+ "facets": {
+ "transcript": "Transcripción",
+ "captions": "Subtítulos"
},
"panes": {
"help": "Ayuda"
- },
- "export": {
- "chooseSaveLocation": "Elegir ubicación de guardado",
- "gifButton": "Exportar GIF",
- "videoButton": "Exportar video"
- },
- "exportFormat": {
- "mp4Video": "Video MP4",
- "mp4Description": "Archivo de video de alta calidad",
- "gifAnimation": "Animación GIF",
- "gifDescription": "Imagen animada para compartir",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Guardar proyecto",
- "new": "Nuevo proyecto",
- "load": "Cargar proyecto"
- },
- "facets": {
- "captions": "Subtítulos",
- "transcript": "Transcripción"
- },
- "trim": {
- "deleteRegion": "Eliminar región de recorte"
}
}
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 1e832e129..0c720e1c7 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
- "x": "X (%)",
- "title": "Position du focus"
- },
- "threeD": {
- "preset": {
- "right": "Droite",
- "left": "Gauche",
- "iso": "Iso"
- },
- "none": "Aucune",
- "title": "Rotation 3D"
- },
- "focusMode": {
- "manual": "Manuel",
- "title": "Mode focus",
- "autoDescription": "La caméra suit la position du curseur enregistré",
- "lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
- "auto": "Auto"
- },
- "selectRegion": "Sélectionnez une région de zoom à ajuster",
- "customScale": "Zoom personnalisé",
- "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "level": "Niveau de zoom",
- "deleteZoom": "Supprimer le zoom"
- },
- "audioTrack": {
- "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
- "defaultLabel": "Piste audio",
- "fadeOut": "Fondu de sortie",
- "add": "Ajouter une piste audio",
- "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
- "loop": "Boucle",
- "remove": "Supprimer la piste",
- "fadeIn": "Fondu d'entrée",
- "mute": "Muet",
- "importFailed": "Impossible d’ajouter l’audio"
- },
- "cursor": {
- "clipToBounds": "Rogner au canevas",
- "size": "Taille",
- "motionBlur": "Flou de mouvement",
- "theme": "Style du curseur",
- "clickBounce": "Rebond au clic",
- "title": "Curseur",
- "smoothing": "Lissage",
- "themeDefault": "Par défaut",
- "show": "Afficher le curseur",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
- "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique."
- },
"annotation": {
- "blurIntensity": "Intensité du flou",
+ "blurTypeBlur": "Gaussien",
+ "textPlaceholder": "Saisissez votre texte...",
+ "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
+ "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "blurColorBlack": "Noir",
"textContent": "Contenu du texte",
- "blurShapeFreehand": "Main levée",
- "active": "Actif",
- "blurShapeRectangle": "Rectangle",
"customFonts": "Polices personnalisées",
+ "clearBackground": "Supprimer l'arrière-plan",
"title": "Paramètres d'annotation",
+ "typeBlur": "Flou",
+ "color": "Couleur",
"blurShapeOval": "Ovale",
- "blurType": "Type de flou",
- "mosaicBlockSize": "Taille des blocs de mosaique",
- "colorPalette": "Palette de couleurs",
- "textColor": "Couleur du texte",
- "colorWheel": "Roue chromatique",
- "shortcutsAndTips": "Raccourcis & Astuces",
"defaultText": "Bonjour",
- "clearBackground": "Supprimer l'arrière-plan",
- "type": "Type",
- "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
- "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "blurColor": "Couleur du flou",
+ "blurShapeFreehand": "Main levée",
+ "typeImage": "Image",
+ "size": "Taille",
+ "textColor": "Couleur du texte",
"background": "Arrière-plan",
- "blurShape": "Forme du flou",
"arrowColor": "Couleur de la flèche",
- "invalidImageType": "Type de fichier invalide",
- "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "blurColorBlack": "Noir",
- "blurTypeBlur": "Gaussien",
- "none": "Aucun",
+ "arrowDirection": "Direction de la flèche",
+ "blurTypeMosaic": "Mosaïque",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
- "size": "Taille",
+ "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+ "blurType": "Type de flou",
+ "deleteAnnotation": "Supprimer l'annotation",
+ "fontStyle": "Style de police",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "selectStyle": "Choisir un style",
+ "typeText": "Texte",
+ "type": "Type",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "shortcutsAndTips": "Raccourcis & Astuces",
"imageUploadSuccess": "Image téléversée avec succès !",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
+ "blurShape": "Forme du flou",
+ "none": "Aucun",
"blurColorWhite": "Blanc",
- "arrowDirection": "Direction de la flèche",
- "typeImage": "Image",
- "typeText": "Texte",
+ "blurShapeRectangle": "Rectangle",
+ "colorWheel": "Roue chromatique",
"typeArrow": "Flèche",
- "color": "Couleur",
- "blurColor": "Couleur du flou",
- "selectStyle": "Choisir un style",
- "blurTypeMosaic": "Mosaïque",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
- "textPlaceholder": "Saisissez votre texte...",
+ "colorPalette": "Palette de couleurs",
"uploadImage": "Téléverser une image",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
- "typeBlur": "Flou",
- "deleteAnnotation": "Supprimer l'annotation",
- "fontStyle": "Style de police"
+ "invalidImageType": "Type de fichier invalide",
+ "blurIntensity": "Intensité du flou",
+ "active": "Actif"
+ },
+ "background": {
+ "colorPalette": "Palette de couleurs",
+ "custom": "Personnalisé",
+ "colorLabel": "Couleur {{color}}",
+ "imageLabel": "Fond {{index}}",
+ "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
+ "colorWheel": "Roue chromatique",
+ "presets": "Préréglages",
+ "gradient": "Dégradé",
+ "uploadCustom": "Téléverser une image",
+ "customWallpaper": "Fond personnalisé",
+ "gradientLabel": "Dégradé {{index}}",
+ "title": "Arrière-plan",
+ "color": "Couleur",
+ "image": "Image",
+ "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
+ "imageReadFailed": "Impossible de lire ce fichier image."
+ },
+ "customFont": {
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
+ "errorEmptyName": "Veuillez saisir un nom de police",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Ajouter une police Google",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
+ "failedToAdd": "Échec de l'ajout de la police",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
+ "namePlaceholder": "Ma police personnalisée",
+ "nameLabel": "Nom d'affichage",
+ "successMessage": "Police « {{fontName}} » ajoutée avec succès",
+ "addButton": "Ajouter la police",
+ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
+ "urlLabel": "URL d'import Google Fonts",
+ "addingButton": "Ajout en cours..."
},
"layout": {
- "webcamCropY": "Déplacement vertical",
- "reactiveWebcam": "Réduire au zoom",
- "shapes": {
- "rectangle": "Rect.",
- "square": "Carré",
- "rounded": "Arrondi",
- "circle": "Cercle"
- },
- "preset": "Préréglage",
- "dualFrame": "Double cadre",
"bgModes": {
- "none": "Original",
"transparent": "Détouré",
"custom": "Personnalisé",
- "blur": "Flouté"
+ "blur": "Flouté",
+ "none": "Original"
},
- "webcamCropZoom": "Zoom du recadrage",
- "webcamShape": "Forme de la caméra",
- "webcamBlurIntensity": "Intensité du flou",
+ "reactiveWebcam": "Réduire au zoom",
+ "webcamCropX": "Déplacement horizontal",
+ "webcamBackground": "Arrière-plan de la caméra",
+ "mirrorWebcam": "Inverser la webcam",
+ "webcamFraming": "Cadrage de la webcam",
"title": "Disposition caméra",
"reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
- "webcamFraming": "Cadrage de la webcam",
- "webcamCropX": "Déplacement horizontal",
"selectPreset": "Choisir un préréglage",
+ "noWebcam": "Sans webcam",
+ "webcamShape": "Forme de la caméra",
"helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
- "mirrorWebcam": "Inverser la webcam",
"help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
- "webcamBackground": "Arrière-plan de la caméra",
- "noWebcam": "Sans webcam",
- "pictureInPicture": "Incrustation d'image",
+ "shapes": {
+ "circle": "Cercle",
+ "square": "Carré",
+ "rectangle": "Rect.",
+ "rounded": "Arrondi"
+ },
"webcamSize": "Taille de la caméra",
- "verticalStack": "Empilement vertical"
+ "verticalStack": "Empilement vertical",
+ "webcamBlurIntensity": "Intensité du flou",
+ "dualFrame": "Double cadre",
+ "webcamCropY": "Déplacement vertical",
+ "webcamCropZoom": "Zoom du recadrage",
+ "pictureInPicture": "Incrustation d'image",
+ "preset": "Préréglage"
},
- "speed": {
- "customPlaybackSpeed": "Vitesse de lecture personnalisée",
- "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
- "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
- "playbackSpeed": "Vitesse de lecture",
- "deleteRegion": "Supprimer la région de vitesse",
- "selectRegion": "Sélectionnez une région de vitesse à ajuster"
+ "export": {
+ "gifButton": "Exporter le GIF",
+ "chooseSaveLocation": "Choisir l'emplacement d'enregistrement",
+ "videoButton": "Exporter la vidéo"
},
"imageUpload": {
- "failedToUpload": "Échec du téléversement de l'image",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
"uploadSuccess": "Image personnalisée téléversée avec succès !",
"errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
- "invalidFileType": "Type de fichier invalide",
- "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG."
+ "failedToUpload": "Échec du téléversement de l'image",
+ "invalidFileType": "Type de fichier invalide"
+ },
+ "effects": {
+ "shadow": "Ombre",
+ "motion": "Mouvement",
+ "off": "désactivé",
+ "on": "activé",
+ "format": "Format",
+ "fitClip": "Ajuster",
+ "fitClipMany": "{{count}} clips",
+ "fitClipFew": "{{count}} clips",
+ "fitClipOne": "{{count}} clip",
+ "frame": "Cadre",
+ "padding": "Marge",
+ "title": "Composition",
+ "blurBg": "Flou arrière-plan",
+ "formatOriginal": "Original",
+ "roundness": "Arrondi",
+ "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
+ "motionBlur": "Flou de mouvement"
+ },
+ "audioTrack": {
+ "fadeOut": "Fondu de sortie",
+ "importFailed": "Impossible d’ajouter l’audio",
+ "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
+ "loop": "Boucle",
+ "remove": "Supprimer la piste",
+ "mute": "Muet",
+ "defaultLabel": "Piste audio",
+ "add": "Ajouter une piste audio",
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
+ "fadeIn": "Fondu d'entrée"
},
"transcript": {
- "blankedWord": "vidé",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film. Survolez un mot marqué pour annuler.",
+ "noAudio": "Ce média n'a pas de piste audio",
+ "insertAria": "Nouveau mot",
+ "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
+ "laneRecording": "Enregistrement",
"editWord": "Modifier « {{word}} »",
+ "laneLabel": "Lire la transcription depuis",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
"editorAria": "Transcription de {{filename}}",
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "silence": "[silence {{duration}} s]",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
"noTranscript": "Aucune transcription pour l'instant",
+ "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
+ "blankedWord": "vidé",
+ "removeInserted": "Supprimer « {{word}} »",
"clipLabel": "Clip {{index}}",
- "trimSilence": "Couper le silence ({{duration}} s)",
- "laneVoiceover": "Voix off",
- "restoreSilence": "Restaurer le silence ({{duration}} s)",
- "transcribeNow": "Transcrire maintenant",
+ "transcribing": "Transcription…",
"whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
- "removeInserted": "Supprimer « {{word}} »",
- "insertedWord": "Ajouté par vous — aucun son derrière",
- "restoreWord": "Restaurer « {{word}} »",
- "silence": "[silence {{duration}} s]",
- "correctedWord": "Corrigé — la transcription disait « {{original}} »",
- "laneRecording": "Enregistrement",
- "noClips": "Aucun clip pour l'instant",
- "noAudio": "Ce média n'a pas de piste audio",
- "laneLabel": "Lire la transcription depuis",
+ "restoreSilence": "Restaurer le silence ({{duration}} s)",
"revertWord": "Rétablir « {{original}} »",
+ "helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
+ "laneVoiceover": "Voix off",
+ "noClips": "Aucun clip pour l'instant",
+ "trimSilence": "Couper le silence ({{duration}} s)",
+ "restoreWord": "Restaurer « {{word}} »",
"title": "Transcription actuelle",
- "transcribing": "Transcription…",
- "insertAria": "Nouveau mot",
- "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération."
+ "transcribeNow": "Transcrire maintenant"
+ },
+ "cursor": {
+ "title": "Curseur",
+ "show": "Afficher le curseur",
+ "clipToBounds": "Rogner au canevas",
+ "motionBlur": "Flou de mouvement",
+ "smoothing": "Lissage",
+ "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
+ "themeDefault": "Par défaut",
+ "clickBounce": "Rebond au clic",
+ "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
+ "theme": "Style du curseur",
+ "size": "Taille"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
+ "title": "Position du focus",
+ "y": "Y (%)"
+ },
+ "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
+ "manual": "Manuel",
+ "auto": "Auto",
+ "title": "Mode focus",
+ "autoDescription": "La caméra suit la position du curseur enregistré"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Droite",
+ "iso": "Iso",
+ "left": "Gauche"
+ },
+ "none": "Aucune",
+ "title": "Rotation 3D"
+ },
+ "selectRegion": "Sélectionnez une région de zoom à ajuster",
+ "deleteZoom": "Supprimer le zoom",
+ "customScale": "Zoom personnalisé",
+ "level": "Niveau de zoom"
+ },
+ "project": {
+ "save": "Enregistrer le projet",
+ "load": "Charger un projet",
+ "new": "Nouveau projet"
},
"captions": {
- "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
- "distanceFromTop": "Distance depuis le haut",
- "minWords": "Mots min. par ligne",
- "showBackground": "Afficher le fond",
- "lineLength": "Longueur des lignes",
- "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "translating": "Traduction…",
- "distanceFromLeft": "Distance depuis la gauche",
- "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
- "position": "Position",
- "translateFailed": "La traduction a échoué.",
- "backgroundOpacity": "Opacité",
- "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
- "original": "Original (transcription)",
- "font": "Police",
+ "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
"distanceFromRight": "Distance depuis la droite",
+ "distanceFromBottom": "Distance depuis le bas",
+ "font": "Police",
+ "distanceFromTop": "Distance depuis le haut",
+ "alignLeft": "Gauche",
+ "removeLegacyAnnotations": "Supprimer les anciennes annotations",
"textColor": "Couleur du texte",
- "alignCenter": "Centre",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "text": "Texte",
+ "anchorTop": "Haut",
"language": "Langue",
+ "translate": "Traduire",
"show": "Afficher les sous-titres",
- "anchorTop": "Haut",
- "backgroundColor": "Couleur du fond",
- "removeLegacyAnnotations": "Supprimer les anciennes annotations",
- "background": "Fond",
- "bold": "Gras",
- "distanceFromBottom": "Distance depuis le bas",
"fontSize": "Taille",
+ "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "backgroundOpacity": "Opacité",
+ "translating": "Traduction…",
"anchorBottom": "Bas",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
- "maxWords": "Mots max. par ligne",
- "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
- "translate": "Traduire",
"deleteTranslation": "Supprimer cette traduction",
+ "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
+ "backgroundColor": "Couleur du fond",
+ "translateFailed": "La traduction a échoué.",
"anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
- "text": "Texte",
- "displayLanguage": "Affichage",
+ "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
+ "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
"alignRight": "Droite",
- "alignLeft": "Gauche"
+ "displayLanguage": "Affichage",
+ "distanceFromLeft": "Distance depuis la gauche",
+ "minWords": "Mots min. par ligne",
+ "showBackground": "Afficher le fond",
+ "maxWords": "Mots max. par ligne",
+ "bold": "Gras",
+ "background": "Fond",
+ "alignCenter": "Centre",
+ "original": "Original (transcription)",
+ "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
+ "lineLength": "Longueur des lignes",
+ "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
+ "position": "Position"
},
- "support": {
- "saveDiagnostics": "Enregistrer les diagnostics",
- "reportBug": "Signaler un bug",
- "starOnGithub": "Étoile sur GitHub"
+ "textAnimation": {
+ "none": "Aucune",
+ "title": "Animation de texte",
+ "pop": "Apparition",
+ "slideLeft": "Glisser à gauche",
+ "selectAnimation": "Sélectionner une animation",
+ "fade": "Fondu",
+ "rise": "Monter",
+ "typewriter": "Machine à écrire",
+ "pulse": "Pulsation"
},
- "customFont": {
- "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
- "urlLabel": "URL d'import Google Fonts",
- "addingButton": "Ajout en cours...",
- "namePlaceholder": "Ma police personnalisée",
- "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
- "dialogTitle": "Ajouter une police Google",
- "failedToAdd": "Échec de l'ajout de la police",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
- "addButton": "Ajouter la police",
- "errorEmptyName": "Veuillez saisir un nom de police",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
- "nameLabel": "Nom d'affichage",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
- "successMessage": "Police « {{fontName}} » ajoutée avec succès",
- "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez."
+ "gifSettings": {
+ "loop": "GIF en boucle",
+ "frameRate": "Fréquence d'images GIF",
+ "size": "Taille du GIF"
},
- "background": {
- "presets": "Préréglages",
- "image": "Image",
- "title": "Arrière-plan",
- "custom": "Personnalisé",
- "colorLabel": "Couleur {{color}}",
- "imageLabel": "Fond {{index}}",
- "customWallpaper": "Fond personnalisé",
- "colorPalette": "Palette de couleurs",
- "uploadCustom": "Téléverser une image",
- "color": "Couleur",
- "imageReadFailed": "Impossible de lire ce fichier image.",
- "gradientLabel": "Dégradé {{index}}",
- "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
- "gradient": "Dégradé",
- "colorWheel": "Roue chromatique",
- "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque."
+ "exportFormat": {
+ "gifDescription": "Image animée pour le partage",
+ "mp4Description": "Fichier vidéo haute qualité",
+ "mp4Video": "Vidéo MP4",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animation GIF"
},
"audio": {
+ "title": "Audio",
"reset": "Réinitialiser l’audio",
- "outputGain": "Niveau de sortie",
"help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
- "title": "Audio"
+ "outputGain": "Niveau de sortie"
},
- "textAnimation": {
- "pulse": "Pulsation",
- "selectAnimation": "Sélectionner une animation",
- "fade": "Fondu",
- "typewriter": "Machine à écrire",
- "slideLeft": "Glisser à gauche",
- "none": "Aucune",
- "rise": "Monter",
- "pop": "Apparition",
- "title": "Animation de texte"
+ "language": {
+ "title": "Langue"
+ },
+ "support": {
+ "saveDiagnostics": "Enregistrer les diagnostics",
+ "reportBug": "Signaler un bug",
+ "starOnGithub": "Étoile sur GitHub"
},
"crop": {
- "title": "Recadrage",
- "unlockAspectRatio": "Déverrouiller le ratio",
+ "cropVideo": "Recadrer la vidéo",
"free": "Libre",
- "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
- "done": "Terminer",
"lockAspectRatio": "Verrouiller le ratio",
- "ratio": "Ratio",
- "cropVideo": "Recadrer la vidéo"
+ "done": "Terminer",
+ "title": "Recadrage",
+ "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
+ "unlockAspectRatio": "Déverrouiller le ratio",
+ "ratio": "Ratio"
+ },
+ "speed": {
+ "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
+ "playbackSpeed": "Vitesse de lecture",
+ "customPlaybackSpeed": "Vitesse de lecture personnalisée",
+ "deleteRegion": "Supprimer la région de vitesse",
+ "selectRegion": "Sélectionnez une région de vitesse à ajuster",
+ "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté."
+ },
+ "trim": {
+ "deleteRegion": "Supprimer la région de coupe"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "Résolution d'export"
},
- "effects": {
- "off": "désactivé",
- "on": "activé",
- "fitClip": "Ajuster",
- "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
- "fitClipFew": "{{count}} clips",
- "blurBg": "Flou arrière-plan",
- "motion": "Mouvement",
- "shadow": "Ombre",
- "fitClipOne": "{{count}} clip",
- "format": "Format",
- "roundness": "Arrondi",
- "fitClipMany": "{{count}} clips",
- "formatOriginal": "Original",
- "frame": "Cadre",
- "motionBlur": "Flou de mouvement",
- "padding": "Marge",
- "title": "Composition"
- },
- "language": {
- "title": "Langue"
- },
- "gifSettings": {
- "size": "Taille du GIF",
- "loop": "GIF en boucle",
- "frameRate": "Fréquence d'images GIF"
+ "facets": {
+ "transcript": "Transcription",
+ "captions": "Sous-titres"
},
"panes": {
"help": "Aide"
- },
- "export": {
- "chooseSaveLocation": "Choisir l'emplacement d'enregistrement",
- "gifButton": "Exporter le GIF",
- "videoButton": "Exporter la vidéo"
- },
- "exportFormat": {
- "mp4Video": "Vidéo MP4",
- "mp4Description": "Fichier vidéo haute qualité",
- "gifAnimation": "Animation GIF",
- "gifDescription": "Image animée pour le partage",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Enregistrer le projet",
- "new": "Nouveau projet",
- "load": "Charger un projet"
- },
- "facets": {
- "captions": "Sous-titres",
- "transcript": "Transcription"
- },
- "trim": {
- "deleteRegion": "Supprimer la région de coupe"
}
}
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 161ed421e..291d4fa5e 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
- "x": "X (%)",
- "title": "Posizione messa a fuoco"
- },
- "threeD": {
- "preset": {
- "right": "Destra",
- "left": "Sinistra",
- "iso": "Iso"
- },
- "none": "Nessuna",
- "title": "Rotazione 3D"
- },
- "focusMode": {
- "manual": "Manuale",
- "title": "Modalità messa a fuoco",
- "autoDescription": "La fotocamera segue la posizione del cursore registrato",
- "lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
- "auto": "Automatico"
- },
- "selectRegion": "Seleziona una regione zoom da regolare",
- "customScale": "Zoom personalizzato",
- "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
- "level": "Livello zoom",
- "deleteZoom": "Elimina zoom"
- },
- "audioTrack": {
- "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
- "defaultLabel": "Traccia audio",
- "fadeOut": "Dissolvenza in uscita",
- "add": "Aggiungi traccia audio",
- "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
- "loop": "Ripeti",
- "remove": "Elimina traccia",
- "fadeIn": "Dissolvenza in entrata",
- "mute": "Muto",
- "importFailed": "Impossibile aggiungere l’audio"
- },
- "cursor": {
- "clipToBounds": "Ritaglia al canvas",
- "size": "Dimensione",
- "motionBlur": "Sfocatura movimento",
- "theme": "Stile del cursore",
- "clickBounce": "Rimbalzo clic",
- "title": "Cursore",
- "smoothing": "Smussatura",
- "themeDefault": "Predefinito",
- "show": "Mostra cursore",
- "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
- "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica."
- },
"annotation": {
- "blurIntensity": "Intensità sfocatura",
+ "blurTypeBlur": "Gaussiano",
+ "textPlaceholder": "Inserisci il tuo testo...",
+ "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
+ "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
+ "blurColorBlack": "Nero",
"textContent": "Contenuto testo",
- "blurShapeFreehand": "A mano libera",
- "active": "Attivo",
- "blurShapeRectangle": "Rettangolo",
"customFonts": "Caratteri personalizzati",
+ "clearBackground": "Rimuovi sfondo",
"title": "Impostazioni annotazione",
+ "typeBlur": "Sfocatura",
+ "color": "Colore",
"blurShapeOval": "Ovale",
- "blurType": "Tipo sfocatura",
- "mosaicBlockSize": "Dimensione blocco mosaico",
- "colorPalette": "Tavolozza dei colori",
- "textColor": "Colore testo",
- "colorWheel": "Ruota dei colori",
- "shortcutsAndTips": "Scorciatoie e suggerimenti",
"defaultText": "Ciao",
- "clearBackground": "Rimuovi sfondo",
- "type": "Tipo",
- "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
- "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
+ "blurColor": "Colore sfocatura",
+ "blurShapeFreehand": "A mano libera",
+ "typeImage": "Immagine",
+ "size": "Dimensione",
+ "textColor": "Colore testo",
"background": "Sfondo",
- "blurShape": "Forma sfocatura",
"arrowColor": "Colore freccia",
- "invalidImageType": "Tipo di file non valido",
- "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
- "blurColorBlack": "Nero",
- "blurTypeBlur": "Gaussiano",
- "none": "Nessuno",
+ "arrowDirection": "Direzione freccia",
+ "blurTypeMosaic": "Mosaico",
"supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
- "size": "Dimensione",
+ "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
+ "blurType": "Tipo sfocatura",
+ "deleteAnnotation": "Elimina annotazione",
+ "fontStyle": "Stile carattere",
+ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
+ "selectStyle": "Seleziona stile",
+ "typeText": "Testo",
+ "type": "Tipo",
+ "strokeWidth": "Larghezza tratto: {{width}}px",
+ "shortcutsAndTips": "Scorciatoie e suggerimenti",
"imageUploadSuccess": "Immagine caricata con successo!",
+ "mosaicBlockSize": "Dimensione blocco mosaico",
+ "blurShape": "Forma sfocatura",
+ "none": "Nessuno",
"blurColorWhite": "Bianco",
- "arrowDirection": "Direzione freccia",
- "typeImage": "Immagine",
- "typeText": "Testo",
+ "blurShapeRectangle": "Rettangolo",
+ "colorWheel": "Ruota dei colori",
"typeArrow": "Freccia",
- "color": "Colore",
- "blurColor": "Colore sfocatura",
- "selectStyle": "Seleziona stile",
- "blurTypeMosaic": "Mosaico",
- "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
- "textPlaceholder": "Inserisci il tuo testo...",
+ "colorPalette": "Tavolozza dei colori",
"uploadImage": "Carica immagine",
- "strokeWidth": "Larghezza tratto: {{width}}px",
- "typeBlur": "Sfocatura",
- "deleteAnnotation": "Elimina annotazione",
- "fontStyle": "Stile carattere"
+ "invalidImageType": "Tipo di file non valido",
+ "blurIntensity": "Intensità sfocatura",
+ "active": "Attivo"
+ },
+ "background": {
+ "colorPalette": "Tavolozza dei colori",
+ "custom": "Personalizzato",
+ "colorLabel": "Colore {{color}}",
+ "imageLabel": "Sfondo {{index}}",
+ "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
+ "colorWheel": "Ruota dei colori",
+ "presets": "Predefiniti",
+ "gradient": "Sfumatura",
+ "uploadCustom": "Carica personalizzato",
+ "customWallpaper": "Sfondo personalizzato",
+ "gradientLabel": "Sfumatura {{index}}",
+ "title": "Sfondo",
+ "color": "Colore",
+ "image": "Immagine",
+ "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
+ "imageReadFailed": "Impossibile leggere quel file immagine."
+ },
+ "customFont": {
+ "nameHelp": "Così apparirà il font nel selettore",
+ "errorEmptyName": "Inserisci un nome per il font",
+ "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Aggiungi font Google",
+ "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
+ "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
+ "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
+ "failedToAdd": "Impossibile aggiungere il font",
+ "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
+ "namePlaceholder": "Il mio font personalizzato",
+ "nameLabel": "Nome visualizzato",
+ "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
+ "addButton": "Aggiungi font",
+ "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
+ "urlLabel": "URL importazione Google Fonts",
+ "addingButton": "Aggiunta in corso..."
},
"layout": {
- "webcamCropY": "Spostamento verticale",
- "reactiveWebcam": "Riduci con lo zoom",
- "shapes": {
- "rectangle": "Rett.",
- "square": "Quadrato",
- "rounded": "Arrotondato",
- "circle": "Cerchio"
- },
- "preset": "Predefinito",
- "dualFrame": "Doppio frame",
"bgModes": {
- "none": "Originale",
"transparent": "Scontornato",
"custom": "Personalizzato",
- "blur": "Sfocato"
+ "blur": "Sfocato",
+ "none": "Originale"
},
- "webcamCropZoom": "Zoom ritaglio",
- "webcamShape": "Forma fotocamera",
- "webcamBlurIntensity": "Intensità sfocatura",
+ "reactiveWebcam": "Riduci con lo zoom",
+ "webcamCropX": "Spostamento orizzontale",
+ "webcamBackground": "Sfondo della fotocamera",
+ "mirrorWebcam": "Specchia webcam",
+ "webcamFraming": "Inquadratura webcam",
"title": "Disposizione camera",
"reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
- "webcamFraming": "Inquadratura webcam",
- "webcamCropX": "Spostamento orizzontale",
"selectPreset": "Seleziona predefinito",
+ "noWebcam": "Nessuna webcam",
+ "webcamShape": "Forma fotocamera",
"helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
- "mirrorWebcam": "Specchia webcam",
"help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
- "webcamBackground": "Sfondo della fotocamera",
- "noWebcam": "Nessuna webcam",
- "pictureInPicture": "Immagine nell'immagine",
+ "shapes": {
+ "circle": "Cerchio",
+ "square": "Quadrato",
+ "rectangle": "Rett.",
+ "rounded": "Arrotondato"
+ },
"webcamSize": "Dimensione webcam",
- "verticalStack": "Pila verticale"
+ "verticalStack": "Pila verticale",
+ "webcamBlurIntensity": "Intensità sfocatura",
+ "dualFrame": "Doppio frame",
+ "webcamCropY": "Spostamento verticale",
+ "webcamCropZoom": "Zoom ritaglio",
+ "pictureInPicture": "Immagine nell'immagine",
+ "preset": "Predefinito"
},
- "speed": {
- "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
- "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata.",
- "maxSpeedError": "La velocità non può superare {{max}}×",
- "playbackSpeed": "Velocità di riproduzione",
- "deleteRegion": "Elimina regione velocità",
- "selectRegion": "Seleziona una regione velocità da regolare"
+ "export": {
+ "gifButton": "Esporta GIF",
+ "chooseSaveLocation": "Scegli posizione di salvataggio",
+ "videoButton": "Esporta video"
},
"imageUpload": {
- "failedToUpload": "Impossibile caricare l'immagine",
+ "jpgOnly": "Carica un file immagine JPG o JPEG.",
"uploadSuccess": "Immagine personalizzata caricata con successo!",
"errorReading": "Si è verificato un errore durante la lettura del file.",
- "invalidFileType": "Tipo di file non valido",
- "jpgOnly": "Carica un file immagine JPG o JPEG."
+ "failedToUpload": "Impossibile caricare l'immagine",
+ "invalidFileType": "Tipo di file non valido"
+ },
+ "effects": {
+ "shadow": "Ombra",
+ "motion": "Movimento",
+ "off": "spento",
+ "on": "acceso",
+ "format": "Formato",
+ "fitClip": "Adatta",
+ "fitClipMany": "{{count}} clip",
+ "fitClipFew": "{{count}} clip",
+ "fitClipOne": "{{count}} clip",
+ "frame": "Cornice",
+ "padding": "Spaziatura",
+ "title": "Composizione",
+ "blurBg": "Sfuma sfondo",
+ "formatOriginal": "Originale",
+ "roundness": "Arrotondamento",
+ "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
+ "motionBlur": "Sfocatura movimento"
+ },
+ "audioTrack": {
+ "fadeOut": "Dissolvenza in uscita",
+ "importFailed": "Impossibile aggiungere l’audio",
+ "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
+ "loop": "Ripeti",
+ "remove": "Elimina traccia",
+ "mute": "Muto",
+ "defaultLabel": "Traccia audio",
+ "add": "Aggiungi traccia audio",
+ "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "fadeIn": "Dissolvenza in entrata"
},
"transcript": {
- "blankedWord": "svuotata",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video. Passa sopra una parola contrassegnata per annullare.",
+ "noAudio": "Questo contenuto non ha una traccia audio",
+ "insertAria": "Nuova parola",
+ "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
+ "laneRecording": "Registrazione",
"editWord": "Modifica «{{word}}»",
+ "laneLabel": "Leggi la trascrizione da",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
"editorAria": "Trascrizione di {{filename}}",
+ "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
+ "silence": "[silenzio {{duration}} s]",
+ "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
"noTranscript": "Ancora nessuna trascrizione",
+ "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
+ "blankedWord": "svuotata",
+ "removeInserted": "Elimina «{{word}}»",
"clipLabel": "Clip {{index}}",
- "trimSilence": "Taglia silenzio ({{duration}} s)",
- "laneVoiceover": "Voce fuori campo",
- "restoreSilence": "Ripristina silenzio ({{duration}} s)",
- "transcribeNow": "Trascrivi ora",
+ "transcribing": "Trascrizione…",
"whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
- "removeInserted": "Elimina «{{word}}»",
- "insertedWord": "Aggiunta da te — nessun audio dietro",
- "restoreWord": "Ripristina «{{word}}»",
- "silence": "[silenzio {{duration}} s]",
- "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
- "laneRecording": "Registrazione",
- "noClips": "Ancora nessun clip",
- "noAudio": "Questo contenuto non ha una traccia audio",
- "laneLabel": "Leggi la trascrizione da",
+ "restoreSilence": "Ripristina silenzio ({{duration}} s)",
"revertWord": "Ripristina «{{original}}»",
+ "helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
+ "laneVoiceover": "Voce fuori campo",
+ "noClips": "Ancora nessun clip",
+ "trimSilence": "Taglia silenzio ({{duration}} s)",
+ "restoreWord": "Ripristina «{{word}}»",
"title": "Trascrizione corrente",
- "transcribing": "Trascrizione…",
- "insertAria": "Nuova parola",
- "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala."
+ "transcribeNow": "Trascrivi ora"
+ },
+ "cursor": {
+ "title": "Cursore",
+ "show": "Mostra cursore",
+ "clipToBounds": "Ritaglia al canvas",
+ "motionBlur": "Sfocatura movimento",
+ "smoothing": "Smussatura",
+ "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
+ "themeDefault": "Predefinito",
+ "clickBounce": "Rimbalzo clic",
+ "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
+ "theme": "Stile del cursore",
+ "size": "Dimensione"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
+ "title": "Posizione messa a fuoco",
+ "y": "Y (%)"
+ },
+ "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
+ "manual": "Manuale",
+ "auto": "Automatico",
+ "title": "Modalità messa a fuoco",
+ "autoDescription": "La fotocamera segue la posizione del cursore registrato"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Destra",
+ "iso": "Iso",
+ "left": "Sinistra"
+ },
+ "none": "Nessuna",
+ "title": "Rotazione 3D"
+ },
+ "selectRegion": "Seleziona una regione zoom da regolare",
+ "deleteZoom": "Elimina zoom",
+ "customScale": "Zoom personalizzato",
+ "level": "Livello zoom"
+ },
+ "project": {
+ "save": "Salva progetto",
+ "load": "Carica progetto",
+ "new": "Nuovo progetto"
},
"captions": {
- "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
- "distanceFromTop": "Distanza dall'alto",
- "minWords": "Parole min. per riga",
- "showBackground": "Mostra sfondo",
- "lineLength": "Lunghezza riga",
- "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
- "translating": "Traduzione…",
- "distanceFromLeft": "Distanza da sinistra",
- "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
- "position": "Posizione",
- "translateFailed": "Traduzione non riuscita.",
- "backgroundOpacity": "Opacità",
- "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
- "original": "Originale (trascrizione)",
- "font": "Carattere",
+ "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
"distanceFromRight": "Distanza da destra",
+ "distanceFromBottom": "Distanza dal basso",
+ "font": "Carattere",
+ "distanceFromTop": "Distanza dall'alto",
+ "alignLeft": "Sinistra",
+ "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
"textColor": "Colore del testo",
- "alignCenter": "Centro",
- "translateHint": "Traduci la trascrizione con il provider IA configurato",
+ "text": "Testo",
+ "anchorTop": "Alto",
"language": "Lingua",
+ "translate": "Traduci",
"show": "Mostra sottotitoli",
- "anchorTop": "Alto",
- "backgroundColor": "Colore dello sfondo",
- "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
- "background": "Sfondo",
- "bold": "Grassetto",
- "distanceFromBottom": "Distanza dal basso",
"fontSize": "Dimensione",
+ "translateHint": "Traduci la trascrizione con il provider IA configurato",
+ "backgroundOpacity": "Opacità",
+ "translating": "Traduzione…",
"anchorBottom": "Basso",
- "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
- "maxWords": "Parole max. per riga",
- "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
- "translate": "Traduci",
"deleteTranslation": "Elimina questa traduzione",
+ "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
+ "backgroundColor": "Colore dello sfondo",
+ "translateFailed": "Traduzione non riuscita.",
"anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
- "text": "Testo",
- "displayLanguage": "Visualizzazione",
+ "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
+ "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
"alignRight": "Destra",
- "alignLeft": "Sinistra"
+ "displayLanguage": "Visualizzazione",
+ "distanceFromLeft": "Distanza da sinistra",
+ "minWords": "Parole min. per riga",
+ "showBackground": "Mostra sfondo",
+ "maxWords": "Parole max. per riga",
+ "bold": "Grassetto",
+ "background": "Sfondo",
+ "alignCenter": "Centro",
+ "original": "Originale (trascrizione)",
+ "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
+ "lineLength": "Lunghezza riga",
+ "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
+ "position": "Posizione"
},
- "support": {
- "saveDiagnostics": "Salva dati diagnostici",
- "reportBug": "Segnala bug",
- "starOnGithub": "Metti stella su GitHub"
+ "textAnimation": {
+ "none": "Nessuna",
+ "title": "Animazione testo",
+ "pop": "Apparizione",
+ "slideLeft": "Scivola a sinistra",
+ "selectAnimation": "Seleziona animazione",
+ "fade": "Dissolvenza",
+ "rise": "Ascesa",
+ "typewriter": "Macchina da scrivere",
+ "pulse": "Pulsazione"
},
- "customFont": {
- "nameHelp": "Così apparirà il font nel selettore",
- "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
- "urlLabel": "URL importazione Google Fonts",
- "addingButton": "Aggiunta in corso...",
- "namePlaceholder": "Il mio font personalizzato",
- "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
- "dialogTitle": "Aggiungi font Google",
- "failedToAdd": "Impossibile aggiungere il font",
- "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
- "addButton": "Aggiungi font",
- "errorEmptyName": "Inserisci un nome per il font",
- "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
- "nameLabel": "Nome visualizzato",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
- "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
- "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova."
+ "gifSettings": {
+ "loop": "GIF in loop",
+ "frameRate": "Frequenza fotogrammi GIF",
+ "size": "Dimensione GIF"
},
- "background": {
- "presets": "Predefiniti",
- "image": "Immagine",
- "title": "Sfondo",
- "custom": "Personalizzato",
- "colorLabel": "Colore {{color}}",
- "imageLabel": "Sfondo {{index}}",
- "customWallpaper": "Sfondo personalizzato",
- "colorPalette": "Tavolozza dei colori",
- "uploadCustom": "Carica personalizzato",
- "color": "Colore",
- "imageReadFailed": "Impossibile leggere quel file immagine.",
- "gradientLabel": "Sfumatura {{index}}",
- "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
- "gradient": "Sfumatura",
- "colorWheel": "Ruota dei colori",
- "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco."
+ "exportFormat": {
+ "gifDescription": "Immagine animata per la condivisione",
+ "mp4Description": "File video di alta qualità",
+ "mp4Video": "Video MP4",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animazione GIF"
},
"audio": {
+ "title": "Audio",
"reset": "Reimposta audio",
- "outputGain": "Livello di uscita",
"help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
- "title": "Audio"
+ "outputGain": "Livello di uscita"
},
- "textAnimation": {
- "pulse": "Pulsazione",
- "selectAnimation": "Seleziona animazione",
- "fade": "Dissolvenza",
- "typewriter": "Macchina da scrivere",
- "slideLeft": "Scivola a sinistra",
- "none": "Nessuna",
- "rise": "Ascesa",
- "pop": "Apparizione",
- "title": "Animazione testo"
+ "language": {
+ "title": "Lingua"
+ },
+ "support": {
+ "saveDiagnostics": "Salva dati diagnostici",
+ "reportBug": "Segnala bug",
+ "starOnGithub": "Metti stella su GitHub"
},
"crop": {
- "title": "Ritaglia",
- "unlockAspectRatio": "Sblocca proporzioni",
+ "cropVideo": "Ritaglia video",
"free": "Libero",
- "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
- "done": "Fatto",
"lockAspectRatio": "Blocca proporzioni",
- "ratio": "Proporzioni",
- "cropVideo": "Ritaglia video"
+ "done": "Fatto",
+ "title": "Ritaglia",
+ "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
+ "unlockAspectRatio": "Sblocca proporzioni",
+ "ratio": "Proporzioni"
+ },
+ "speed": {
+ "maxSpeedError": "La velocità non può superare {{max}}×",
+ "playbackSpeed": "Velocità di riproduzione",
+ "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
+ "deleteRegion": "Elimina regione velocità",
+ "selectRegion": "Seleziona una regione velocità da regolare",
+ "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata."
+ },
+ "trim": {
+ "deleteRegion": "Elimina regione taglio"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Originale",
"title": "Risoluzione esportazione"
},
- "effects": {
- "off": "spento",
- "on": "acceso",
- "fitClip": "Adatta",
- "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
- "fitClipFew": "{{count}} clip",
- "blurBg": "Sfuma sfondo",
- "motion": "Movimento",
- "shadow": "Ombra",
- "fitClipOne": "{{count}} clip",
- "format": "Formato",
- "roundness": "Arrotondamento",
- "fitClipMany": "{{count}} clip",
- "formatOriginal": "Originale",
- "frame": "Cornice",
- "motionBlur": "Sfocatura movimento",
- "padding": "Spaziatura",
- "title": "Composizione"
- },
- "language": {
- "title": "Lingua"
- },
- "gifSettings": {
- "size": "Dimensione GIF",
- "loop": "GIF in loop",
- "frameRate": "Frequenza fotogrammi GIF"
+ "facets": {
+ "transcript": "Trascrizione",
+ "captions": "Sottotitoli"
},
"panes": {
"help": "Aiuto"
- },
- "export": {
- "chooseSaveLocation": "Scegli posizione di salvataggio",
- "gifButton": "Esporta GIF",
- "videoButton": "Esporta video"
- },
- "exportFormat": {
- "mp4Video": "Video MP4",
- "mp4Description": "File video di alta qualità",
- "gifAnimation": "Animazione GIF",
- "gifDescription": "Immagine animata per la condivisione",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Salva progetto",
- "new": "Nuovo progetto",
- "load": "Carica progetto"
- },
- "facets": {
- "captions": "Sottotitoli",
- "transcript": "Trascrizione"
- },
- "trim": {
- "deleteRegion": "Elimina regione taglio"
}
}
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index cea7e016e..c2dec7673 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
- "x": "X (%)",
- "title": "フォーカス位置"
- },
- "threeD": {
- "preset": {
- "right": "右",
- "left": "左",
- "iso": "Iso"
- },
- "none": "なし",
- "title": "3D回転"
- },
- "focusMode": {
- "manual": "手動",
- "title": "フォーカスモード",
- "autoDescription": "表示範囲が録画中のカーソル位置に追従します",
- "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
- "auto": "自動"
- },
- "selectRegion": "ズーム範囲を選択して調整",
- "customScale": "カスタムズーム",
- "previewHold": "押している間ズーム効果をプレビュー",
- "level": "ズーム倍率",
- "deleteZoom": "ズームを削除"
- },
- "audioTrack": {
- "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
- "defaultLabel": "オーディオトラック",
- "fadeOut": "フェードアウト",
- "add": "オーディオトラックを追加",
- "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
- "loop": "ループ",
- "remove": "トラックを削除",
- "fadeIn": "フェードイン",
- "mute": "ミュート",
- "importFailed": "オーディオを追加できませんでした"
- },
- "cursor": {
- "clipToBounds": "キャンバスにクリップ",
- "size": "サイズ",
- "motionBlur": "モーションブラー",
- "theme": "カーソルのスタイル",
- "clickBounce": "クリックバウンス",
- "title": "カーソル",
- "smoothing": "スムージング",
- "themeDefault": "デフォルト",
- "show": "カーソルを表示",
- "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
- "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。"
- },
"annotation": {
- "blurIntensity": "ぼかしの強さ",
+ "blurTypeBlur": "ガウス",
+ "textPlaceholder": "テキストを入力してください...",
+ "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
+ "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "blurColorBlack": "黒",
"textContent": "テキスト内容",
- "blurShapeFreehand": "自由形状",
- "active": "アクティブ",
- "blurShapeRectangle": "長方形",
"customFonts": "カスタムフォント",
+ "clearBackground": "背景をクリア",
"title": "注釈設定",
+ "typeBlur": "ぼかし",
+ "color": "色",
"blurShapeOval": "楕円",
- "blurType": "ぼかしの種類",
- "mosaicBlockSize": "モザイクブロックのサイズ",
- "colorPalette": "カラーパレット",
- "textColor": "文字色",
- "colorWheel": "カラーホイール",
- "shortcutsAndTips": "ショートカットとヒント",
"defaultText": "こんにちは",
- "clearBackground": "背景をクリア",
- "type": "種類",
- "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
- "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "blurColor": "ぼかしの色",
+ "blurShapeFreehand": "自由形状",
+ "typeImage": "画像",
+ "size": "サイズ",
+ "textColor": "文字色",
"background": "背景",
- "blurShape": "ぼかしの形状",
"arrowColor": "矢印の色",
- "invalidImageType": "無効なファイル形式",
- "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
- "blurColorBlack": "黒",
- "blurTypeBlur": "ガウス",
- "none": "なし",
+ "arrowDirection": "矢印の方向",
+ "blurTypeMosaic": "モザイク",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
- "size": "サイズ",
+ "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
+ "blurType": "ぼかしの種類",
+ "deleteAnnotation": "注釈を削除",
+ "fontStyle": "フォントスタイル",
+ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
+ "selectStyle": "スタイルを選択",
+ "typeText": "テキスト",
+ "type": "種類",
+ "strokeWidth": "線の太さ: {{width}}px",
+ "shortcutsAndTips": "ショートカットとヒント",
"imageUploadSuccess": "画像を読み込みました。",
+ "mosaicBlockSize": "モザイクブロックのサイズ",
+ "blurShape": "ぼかしの形状",
+ "none": "なし",
"blurColorWhite": "白",
- "arrowDirection": "矢印の方向",
- "typeImage": "画像",
- "typeText": "テキスト",
+ "blurShapeRectangle": "長方形",
+ "colorWheel": "カラーホイール",
"typeArrow": "矢印",
- "color": "色",
- "blurColor": "ぼかしの色",
- "selectStyle": "スタイルを選択",
- "blurTypeMosaic": "モザイク",
- "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
- "textPlaceholder": "テキストを入力してください...",
+ "colorPalette": "カラーパレット",
"uploadImage": "画像を読み込む",
- "strokeWidth": "線の太さ: {{width}}px",
- "typeBlur": "ぼかし",
- "deleteAnnotation": "注釈を削除",
- "fontStyle": "フォントスタイル"
+ "invalidImageType": "無効なファイル形式",
+ "blurIntensity": "ぼかしの強さ",
+ "active": "アクティブ"
+ },
+ "background": {
+ "colorPalette": "カラーパレット",
+ "custom": "カスタム",
+ "colorLabel": "色 {{color}}",
+ "imageLabel": "背景 {{index}}",
+ "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
+ "colorWheel": "カラーホイール",
+ "presets": "プリセット",
+ "gradient": "グラデーション",
+ "uploadCustom": "カスタム画像を読み込む",
+ "customWallpaper": "カスタム壁紙",
+ "gradientLabel": "グラデーション {{index}}",
+ "title": "背景",
+ "color": "色",
+ "image": "画像",
+ "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
+ "imageReadFailed": "この画像ファイルを読み込めませんでした。"
+ },
+ "customFont": {
+ "nameHelp": "フォントセレクターに表示される名前です",
+ "errorEmptyName": "フォント名を入力してください",
+ "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Googleフォントを追加",
+ "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
+ "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
+ "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
+ "failedToAdd": "フォントの追加に失敗しました",
+ "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
+ "namePlaceholder": "マイカスタムフォント",
+ "nameLabel": "表示名",
+ "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
+ "addButton": "フォントを追加",
+ "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
+ "urlLabel": "GoogleフォントのインポートURL",
+ "addingButton": "追加中..."
},
"layout": {
- "webcamCropY": "垂直方向に移動",
- "reactiveWebcam": "ズーム時に縮小",
- "shapes": {
- "rectangle": "長方形",
- "square": "正方形",
- "rounded": "角丸",
- "circle": "円"
- },
- "preset": "プリセット",
- "dualFrame": "デュアルフレーム",
"bgModes": {
- "none": "オリジナル",
"transparent": "切り抜き",
"custom": "カスタム",
- "blur": "ぼかし"
+ "blur": "ぼかし",
+ "none": "オリジナル"
},
- "webcamCropZoom": "クロップのズーム",
- "webcamShape": "カメラの形状",
- "webcamBlurIntensity": "ぼかしの強さ",
+ "reactiveWebcam": "ズーム時に縮小",
+ "webcamCropX": "水平方向に移動",
+ "webcamBackground": "カメラ背景",
+ "mirrorWebcam": "Webカメラを反転",
+ "webcamFraming": "ウェブカメラの構図",
"title": "カメラレイアウト",
"reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
- "webcamFraming": "ウェブカメラの構図",
- "webcamCropX": "水平方向に移動",
"selectPreset": "プリセットを選択",
+ "noWebcam": "Webカメラなし",
+ "webcamShape": "カメラの形状",
"helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
- "mirrorWebcam": "Webカメラを反転",
"help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
- "webcamBackground": "カメラ背景",
- "noWebcam": "Webカメラなし",
- "pictureInPicture": "ピクチャーインピクチャ",
+ "shapes": {
+ "circle": "円",
+ "square": "正方形",
+ "rectangle": "長方形",
+ "rounded": "角丸"
+ },
"webcamSize": "カメラのサイズ",
- "verticalStack": "縦並び"
+ "verticalStack": "縦並び",
+ "webcamBlurIntensity": "ぼかしの強さ",
+ "dualFrame": "デュアルフレーム",
+ "webcamCropY": "垂直方向に移動",
+ "webcamCropZoom": "クロップのズーム",
+ "pictureInPicture": "ピクチャーインピクチャ",
+ "preset": "プリセット"
},
- "speed": {
- "customPlaybackSpeed": "カスタム再生速度",
- "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。",
- "maxSpeedError": "速度は{{max}}×を超えることはできません",
- "playbackSpeed": "再生速度",
- "deleteRegion": "再生速度の範囲を削除",
- "selectRegion": "再生速度の範囲を選択して調整"
+ "export": {
+ "gifButton": "GIF をエクスポート",
+ "chooseSaveLocation": "保存場所を選択",
+ "videoButton": "動画をエクスポート"
},
"imageUpload": {
- "failedToUpload": "画像の読み込みに失敗しました",
+ "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
"uploadSuccess": "カスタム画像を読み込みました。",
"errorReading": "ファイルの読み取り中にエラーが発生しました。",
- "invalidFileType": "無効なファイル形式",
- "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。"
+ "failedToUpload": "画像の読み込みに失敗しました",
+ "invalidFileType": "無効なファイル形式"
+ },
+ "effects": {
+ "shadow": "影",
+ "motion": "モーション",
+ "off": "オフ",
+ "on": "オン",
+ "format": "フォーマット",
+ "fitClip": "合わせる",
+ "fitClipMany": "{{count}} クリップ",
+ "fitClipFew": "{{count}} クリップ",
+ "fitClipOne": "{{count}} クリップ",
+ "frame": "フレーム",
+ "padding": "余白",
+ "title": "コンポジション",
+ "blurBg": "背景をぼかす",
+ "formatOriginal": "元のサイズ",
+ "roundness": "丸み",
+ "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
+ "motionBlur": "モーションブラー"
+ },
+ "audioTrack": {
+ "fadeOut": "フェードアウト",
+ "importFailed": "オーディオを追加できませんでした",
+ "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
+ "loop": "ループ",
+ "remove": "トラックを削除",
+ "mute": "ミュート",
+ "defaultLabel": "オーディオトラック",
+ "add": "オーディオトラックを追加",
+ "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "fadeIn": "フェードイン"
},
"transcript": {
- "blankedWord": "空欄",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "noAudio": "このメディアには音声トラックがありません",
+ "insertAria": "新しい単語",
+ "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
+ "laneRecording": "録画",
"editWord": "「{{word}}」を編集",
+ "laneLabel": "文字起こしの読み込み元",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
"editorAria": "{{filename}} の文字起こし",
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "silence": "[無音 {{duration}} 秒]",
+ "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
"noTranscript": "文字起こしがまだありません",
+ "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
+ "blankedWord": "空欄",
+ "removeInserted": "「{{word}}」を削除",
"clipLabel": "クリップ {{index}}",
- "trimSilence": "無音をトリム({{duration}} 秒)",
- "laneVoiceover": "ナレーション",
- "restoreSilence": "無音を元に戻す({{duration}} 秒)",
- "transcribeNow": "今すぐ文字起こし",
+ "transcribing": "文字起こし中…",
"whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
- "removeInserted": "「{{word}}」を削除",
- "insertedWord": "あなたが追加した単語 — 音声はありません",
- "restoreWord": "「{{word}}」を元に戻す",
- "silence": "[無音 {{duration}} 秒]",
- "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
- "laneRecording": "録画",
- "noClips": "クリップがまだありません",
- "noAudio": "このメディアには音声トラックがありません",
- "laneLabel": "文字起こしの読み込み元",
+ "restoreSilence": "無音を元に戻す({{duration}} 秒)",
"revertWord": "「{{original}}」に戻す",
+ "helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
+ "laneVoiceover": "ナレーション",
+ "noClips": "クリップがまだありません",
+ "trimSilence": "無音をトリム({{duration}} 秒)",
+ "restoreWord": "「{{word}}」を元に戻す",
"title": "現在の文字起こし",
- "transcribing": "文字起こし中…",
- "insertAria": "新しい単語",
- "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。"
+ "transcribeNow": "今すぐ文字起こし"
+ },
+ "cursor": {
+ "title": "カーソル",
+ "show": "カーソルを表示",
+ "clipToBounds": "キャンバスにクリップ",
+ "motionBlur": "モーションブラー",
+ "smoothing": "スムージング",
+ "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
+ "themeDefault": "デフォルト",
+ "clickBounce": "クリックバウンス",
+ "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
+ "theme": "カーソルのスタイル",
+ "size": "サイズ"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
+ "title": "フォーカス位置",
+ "y": "Y (%)"
+ },
+ "previewHold": "押している間ズーム効果をプレビュー",
+ "focusMode": {
+ "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
+ "manual": "手動",
+ "auto": "自動",
+ "title": "フォーカスモード",
+ "autoDescription": "表示範囲が録画中のカーソル位置に追従します"
+ },
+ "threeD": {
+ "preset": {
+ "right": "右",
+ "iso": "Iso",
+ "left": "左"
+ },
+ "none": "なし",
+ "title": "3D回転"
+ },
+ "selectRegion": "ズーム範囲を選択して調整",
+ "deleteZoom": "ズームを削除",
+ "customScale": "カスタムズーム",
+ "level": "ズーム倍率"
+ },
+ "project": {
+ "save": "プロジェクトを保存",
+ "load": "プロジェクトを読み込む",
+ "new": "新規プロジェクト"
},
"captions": {
- "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
- "distanceFromTop": "上端からの距離",
- "minWords": "1 行の最小単語数",
- "showBackground": "背景を表示",
- "lineLength": "行の長さ",
- "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
- "translating": "翻訳中…",
- "distanceFromLeft": "左端からの距離",
- "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
- "position": "位置",
- "translateFailed": "翻訳に失敗しました。",
- "backgroundOpacity": "不透明度",
- "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
- "original": "オリジナル(文字起こし)",
- "font": "フォント",
+ "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
"distanceFromRight": "右端からの距離",
+ "distanceFromBottom": "下端からの距離",
+ "font": "フォント",
+ "distanceFromTop": "上端からの距離",
+ "alignLeft": "左",
+ "removeLegacyAnnotations": "古い字幕の注釈を削除",
"textColor": "文字色",
- "alignCenter": "中央",
- "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "text": "テキスト",
+ "anchorTop": "上",
"language": "言語",
+ "translate": "翻訳",
"show": "字幕を表示",
- "anchorTop": "上",
- "backgroundColor": "背景色",
- "removeLegacyAnnotations": "古い字幕の注釈を削除",
- "background": "背景",
- "bold": "太字",
- "distanceFromBottom": "下端からの距離",
"fontSize": "サイズ",
+ "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "backgroundOpacity": "不透明度",
+ "translating": "翻訳中…",
"anchorBottom": "下",
- "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
- "maxWords": "1 行の最大単語数",
- "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
- "translate": "翻訳",
"deleteTranslation": "この翻訳を削除",
+ "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
+ "backgroundColor": "背景色",
+ "translateFailed": "翻訳に失敗しました。",
"anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
- "text": "テキスト",
- "displayLanguage": "表示",
+ "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
+ "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
"alignRight": "右",
- "alignLeft": "左"
+ "displayLanguage": "表示",
+ "distanceFromLeft": "左端からの距離",
+ "minWords": "1 行の最小単語数",
+ "showBackground": "背景を表示",
+ "maxWords": "1 行の最大単語数",
+ "bold": "太字",
+ "background": "背景",
+ "alignCenter": "中央",
+ "original": "オリジナル(文字起こし)",
+ "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
+ "lineLength": "行の長さ",
+ "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
+ "position": "位置"
},
- "support": {
- "saveDiagnostics": "診断情報を保存",
- "reportBug": "バグを報告",
- "starOnGithub": "GitHub でスターを付ける"
+ "textAnimation": {
+ "none": "なし",
+ "title": "テキストアニメーション",
+ "pop": "ポップ",
+ "slideLeft": "左へスライド",
+ "selectAnimation": "アニメーションを選択",
+ "fade": "フェード",
+ "rise": "上昇",
+ "typewriter": "タイプライター",
+ "pulse": "パルス"
},
- "customFont": {
- "nameHelp": "フォントセレクターに表示される名前です",
- "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
- "urlLabel": "GoogleフォントのインポートURL",
- "addingButton": "追加中...",
- "namePlaceholder": "マイカスタムフォント",
- "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
- "dialogTitle": "Googleフォントを追加",
- "failedToAdd": "フォントの追加に失敗しました",
- "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
- "addButton": "フォントを追加",
- "errorEmptyName": "フォント名を入力してください",
- "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
- "nameLabel": "表示名",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
- "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
- "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。"
+ "gifSettings": {
+ "loop": "GIF をループする",
+ "frameRate": "GIF フレームレート",
+ "size": "GIF サイズ"
},
- "background": {
- "presets": "プリセット",
- "image": "画像",
- "title": "背景",
- "custom": "カスタム",
- "colorLabel": "色 {{color}}",
- "imageLabel": "背景 {{index}}",
- "customWallpaper": "カスタム壁紙",
- "colorPalette": "カラーパレット",
- "uploadCustom": "カスタム画像を読み込む",
- "color": "色",
- "imageReadFailed": "この画像ファイルを読み込めませんでした。",
- "gradientLabel": "グラデーション {{index}}",
- "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
- "gradient": "グラデーション",
- "colorWheel": "カラーホイール",
- "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。"
+ "exportFormat": {
+ "gifDescription": "共有用のアニメーション画像",
+ "mp4Description": "高品質の動画ファイル",
+ "mp4Video": "MP4 動画",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF アニメーション"
},
"audio": {
+ "title": "オーディオ",
"reset": "オーディオをリセット",
- "outputGain": "出力レベル",
"help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
- "title": "オーディオ"
+ "outputGain": "出力レベル"
},
- "textAnimation": {
- "pulse": "パルス",
- "selectAnimation": "アニメーションを選択",
- "fade": "フェード",
- "typewriter": "タイプライター",
- "slideLeft": "左へスライド",
- "none": "なし",
- "rise": "上昇",
- "pop": "ポップ",
- "title": "テキストアニメーション"
+ "language": {
+ "title": "言語"
+ },
+ "support": {
+ "saveDiagnostics": "診断情報を保存",
+ "reportBug": "バグを報告",
+ "starOnGithub": "GitHub でスターを付ける"
},
"crop": {
- "title": "クロップ",
- "unlockAspectRatio": "アスペクト比の固定を解除",
+ "cropVideo": "動画をクロップ",
"free": "自由",
- "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
- "done": "完了",
"lockAspectRatio": "アスペクト比を固定",
- "ratio": "比率",
- "cropVideo": "動画をクロップ"
+ "done": "完了",
+ "title": "クロップ",
+ "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
+ "unlockAspectRatio": "アスペクト比の固定を解除",
+ "ratio": "比率"
+ },
+ "speed": {
+ "maxSpeedError": "速度は{{max}}×を超えることはできません",
+ "playbackSpeed": "再生速度",
+ "customPlaybackSpeed": "カスタム再生速度",
+ "deleteRegion": "再生速度の範囲を削除",
+ "selectRegion": "再生速度の範囲を選択して調整",
+ "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。"
+ },
+ "trim": {
+ "deleteRegion": "トリム範囲を削除"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "書き出し解像度"
},
- "effects": {
- "off": "オフ",
- "on": "オン",
- "fitClip": "合わせる",
- "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
- "fitClipFew": "{{count}} クリップ",
- "blurBg": "背景をぼかす",
- "motion": "モーション",
- "shadow": "影",
- "fitClipOne": "{{count}} クリップ",
- "format": "フォーマット",
- "roundness": "丸み",
- "fitClipMany": "{{count}} クリップ",
- "formatOriginal": "元のサイズ",
- "frame": "フレーム",
- "motionBlur": "モーションブラー",
- "padding": "余白",
- "title": "コンポジション"
- },
- "language": {
- "title": "言語"
- },
- "gifSettings": {
- "size": "GIF サイズ",
- "loop": "GIF をループする",
- "frameRate": "GIF フレームレート"
+ "facets": {
+ "transcript": "文字起こし",
+ "captions": "字幕"
},
"panes": {
"help": "ヘルプ"
- },
- "export": {
- "chooseSaveLocation": "保存場所を選択",
- "gifButton": "GIF をエクスポート",
- "videoButton": "動画をエクスポート"
- },
- "exportFormat": {
- "mp4Video": "MP4 動画",
- "mp4Description": "高品質の動画ファイル",
- "gifAnimation": "GIF アニメーション",
- "gifDescription": "共有用のアニメーション画像",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "プロジェクトを保存",
- "new": "新規プロジェクト",
- "load": "プロジェクトを読み込む"
- },
- "facets": {
- "captions": "字幕",
- "transcript": "文字起こし"
- },
- "trim": {
- "deleteRegion": "トリム範囲を削除"
}
}
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 6bc890083..dd2cd62b1 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
- "x": "X (%)",
- "title": "포커스 위치"
- },
- "threeD": {
- "preset": {
- "right": "오른쪽",
- "left": "왼쪽",
- "iso": "Iso"
- },
- "none": "없음",
- "title": "3D 회전"
- },
- "focusMode": {
- "manual": "수동",
- "title": "포커스 모드",
- "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
- "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
- "auto": "자동"
- },
- "selectRegion": "조정할 줌 구간을 선택하세요",
- "customScale": "커스텀 줌",
- "previewHold": "누르고 있으면 줌 효과 미리보기",
- "level": "줌 레벨",
- "deleteZoom": "줌 삭제"
- },
- "audioTrack": {
- "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
- "defaultLabel": "오디오 트랙",
- "fadeOut": "페이드 아웃",
- "add": "오디오 트랙 추가",
- "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
- "loop": "반복",
- "remove": "트랙 삭제",
- "fadeIn": "페이드 인",
- "mute": "음소거",
- "importFailed": "오디오를 추가할 수 없습니다"
- },
- "cursor": {
- "clipToBounds": "캔버스에 맞춰 자르기",
- "size": "크기",
- "motionBlur": "모션 블러",
- "theme": "커서 스타일",
- "clickBounce": "클릭 바운스",
- "title": "커서",
- "smoothing": "부드러움",
- "themeDefault": "기본",
- "show": "커서 표시",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
- "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다."
- },
"annotation": {
- "blurIntensity": "블러 강도",
+ "blurTypeBlur": "가우시안",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "blurColorBlack": "검정",
"textContent": "텍스트 내용",
- "blurShapeFreehand": "자유 곡선",
- "active": "활성",
- "blurShapeRectangle": "사각형",
"customFonts": "커스텀 폰트",
+ "clearBackground": "배경 지우기",
"title": "주석 설정",
+ "typeBlur": "블러",
+ "color": "색상",
"blurShapeOval": "타원",
- "blurType": "블러 종류",
- "mosaicBlockSize": "모자이크 블록 크기",
- "colorPalette": "색상 팔레트",
- "textColor": "텍스트 색상",
- "colorWheel": "색상 휠",
- "shortcutsAndTips": "단축키 및 팁",
"defaultText": "안녕하세요",
- "clearBackground": "배경 지우기",
- "type": "유형",
- "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
- "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "blurColor": "블러 색상",
+ "blurShapeFreehand": "자유 곡선",
+ "typeImage": "이미지",
+ "size": "크기",
+ "textColor": "텍스트 색상",
"background": "배경",
- "blurShape": "블러 모양",
"arrowColor": "화살표 색상",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "blurColorBlack": "검정",
- "blurTypeBlur": "가우시안",
- "none": "없음",
+ "arrowDirection": "화살표 방향",
+ "blurTypeMosaic": "모자이크",
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
- "size": "크기",
+ "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
+ "blurType": "블러 종류",
+ "deleteAnnotation": "주석 삭제",
+ "fontStyle": "폰트 스타일",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "selectStyle": "스타일 선택",
+ "typeText": "텍스트",
+ "type": "유형",
+ "strokeWidth": "선 두께: {{width}}px",
+ "shortcutsAndTips": "단축키 및 팁",
"imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "mosaicBlockSize": "모자이크 블록 크기",
+ "blurShape": "블러 모양",
+ "none": "없음",
"blurColorWhite": "흰색",
- "arrowDirection": "화살표 방향",
- "typeImage": "이미지",
- "typeText": "텍스트",
+ "blurShapeRectangle": "사각형",
+ "colorWheel": "색상 휠",
"typeArrow": "화살표",
- "color": "색상",
- "blurColor": "블러 색상",
- "selectStyle": "스타일 선택",
- "blurTypeMosaic": "모자이크",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
- "textPlaceholder": "텍스트를 입력하세요...",
+ "colorPalette": "색상 팔레트",
"uploadImage": "이미지 업로드",
- "strokeWidth": "선 두께: {{width}}px",
- "typeBlur": "블러",
- "deleteAnnotation": "주석 삭제",
- "fontStyle": "폰트 스타일"
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "blurIntensity": "블러 강도",
+ "active": "활성"
+ },
+ "background": {
+ "colorPalette": "색상 팔레트",
+ "custom": "사용자 지정",
+ "colorLabel": "색상 {{color}}",
+ "imageLabel": "배경 {{index}}",
+ "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
+ "colorWheel": "색상 휠",
+ "presets": "프리셋",
+ "gradient": "그라디언트",
+ "uploadCustom": "직접 업로드",
+ "customWallpaper": "사용자 배경",
+ "gradientLabel": "그라디언트 {{index}}",
+ "title": "배경",
+ "color": "색상",
+ "image": "이미지",
+ "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
+ "imageReadFailed": "이미지 파일을 읽을 수 없습니다."
+ },
+ "customFont": {
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Google 폰트 추가",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
+ "failedToAdd": "폰트 추가에 실패했습니다",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "namePlaceholder": "내 커스텀 폰트",
+ "nameLabel": "표시 이름",
+ "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
+ "addButton": "폰트 추가",
+ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "addingButton": "추가 중..."
},
"layout": {
- "webcamCropY": "세로 이동",
- "reactiveWebcam": "확대 시 축소",
- "shapes": {
- "rectangle": "직사각형",
- "square": "정사각형",
- "rounded": "둥근 모서리",
- "circle": "원형"
- },
- "preset": "프리셋",
- "dualFrame": "듀얼 프레임",
"bgModes": {
- "none": "원본",
"transparent": "누끼",
"custom": "사용자 지정",
- "blur": "블러"
+ "blur": "블러",
+ "none": "원본"
},
- "webcamCropZoom": "자르기 확대",
- "webcamShape": "카메라 모양",
- "webcamBlurIntensity": "블러 강도",
+ "reactiveWebcam": "확대 시 축소",
+ "webcamCropX": "가로 이동",
+ "webcamBackground": "카메라 배경",
+ "mirrorWebcam": "웹캠 미러링",
+ "webcamFraming": "웹캠 구도",
"title": "카메라 레이아웃",
"reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
- "webcamFraming": "웹캠 구도",
- "webcamCropX": "가로 이동",
"selectPreset": "프리셋 선택",
+ "noWebcam": "웹캠 없음",
+ "webcamShape": "카메라 모양",
"helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
- "mirrorWebcam": "웹캠 미러링",
"help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
- "webcamBackground": "카메라 배경",
- "noWebcam": "웹캠 없음",
- "pictureInPicture": "화면 속 화면",
+ "shapes": {
+ "circle": "원형",
+ "square": "정사각형",
+ "rectangle": "직사각형",
+ "rounded": "둥근 모서리"
+ },
"webcamSize": "웹캠 크기",
- "verticalStack": "세로 배치"
+ "verticalStack": "세로 배치",
+ "webcamBlurIntensity": "블러 강도",
+ "dualFrame": "듀얼 프레임",
+ "webcamCropY": "세로 이동",
+ "webcamCropZoom": "자르기 확대",
+ "pictureInPicture": "화면 속 화면",
+ "preset": "프리셋"
},
- "speed": {
- "customPlaybackSpeed": "재생 속도 직접 입력",
- "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
- "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
- "playbackSpeed": "재생 속도",
- "deleteRegion": "속도 구간 삭제",
- "selectRegion": "조정할 속도 구간을 선택하세요"
+ "export": {
+ "gifButton": "GIF 내보내기",
+ "chooseSaveLocation": "저장 위치 선택",
+ "videoButton": "비디오 내보내기"
},
"imageUpload": {
- "failedToUpload": "이미지 업로드에 실패했습니다",
+ "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
"uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
"errorReading": "파일을 읽는 중 오류가 발생했습니다.",
- "invalidFileType": "지원하지 않는 파일 형식입니다",
- "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요."
+ "failedToUpload": "이미지 업로드에 실패했습니다",
+ "invalidFileType": "지원하지 않는 파일 형식입니다"
+ },
+ "effects": {
+ "shadow": "그림자",
+ "motion": "모션",
+ "off": "끄기",
+ "on": "켜기",
+ "format": "형식",
+ "fitClip": "맞추기",
+ "fitClipMany": "{{count}}개 클립",
+ "fitClipFew": "{{count}}개 클립",
+ "fitClipOne": "{{count}}개 클립",
+ "frame": "프레임",
+ "padding": "여백",
+ "title": "컴포지션",
+ "blurBg": "배경 흐림",
+ "formatOriginal": "원본",
+ "roundness": "모서리 둥글기",
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "motionBlur": "모션 블러"
+ },
+ "audioTrack": {
+ "fadeOut": "페이드 아웃",
+ "importFailed": "오디오를 추가할 수 없습니다",
+ "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
+ "loop": "반복",
+ "remove": "트랙 삭제",
+ "mute": "음소거",
+ "defaultLabel": "오디오 트랙",
+ "add": "오디오 트랙 추가",
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "fadeIn": "페이드 인"
},
"transcript": {
- "blankedWord": "비움",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 두 단어 사이에 입력하면 호박색 단어가 추가됩니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "insertAria": "새 단어",
+ "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
+ "laneRecording": "녹화",
"editWord": "\"{{word}}\" 편집",
+ "laneLabel": "전사본을 읽어올 소스",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
"editorAria": "{{filename}}의 전사",
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "silence": "[무음 {{duration}}초]",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
"noTranscript": "아직 전사가 없습니다",
+ "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
+ "blankedWord": "비움",
+ "removeInserted": "\"{{word}}\" 삭제",
"clipLabel": "클립 {{index}}",
- "trimSilence": "무음 자르기 ({{duration}}초)",
- "laneVoiceover": "내레이션",
- "restoreSilence": "무음 복원 ({{duration}}초)",
- "transcribeNow": "지금 전사하기",
+ "transcribing": "전사 중…",
"whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
- "removeInserted": "\"{{word}}\" 삭제",
- "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
- "restoreWord": "\"{{word}}\" 복원",
- "silence": "[무음 {{duration}}초]",
- "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
- "laneRecording": "녹화",
- "noClips": "아직 클립이 없습니다",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
- "laneLabel": "전사본을 읽어올 소스",
+ "restoreSilence": "무음 복원 ({{duration}}초)",
"revertWord": "\"{{original}}\"(으)로 되돌리기",
+ "helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
+ "laneVoiceover": "내레이션",
+ "noClips": "아직 클립이 없습니다",
+ "trimSilence": "무음 자르기 ({{duration}}초)",
+ "restoreWord": "\"{{word}}\" 복원",
"title": "현재 전사",
- "transcribing": "전사 중…",
- "insertAria": "새 단어",
- "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요."
+ "transcribeNow": "지금 전사하기"
+ },
+ "cursor": {
+ "title": "커서",
+ "show": "커서 표시",
+ "clipToBounds": "캔버스에 맞춰 자르기",
+ "motionBlur": "모션 블러",
+ "smoothing": "부드러움",
+ "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
+ "themeDefault": "기본",
+ "clickBounce": "클릭 바운스",
+ "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
+ "theme": "커서 스타일",
+ "size": "크기"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
+ "title": "포커스 위치",
+ "y": "Y (%)"
+ },
+ "previewHold": "누르고 있으면 줌 효과 미리보기",
+ "focusMode": {
+ "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
+ "manual": "수동",
+ "auto": "자동",
+ "title": "포커스 모드",
+ "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
+ },
+ "threeD": {
+ "preset": {
+ "right": "오른쪽",
+ "iso": "Iso",
+ "left": "왼쪽"
+ },
+ "none": "없음",
+ "title": "3D 회전"
+ },
+ "selectRegion": "조정할 줌 구간을 선택하세요",
+ "deleteZoom": "줌 삭제",
+ "customScale": "커스텀 줌",
+ "level": "줌 레벨"
+ },
+ "project": {
+ "save": "프로젝트 저장",
+ "load": "프로젝트 불러오기",
+ "new": "새 프로젝트"
},
"captions": {
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "distanceFromTop": "위에서의 거리",
- "minWords": "줄당 최소 단어 수",
- "showBackground": "배경 표시",
- "lineLength": "줄 길이",
- "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "translating": "번역 중…",
- "distanceFromLeft": "왼쪽에서의 거리",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
- "position": "위치",
- "translateFailed": "번역에 실패했습니다.",
- "backgroundOpacity": "불투명도",
- "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
- "original": "원본 (전사)",
- "font": "글꼴",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
"distanceFromRight": "오른쪽에서의 거리",
+ "distanceFromBottom": "아래에서의 거리",
+ "font": "글꼴",
+ "distanceFromTop": "위에서의 거리",
+ "alignLeft": "왼쪽",
+ "removeLegacyAnnotations": "이전 자막 주석 제거",
"textColor": "글자 색",
- "alignCenter": "가운데",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "text": "텍스트",
+ "anchorTop": "위",
"language": "언어",
+ "translate": "번역",
"show": "자막 표시",
- "anchorTop": "위",
- "backgroundColor": "배경 색",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "background": "배경",
- "bold": "굵게",
- "distanceFromBottom": "아래에서의 거리",
"fontSize": "크기",
+ "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "backgroundOpacity": "불투명도",
+ "translating": "번역 중…",
"anchorBottom": "아래",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
- "maxWords": "줄당 최대 단어 수",
- "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
- "translate": "번역",
"deleteTranslation": "이 번역 삭제",
+ "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
+ "backgroundColor": "배경 색",
+ "translateFailed": "번역에 실패했습니다.",
"anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
- "text": "텍스트",
- "displayLanguage": "표시",
+ "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
+ "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
"alignRight": "오른쪽",
- "alignLeft": "왼쪽"
+ "displayLanguage": "표시",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "minWords": "줄당 최소 단어 수",
+ "showBackground": "배경 표시",
+ "maxWords": "줄당 최대 단어 수",
+ "bold": "굵게",
+ "background": "배경",
+ "alignCenter": "가운데",
+ "original": "원본 (전사)",
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "lineLength": "줄 길이",
+ "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "position": "위치"
},
- "support": {
- "saveDiagnostics": "Save Diagnostics",
- "reportBug": "버그 신고",
- "starOnGithub": "GitHub에 Star 남기기"
+ "textAnimation": {
+ "none": "없음",
+ "title": "텍스트 애니메이션",
+ "pop": "팝",
+ "slideLeft": "왼쪽 슬라이드",
+ "selectAnimation": "애니메이션 선택",
+ "fade": "페이드",
+ "rise": "상승",
+ "typewriter": "타자기",
+ "pulse": "펄스"
},
- "customFont": {
- "nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
- "urlLabel": "Google Fonts 가져오기 URL",
- "addingButton": "추가 중...",
- "namePlaceholder": "내 커스텀 폰트",
- "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
- "dialogTitle": "Google 폰트 추가",
- "failedToAdd": "폰트 추가에 실패했습니다",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
- "addButton": "폰트 추가",
- "errorEmptyName": "폰트 이름을 입력해 주세요",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
- "nameLabel": "표시 이름",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
- "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요."
+ "gifSettings": {
+ "loop": "GIF 반복",
+ "frameRate": "GIF 프레임 속도",
+ "size": "GIF 크기"
},
- "background": {
- "presets": "프리셋",
- "image": "이미지",
- "title": "배경",
- "custom": "사용자 지정",
- "colorLabel": "색상 {{color}}",
- "imageLabel": "배경 {{index}}",
- "customWallpaper": "사용자 배경",
- "colorPalette": "색상 팔레트",
- "uploadCustom": "직접 업로드",
- "color": "색상",
- "imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "gradientLabel": "그라디언트 {{index}}",
- "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
- "gradient": "그라디언트",
- "colorWheel": "색상 휠",
- "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다."
+ "exportFormat": {
+ "gifDescription": "공유용 애니메이션 이미지",
+ "mp4Description": "고화질 비디오 파일",
+ "mp4Video": "MP4 비디오",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF 애니메이션"
},
"audio": {
+ "title": "오디오",
"reset": "오디오 재설정",
- "outputGain": "출력 레벨",
"help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
- "title": "오디오"
+ "outputGain": "출력 레벨"
},
- "textAnimation": {
- "pulse": "펄스",
- "selectAnimation": "애니메이션 선택",
- "fade": "페이드",
- "typewriter": "타자기",
- "slideLeft": "왼쪽 슬라이드",
- "none": "없음",
- "rise": "상승",
- "pop": "팝",
- "title": "텍스트 애니메이션"
+ "language": {
+ "title": "언어"
+ },
+ "support": {
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "버그 신고",
+ "starOnGithub": "GitHub에 Star 남기기"
},
"crop": {
- "title": "자르기",
- "unlockAspectRatio": "화면 비율 해제",
+ "cropVideo": "비디오 자르기",
"free": "자유",
- "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
- "done": "완료",
"lockAspectRatio": "화면 비율 고정",
- "ratio": "비율",
- "cropVideo": "비디오 자르기"
+ "done": "완료",
+ "title": "자르기",
+ "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
+ "unlockAspectRatio": "화면 비율 해제",
+ "ratio": "비율"
+ },
+ "speed": {
+ "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
+ "playbackSpeed": "재생 속도",
+ "customPlaybackSpeed": "재생 속도 직접 입력",
+ "deleteRegion": "속도 구간 삭제",
+ "selectRegion": "조정할 속도 구간을 선택하세요",
+ "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다."
+ },
+ "trim": {
+ "deleteRegion": "트림 구간 삭제"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "내보내기 해상도"
},
- "effects": {
- "off": "끄기",
- "on": "켜기",
- "fitClip": "맞추기",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
- "fitClipFew": "{{count}}개 클립",
- "blurBg": "배경 흐림",
- "motion": "모션",
- "shadow": "그림자",
- "fitClipOne": "{{count}}개 클립",
- "format": "형식",
- "roundness": "모서리 둥글기",
- "fitClipMany": "{{count}}개 클립",
- "formatOriginal": "원본",
- "frame": "프레임",
- "motionBlur": "모션 블러",
- "padding": "여백",
- "title": "컴포지션"
- },
- "language": {
- "title": "언어"
- },
- "gifSettings": {
- "size": "GIF 크기",
- "loop": "GIF 반복",
- "frameRate": "GIF 프레임 속도"
+ "facets": {
+ "transcript": "대본",
+ "captions": "자막"
},
"panes": {
"help": "도움말"
- },
- "export": {
- "chooseSaveLocation": "저장 위치 선택",
- "gifButton": "GIF 내보내기",
- "videoButton": "비디오 내보내기"
- },
- "exportFormat": {
- "mp4Video": "MP4 비디오",
- "mp4Description": "고화질 비디오 파일",
- "gifAnimation": "GIF 애니메이션",
- "gifDescription": "공유용 애니메이션 이미지",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "프로젝트 저장",
- "new": "새 프로젝트",
- "load": "프로젝트 불러오기"
- },
- "facets": {
- "captions": "자막",
- "transcript": "대본"
- },
- "trim": {
- "deleteRegion": "트림 구간 삭제"
}
}
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index a71c7bcd5..4cd8a4ebb 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
- "x": "X (%)",
- "title": "Posição do Foco"
- },
- "threeD": {
- "preset": {
- "right": "Direita",
- "left": "Esquerda",
- "iso": "Iso"
- },
- "none": "Nenhuma",
- "title": "Rotação 3D"
- },
- "focusMode": {
- "manual": "Manual",
- "title": "Modo de Foco",
- "autoDescription": "A câmera segue a posição do cursor gravado",
- "lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
- "auto": "Automático"
- },
- "selectRegion": "Selecione uma região de zoom para ajustar",
- "customScale": "Zoom Personalizado",
- "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
- "level": "Nível de Zoom",
- "deleteZoom": "Excluir Zoom"
- },
- "audioTrack": {
- "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
- "defaultLabel": "Faixa de áudio",
- "fadeOut": "Fade out",
- "add": "Adicionar faixa de áudio",
- "slipHint": "Alt + arrastar para deslizar o áudio dentro",
- "loop": "Repetir",
- "remove": "Excluir faixa",
- "fadeIn": "Fade in",
- "mute": "Silenciar",
- "importFailed": "Não foi possível adicionar o áudio"
- },
- "cursor": {
- "clipToBounds": "Recortar à tela",
- "size": "Tamanho",
- "motionBlur": "Desfoque de movimento",
- "theme": "Estilo do cursor",
- "clickBounce": "Rebote ao clicar",
- "title": "Cursor",
- "smoothing": "Suavização",
- "themeDefault": "Padrão",
- "show": "Mostrar cursor",
- "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
- "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar."
- },
"annotation": {
- "blurIntensity": "Intensidade do Desfoque",
+ "blurTypeBlur": "Gaussiano",
+ "textPlaceholder": "Digite seu texto...",
+ "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
+ "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
+ "blurColorBlack": "Preto",
"textContent": "Conteúdo do Texto",
- "blurShapeFreehand": "Mão Livre",
- "active": "Ativo",
- "blurShapeRectangle": "Retângulo",
"customFonts": "Fontes Personalizadas",
+ "clearBackground": "Limpar Fundo",
"title": "Configurações de Anotação",
+ "typeBlur": "Desfoque",
+ "color": "Cor",
"blurShapeOval": "Oval",
- "blurType": "Tipo de Desfoque",
- "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
- "colorPalette": "Paleta de Cores",
- "textColor": "Cor do Texto",
- "colorWheel": "Roda de Cores",
- "shortcutsAndTips": "Atalhos e Dicas",
"defaultText": "Olá",
- "clearBackground": "Limpar Fundo",
- "type": "Tipo",
- "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
- "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
+ "blurColor": "Cor do Desfoque",
+ "blurShapeFreehand": "Mão Livre",
+ "typeImage": "Imagem",
+ "size": "Tamanho",
+ "textColor": "Cor do Texto",
"background": "Fundo",
- "blurShape": "Formato do Desfoque",
"arrowColor": "Cor da Seta",
- "invalidImageType": "Tipo de imagem inválido",
- "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
- "blurColorBlack": "Preto",
- "blurTypeBlur": "Gaussiano",
- "none": "Nenhum",
+ "arrowDirection": "Direção da Seta",
+ "blurTypeMosaic": "Mosaico",
"supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
- "size": "Tamanho",
+ "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
+ "blurType": "Tipo de Desfoque",
+ "deleteAnnotation": "Excluir Anotação",
+ "fontStyle": "Estilo da Fonte",
+ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
+ "selectStyle": "Selecionar estilo",
+ "typeText": "Texto",
+ "type": "Tipo",
+ "strokeWidth": "Largura do Traço: {{width}}px",
+ "shortcutsAndTips": "Atalhos e Dicas",
"imageUploadSuccess": "Imagem enviada com sucesso!",
+ "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
+ "blurShape": "Formato do Desfoque",
+ "none": "Nenhum",
"blurColorWhite": "Branco",
- "arrowDirection": "Direção da Seta",
- "typeImage": "Imagem",
- "typeText": "Texto",
+ "blurShapeRectangle": "Retângulo",
+ "colorWheel": "Roda de Cores",
"typeArrow": "Seta",
- "color": "Cor",
- "blurColor": "Cor do Desfoque",
- "selectStyle": "Selecionar estilo",
- "blurTypeMosaic": "Mosaico",
- "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
- "textPlaceholder": "Digite seu texto...",
+ "colorPalette": "Paleta de Cores",
"uploadImage": "Enviar Imagem",
- "strokeWidth": "Largura do Traço: {{width}}px",
- "typeBlur": "Desfoque",
- "deleteAnnotation": "Excluir Anotação",
- "fontStyle": "Estilo da Fonte"
+ "invalidImageType": "Tipo de imagem inválido",
+ "blurIntensity": "Intensidade do Desfoque",
+ "active": "Ativo"
+ },
+ "background": {
+ "colorPalette": "Paleta de Cores",
+ "custom": "Personalizado",
+ "colorLabel": "Cor {{color}}",
+ "imageLabel": "Fundo {{index}}",
+ "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
+ "colorWheel": "Roda de Cores",
+ "presets": "Predefinições",
+ "gradient": "Gradiente",
+ "uploadCustom": "Enviar Personalizada",
+ "customWallpaper": "Papel de parede personalizado",
+ "gradientLabel": "Gradiente {{index}}",
+ "title": "Fundo",
+ "color": "Cor",
+ "image": "Imagem",
+ "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
+ "imageReadFailed": "Não foi possível ler esse arquivo de imagem."
+ },
+ "customFont": {
+ "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
+ "errorEmptyName": "Por favor, insira um nome para a fonte",
+ "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Adicionar Google Font",
+ "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
+ "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
+ "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
+ "failedToAdd": "Falha ao adicionar fonte",
+ "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
+ "namePlaceholder": "Minha Fonte Personalizada",
+ "nameLabel": "Nome de Exibição",
+ "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
+ "addButton": "Adicionar Fonte",
+ "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
+ "urlLabel": "URL de Importação do Google Fonts",
+ "addingButton": "Adicionando..."
},
"layout": {
- "webcamCropY": "Deslocamento vertical",
- "reactiveWebcam": "Encolher ao ampliar",
- "shapes": {
- "rectangle": "Ret.",
- "square": "Quadrado",
- "rounded": "Arredondado",
- "circle": "Círculo"
- },
- "preset": "Predefinição",
- "dualFrame": "Quadro Duplo",
"bgModes": {
- "none": "Original",
"transparent": "Recorte",
"custom": "Personalizado",
- "blur": "Desfocado"
+ "blur": "Desfocado",
+ "none": "Original"
},
- "webcamCropZoom": "Zoom do recorte",
- "webcamShape": "Formato da Câmera",
- "webcamBlurIntensity": "Intensidade do desfoque",
+ "reactiveWebcam": "Encolher ao ampliar",
+ "webcamCropX": "Deslocamento horizontal",
+ "webcamBackground": "Plano de fundo da câmera",
+ "mirrorWebcam": "Espelhar Webcam",
+ "webcamFraming": "Enquadramento da webcam",
"title": "Layout da câmera",
"reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
- "webcamFraming": "Enquadramento da webcam",
- "webcamCropX": "Deslocamento horizontal",
"selectPreset": "Selecionar predefinição",
+ "noWebcam": "Sem Webcam",
+ "webcamShape": "Formato da Câmera",
"helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
- "mirrorWebcam": "Espelhar Webcam",
"help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
- "webcamBackground": "Plano de fundo da câmera",
- "noWebcam": "Sem Webcam",
- "pictureInPicture": "Picture in Picture",
+ "shapes": {
+ "circle": "Círculo",
+ "square": "Quadrado",
+ "rectangle": "Ret.",
+ "rounded": "Arredondado"
+ },
"webcamSize": "Tamanho da Webcam",
- "verticalStack": "Empilhamento Vertical"
+ "verticalStack": "Empilhamento Vertical",
+ "webcamBlurIntensity": "Intensidade do desfoque",
+ "dualFrame": "Quadro Duplo",
+ "webcamCropY": "Deslocamento vertical",
+ "webcamCropZoom": "Zoom do recorte",
+ "pictureInPicture": "Picture in Picture",
+ "preset": "Predefinição"
},
- "speed": {
- "customPlaybackSpeed": "Velocidade Personalizada",
- "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada.",
- "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
- "playbackSpeed": "Velocidade de Reprodução",
- "deleteRegion": "Excluir Região de Velocidade",
- "selectRegion": "Selecione uma região de velocidade para ajustar"
+ "export": {
+ "gifButton": "Exportar GIF",
+ "chooseSaveLocation": "Escolher Local para Salvar",
+ "videoButton": "Exportar Vídeo"
},
"imageUpload": {
- "failedToUpload": "Falha ao enviar imagem",
+ "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
"uploadSuccess": "Imagem personalizada enviada com sucesso!",
"errorReading": "Ocorreu um erro ao ler o arquivo.",
- "invalidFileType": "Tipo de arquivo inválido",
- "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG."
+ "failedToUpload": "Falha ao enviar imagem",
+ "invalidFileType": "Tipo de arquivo inválido"
+ },
+ "effects": {
+ "shadow": "Sombra",
+ "motion": "Movimento",
+ "off": "desativado",
+ "on": "ativado",
+ "format": "Formato",
+ "fitClip": "Ajustar",
+ "fitClipMany": "{{count}} clipes",
+ "fitClipFew": "{{count}} clipes",
+ "fitClipOne": "{{count}} clipe",
+ "frame": "Moldura",
+ "padding": "Espaçamento",
+ "title": "Composição",
+ "blurBg": "Desfocar Fundo",
+ "formatOriginal": "Original",
+ "roundness": "Arredondamento",
+ "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
+ "motionBlur": "Desfoque de Movimento"
+ },
+ "audioTrack": {
+ "fadeOut": "Fade out",
+ "importFailed": "Não foi possível adicionar o áudio",
+ "slipHint": "Alt + arrastar para deslizar o áudio dentro",
+ "loop": "Repetir",
+ "remove": "Excluir faixa",
+ "mute": "Silenciar",
+ "defaultLabel": "Faixa de áudio",
+ "add": "Adicionar faixa de áudio",
+ "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "fadeIn": "Fade in"
},
"transcript": {
- "blankedWord": "apagada",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "noAudio": "Esta mídia não tem faixa de áudio",
+ "insertAria": "Nova palavra",
+ "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
+ "laneRecording": "Gravação",
"editWord": "Editar \"{{word}}\"",
+ "laneLabel": "Ler a transcrição de",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
"editorAria": "Transcrição de {{filename}}",
+ "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
+ "silence": "[silêncio {{duration}} s]",
+ "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
"noTranscript": "Nenhuma transcrição ainda",
+ "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
+ "blankedWord": "apagada",
+ "removeInserted": "Excluir \"{{word}}\"",
"clipLabel": "Clipe {{index}}",
- "trimSilence": "Cortar silêncio ({{duration}} s)",
- "laneVoiceover": "Narração",
- "restoreSilence": "Restaurar silêncio ({{duration}} s)",
- "transcribeNow": "Transcrever agora",
+ "transcribing": "Transcrevendo…",
"whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
- "removeInserted": "Excluir \"{{word}}\"",
- "insertedWord": "Adicionada por você — sem áudio por trás",
- "restoreWord": "Restaurar \"{{word}}\"",
- "silence": "[silêncio {{duration}} s]",
- "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
- "laneRecording": "Gravação",
- "noClips": "Nenhum clipe ainda",
- "noAudio": "Esta mídia não tem faixa de áudio",
- "laneLabel": "Ler a transcrição de",
+ "restoreSilence": "Restaurar silêncio ({{duration}} s)",
"revertWord": "Restaurar \"{{original}}\"",
+ "helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
+ "laneVoiceover": "Narração",
+ "noClips": "Nenhum clipe ainda",
+ "trimSilence": "Cortar silêncio ({{duration}} s)",
+ "restoreWord": "Restaurar \"{{word}}\"",
"title": "Transcrição atual",
- "transcribing": "Transcrevendo…",
- "insertAria": "Nova palavra",
- "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente."
+ "transcribeNow": "Transcrever agora"
+ },
+ "cursor": {
+ "title": "Cursor",
+ "show": "Mostrar cursor",
+ "clipToBounds": "Recortar à tela",
+ "motionBlur": "Desfoque de movimento",
+ "smoothing": "Suavização",
+ "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
+ "themeDefault": "Padrão",
+ "clickBounce": "Rebote ao clicar",
+ "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
+ "theme": "Estilo do cursor",
+ "size": "Tamanho"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
+ "title": "Posição do Foco",
+ "y": "Y (%)"
+ },
+ "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
+ "manual": "Manual",
+ "auto": "Automático",
+ "title": "Modo de Foco",
+ "autoDescription": "A câmera segue a posição do cursor gravado"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Direita",
+ "iso": "Iso",
+ "left": "Esquerda"
+ },
+ "none": "Nenhuma",
+ "title": "Rotação 3D"
+ },
+ "selectRegion": "Selecione uma região de zoom para ajustar",
+ "deleteZoom": "Excluir Zoom",
+ "customScale": "Zoom Personalizado",
+ "level": "Nível de Zoom"
+ },
+ "project": {
+ "save": "Salvar Projeto",
+ "load": "Carregar Projeto",
+ "new": "Novo Projeto"
},
"captions": {
- "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
- "distanceFromTop": "Distância do topo",
- "minWords": "Mín. de palavras por linha",
- "showBackground": "Mostrar fundo",
- "lineLength": "Comprimento da linha",
- "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
- "translating": "Traduzindo…",
- "distanceFromLeft": "Distância da esquerda",
- "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
- "position": "Posição",
- "translateFailed": "A tradução falhou.",
- "backgroundOpacity": "Opacidade",
- "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
- "original": "Original (transcrição)",
- "font": "Fonte",
+ "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
"distanceFromRight": "Distância da direita",
+ "distanceFromBottom": "Distância da base",
+ "font": "Fonte",
+ "distanceFromTop": "Distância do topo",
+ "alignLeft": "Esquerda",
+ "removeLegacyAnnotations": "Remover anotações de legenda antigas",
"textColor": "Cor do texto",
- "alignCenter": "Centro",
- "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
+ "text": "Texto",
+ "anchorTop": "Topo",
"language": "Idioma",
+ "translate": "Traduzir",
"show": "Mostrar legendas",
- "anchorTop": "Topo",
- "backgroundColor": "Cor do fundo",
- "removeLegacyAnnotations": "Remover anotações de legenda antigas",
- "background": "Fundo",
- "bold": "Negrito",
- "distanceFromBottom": "Distância da base",
"fontSize": "Tamanho",
+ "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
+ "backgroundOpacity": "Opacidade",
+ "translating": "Traduzindo…",
"anchorBottom": "Base",
- "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
- "maxWords": "Máx. de palavras por linha",
- "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
- "translate": "Traduzir",
"deleteTranslation": "Excluir esta tradução",
+ "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
+ "backgroundColor": "Cor do fundo",
+ "translateFailed": "A tradução falhou.",
"anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
- "text": "Texto",
- "displayLanguage": "Exibição",
+ "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
+ "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
"alignRight": "Direita",
- "alignLeft": "Esquerda"
+ "displayLanguage": "Exibição",
+ "distanceFromLeft": "Distância da esquerda",
+ "minWords": "Mín. de palavras por linha",
+ "showBackground": "Mostrar fundo",
+ "maxWords": "Máx. de palavras por linha",
+ "bold": "Negrito",
+ "background": "Fundo",
+ "alignCenter": "Centro",
+ "original": "Original (transcrição)",
+ "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
+ "lineLength": "Comprimento da linha",
+ "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
+ "position": "Posição"
},
- "support": {
- "saveDiagnostics": "Salvar Diagnósticos",
- "reportBug": "Relatar Bug",
- "starOnGithub": "Dar Estrela no GitHub"
+ "textAnimation": {
+ "none": "Nenhuma",
+ "title": "Animação de Texto",
+ "pop": "Aparecer",
+ "slideLeft": "Deslizar à Esquerda",
+ "selectAnimation": "Selecionar animação",
+ "fade": "Esmaecer",
+ "rise": "Subir",
+ "typewriter": "Máquina de Escrever",
+ "pulse": "Pulsar"
},
- "customFont": {
- "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
- "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
- "urlLabel": "URL de Importação do Google Fonts",
- "addingButton": "Adicionando...",
- "namePlaceholder": "Minha Fonte Personalizada",
- "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
- "dialogTitle": "Adicionar Google Font",
- "failedToAdd": "Falha ao adicionar fonte",
- "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
- "addButton": "Adicionar Fonte",
- "errorEmptyName": "Por favor, insira um nome para a fonte",
- "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
- "nameLabel": "Nome de Exibição",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
- "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
- "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente."
+ "gifSettings": {
+ "loop": "Loop no GIF",
+ "frameRate": "Taxa de Quadros do GIF",
+ "size": "Tamanho do GIF"
},
- "background": {
- "presets": "Predefinições",
- "image": "Imagem",
- "title": "Fundo",
- "custom": "Personalizado",
- "colorLabel": "Cor {{color}}",
- "imageLabel": "Fundo {{index}}",
- "customWallpaper": "Papel de parede personalizado",
- "colorPalette": "Paleta de Cores",
- "uploadCustom": "Enviar Personalizada",
- "color": "Cor",
- "imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
- "gradientLabel": "Gradiente {{index}}",
- "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
- "gradient": "Gradiente",
- "colorWheel": "Roda de Cores",
- "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco."
+ "exportFormat": {
+ "gifDescription": "Imagem animada para compartilhamento",
+ "mp4Description": "Arquivo de vídeo de alta qualidade",
+ "mp4Video": "Vídeo MP4",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animação GIF"
},
"audio": {
+ "title": "Áudio",
"reset": "Redefinir áudio",
- "outputGain": "Nível de saída",
"help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
- "title": "Áudio"
+ "outputGain": "Nível de saída"
},
- "textAnimation": {
- "pulse": "Pulsar",
- "selectAnimation": "Selecionar animação",
- "fade": "Esmaecer",
- "typewriter": "Máquina de Escrever",
- "slideLeft": "Deslizar à Esquerda",
- "none": "Nenhuma",
- "rise": "Subir",
- "pop": "Aparecer",
- "title": "Animação de Texto"
+ "language": {
+ "title": "Idioma"
+ },
+ "support": {
+ "saveDiagnostics": "Salvar Diagnósticos",
+ "reportBug": "Relatar Bug",
+ "starOnGithub": "Dar Estrela no GitHub"
},
"crop": {
- "title": "Cortar",
- "unlockAspectRatio": "Desbloquear proporção",
+ "cropVideo": "Cortar Vídeo",
"free": "Livre",
- "dragInstruction": "Arraste cada lado para ajustar a área de corte",
- "done": "Concluir",
"lockAspectRatio": "Bloquear proporção",
- "ratio": "Proporção",
- "cropVideo": "Cortar Vídeo"
+ "done": "Concluir",
+ "title": "Cortar",
+ "dragInstruction": "Arraste cada lado para ajustar a área de corte",
+ "unlockAspectRatio": "Desbloquear proporção",
+ "ratio": "Proporção"
+ },
+ "speed": {
+ "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
+ "playbackSpeed": "Velocidade de Reprodução",
+ "customPlaybackSpeed": "Velocidade Personalizada",
+ "deleteRegion": "Excluir Região de Velocidade",
+ "selectRegion": "Selecione uma região de velocidade para ajustar",
+ "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada."
+ },
+ "trim": {
+ "deleteRegion": "Excluir Região de Recorte"
},
"exportQuality": {
"low": "Baixa",
@@ -293,59 +344,11 @@
"high": "Alta",
"title": "Qualidade de Exportação"
},
- "effects": {
- "off": "desativado",
- "on": "ativado",
- "fitClip": "Ajustar",
- "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
- "fitClipFew": "{{count}} clipes",
- "blurBg": "Desfocar Fundo",
- "motion": "Movimento",
- "shadow": "Sombra",
- "fitClipOne": "{{count}} clipe",
- "format": "Formato",
- "roundness": "Arredondamento",
- "fitClipMany": "{{count}} clipes",
- "formatOriginal": "Original",
- "frame": "Moldura",
- "motionBlur": "Desfoque de Movimento",
- "padding": "Espaçamento",
- "title": "Composição"
- },
- "language": {
- "title": "Idioma"
- },
- "gifSettings": {
- "size": "Tamanho do GIF",
- "loop": "Loop no GIF",
- "frameRate": "Taxa de Quadros do GIF"
+ "facets": {
+ "transcript": "Transcrição",
+ "captions": "Legendas"
},
"panes": {
"help": "Ajuda"
- },
- "export": {
- "chooseSaveLocation": "Escolher Local para Salvar",
- "gifButton": "Exportar GIF",
- "videoButton": "Exportar Vídeo"
- },
- "exportFormat": {
- "mp4Video": "Vídeo MP4",
- "mp4Description": "Arquivo de vídeo de alta qualidade",
- "gifAnimation": "Animação GIF",
- "gifDescription": "Imagem animada para compartilhamento",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Salvar Projeto",
- "new": "Novo Projeto",
- "load": "Carregar Projeto"
- },
- "facets": {
- "captions": "Legendas",
- "transcript": "Transcrição"
- },
- "trim": {
- "deleteRegion": "Excluir Região de Recorte"
}
}
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index cfb6fb425..8396029d1 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = край слева / сверху, 100 = край справа / снизу",
- "x": "X (%)",
- "title": "Положение фокуса"
- },
- "threeD": {
- "preset": {
- "right": "Справа",
- "left": "Слева",
- "iso": "Изометрия"
- },
- "none": "Нет",
- "title": "3D вращение"
- },
- "focusMode": {
- "manual": "Ручной",
- "title": "Режим фокуса",
- "autoDescription": "Камера следует за записанной позицией курсора",
- "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
- "auto": "Авто"
- },
- "selectRegion": "Выберите область масштабирования для настройки",
- "customScale": "Пользовательский масштаб",
- "previewHold": "Удерживайте для предпросмотра эффекта зума",
- "level": "Уровень масштабирования",
- "deleteZoom": "Удалить масштабирование"
- },
- "audioTrack": {
- "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
- "defaultLabel": "Аудиодорожка",
- "fadeOut": "Затухание",
- "add": "Добавить аудиодорожку",
- "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
- "loop": "Повтор",
- "remove": "Удалить дорожку",
- "fadeIn": "Нарастание",
- "mute": "Без звука",
- "importFailed": "Не удалось добавить аудио"
- },
- "cursor": {
- "clipToBounds": "Обрезать по холсту",
- "size": "Размер",
- "motionBlur": "Размытие движения",
- "theme": "Стиль курсора",
- "clickBounce": "Отскок при клике",
- "title": "Курсор",
- "smoothing": "Сглаживание",
- "themeDefault": "По умолчанию",
- "show": "Показывать курсор",
- "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
- "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании."
- },
"annotation": {
- "blurIntensity": "Интенсивность размытия",
+ "blurTypeBlur": "Гауссово",
+ "textPlaceholder": "Введите ваш текст...",
+ "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
+ "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
+ "blurColorBlack": "Чёрный",
"textContent": "Содержание текста",
- "blurShapeFreehand": "От руки",
- "active": "Активно",
- "blurShapeRectangle": "Прямоугольник",
"customFonts": "Пользовательские шрифты",
+ "clearBackground": "Очистить фон",
"title": "Настройки аннотаций",
+ "typeBlur": "Размытие",
+ "color": "Цвет",
"blurShapeOval": "Овал",
- "blurType": "Тип размытия",
- "mosaicBlockSize": "Размер блока мозаики",
- "colorPalette": "Палитра цветов",
- "textColor": "Цвет текста",
- "colorWheel": "Цветовой круг",
- "shortcutsAndTips": "Горячие клавиши и советы",
"defaultText": "Привет",
- "clearBackground": "Очистить фон",
- "type": "Тип",
- "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
- "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
+ "blurColor": "Цвет размытия",
+ "blurShapeFreehand": "От руки",
+ "typeImage": "Изображение",
+ "size": "Размер",
+ "textColor": "Цвет текста",
"background": "Фон",
- "blurShape": "Форма размытия",
"arrowColor": "Цвет стрелки",
- "invalidImageType": "Неверный тип файла",
- "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
- "blurColorBlack": "Чёрный",
- "blurTypeBlur": "Гауссово",
- "none": "Нет",
+ "arrowDirection": "Направление стрелки",
+ "blurTypeMosaic": "Мозаика",
"supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
- "size": "Размер",
+ "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
+ "blurType": "Тип размытия",
+ "deleteAnnotation": "Удалить аннотацию",
+ "fontStyle": "Стиль шрифта",
+ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
+ "selectStyle": "Выбрать стиль",
+ "typeText": "Текст",
+ "type": "Тип",
+ "strokeWidth": "Толщина линии: {{width}}px",
+ "shortcutsAndTips": "Горячие клавиши и советы",
"imageUploadSuccess": "Изображение успешно загружено!",
+ "mosaicBlockSize": "Размер блока мозаики",
+ "blurShape": "Форма размытия",
+ "none": "Нет",
"blurColorWhite": "Белый",
- "arrowDirection": "Направление стрелки",
- "typeImage": "Изображение",
- "typeText": "Текст",
+ "blurShapeRectangle": "Прямоугольник",
+ "colorWheel": "Цветовой круг",
"typeArrow": "Стрелка",
- "color": "Цвет",
- "blurColor": "Цвет размытия",
- "selectStyle": "Выбрать стиль",
- "blurTypeMosaic": "Мозаика",
- "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
- "textPlaceholder": "Введите ваш текст...",
+ "colorPalette": "Палитра цветов",
"uploadImage": "Загрузить изображение",
- "strokeWidth": "Толщина линии: {{width}}px",
- "typeBlur": "Размытие",
- "deleteAnnotation": "Удалить аннотацию",
- "fontStyle": "Стиль шрифта"
+ "invalidImageType": "Неверный тип файла",
+ "blurIntensity": "Интенсивность размытия",
+ "active": "Активно"
+ },
+ "background": {
+ "colorPalette": "Палитра цветов",
+ "custom": "Свой",
+ "colorLabel": "Цвет {{color}}",
+ "imageLabel": "Фон {{index}}",
+ "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
+ "colorWheel": "Цветовой круг",
+ "presets": "Пресеты",
+ "gradient": "Градиент",
+ "uploadCustom": "Загрузить свой",
+ "customWallpaper": "Свои обои",
+ "gradientLabel": "Градиент {{index}}",
+ "title": "Фон",
+ "color": "Цвет",
+ "image": "Изображение",
+ "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
+ "imageReadFailed": "Не удалось прочитать этот файл изображения."
+ },
+ "customFont": {
+ "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
+ "errorEmptyName": "Пожалуйста, введите имя шрифта",
+ "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Добавить шрифт Google",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
+ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
+ "failedToAdd": "Не удалось добавить шрифт",
+ "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "nameLabel": "Отображаемое имя",
+ "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
+ "addButton": "Добавить шрифт",
+ "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
+ "urlLabel": "URL импорта Google Fonts",
+ "addingButton": "Добавление..."
},
"layout": {
- "webcamCropY": "Смещение по вертикали",
- "reactiveWebcam": "Уменьшать при зуме",
- "shapes": {
- "rectangle": "Прямоуг.",
- "square": "Квадрат",
- "rounded": "Скруглённый",
- "circle": "Круг"
- },
- "preset": "Пресет",
- "dualFrame": "Двойной кадр",
"bgModes": {
- "none": "Оригинал",
"transparent": "Вырезка",
"custom": "Пользовательский",
- "blur": "Размытие"
+ "blur": "Размытие",
+ "none": "Оригинал"
},
- "webcamCropZoom": "Масштаб обрезки",
- "webcamShape": "Форма камеры",
- "webcamBlurIntensity": "Интенсивность размытия",
+ "reactiveWebcam": "Уменьшать при зуме",
+ "webcamCropX": "Смещение по горизонтали",
+ "webcamBackground": "Фон камеры",
+ "mirrorWebcam": "Зеркалить веб-камеру",
+ "webcamFraming": "Кадрирование веб-камеры",
"title": "Расположение камеры",
"reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
- "webcamFraming": "Кадрирование веб-камеры",
- "webcamCropX": "Смещение по горизонтали",
"selectPreset": "Выбрать пресет",
+ "noWebcam": "Без веб-камеры",
+ "webcamShape": "Форма камеры",
"helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
- "mirrorWebcam": "Зеркалить веб-камеру",
"help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
- "webcamBackground": "Фон камеры",
- "noWebcam": "Без веб-камеры",
- "pictureInPicture": "Картинка в картинке",
+ "shapes": {
+ "circle": "Круг",
+ "square": "Квадрат",
+ "rectangle": "Прямоуг.",
+ "rounded": "Скруглённый"
+ },
"webcamSize": "Размер веб-камеры",
- "verticalStack": "Вертикальный стек"
+ "verticalStack": "Вертикальный стек",
+ "webcamBlurIntensity": "Интенсивность размытия",
+ "dualFrame": "Двойной кадр",
+ "webcamCropY": "Смещение по вертикали",
+ "webcamCropZoom": "Масштаб обрезки",
+ "pictureInPicture": "Картинка в картинке",
+ "preset": "Пресет"
},
- "speed": {
- "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
- "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет.",
- "maxSpeedError": "Скорость не может быть выше {{max}}×",
- "playbackSpeed": "Скорость воспроизведения",
- "deleteRegion": "Удалить область скорости",
- "selectRegion": "Выберите область скорости для настройки"
+ "export": {
+ "gifButton": "Экспорт GIF",
+ "chooseSaveLocation": "Выбрать место сохранения",
+ "videoButton": "Экспорт видео"
},
"imageUpload": {
- "failedToUpload": "Не удалось загрузить изображение",
+ "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
"uploadSuccess": "Пользовательское изображение успешно загружено!",
"errorReading": "Произошла ошибка при чтении файла.",
- "invalidFileType": "Неверный тип файла",
- "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG."
+ "failedToUpload": "Не удалось загрузить изображение",
+ "invalidFileType": "Неверный тип файла"
+ },
+ "effects": {
+ "shadow": "Тень",
+ "motion": "Движение",
+ "off": "выкл",
+ "on": "вкл",
+ "format": "Формат",
+ "fitClip": "Подогнать",
+ "fitClipMany": "{{count}} клипов",
+ "fitClipFew": "{{count}} клипа",
+ "fitClipOne": "{{count}} клип",
+ "frame": "Рамка",
+ "padding": "Отступ",
+ "title": "Композиция",
+ "blurBg": "Размытие фона",
+ "formatOriginal": "Исходный",
+ "roundness": "Скругление",
+ "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
+ "motionBlur": "Размытие движения"
+ },
+ "audioTrack": {
+ "fadeOut": "Затухание",
+ "importFailed": "Не удалось добавить аудио",
+ "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
+ "loop": "Повтор",
+ "remove": "Удалить дорожку",
+ "mute": "Без звука",
+ "defaultLabel": "Аудиодорожка",
+ "add": "Добавить аудиодорожку",
+ "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "fadeIn": "Нарастание"
},
"transcript": {
- "blankedWord": "очищено",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "insertAria": "Новое слово",
+ "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
+ "laneRecording": "Запись",
"editWord": "Изменить «{{word}}»",
+ "laneLabel": "Читать расшифровку из",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
"editorAria": "Расшифровка «{{filename}}»",
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "silence": "[тишина {{duration}} с]",
+ "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
"noTranscript": "Расшифровки пока нет",
+ "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
+ "blankedWord": "очищено",
+ "removeInserted": "Удалить «{{word}}»",
"clipLabel": "Клип {{index}}",
- "trimSilence": "Вырезать тишину ({{duration}} с)",
- "laneVoiceover": "Закадровый голос",
- "restoreSilence": "Вернуть тишину ({{duration}} с)",
- "transcribeNow": "Расшифровать сейчас",
+ "transcribing": "Расшифровка…",
"whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
- "removeInserted": "Удалить «{{word}}»",
- "insertedWord": "Добавлено вами — за ним нет звука",
- "restoreWord": "Вернуть «{{word}}»",
- "silence": "[тишина {{duration}} с]",
- "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
- "laneRecording": "Запись",
- "noClips": "Клипов пока нет",
- "noAudio": "В этом медиафайле нет аудиодорожки",
- "laneLabel": "Читать расшифровку из",
+ "restoreSilence": "Вернуть тишину ({{duration}} с)",
"revertWord": "Вернуть «{{original}}»",
+ "helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
+ "laneVoiceover": "Закадровый голос",
+ "noClips": "Клипов пока нет",
+ "trimSilence": "Вырезать тишину ({{duration}} с)",
+ "restoreWord": "Вернуть «{{word}}»",
"title": "Текущая расшифровка",
- "transcribing": "Расшифровка…",
- "insertAria": "Новое слово",
- "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново."
+ "transcribeNow": "Расшифровать сейчас"
+ },
+ "cursor": {
+ "title": "Курсор",
+ "show": "Показывать курсор",
+ "clipToBounds": "Обрезать по холсту",
+ "motionBlur": "Размытие движения",
+ "smoothing": "Сглаживание",
+ "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
+ "themeDefault": "По умолчанию",
+ "clickBounce": "Отскок при клике",
+ "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
+ "theme": "Стиль курсора",
+ "size": "Размер"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = край слева / сверху, 100 = край справа / снизу",
+ "title": "Положение фокуса",
+ "y": "Y (%)"
+ },
+ "previewHold": "Удерживайте для предпросмотра эффекта зума",
+ "focusMode": {
+ "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
+ "manual": "Ручной",
+ "auto": "Авто",
+ "title": "Режим фокуса",
+ "autoDescription": "Камера следует за записанной позицией курсора"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Справа",
+ "iso": "Изометрия",
+ "left": "Слева"
+ },
+ "none": "Нет",
+ "title": "3D вращение"
+ },
+ "selectRegion": "Выберите область масштабирования для настройки",
+ "deleteZoom": "Удалить масштабирование",
+ "customScale": "Пользовательский масштаб",
+ "level": "Уровень масштабирования"
+ },
+ "project": {
+ "save": "Сохранить проект",
+ "load": "Загрузить проект",
+ "new": "Новый проект"
},
"captions": {
- "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
- "distanceFromTop": "Отступ сверху",
- "minWords": "Мин. слов в строке",
- "showBackground": "Показывать фон",
- "lineLength": "Длина строки",
- "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
- "translating": "Перевод…",
- "distanceFromLeft": "Отступ слева",
- "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
- "position": "Положение",
- "translateFailed": "Не удалось перевести.",
- "backgroundOpacity": "Непрозрачность",
- "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
- "original": "Оригинал (расшифровка)",
- "font": "Шрифт",
+ "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
"distanceFromRight": "Отступ справа",
+ "distanceFromBottom": "Отступ снизу",
+ "font": "Шрифт",
+ "distanceFromTop": "Отступ сверху",
+ "alignLeft": "Слева",
+ "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
"textColor": "Цвет текста",
- "alignCenter": "По центру",
- "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "text": "Текст",
+ "anchorTop": "Сверху",
"language": "Язык",
+ "translate": "Перевести",
"show": "Показывать субтитры",
- "anchorTop": "Сверху",
- "backgroundColor": "Цвет фона",
- "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
- "background": "Фон",
- "bold": "Полужирный",
- "distanceFromBottom": "Отступ снизу",
"fontSize": "Размер",
+ "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "backgroundOpacity": "Непрозрачность",
+ "translating": "Перевод…",
"anchorBottom": "Снизу",
- "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
- "maxWords": "Макс. слов в строке",
- "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
- "translate": "Перевести",
"deleteTranslation": "Удалить этот перевод",
+ "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
+ "backgroundColor": "Цвет фона",
+ "translateFailed": "Не удалось перевести.",
"anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
- "text": "Текст",
- "displayLanguage": "Отображение",
+ "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
+ "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
"alignRight": "Справа",
- "alignLeft": "Слева"
+ "displayLanguage": "Отображение",
+ "distanceFromLeft": "Отступ слева",
+ "minWords": "Мин. слов в строке",
+ "showBackground": "Показывать фон",
+ "maxWords": "Макс. слов в строке",
+ "bold": "Полужирный",
+ "background": "Фон",
+ "alignCenter": "По центру",
+ "original": "Оригинал (расшифровка)",
+ "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
+ "lineLength": "Длина строки",
+ "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
+ "position": "Положение"
},
- "support": {
- "saveDiagnostics": "Сохранить диагностику",
- "reportBug": "Сообщить об ошибке",
- "starOnGithub": "Звезда на GitHub"
+ "textAnimation": {
+ "none": "Нет",
+ "title": "Анимация текста",
+ "pop": "Всплытие",
+ "slideLeft": "Скольжение влево",
+ "selectAnimation": "Выбрать анимацию",
+ "fade": "Затухание",
+ "rise": "Подъем",
+ "typewriter": "Пишущая машинка",
+ "pulse": "Импульс"
},
- "customFont": {
- "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
- "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
- "urlLabel": "URL импорта Google Fonts",
- "addingButton": "Добавление...",
- "namePlaceholder": "Мой пользовательский шрифт",
- "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
- "dialogTitle": "Добавить шрифт Google",
- "failedToAdd": "Не удалось добавить шрифт",
- "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
- "addButton": "Добавить шрифт",
- "errorEmptyName": "Пожалуйста, введите имя шрифта",
- "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
- "nameLabel": "Отображаемое имя",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
- "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
- "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова."
+ "gifSettings": {
+ "loop": "Зациклить GIF",
+ "frameRate": "Частота кадров GIF",
+ "size": "Размер GIF"
},
- "background": {
- "presets": "Пресеты",
- "image": "Изображение",
- "title": "Фон",
- "custom": "Свой",
- "colorLabel": "Цвет {{color}}",
- "imageLabel": "Фон {{index}}",
- "customWallpaper": "Свои обои",
- "colorPalette": "Палитра цветов",
- "uploadCustom": "Загрузить свой",
- "color": "Цвет",
- "imageReadFailed": "Не удалось прочитать этот файл изображения.",
- "gradientLabel": "Градиент {{index}}",
- "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
- "gradient": "Градиент",
- "colorWheel": "Цветовой круг",
- "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска."
+ "exportFormat": {
+ "gifDescription": "Анимированное изображение для обмена",
+ "mp4Description": "Видеофайл высокого качества",
+ "mp4Video": "MP4 видео",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF анимация"
},
"audio": {
+ "title": "Аудио",
"reset": "Сбросить аудио",
- "outputGain": "Уровень выхода",
"help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
- "title": "Аудио"
+ "outputGain": "Уровень выхода"
},
- "textAnimation": {
- "pulse": "Импульс",
- "selectAnimation": "Выбрать анимацию",
- "fade": "Затухание",
- "typewriter": "Пишущая машинка",
- "slideLeft": "Скольжение влево",
- "none": "Нет",
- "rise": "Подъем",
- "pop": "Всплытие",
- "title": "Анимация текста"
+ "language": {
+ "title": "Язык"
+ },
+ "support": {
+ "saveDiagnostics": "Сохранить диагностику",
+ "reportBug": "Сообщить об ошибке",
+ "starOnGithub": "Звезда на GitHub"
},
"crop": {
- "title": "Обрезка",
- "unlockAspectRatio": "Разблокировать соотношение сторон",
+ "cropVideo": "Обрезать видео",
"free": "Свободно",
- "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
- "done": "Готово",
"lockAspectRatio": "Заблокировать соотношение сторон",
- "ratio": "Соотношение сторон",
- "cropVideo": "Обрезать видео"
+ "done": "Готово",
+ "title": "Обрезка",
+ "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
+ "unlockAspectRatio": "Разблокировать соотношение сторон",
+ "ratio": "Соотношение сторон"
+ },
+ "speed": {
+ "maxSpeedError": "Скорость не может быть выше {{max}}×",
+ "playbackSpeed": "Скорость воспроизведения",
+ "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
+ "deleteRegion": "Удалить область скорости",
+ "selectRegion": "Выберите область скорости для настройки",
+ "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет."
+ },
+ "trim": {
+ "deleteRegion": "Удалить область обрезки"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "Разрешение экспорта"
},
- "effects": {
- "off": "выкл",
- "on": "вкл",
- "fitClip": "Подогнать",
- "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
- "fitClipFew": "{{count}} клипа",
- "blurBg": "Размытие фона",
- "motion": "Движение",
- "shadow": "Тень",
- "fitClipOne": "{{count}} клип",
- "format": "Формат",
- "roundness": "Скругление",
- "fitClipMany": "{{count}} клипов",
- "formatOriginal": "Исходный",
- "frame": "Рамка",
- "motionBlur": "Размытие движения",
- "padding": "Отступ",
- "title": "Композиция"
- },
- "language": {
- "title": "Язык"
- },
- "gifSettings": {
- "size": "Размер GIF",
- "loop": "Зациклить GIF",
- "frameRate": "Частота кадров GIF"
+ "facets": {
+ "transcript": "Транскрипт",
+ "captions": "Субтитры"
},
"panes": {
"help": "Справка"
- },
- "export": {
- "chooseSaveLocation": "Выбрать место сохранения",
- "gifButton": "Экспорт GIF",
- "videoButton": "Экспорт видео"
- },
- "exportFormat": {
- "mp4Video": "MP4 видео",
- "mp4Description": "Видеофайл высокого качества",
- "gifAnimation": "GIF анимация",
- "gifDescription": "Анимированное изображение для обмена",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Сохранить проект",
- "new": "Новый проект",
- "load": "Загрузить проект"
- },
- "facets": {
- "captions": "Субтитры",
- "transcript": "Транскрипт"
- },
- "trim": {
- "deleteRegion": "Удалить область обрезки"
}
}
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 387671419..b526b104c 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
- "x": "X (%)",
- "title": "Odak Konumu"
- },
- "threeD": {
- "preset": {
- "right": "Sağ",
- "left": "Sol",
- "iso": "Iso"
- },
- "none": "Yok",
- "title": "3D Döndürme"
- },
- "focusMode": {
- "manual": "Manuel",
- "title": "Odak Modu",
- "autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
- "lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
- "auto": "Otomatik"
- },
- "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
- "customScale": "Özel Yakınlaştırma",
- "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
- "level": "Yakınlaştırma Seviyesi",
- "deleteZoom": "Yakınlaştırmayı Sil"
- },
- "audioTrack": {
- "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
- "defaultLabel": "Ses parçası",
- "fadeOut": "Kararma",
- "add": "Ses parçası ekle",
- "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
- "loop": "Döngü",
- "remove": "Parçayı sil",
- "fadeIn": "Açılma",
- "mute": "Sessiz",
- "importFailed": "Ses eklenemedi"
- },
- "cursor": {
- "clipToBounds": "Tuvale Kırp",
- "size": "Boyut",
- "motionBlur": "Hareket Bulanıklığı",
- "theme": "İmleç Stili",
- "clickBounce": "Tıklama Sıçraması",
- "title": "İmleç",
- "smoothing": "Yumuşatma",
- "themeDefault": "Varsayılan",
- "show": "İmleci Göster",
- "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
- "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır."
- },
"annotation": {
- "blurIntensity": "Bulanıklık Yoğunluğu",
+ "blurTypeBlur": "Gauss",
+ "textPlaceholder": "Metninizi girin...",
+ "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
+ "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
+ "blurColorBlack": "Siyah",
"textContent": "Metin İçeriği",
- "blurShapeFreehand": "Serbest",
- "active": "Aktif",
- "blurShapeRectangle": "Dikdörtgen",
"customFonts": "Özel Yazı Tipleri",
+ "clearBackground": "Arka Planı Temizle",
"title": "Açıklama Ayarları",
+ "typeBlur": "Bulanık",
+ "color": "Renk",
"blurShapeOval": "Oval",
- "blurType": "Bulanıklık Türü",
- "mosaicBlockSize": "Mozaik Blok Boyutu",
- "colorPalette": "Renk paleti",
- "textColor": "Metin Rengi",
- "colorWheel": "Renk çarkı",
- "shortcutsAndTips": "Kısayollar ve İpuçları",
"defaultText": "Merhaba",
- "clearBackground": "Arka Planı Temizle",
- "type": "Tür",
- "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
- "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
+ "blurColor": "Bulanıklık Rengi",
+ "blurShapeFreehand": "Serbest",
+ "typeImage": "Görüntü",
+ "size": "Boyut",
+ "textColor": "Metin Rengi",
"background": "Arka Plan",
- "blurShape": "Bulanık Şekli",
"arrowColor": "Ok Rengi",
- "invalidImageType": "Geçersiz dosya türü",
- "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
- "blurColorBlack": "Siyah",
- "blurTypeBlur": "Gauss",
- "none": "Yok",
+ "arrowDirection": "Ok Yönü",
+ "blurTypeMosaic": "Mozaik",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
- "size": "Boyut",
+ "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "blurType": "Bulanıklık Türü",
+ "deleteAnnotation": "Açıklamayı Sil",
+ "fontStyle": "Yazı Tipi Stili",
+ "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
+ "selectStyle": "Stil seçin",
+ "typeText": "Metin",
+ "type": "Tür",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
+ "mosaicBlockSize": "Mozaik Blok Boyutu",
+ "blurShape": "Bulanık Şekli",
+ "none": "Yok",
"blurColorWhite": "Beyaz",
- "arrowDirection": "Ok Yönü",
- "typeImage": "Görüntü",
- "typeText": "Metin",
+ "blurShapeRectangle": "Dikdörtgen",
+ "colorWheel": "Renk çarkı",
"typeArrow": "Ok",
- "color": "Renk",
- "blurColor": "Bulanıklık Rengi",
- "selectStyle": "Stil seçin",
- "blurTypeMosaic": "Mozaik",
- "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
- "textPlaceholder": "Metninizi girin...",
+ "colorPalette": "Renk paleti",
"uploadImage": "Görüntü Yükle",
- "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
- "typeBlur": "Bulanık",
- "deleteAnnotation": "Açıklamayı Sil",
- "fontStyle": "Yazı Tipi Stili"
+ "invalidImageType": "Geçersiz dosya türü",
+ "blurIntensity": "Bulanıklık Yoğunluğu",
+ "active": "Aktif"
+ },
+ "background": {
+ "colorPalette": "Renk paleti",
+ "custom": "Özel",
+ "colorLabel": "Renk {{color}}",
+ "imageLabel": "Arka plan {{index}}",
+ "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
+ "colorWheel": "Renk çarkı",
+ "presets": "Ön ayarlar",
+ "gradient": "Gradyan",
+ "uploadCustom": "Özel Yükle",
+ "customWallpaper": "Özel duvar kâğıdı",
+ "gradientLabel": "Gradyan {{index}}",
+ "title": "Arka Plan",
+ "color": "Renk",
+ "image": "Görüntü",
+ "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
+ "imageReadFailed": "Bu görsel dosyası okunamadı."
+ },
+ "customFont": {
+ "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
+ "errorEmptyName": "Lütfen bir yazı tipi adı girin",
+ "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Google Yazı Tipi Ekle",
+ "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
+ "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
+ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
+ "failedToAdd": "Yazı tipi eklenemedi",
+ "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
+ "namePlaceholder": "Özel Yazı Tipim",
+ "nameLabel": "Görünen Ad",
+ "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
+ "addButton": "Yazı Tipi Ekle",
+ "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
+ "urlLabel": "Google Fonts İçe Aktarım URL'si",
+ "addingButton": "Ekleniyor..."
},
"layout": {
- "webcamCropY": "Dikey kaydırma",
- "reactiveWebcam": "Yakınlaştırınca küçült",
- "shapes": {
- "rectangle": "Dikdörtgen",
- "square": "Kare",
- "rounded": "Yuvarlatılmış",
- "circle": "Daire"
- },
- "preset": "Ön Ayar",
- "dualFrame": "Çift Kare",
"bgModes": {
- "none": "Orijinal",
"transparent": "Kesme",
"custom": "Özel",
- "blur": "Bulanık"
+ "blur": "Bulanık",
+ "none": "Orijinal"
},
- "webcamCropZoom": "Kırpma yakınlaştırması",
- "webcamShape": "Kamera Şekli",
- "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "reactiveWebcam": "Yakınlaştırınca küçült",
+ "webcamCropX": "Yatay kaydırma",
+ "webcamBackground": "Kamera Arka Planı",
+ "mirrorWebcam": "Web kamerasını aynala",
+ "webcamFraming": "Webcam kadrajı",
"title": "Kamera düzeni",
"reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
- "webcamFraming": "Webcam kadrajı",
- "webcamCropX": "Yatay kaydırma",
"selectPreset": "Ön ayar seçin",
+ "noWebcam": "Web kamerası yok",
+ "webcamShape": "Kamera Şekli",
"helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
- "mirrorWebcam": "Web kamerasını aynala",
"help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
- "webcamBackground": "Kamera Arka Planı",
- "noWebcam": "Web kamerası yok",
- "pictureInPicture": "Resim İçinde Resim",
+ "shapes": {
+ "circle": "Daire",
+ "square": "Kare",
+ "rectangle": "Dikdörtgen",
+ "rounded": "Yuvarlatılmış"
+ },
"webcamSize": "Webcam Boyutu",
- "verticalStack": "Dikey Yığın"
+ "verticalStack": "Dikey Yığın",
+ "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "dualFrame": "Çift Kare",
+ "webcamCropY": "Dikey kaydırma",
+ "webcamCropZoom": "Kırpma yakınlaştırması",
+ "pictureInPicture": "Resim İçinde Resim",
+ "preset": "Ön Ayar"
},
- "speed": {
- "customPlaybackSpeed": "Özel Oynatma Hızı",
- "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez.",
- "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
- "playbackSpeed": "Oynatma Hızı",
- "deleteRegion": "Hız Bölgesini Sil",
- "selectRegion": "Ayarlamak için bir hız bölgesi seçin"
+ "export": {
+ "gifButton": "GIF Olarak Dışa Aktar",
+ "chooseSaveLocation": "Kayıt Konumu Seç",
+ "videoButton": "Videoyu Dışa Aktar"
},
"imageUpload": {
- "failedToUpload": "Görüntü yüklenemedi",
+ "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.",
"uploadSuccess": "Özel görüntü başarıyla yüklendi!",
"errorReading": "Dosya okunurken bir hata oluştu.",
- "invalidFileType": "Geçersiz dosya türü",
- "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin."
+ "failedToUpload": "Görüntü yüklenemedi",
+ "invalidFileType": "Geçersiz dosya türü"
+ },
+ "effects": {
+ "shadow": "Gölge",
+ "motion": "Hareket",
+ "off": "kapalı",
+ "on": "açık",
+ "format": "Biçim",
+ "fitClip": "Sığdır",
+ "fitClipMany": "{{count}} klip",
+ "fitClipFew": "{{count}} klip",
+ "fitClipOne": "{{count}} klip",
+ "frame": "Çerçeve",
+ "padding": "Dolgu",
+ "title": "Kompozisyon",
+ "blurBg": "Arka Planı Bulanıklaştır",
+ "formatOriginal": "Orijinal",
+ "roundness": "Yuvarlaklık",
+ "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
+ "motionBlur": "Hareket Bulanıklığı"
+ },
+ "audioTrack": {
+ "fadeOut": "Kararma",
+ "importFailed": "Ses eklenemedi",
+ "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
+ "loop": "Döngü",
+ "remove": "Parçayı sil",
+ "mute": "Sessiz",
+ "defaultLabel": "Ses parçası",
+ "add": "Ses parçası ekle",
+ "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "fadeIn": "Açılma"
},
"transcript": {
- "blankedWord": "boşaltıldı",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "noAudio": "Bu medyada ses parçası yok",
+ "insertAria": "Yeni kelime",
+ "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
+ "laneRecording": "Kayıt",
"editWord": "\"{{word}}\" kelimesini düzenle",
+ "laneLabel": "Deşifreyi şuradan oku",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
"editorAria": "{{filename}} dökümü",
+ "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
+ "silence": "[sessizlik {{duration}} sn]",
+ "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
"noTranscript": "Henüz döküm yok",
+ "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
+ "blankedWord": "boşaltıldı",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
"clipLabel": "Klip {{index}}",
- "trimSilence": "Sessizliği kırp ({{duration}} sn)",
- "laneVoiceover": "Dış ses",
- "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
- "transcribeNow": "Şimdi dökümünü çıkar",
+ "transcribing": "Döküm çıkarılıyor…",
"whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
- "removeInserted": "\"{{word}}\" kelimesini sil",
- "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
- "restoreWord": "\"{{word}}\" kelimesini geri al",
- "silence": "[sessizlik {{duration}} sn]",
- "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
- "laneRecording": "Kayıt",
- "noClips": "Henüz klip yok",
- "noAudio": "Bu medyada ses parçası yok",
- "laneLabel": "Deşifreyi şuradan oku",
+ "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
"revertWord": "\"{{original}}\" haline getir",
+ "helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
+ "laneVoiceover": "Dış ses",
+ "noClips": "Henüz klip yok",
+ "trimSilence": "Sessizliği kırp ({{duration}} sn)",
+ "restoreWord": "\"{{word}}\" kelimesini geri al",
"title": "Geçerli döküm",
- "transcribing": "Döküm çıkarılıyor…",
- "insertAria": "Yeni kelime",
- "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun."
+ "transcribeNow": "Şimdi dökümünü çıkar"
+ },
+ "cursor": {
+ "title": "İmleç",
+ "show": "İmleci Göster",
+ "clipToBounds": "Tuvale Kırp",
+ "motionBlur": "Hareket Bulanıklığı",
+ "smoothing": "Yumuşatma",
+ "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
+ "themeDefault": "Varsayılan",
+ "clickBounce": "Tıklama Sıçraması",
+ "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
+ "theme": "İmleç Stili",
+ "size": "Boyut"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
+ "title": "Odak Konumu",
+ "y": "Y (%)"
+ },
+ "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
+ "focusMode": {
+ "lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
+ "manual": "Manuel",
+ "auto": "Otomatik",
+ "title": "Odak Modu",
+ "autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Sağ",
+ "iso": "Iso",
+ "left": "Sol"
+ },
+ "none": "Yok",
+ "title": "3D Döndürme"
+ },
+ "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
+ "deleteZoom": "Yakınlaştırmayı Sil",
+ "customScale": "Özel Yakınlaştırma",
+ "level": "Yakınlaştırma Seviyesi"
+ },
+ "project": {
+ "save": "Projeyi Kaydet",
+ "load": "Proje Yükle",
+ "new": "Yeni Proje"
},
"captions": {
- "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
- "distanceFromTop": "Üstten uzaklık",
- "minWords": "Satır başına en az kelime",
- "showBackground": "Arka planı göster",
- "lineLength": "Satır uzunluğu",
- "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
- "translating": "Çevriliyor…",
- "distanceFromLeft": "Soldan uzaklık",
- "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
- "position": "Konum",
- "translateFailed": "Çeviri başarısız oldu.",
- "backgroundOpacity": "Saydamlık",
- "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
- "original": "Özgün (döküm)",
- "font": "Yazı tipi",
+ "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
"distanceFromRight": "Sağdan uzaklık",
+ "distanceFromBottom": "Alttan uzaklık",
+ "font": "Yazı tipi",
+ "distanceFromTop": "Üstten uzaklık",
+ "alignLeft": "Sol",
+ "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
"textColor": "Metin rengi",
- "alignCenter": "Orta",
- "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
+ "text": "Metin",
+ "anchorTop": "Üst",
"language": "Dil",
+ "translate": "Çevir",
"show": "Altyazıları göster",
- "anchorTop": "Üst",
- "backgroundColor": "Arka plan rengi",
- "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
- "background": "Arka plan",
- "bold": "Kalın",
- "distanceFromBottom": "Alttan uzaklık",
"fontSize": "Boyut",
+ "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
+ "backgroundOpacity": "Saydamlık",
+ "translating": "Çevriliyor…",
"anchorBottom": "Alt",
- "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
- "maxWords": "Satır başına en çok kelime",
- "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
- "translate": "Çevir",
"deleteTranslation": "Bu çeviriyi sil",
+ "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
+ "backgroundColor": "Arka plan rengi",
+ "translateFailed": "Çeviri başarısız oldu.",
"anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
- "text": "Metin",
- "displayLanguage": "Görüntüleme",
+ "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
+ "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
"alignRight": "Sağ",
- "alignLeft": "Sol"
+ "displayLanguage": "Görüntüleme",
+ "distanceFromLeft": "Soldan uzaklık",
+ "minWords": "Satır başına en az kelime",
+ "showBackground": "Arka planı göster",
+ "maxWords": "Satır başına en çok kelime",
+ "bold": "Kalın",
+ "background": "Arka plan",
+ "alignCenter": "Orta",
+ "original": "Özgün (döküm)",
+ "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
+ "lineLength": "Satır uzunluğu",
+ "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
+ "position": "Konum"
},
- "support": {
- "saveDiagnostics": "Teşhis Verilerini Kaydet",
- "reportBug": "Hata Bildir",
- "starOnGithub": "GitHub'da Yıldızla"
+ "textAnimation": {
+ "none": "Yok",
+ "title": "Metin Animasyonu",
+ "pop": "Fırlama",
+ "slideLeft": "Sola Kaydırma",
+ "selectAnimation": "Animasyon seçin",
+ "fade": "Belirme",
+ "rise": "Yükselme",
+ "typewriter": "Daktilo",
+ "pulse": "Nabız"
},
- "customFont": {
- "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
- "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
- "urlLabel": "Google Fonts İçe Aktarım URL'si",
- "addingButton": "Ekleniyor...",
- "namePlaceholder": "Özel Yazı Tipim",
- "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
- "dialogTitle": "Google Yazı Tipi Ekle",
- "failedToAdd": "Yazı tipi eklenemedi",
- "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
- "addButton": "Yazı Tipi Ekle",
- "errorEmptyName": "Lütfen bir yazı tipi adı girin",
- "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
- "nameLabel": "Görünen Ad",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
- "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
- "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin."
+ "gifSettings": {
+ "loop": "GIF Döngüsü",
+ "frameRate": "GIF Kare Hızı",
+ "size": "GIF Boyutu"
},
- "background": {
- "presets": "Ön ayarlar",
- "image": "Görüntü",
- "title": "Arka Plan",
- "custom": "Özel",
- "colorLabel": "Renk {{color}}",
- "imageLabel": "Arka plan {{index}}",
- "customWallpaper": "Özel duvar kâğıdı",
- "colorPalette": "Renk paleti",
- "uploadCustom": "Özel Yükle",
- "color": "Renk",
- "imageReadFailed": "Bu görsel dosyası okunamadı.",
- "gradientLabel": "Gradyan {{index}}",
- "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
- "gradient": "Gradyan",
- "colorWheel": "Renk çarkı",
- "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel."
+ "exportFormat": {
+ "gifDescription": "Paylaşım için hareketli görüntü",
+ "mp4Description": "Yüksek kaliteli video dosyası",
+ "mp4Video": "MP4 Video",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF Animasyon"
},
"audio": {
+ "title": "Ses",
"reset": "Sesi sıfırla",
- "outputGain": "Çıkış seviyesi",
"help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
- "title": "Ses"
+ "outputGain": "Çıkış seviyesi"
},
- "textAnimation": {
- "pulse": "Nabız",
- "selectAnimation": "Animasyon seçin",
- "fade": "Belirme",
- "typewriter": "Daktilo",
- "slideLeft": "Sola Kaydırma",
- "none": "Yok",
- "rise": "Yükselme",
- "pop": "Fırlama",
- "title": "Metin Animasyonu"
+ "language": {
+ "title": "Dil"
+ },
+ "support": {
+ "saveDiagnostics": "Teşhis Verilerini Kaydet",
+ "reportBug": "Hata Bildir",
+ "starOnGithub": "GitHub'da Yıldızla"
},
"crop": {
- "title": "Kırpma",
- "unlockAspectRatio": "En boy oranının kilidini aç",
+ "cropVideo": "Videoyu Kırp",
"free": "Serbest",
- "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
- "done": "Tamam",
"lockAspectRatio": "En boy oranını kilitle",
- "ratio": "Oran",
- "cropVideo": "Videoyu Kırp"
+ "done": "Tamam",
+ "title": "Kırpma",
+ "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
+ "unlockAspectRatio": "En boy oranının kilidini aç",
+ "ratio": "Oran"
+ },
+ "speed": {
+ "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
+ "playbackSpeed": "Oynatma Hızı",
+ "customPlaybackSpeed": "Özel Oynatma Hızı",
+ "deleteRegion": "Hız Bölgesini Sil",
+ "selectRegion": "Ayarlamak için bir hız bölgesi seçin",
+ "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez."
+ },
+ "trim": {
+ "deleteRegion": "Kırpma Bölgesini Sil"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "Dışa aktarma çözünürlüğü"
},
- "effects": {
- "off": "kapalı",
- "on": "açık",
- "fitClip": "Sığdır",
- "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
- "fitClipFew": "{{count}} klip",
- "blurBg": "Arka Planı Bulanıklaştır",
- "motion": "Hareket",
- "shadow": "Gölge",
- "fitClipOne": "{{count}} klip",
- "format": "Biçim",
- "roundness": "Yuvarlaklık",
- "fitClipMany": "{{count}} klip",
- "formatOriginal": "Orijinal",
- "frame": "Çerçeve",
- "motionBlur": "Hareket Bulanıklığı",
- "padding": "Dolgu",
- "title": "Kompozisyon"
- },
- "language": {
- "title": "Dil"
- },
- "gifSettings": {
- "size": "GIF Boyutu",
- "loop": "GIF Döngüsü",
- "frameRate": "GIF Kare Hızı"
+ "facets": {
+ "transcript": "Metin Dökümü",
+ "captions": "Altyazılar"
},
"panes": {
"help": "Yardım"
- },
- "export": {
- "chooseSaveLocation": "Kayıt Konumu Seç",
- "gifButton": "GIF Olarak Dışa Aktar",
- "videoButton": "Videoyu Dışa Aktar"
- },
- "exportFormat": {
- "mp4Video": "MP4 Video",
- "mp4Description": "Yüksek kaliteli video dosyası",
- "gifAnimation": "GIF Animasyon",
- "gifDescription": "Paylaşım için hareketli görüntü",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Projeyi Kaydet",
- "new": "Yeni Proje",
- "load": "Proje Yükle"
- },
- "facets": {
- "captions": "Altyazılar",
- "transcript": "Metin Dökümü"
- },
- "trim": {
- "deleteRegion": "Kırpma Bölgesini Sil"
}
}
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index c88886729..946aa1174 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
- "x": "X (%)",
- "title": "Vị trí tiêu điểm"
- },
- "threeD": {
- "preset": {
- "right": "Phải",
- "left": "Trái",
- "iso": "Đẳng phối"
- },
- "none": "Không",
- "title": "Xoay 3D"
- },
- "focusMode": {
- "manual": "Thủ công",
- "title": "Chế độ lấy nét",
- "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
- "lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
- "auto": "Tự động"
- },
- "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
- "customScale": "Thu phóng tùy chỉnh",
- "previewHold": "Giữ để xem trước hiệu ứng phóng to",
- "level": "Mức độ thu phóng",
- "deleteZoom": "Xóa thu phóng"
- },
- "audioTrack": {
- "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
- "defaultLabel": "Bản âm thanh",
- "fadeOut": "Mờ ra",
- "add": "Thêm bản âm thanh",
- "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
- "loop": "Lặp",
- "remove": "Xóa bản nhạc",
- "fadeIn": "Mờ vào",
- "mute": "Tắt tiếng",
- "importFailed": "Không thể thêm âm thanh"
- },
- "cursor": {
- "clipToBounds": "Cắt theo khung",
- "size": "Kích thước",
- "motionBlur": "Làm mờ chuyển động",
- "theme": "Kiểu con trỏ",
- "clickBounce": "Nảy khi nhấp",
- "title": "Con trỏ",
- "smoothing": "Làm mượt",
- "themeDefault": "Mặc định",
- "show": "Hiện con trỏ",
- "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
- "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy."
- },
"annotation": {
- "blurIntensity": "Cường độ làm mờ",
+ "blurTypeBlur": "Gaussian",
+ "textPlaceholder": "Nhập văn bản của bạn...",
+ "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
+ "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
+ "blurColorBlack": "Đen",
"textContent": "Nội dung văn bản",
- "blurShapeFreehand": "Vẽ tự do",
- "active": "Hoạt động",
- "blurShapeRectangle": "Chữ nhật",
"customFonts": "Phông chữ tùy chỉnh",
+ "clearBackground": "Xóa nền",
"title": "Cài đặt chú thích",
+ "typeBlur": "Làm mờ",
+ "color": "Màu sắc",
"blurShapeOval": "Bầu dục",
- "blurType": "Loại làm mờ",
- "mosaicBlockSize": "Kích thước khối khảm",
- "colorPalette": "Bảng màu",
- "textColor": "Màu văn bản",
- "colorWheel": "Vòng màu",
- "shortcutsAndTips": "Phím tắt & Mẹo",
"defaultText": "Xin chào",
- "clearBackground": "Xóa nền",
- "type": "Loại",
- "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
- "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
+ "blurColor": "Màu làm mờ",
+ "blurShapeFreehand": "Vẽ tự do",
+ "typeImage": "Hình ảnh",
+ "size": "Kích thước",
+ "textColor": "Màu văn bản",
"background": "Nền",
- "blurShape": "Hình dạng làm mờ",
"arrowColor": "Màu mũi tên",
- "invalidImageType": "Loại tệp không hợp lệ",
- "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
- "blurColorBlack": "Đen",
- "blurTypeBlur": "Gaussian",
- "none": "Không có",
+ "arrowDirection": "Hướng mũi tên",
+ "blurTypeMosaic": "Khảm",
"supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
- "size": "Kích thước",
+ "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
+ "blurType": "Loại làm mờ",
+ "deleteAnnotation": "Xóa chú thích",
+ "fontStyle": "Kiểu phông chữ",
+ "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
+ "selectStyle": "Chọn kiểu",
+ "typeText": "Văn bản",
+ "type": "Loại",
+ "strokeWidth": "Độ dày nét: {{width}}px",
+ "shortcutsAndTips": "Phím tắt & Mẹo",
"imageUploadSuccess": "Tải lên hình ảnh thành công!",
+ "mosaicBlockSize": "Kích thước khối khảm",
+ "blurShape": "Hình dạng làm mờ",
+ "none": "Không có",
"blurColorWhite": "Trắng",
- "arrowDirection": "Hướng mũi tên",
- "typeImage": "Hình ảnh",
- "typeText": "Văn bản",
+ "blurShapeRectangle": "Chữ nhật",
+ "colorWheel": "Vòng màu",
"typeArrow": "Mũi tên",
- "color": "Màu sắc",
- "blurColor": "Màu làm mờ",
- "selectStyle": "Chọn kiểu",
- "blurTypeMosaic": "Khảm",
- "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
- "textPlaceholder": "Nhập văn bản của bạn...",
+ "colorPalette": "Bảng màu",
"uploadImage": "Tải lên hình ảnh",
- "strokeWidth": "Độ dày nét: {{width}}px",
- "typeBlur": "Làm mờ",
- "deleteAnnotation": "Xóa chú thích",
- "fontStyle": "Kiểu phông chữ"
+ "invalidImageType": "Loại tệp không hợp lệ",
+ "blurIntensity": "Cường độ làm mờ",
+ "active": "Hoạt động"
+ },
+ "background": {
+ "colorPalette": "Bảng màu",
+ "custom": "Tùy chỉnh",
+ "colorLabel": "Màu {{color}}",
+ "imageLabel": "Nền {{index}}",
+ "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
+ "colorWheel": "Vòng màu",
+ "presets": "Có sẵn",
+ "gradient": "Dải màu",
+ "uploadCustom": "Tải lên tùy chỉnh",
+ "customWallpaper": "Ảnh nền tùy chỉnh",
+ "gradientLabel": "Dải màu {{index}}",
+ "title": "Nền",
+ "color": "Màu sắc",
+ "image": "Hình ảnh",
+ "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
+ "imageReadFailed": "Không thể đọc tệp ảnh này."
+ },
+ "customFont": {
+ "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
+ "errorEmptyName": "Vui lòng nhập tên phông chữ",
+ "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Thêm Google Font",
+ "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
+ "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
+ "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
+ "failedToAdd": "Thêm phông chữ thất bại",
+ "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
+ "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
+ "nameLabel": "Tên hiển thị",
+ "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
+ "addButton": "Thêm phông chữ",
+ "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
+ "urlLabel": "URL nhập Google Fonts",
+ "addingButton": "Đang thêm..."
},
"layout": {
- "webcamCropY": "Dịch chuyển dọc",
- "reactiveWebcam": "Thu nhỏ khi phóng to",
- "shapes": {
- "rectangle": "Chữ nhật",
- "square": "Vuông",
- "rounded": "Bo góc",
- "circle": "Tròn"
- },
- "preset": "Cài đặt sẵn",
- "dualFrame": "Khung kép",
"bgModes": {
- "none": "Gốc",
"transparent": "Tách nền",
"custom": "Tùy chỉnh",
- "blur": "Làm mờ"
+ "blur": "Làm mờ",
+ "none": "Gốc"
},
- "webcamCropZoom": "Thu phóng vùng cắt",
- "webcamShape": "Hình dạng máy ảnh",
- "webcamBlurIntensity": "Độ mờ",
+ "reactiveWebcam": "Thu nhỏ khi phóng to",
+ "webcamCropX": "Dịch chuyển ngang",
+ "webcamBackground": "Nền máy ảnh",
+ "mirrorWebcam": "Lật webcam",
+ "webcamFraming": "Khung hình webcam",
"title": "Bố cục camera",
"reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
- "webcamFraming": "Khung hình webcam",
- "webcamCropX": "Dịch chuyển ngang",
"selectPreset": "Chọn cài đặt sẵn",
+ "noWebcam": "Không có webcam",
+ "webcamShape": "Hình dạng máy ảnh",
"helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
- "mirrorWebcam": "Lật webcam",
"help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
- "webcamBackground": "Nền máy ảnh",
- "noWebcam": "Không có webcam",
- "pictureInPicture": "Hình trong hình",
+ "shapes": {
+ "circle": "Tròn",
+ "square": "Vuông",
+ "rectangle": "Chữ nhật",
+ "rounded": "Bo góc"
+ },
"webcamSize": "Kích thước Webcam",
- "verticalStack": "Xếp chồng dọc"
+ "verticalStack": "Xếp chồng dọc",
+ "webcamBlurIntensity": "Độ mờ",
+ "dualFrame": "Khung kép",
+ "webcamCropY": "Dịch chuyển dọc",
+ "webcamCropZoom": "Thu phóng vùng cắt",
+ "pictureInPicture": "Hình trong hình",
+ "preset": "Cài đặt sẵn"
},
- "speed": {
- "customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
- "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng.",
- "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
- "playbackSpeed": "Tốc độ phát",
- "deleteRegion": "Xóa vùng tốc độ",
- "selectRegion": "Chọn vùng tốc độ để điều chỉnh"
+ "export": {
+ "gifButton": "Xuất GIF",
+ "chooseSaveLocation": "Chọn vị trí lưu",
+ "videoButton": "Xuất Video"
},
"imageUpload": {
- "failedToUpload": "Tải lên hình ảnh thất bại",
+ "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
"uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
"errorReading": "Đã xảy ra lỗi khi đọc tệp.",
- "invalidFileType": "Loại tệp không hợp lệ",
- "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG."
+ "failedToUpload": "Tải lên hình ảnh thất bại",
+ "invalidFileType": "Loại tệp không hợp lệ"
+ },
+ "effects": {
+ "shadow": "Bóng đổ",
+ "motion": "Chuyển động",
+ "off": "tắt",
+ "on": "bật",
+ "format": "Định dạng",
+ "fitClip": "Vừa khít",
+ "fitClipMany": "{{count}} clip",
+ "fitClipFew": "{{count}} clip",
+ "fitClipOne": "{{count}} clip",
+ "frame": "Khung",
+ "padding": "Phần đệm",
+ "title": "Bố cục hình ảnh",
+ "blurBg": "Làm mờ nền",
+ "formatOriginal": "Gốc",
+ "roundness": "Độ bo tròn",
+ "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
+ "motionBlur": "Làm mờ chuyển động"
+ },
+ "audioTrack": {
+ "fadeOut": "Mờ ra",
+ "importFailed": "Không thể thêm âm thanh",
+ "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
+ "loop": "Lặp",
+ "remove": "Xóa bản nhạc",
+ "mute": "Tắt tiếng",
+ "defaultLabel": "Bản âm thanh",
+ "add": "Thêm bản âm thanh",
+ "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "fadeIn": "Mờ vào"
},
"transcript": {
- "blankedWord": "đã xoá",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "noAudio": "Media này không có bản âm thanh",
+ "insertAria": "Từ mới",
+ "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
+ "laneRecording": "Bản ghi",
"editWord": "Sửa \"{{word}}\"",
+ "laneLabel": "Đọc bản chép lời từ",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
"editorAria": "Bản chép lời của {{filename}}",
+ "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
+ "silence": "[khoảng lặng {{duration}} giây]",
+ "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
"noTranscript": "Chưa có bản chép lời",
+ "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
+ "blankedWord": "đã xoá",
+ "removeInserted": "Xoá \"{{word}}\"",
"clipLabel": "Clip {{index}}",
- "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
- "laneVoiceover": "Lời thuyết minh",
- "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
- "transcribeNow": "Chép lời ngay",
+ "transcribing": "Đang chép lời…",
"whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
- "removeInserted": "Xoá \"{{word}}\"",
- "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
- "restoreWord": "Khôi phục \"{{word}}\"",
- "silence": "[khoảng lặng {{duration}} giây]",
- "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
- "laneRecording": "Bản ghi",
- "noClips": "Chưa có clip nào",
- "noAudio": "Media này không có bản âm thanh",
- "laneLabel": "Đọc bản chép lời từ",
+ "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
"revertWord": "Khôi phục \"{{original}}\"",
+ "helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
+ "laneVoiceover": "Lời thuyết minh",
+ "noClips": "Chưa có clip nào",
+ "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
+ "restoreWord": "Khôi phục \"{{word}}\"",
"title": "Bản chép lời hiện tại",
- "transcribing": "Đang chép lời…",
- "insertAria": "Từ mới",
- "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại."
+ "transcribeNow": "Chép lời ngay"
+ },
+ "cursor": {
+ "title": "Con trỏ",
+ "show": "Hiện con trỏ",
+ "clipToBounds": "Cắt theo khung",
+ "motionBlur": "Làm mờ chuyển động",
+ "smoothing": "Làm mượt",
+ "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
+ "themeDefault": "Mặc định",
+ "clickBounce": "Nảy khi nhấp",
+ "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
+ "theme": "Kiểu con trỏ",
+ "size": "Kích thước"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
+ "title": "Vị trí tiêu điểm",
+ "y": "Y (%)"
+ },
+ "previewHold": "Giữ để xem trước hiệu ứng phóng to",
+ "focusMode": {
+ "lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
+ "manual": "Thủ công",
+ "auto": "Tự động",
+ "title": "Chế độ lấy nét",
+ "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi"
+ },
+ "threeD": {
+ "preset": {
+ "right": "Phải",
+ "iso": "Đẳng phối",
+ "left": "Trái"
+ },
+ "none": "Không",
+ "title": "Xoay 3D"
+ },
+ "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
+ "deleteZoom": "Xóa thu phóng",
+ "customScale": "Thu phóng tùy chỉnh",
+ "level": "Mức độ thu phóng"
+ },
+ "project": {
+ "save": "Lưu dự án",
+ "load": "Tải dự án",
+ "new": "Dự án mới"
},
"captions": {
- "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
- "distanceFromTop": "Khoảng cách từ trên",
- "minWords": "Số từ tối thiểu mỗi dòng",
- "showBackground": "Hiện nền",
- "lineLength": "Độ dài dòng",
- "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
- "translating": "Đang dịch…",
- "distanceFromLeft": "Khoảng cách từ trái",
- "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
- "position": "Vị trí",
- "translateFailed": "Dịch thất bại.",
- "backgroundOpacity": "Độ mờ",
- "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
- "original": "Gốc (bản chép lời)",
- "font": "Phông chữ",
+ "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
"distanceFromRight": "Khoảng cách từ phải",
+ "distanceFromBottom": "Khoảng cách từ dưới",
+ "font": "Phông chữ",
+ "distanceFromTop": "Khoảng cách từ trên",
+ "alignLeft": "Trái",
+ "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
"textColor": "Màu chữ",
- "alignCenter": "Giữa",
- "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
+ "text": "Văn bản",
+ "anchorTop": "Trên",
"language": "Ngôn ngữ",
+ "translate": "Dịch",
"show": "Hiện phụ đề",
- "anchorTop": "Trên",
- "backgroundColor": "Màu nền",
- "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
- "background": "Nền",
- "bold": "Đậm",
- "distanceFromBottom": "Khoảng cách từ dưới",
"fontSize": "Cỡ chữ",
+ "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
+ "backgroundOpacity": "Độ mờ",
+ "translating": "Đang dịch…",
"anchorBottom": "Dưới",
- "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
- "maxWords": "Số từ tối đa mỗi dòng",
- "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
- "translate": "Dịch",
"deleteTranslation": "Xóa bản dịch này",
+ "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
+ "backgroundColor": "Màu nền",
+ "translateFailed": "Dịch thất bại.",
"anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
- "text": "Văn bản",
- "displayLanguage": "Hiển thị",
+ "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
+ "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
"alignRight": "Phải",
- "alignLeft": "Trái"
+ "displayLanguage": "Hiển thị",
+ "distanceFromLeft": "Khoảng cách từ trái",
+ "minWords": "Số từ tối thiểu mỗi dòng",
+ "showBackground": "Hiện nền",
+ "maxWords": "Số từ tối đa mỗi dòng",
+ "bold": "Đậm",
+ "background": "Nền",
+ "alignCenter": "Giữa",
+ "original": "Gốc (bản chép lời)",
+ "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
+ "lineLength": "Độ dài dòng",
+ "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
+ "position": "Vị trí"
},
- "support": {
- "saveDiagnostics": "Lưu thông tin chẩn đoán",
- "reportBug": "Báo cáo lỗi",
- "starOnGithub": "Đánh giá sao trên GitHub"
+ "textAnimation": {
+ "none": "Không có",
+ "title": "Hoạt ảnh văn bản",
+ "pop": "Bật lên",
+ "slideLeft": "Trượt sang trái",
+ "selectAnimation": "Chọn hoạt ảnh",
+ "fade": "Mờ dần",
+ "rise": "Trồi lên",
+ "typewriter": "Máy đánh chữ",
+ "pulse": "Nhấp nháy"
},
- "customFont": {
- "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
- "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
- "urlLabel": "URL nhập Google Fonts",
- "addingButton": "Đang thêm...",
- "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
- "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
- "dialogTitle": "Thêm Google Font",
- "failedToAdd": "Thêm phông chữ thất bại",
- "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
- "addButton": "Thêm phông chữ",
- "errorEmptyName": "Vui lòng nhập tên phông chữ",
- "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
- "nameLabel": "Tên hiển thị",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
- "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
- "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại."
+ "gifSettings": {
+ "loop": "Lặp lại GIF",
+ "frameRate": "Tốc độ khung hình GIF",
+ "size": "Kích thước GIF"
},
- "background": {
- "presets": "Có sẵn",
- "image": "Hình ảnh",
- "title": "Nền",
- "custom": "Tùy chỉnh",
- "colorLabel": "Màu {{color}}",
- "imageLabel": "Nền {{index}}",
- "customWallpaper": "Ảnh nền tùy chỉnh",
- "colorPalette": "Bảng màu",
- "uploadCustom": "Tải lên tùy chỉnh",
- "color": "Màu sắc",
- "imageReadFailed": "Không thể đọc tệp ảnh này.",
- "gradientLabel": "Dải màu {{index}}",
- "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
- "gradient": "Dải màu",
- "colorWheel": "Vòng màu",
- "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa."
+ "exportFormat": {
+ "gifDescription": "Hình ảnh động để chia sẻ",
+ "mp4Description": "Tệp video chất lượng cao",
+ "mp4Video": "Video MP4",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "Ảnh động GIF"
},
"audio": {
+ "title": "Âm thanh",
"reset": "Đặt lại âm thanh",
- "outputGain": "Mức đầu ra",
"help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
- "title": "Âm thanh"
+ "outputGain": "Mức đầu ra"
},
- "textAnimation": {
- "pulse": "Nhấp nháy",
- "selectAnimation": "Chọn hoạt ảnh",
- "fade": "Mờ dần",
- "typewriter": "Máy đánh chữ",
- "slideLeft": "Trượt sang trái",
- "none": "Không có",
- "rise": "Trồi lên",
- "pop": "Bật lên",
- "title": "Hoạt ảnh văn bản"
+ "language": {
+ "title": "Ngôn ngữ"
+ },
+ "support": {
+ "saveDiagnostics": "Lưu thông tin chẩn đoán",
+ "reportBug": "Báo cáo lỗi",
+ "starOnGithub": "Đánh giá sao trên GitHub"
},
"crop": {
- "title": "Cắt xén",
- "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
+ "cropVideo": "Cắt xén video",
"free": "Tự do",
- "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
- "done": "Hoàn tất",
"lockAspectRatio": "Khóa tỷ lệ khung hình",
- "ratio": "Tỷ lệ",
- "cropVideo": "Cắt xén video"
+ "done": "Hoàn tất",
+ "title": "Cắt xén",
+ "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
+ "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
+ "ratio": "Tỷ lệ"
+ },
+ "speed": {
+ "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
+ "playbackSpeed": "Tốc độ phát",
+ "customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
+ "deleteRegion": "Xóa vùng tốc độ",
+ "selectRegion": "Chọn vùng tốc độ để điều chỉnh",
+ "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng."
+ },
+ "trim": {
+ "deleteRegion": "Xóa vùng cắt"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "Độ phân giải xuất"
},
- "effects": {
- "off": "tắt",
- "on": "bật",
- "fitClip": "Vừa khít",
- "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
- "fitClipFew": "{{count}} clip",
- "blurBg": "Làm mờ nền",
- "motion": "Chuyển động",
- "shadow": "Bóng đổ",
- "fitClipOne": "{{count}} clip",
- "format": "Định dạng",
- "roundness": "Độ bo tròn",
- "fitClipMany": "{{count}} clip",
- "formatOriginal": "Gốc",
- "frame": "Khung",
- "motionBlur": "Làm mờ chuyển động",
- "padding": "Phần đệm",
- "title": "Bố cục hình ảnh"
- },
- "language": {
- "title": "Ngôn ngữ"
- },
- "gifSettings": {
- "size": "Kích thước GIF",
- "loop": "Lặp lại GIF",
- "frameRate": "Tốc độ khung hình GIF"
+ "facets": {
+ "transcript": "Bản ghi lời thoại",
+ "captions": "Phụ đề"
},
"panes": {
"help": "Trợ giúp"
- },
- "export": {
- "chooseSaveLocation": "Chọn vị trí lưu",
- "gifButton": "Xuất GIF",
- "videoButton": "Xuất Video"
- },
- "exportFormat": {
- "mp4Video": "Video MP4",
- "mp4Description": "Tệp video chất lượng cao",
- "gifAnimation": "Ảnh động GIF",
- "gifDescription": "Hình ảnh động để chia sẻ",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "Lưu dự án",
- "new": "Dự án mới",
- "load": "Tải dự án"
- },
- "facets": {
- "captions": "Phụ đề",
- "transcript": "Bản ghi lời thoại"
- },
- "trim": {
- "deleteRegion": "Xóa vùng cắt"
}
}
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index effa4a2d7..c70efeb8e 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "x": "X (%)",
- "title": "焦点位置"
- },
- "threeD": {
- "preset": {
- "right": "右",
- "left": "左",
- "iso": "Iso"
- },
- "none": "无",
- "title": "3D 旋转"
- },
- "focusMode": {
- "manual": "手动",
- "title": "对焦模式",
- "autoDescription": "摄像头跟随录制时的光标位置",
- "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
- "auto": "自动"
- },
- "selectRegion": "选择要调整的缩放区域",
- "customScale": "自定义缩放",
- "previewHold": "按住预览放大效果",
- "level": "缩放级别",
- "deleteZoom": "删除缩放"
- },
- "audioTrack": {
- "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
- "defaultLabel": "音频轨道",
- "fadeOut": "淡出",
- "add": "添加音频轨道",
- "slipHint": "按住 Alt 拖动可在其中滑动音频",
- "loop": "循环",
- "remove": "删除轨道",
- "fadeIn": "淡入",
- "mute": "静音",
- "importFailed": "无法添加音频"
- },
- "cursor": {
- "clipToBounds": "裁剪到画布",
- "size": "大小",
- "motionBlur": "运动模糊",
- "theme": "光标样式",
- "clickBounce": "点击弹跳",
- "title": "光标",
- "smoothing": "平滑",
- "themeDefault": "默认",
- "show": "显示光标",
- "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
- "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。"
- },
"annotation": {
- "blurIntensity": "模糊强度",
+ "blurTypeBlur": "高斯",
+ "textPlaceholder": "输入您的文本...",
+ "tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
+ "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
+ "blurColorBlack": "黑色",
"textContent": "文本内容",
- "blurShapeFreehand": "自由手绘",
- "active": "活动",
- "blurShapeRectangle": "矩形",
"customFonts": "自定义字体",
+ "clearBackground": "清除背景",
"title": "标注设置",
+ "typeBlur": "模糊",
+ "color": "颜色",
"blurShapeOval": "椭圆",
- "blurType": "模糊类型",
- "mosaicBlockSize": "马赛克块大小",
- "colorPalette": "颜色调色板",
- "textColor": "文本颜色",
- "colorWheel": "颜色轮",
- "shortcutsAndTips": "快捷键与提示",
"defaultText": "你好",
- "clearBackground": "清除背景",
- "type": "类型",
- "tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
- "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
+ "blurColor": "模糊颜色",
+ "blurShapeFreehand": "自由手绘",
+ "typeImage": "图片",
+ "size": "大小",
+ "textColor": "文本颜色",
"background": "背景",
- "blurShape": "模糊形状",
"arrowColor": "箭头颜色",
- "invalidImageType": "无效的文件类型",
- "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
- "blurColorBlack": "黑色",
- "blurTypeBlur": "高斯",
- "none": "无",
+ "arrowDirection": "箭头方向",
+ "blurTypeMosaic": "马赛克",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
- "size": "大小",
+ "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+ "blurType": "模糊类型",
+ "deleteAnnotation": "删除标注",
+ "fontStyle": "字体样式",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
+ "selectStyle": "选择样式",
+ "typeText": "文本",
+ "type": "类型",
+ "strokeWidth": "描边宽度:{{width}}px",
+ "shortcutsAndTips": "快捷键与提示",
"imageUploadSuccess": "图片上传成功!",
+ "mosaicBlockSize": "马赛克块大小",
+ "blurShape": "模糊形状",
+ "none": "无",
"blurColorWhite": "白色",
- "arrowDirection": "箭头方向",
- "typeImage": "图片",
- "typeText": "文本",
+ "blurShapeRectangle": "矩形",
+ "colorWheel": "颜色轮",
"typeArrow": "箭头",
- "color": "颜色",
- "blurColor": "模糊颜色",
- "selectStyle": "选择样式",
- "blurTypeMosaic": "马赛克",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
- "textPlaceholder": "输入您的文本...",
+ "colorPalette": "颜色调色板",
"uploadImage": "上传图片",
- "strokeWidth": "描边宽度:{{width}}px",
- "typeBlur": "模糊",
- "deleteAnnotation": "删除标注",
- "fontStyle": "字体样式"
+ "invalidImageType": "无效的文件类型",
+ "blurIntensity": "模糊强度",
+ "active": "活动"
+ },
+ "background": {
+ "colorPalette": "颜色调色板",
+ "custom": "自定义",
+ "colorLabel": "颜色 {{color}}",
+ "imageLabel": "背景 {{index}}",
+ "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
+ "colorWheel": "颜色轮",
+ "presets": "预设",
+ "gradient": "渐变",
+ "uploadCustom": "上传自定义",
+ "customWallpaper": "自定义壁纸",
+ "gradientLabel": "渐变 {{index}}",
+ "title": "背景",
+ "color": "颜色",
+ "image": "图片",
+ "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
+ "imageReadFailed": "无法读取该图片文件。"
+ },
+ "customFont": {
+ "nameHelp": "这是字体在字体选择器中显示的名称",
+ "errorEmptyName": "请输入字体名称",
+ "errorExtractFailed": "无法从 URL 中提取字体系列",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "添加 Google 字体",
+ "errorInvalidUrl": "请输入有效的 Google Fonts URL",
+ "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
+ "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
+ "failedToAdd": "添加字体失败",
+ "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
+ "namePlaceholder": "我的自定义字体",
+ "nameLabel": "显示名称",
+ "successMessage": "字体 \"{{fontName}}\" 添加成功",
+ "addButton": "添加字体",
+ "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
+ "urlLabel": "Google Fonts 导入 URL",
+ "addingButton": "添加中..."
},
"layout": {
- "webcamCropY": "垂直移动",
- "reactiveWebcam": "缩放时缩小",
- "shapes": {
- "rectangle": "矩形",
- "square": "正方形",
- "rounded": "圆角",
- "circle": "圆形"
- },
- "preset": "预设",
- "dualFrame": "双画框",
"bgModes": {
- "none": "原画",
"transparent": "抠图",
"custom": "自定义",
- "blur": "模糊"
+ "blur": "模糊",
+ "none": "原画"
},
- "webcamCropZoom": "裁剪缩放",
- "webcamShape": "摄像头形状",
- "webcamBlurIntensity": "模糊强度",
+ "reactiveWebcam": "缩放时缩小",
+ "webcamCropX": "水平移动",
+ "webcamBackground": "摄像头背景",
+ "mirrorWebcam": "镜像摄像头",
+ "webcamFraming": "摄像头构图",
"title": "摄像头布局",
"reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
- "webcamFraming": "摄像头构图",
- "webcamCropX": "水平移动",
"selectPreset": "选择预设",
+ "noWebcam": "无摄像头",
+ "webcamShape": "摄像头形状",
"helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
- "mirrorWebcam": "镜像摄像头",
"help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
- "webcamBackground": "摄像头背景",
- "noWebcam": "无摄像头",
- "pictureInPicture": "画中画",
+ "shapes": {
+ "circle": "圆形",
+ "square": "正方形",
+ "rectangle": "矩形",
+ "rounded": "圆角"
+ },
"webcamSize": "摄像头大小",
- "verticalStack": "垂直堆叠"
+ "verticalStack": "垂直堆叠",
+ "webcamBlurIntensity": "模糊强度",
+ "dualFrame": "双画框",
+ "webcamCropY": "垂直移动",
+ "webcamCropZoom": "裁剪缩放",
+ "pictureInPicture": "画中画",
+ "preset": "预设"
},
- "speed": {
- "customPlaybackSpeed": "自定义播放速度",
- "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。",
- "maxSpeedError": "速度不能超过 {{max}}×",
- "playbackSpeed": "播放速度",
- "deleteRegion": "删除速度区域",
- "selectRegion": "选择要调整的速度区域"
+ "export": {
+ "gifButton": "导出 GIF",
+ "chooseSaveLocation": "选择保存位置",
+ "videoButton": "导出视频"
},
"imageUpload": {
- "failedToUpload": "上传图片失败",
+ "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。",
"uploadSuccess": "自定义图片上传成功!",
"errorReading": "读取文件时出错。",
- "invalidFileType": "无效的文件类型",
- "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。"
+ "failedToUpload": "上传图片失败",
+ "invalidFileType": "无效的文件类型"
+ },
+ "effects": {
+ "shadow": "阴影",
+ "motion": "运动",
+ "off": "关",
+ "on": "开",
+ "format": "格式",
+ "fitClip": "适配",
+ "fitClipMany": "{{count}} 个片段",
+ "fitClipFew": "{{count}} 个片段",
+ "fitClipOne": "{{count}} 个片段",
+ "frame": "画框",
+ "padding": "内边距",
+ "title": "画面合成",
+ "blurBg": "模糊背景",
+ "formatOriginal": "原始",
+ "roundness": "圆角",
+ "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
+ "motionBlur": "运动模糊"
+ },
+ "audioTrack": {
+ "fadeOut": "淡出",
+ "importFailed": "无法添加音频",
+ "slipHint": "按住 Alt 拖动可在其中滑动音频",
+ "loop": "循环",
+ "remove": "删除轨道",
+ "mute": "静音",
+ "defaultLabel": "音频轨道",
+ "add": "添加音频轨道",
+ "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "fadeIn": "淡入"
},
"transcript": {
- "blankedWord": "已清空",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。将鼠标悬停在带标记的词上可撤销。",
+ "noAudio": "此媒体没有音频轨道",
+ "insertAria": "新词",
+ "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
+ "laneRecording": "录制",
"editWord": "编辑“{{word}}”",
+ "laneLabel": "转写文本读取自",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
"editorAria": "{{filename}} 的转录",
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "silence": "[静音 {{duration}} 秒]",
+ "correctedWord": "已更正 — 转录原文为“{{original}}”",
"noTranscript": "暂无转录",
+ "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
+ "blankedWord": "已清空",
+ "removeInserted": "删除“{{word}}”",
"clipLabel": "片段 {{index}}",
- "trimSilence": "修剪静音({{duration}} 秒)",
- "laneVoiceover": "配音",
- "restoreSilence": "恢复静音({{duration}} 秒)",
- "transcribeNow": "立即转录",
+ "transcribing": "转录中…",
"whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
- "removeInserted": "删除“{{word}}”",
- "insertedWord": "你添加的词 — 背后没有声音",
- "restoreWord": "恢复“{{word}}”",
- "silence": "[静音 {{duration}} 秒]",
- "correctedWord": "已更正 — 转录原文为“{{original}}”",
- "laneRecording": "录制",
- "noClips": "暂无片段",
- "noAudio": "此媒体没有音频轨道",
- "laneLabel": "转写文本读取自",
+ "restoreSilence": "恢复静音({{duration}} 秒)",
"revertWord": "还原为“{{original}}”",
+ "helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
+ "laneVoiceover": "配音",
+ "noClips": "暂无片段",
+ "trimSilence": "修剪静音({{duration}} 秒)",
+ "restoreWord": "恢复“{{word}}”",
"title": "当前转录",
- "transcribing": "转录中…",
- "insertAria": "新词",
- "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。"
+ "transcribeNow": "立即转录"
+ },
+ "cursor": {
+ "title": "光标",
+ "show": "显示光标",
+ "clipToBounds": "裁剪到画布",
+ "motionBlur": "运动模糊",
+ "smoothing": "平滑",
+ "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
+ "themeDefault": "默认",
+ "clickBounce": "点击弹跳",
+ "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
+ "theme": "光标样式",
+ "size": "大小"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "title": "焦点位置",
+ "y": "Y (%)"
+ },
+ "previewHold": "按住预览放大效果",
+ "focusMode": {
+ "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
+ "manual": "手动",
+ "auto": "自动",
+ "title": "对焦模式",
+ "autoDescription": "摄像头跟随录制时的光标位置"
+ },
+ "threeD": {
+ "preset": {
+ "right": "右",
+ "iso": "Iso",
+ "left": "左"
+ },
+ "none": "无",
+ "title": "3D 旋转"
+ },
+ "selectRegion": "选择要调整的缩放区域",
+ "deleteZoom": "删除缩放",
+ "customScale": "自定义缩放",
+ "level": "缩放级别"
+ },
+ "project": {
+ "save": "保存项目",
+ "load": "加载项目",
+ "new": "新建项目"
},
"captions": {
- "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
- "distanceFromTop": "距顶部",
- "minWords": "每行最少词数",
- "showBackground": "显示背景",
- "lineLength": "行长",
- "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
- "translating": "翻译中…",
- "distanceFromLeft": "距左侧",
- "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
- "position": "位置",
- "translateFailed": "翻译失败。",
- "backgroundOpacity": "不透明度",
- "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
- "original": "原文(转录)",
- "font": "字体",
+ "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
"distanceFromRight": "距右侧",
+ "distanceFromBottom": "距底部",
+ "font": "字体",
+ "distanceFromTop": "距顶部",
+ "alignLeft": "左对齐",
+ "removeLegacyAnnotations": "移除旧的字幕批注",
"textColor": "文字颜色",
- "alignCenter": "居中",
- "translateHint": "使用已配置的 AI 提供方翻译转录",
+ "text": "文本",
+ "anchorTop": "顶部",
"language": "语言",
+ "translate": "翻译",
"show": "显示字幕",
- "anchorTop": "顶部",
- "backgroundColor": "背景颜色",
- "removeLegacyAnnotations": "移除旧的字幕批注",
- "background": "背景",
- "bold": "粗体",
- "distanceFromBottom": "距底部",
"fontSize": "字号",
+ "translateHint": "使用已配置的 AI 提供方翻译转录",
+ "backgroundOpacity": "不透明度",
+ "translating": "翻译中…",
"anchorBottom": "底部",
- "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
- "maxWords": "每行最多词数",
- "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
- "translate": "翻译",
"deleteTranslation": "删除此翻译",
+ "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
+ "backgroundColor": "背景颜色",
+ "translateFailed": "翻译失败。",
"anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
- "text": "文本",
- "displayLanguage": "显示",
+ "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
+ "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
"alignRight": "右对齐",
- "alignLeft": "左对齐"
+ "displayLanguage": "显示",
+ "distanceFromLeft": "距左侧",
+ "minWords": "每行最少词数",
+ "showBackground": "显示背景",
+ "maxWords": "每行最多词数",
+ "bold": "粗体",
+ "background": "背景",
+ "alignCenter": "居中",
+ "original": "原文(转录)",
+ "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
+ "lineLength": "行长",
+ "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
+ "position": "位置"
},
- "support": {
- "saveDiagnostics": "保存诊断信息",
- "reportBug": "报告错误",
- "starOnGithub": "在 GitHub 上加星"
+ "textAnimation": {
+ "none": "无",
+ "title": "文本动画",
+ "pop": "弹出",
+ "slideLeft": "向左滑动",
+ "selectAnimation": "选择动画",
+ "fade": "淡入淡出",
+ "rise": "上升",
+ "typewriter": "打字机",
+ "pulse": "脉动"
},
- "customFont": {
- "nameHelp": "这是字体在字体选择器中显示的名称",
- "errorInvalidUrl": "请输入有效的 Google Fonts URL",
- "urlLabel": "Google Fonts 导入 URL",
- "addingButton": "添加中...",
- "namePlaceholder": "我的自定义字体",
- "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
- "dialogTitle": "添加 Google 字体",
- "failedToAdd": "添加字体失败",
- "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
- "addButton": "添加字体",
- "errorEmptyName": "请输入字体名称",
- "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
- "nameLabel": "显示名称",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "无法从 URL 中提取字体系列",
- "successMessage": "字体 \"{{fontName}}\" 添加成功",
- "errorTimeout": "字体加载时间过长。请检查 URL 并重试。"
+ "gifSettings": {
+ "loop": "循环 GIF",
+ "frameRate": "GIF 帧率",
+ "size": "GIF 尺寸"
},
- "background": {
- "presets": "预设",
- "image": "图片",
- "title": "背景",
- "custom": "自定义",
- "colorLabel": "颜色 {{color}}",
- "imageLabel": "背景 {{index}}",
- "customWallpaper": "自定义壁纸",
- "colorPalette": "颜色调色板",
- "uploadCustom": "上传自定义",
- "color": "颜色",
- "imageReadFailed": "无法读取该图片文件。",
- "gradientLabel": "渐变 {{index}}",
- "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
- "gradient": "渐变",
- "colorWheel": "颜色轮",
- "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。"
+ "exportFormat": {
+ "gifDescription": "可分享的动态图片",
+ "mp4Description": "高质量视频文件",
+ "mp4Video": "MP4 视频",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF 动画"
},
"audio": {
+ "title": "音频",
"reset": "重置音频",
- "outputGain": "输出电平",
"help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
- "title": "音频"
+ "outputGain": "输出电平"
},
- "textAnimation": {
- "pulse": "脉动",
- "selectAnimation": "选择动画",
- "fade": "淡入淡出",
- "typewriter": "打字机",
- "slideLeft": "向左滑动",
- "none": "无",
- "rise": "上升",
- "pop": "弹出",
- "title": "文本动画"
+ "language": {
+ "title": "语言"
+ },
+ "support": {
+ "saveDiagnostics": "保存诊断信息",
+ "reportBug": "报告错误",
+ "starOnGithub": "在 GitHub 上加星"
},
"crop": {
- "title": "裁剪",
- "unlockAspectRatio": "解锁宽高比",
+ "cropVideo": "裁剪视频",
"free": "自由",
- "dragInstruction": "拖动每一侧来调整裁剪区域",
- "done": "完成",
"lockAspectRatio": "锁定宽高比",
- "ratio": "比例",
- "cropVideo": "裁剪视频"
+ "done": "完成",
+ "title": "裁剪",
+ "dragInstruction": "拖动每一侧来调整裁剪区域",
+ "unlockAspectRatio": "解锁宽高比",
+ "ratio": "比例"
+ },
+ "speed": {
+ "maxSpeedError": "速度不能超过 {{max}}×",
+ "playbackSpeed": "播放速度",
+ "customPlaybackSpeed": "自定义播放速度",
+ "deleteRegion": "删除速度区域",
+ "selectRegion": "选择要调整的速度区域",
+ "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。"
+ },
+ "trim": {
+ "deleteRegion": "删除剪辑区域"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "导出分辨率"
},
- "effects": {
- "off": "关",
- "on": "开",
- "fitClip": "适配",
- "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
- "fitClipFew": "{{count}} 个片段",
- "blurBg": "模糊背景",
- "motion": "运动",
- "shadow": "阴影",
- "fitClipOne": "{{count}} 个片段",
- "format": "格式",
- "roundness": "圆角",
- "fitClipMany": "{{count}} 个片段",
- "formatOriginal": "原始",
- "frame": "画框",
- "motionBlur": "运动模糊",
- "padding": "内边距",
- "title": "画面合成"
- },
- "language": {
- "title": "语言"
- },
- "gifSettings": {
- "size": "GIF 尺寸",
- "loop": "循环 GIF",
- "frameRate": "GIF 帧率"
+ "facets": {
+ "transcript": "转录文本",
+ "captions": "字幕"
},
"panes": {
"help": "帮助"
- },
- "export": {
- "chooseSaveLocation": "选择保存位置",
- "gifButton": "导出 GIF",
- "videoButton": "导出视频"
- },
- "exportFormat": {
- "mp4Video": "MP4 视频",
- "mp4Description": "高质量视频文件",
- "gifAnimation": "GIF 动画",
- "gifDescription": "可分享的动态图片",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "保存项目",
- "new": "新建项目",
- "load": "加载项目"
- },
- "facets": {
- "captions": "字幕",
- "transcript": "转录文本"
- },
- "trim": {
- "deleteRegion": "删除剪辑区域"
}
}
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 912e93a72..5a416e13e 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -1,291 +1,342 @@
{
- "zoom": {
- "position": {
- "y": "Y (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "x": "X (%)",
- "title": "焦點位置"
- },
- "threeD": {
- "preset": {
- "right": "右",
- "left": "左",
- "iso": "Iso"
- },
- "none": "無",
- "title": "3D 旋轉"
- },
- "focusMode": {
- "manual": "手動",
- "title": "對焦模式",
- "autoDescription": "攝影機跟隨錄製時的游標位置",
- "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
- "auto": "自動"
- },
- "selectRegion": "選擇要調整的縮放區域",
- "customScale": "自訂縮放",
- "previewHold": "按住預覽放大效果",
- "level": "縮放級別",
- "deleteZoom": "刪除縮放"
- },
- "audioTrack": {
- "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
- "defaultLabel": "音訊軌道",
- "fadeOut": "淡出",
- "add": "新增音訊軌道",
- "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
- "loop": "循環",
- "remove": "刪除軌道",
- "fadeIn": "淡入",
- "mute": "靜音",
- "importFailed": "無法新增音訊"
- },
- "cursor": {
- "clipToBounds": "裁切至畫布",
- "size": "大小",
- "motionBlur": "動態模糊",
- "theme": "游標樣式",
- "clickBounce": "點擊彈跳",
- "title": "游標",
- "smoothing": "平滑",
- "themeDefault": "預設",
- "show": "顯示游標",
- "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
- "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。"
- },
"annotation": {
- "blurIntensity": "模糊強度",
+ "blurTypeBlur": "高斯",
+ "textPlaceholder": "輸入您的文字...",
+ "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
+ "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
+ "blurColorBlack": "黑色",
"textContent": "文字內容",
- "blurShapeFreehand": "自由手繪",
- "active": "啟用",
- "blurShapeRectangle": "矩形",
"customFonts": "自訂字體",
+ "clearBackground": "清除背景",
"title": "標註設定",
+ "typeBlur": "模糊",
+ "color": "顏色",
"blurShapeOval": "橢圓",
- "blurType": "模糊類型",
- "mosaicBlockSize": "馬賽克區塊大小",
- "colorPalette": "調色盤",
- "textColor": "文字顏色",
- "colorWheel": "色輪",
- "shortcutsAndTips": "快捷鍵與提示",
"defaultText": "你好",
- "clearBackground": "清除背景",
- "type": "類型",
- "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
- "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
+ "blurColor": "模糊顏色",
+ "blurShapeFreehand": "自由手繪",
+ "typeImage": "圖片",
+ "size": "大小",
+ "textColor": "文字顏色",
"background": "背景",
- "blurShape": "模糊形狀",
"arrowColor": "箭頭顏色",
- "invalidImageType": "無效的檔案類型",
- "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
- "blurColorBlack": "黑色",
- "blurTypeBlur": "高斯",
- "none": "無",
+ "arrowDirection": "箭頭方向",
+ "blurTypeMosaic": "馬賽克",
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
- "size": "大小",
+ "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "blurType": "模糊類型",
+ "deleteAnnotation": "刪除標註",
+ "fontStyle": "字體樣式",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "selectStyle": "選擇樣式",
+ "typeText": "文字",
+ "type": "類型",
+ "strokeWidth": "描邊寬度:{{width}}px",
+ "shortcutsAndTips": "快捷鍵與提示",
"imageUploadSuccess": "圖片上傳成功!",
+ "mosaicBlockSize": "馬賽克區塊大小",
+ "blurShape": "模糊形狀",
+ "none": "無",
"blurColorWhite": "白色",
- "arrowDirection": "箭頭方向",
- "typeImage": "圖片",
- "typeText": "文字",
+ "blurShapeRectangle": "矩形",
+ "colorWheel": "色輪",
"typeArrow": "箭頭",
- "color": "顏色",
- "blurColor": "模糊顏色",
- "selectStyle": "選擇樣式",
- "blurTypeMosaic": "馬賽克",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
- "textPlaceholder": "輸入您的文字...",
+ "colorPalette": "調色盤",
"uploadImage": "上傳圖片",
- "strokeWidth": "描邊寬度:{{width}}px",
- "typeBlur": "模糊",
- "deleteAnnotation": "刪除標註",
- "fontStyle": "字體樣式"
+ "invalidImageType": "無效的檔案類型",
+ "blurIntensity": "模糊強度",
+ "active": "啟用"
+ },
+ "background": {
+ "colorPalette": "調色盤",
+ "custom": "自訂",
+ "colorLabel": "顏色 {{color}}",
+ "imageLabel": "背景 {{index}}",
+ "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
+ "colorWheel": "色輪",
+ "presets": "預設",
+ "gradient": "漸層",
+ "uploadCustom": "上傳自訂",
+ "customWallpaper": "自訂桌布",
+ "gradientLabel": "漸層 {{index}}",
+ "title": "背景",
+ "color": "顏色",
+ "image": "圖片",
+ "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
+ "imageReadFailed": "無法讀取該圖片檔案。"
+ },
+ "customFont": {
+ "nameHelp": "這是字體在字體選擇器中顯示的名稱",
+ "errorEmptyName": "請輸入字體名稱",
+ "errorExtractFailed": "無法從 URL 中提取字體系列",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "新增 Google 字體",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
+ "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
+ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
+ "failedToAdd": "新增字體失敗",
+ "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "namePlaceholder": "我的自訂字體",
+ "nameLabel": "顯示名稱",
+ "successMessage": "字體 \"{{fontName}}\" 新增成功",
+ "addButton": "新增字體",
+ "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
+ "urlLabel": "Google Fonts 匯入 URL",
+ "addingButton": "新增中..."
},
"layout": {
- "webcamCropY": "垂直移動",
- "reactiveWebcam": "縮放時縮小",
- "shapes": {
- "rectangle": "矩形",
- "square": "正方形",
- "rounded": "圓角",
- "circle": "圓形"
- },
- "preset": "預設",
- "dualFrame": "雙畫框",
"bgModes": {
- "none": "原畫",
"transparent": "去背",
"custom": "自訂",
- "blur": "模糊"
+ "blur": "模糊",
+ "none": "原畫"
},
- "webcamCropZoom": "裁切縮放",
- "webcamShape": "攝影機形狀",
- "webcamBlurIntensity": "模糊強度",
+ "reactiveWebcam": "縮放時縮小",
+ "webcamCropX": "水平移動",
+ "webcamBackground": "攝影機背景",
+ "mirrorWebcam": "鏡像攝影機",
+ "webcamFraming": "攝影機構圖",
"title": "攝影機版面",
"reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
- "webcamFraming": "攝影機構圖",
- "webcamCropX": "水平移動",
"selectPreset": "選擇預設",
+ "noWebcam": "無網路攝影機",
+ "webcamShape": "攝影機形狀",
"helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
- "mirrorWebcam": "鏡像攝影機",
"help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
- "webcamBackground": "攝影機背景",
- "noWebcam": "無網路攝影機",
- "pictureInPicture": "子母畫面",
+ "shapes": {
+ "circle": "圓形",
+ "square": "正方形",
+ "rectangle": "矩形",
+ "rounded": "圓角"
+ },
"webcamSize": "攝影機大小",
- "verticalStack": "垂直堆疊"
+ "verticalStack": "垂直堆疊",
+ "webcamBlurIntensity": "模糊強度",
+ "dualFrame": "雙畫框",
+ "webcamCropY": "垂直移動",
+ "webcamCropZoom": "裁切縮放",
+ "pictureInPicture": "子母畫面",
+ "preset": "預設"
},
- "speed": {
- "customPlaybackSpeed": "自訂播放速度",
- "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。",
- "maxSpeedError": "速度不能超過 {{max}}×",
- "playbackSpeed": "播放速度",
- "deleteRegion": "刪除速度區域",
- "selectRegion": "選擇要調整的速度區域"
+ "export": {
+ "gifButton": "匯出 GIF",
+ "chooseSaveLocation": "選擇儲存位置",
+ "videoButton": "匯出影片"
},
"imageUpload": {
- "failedToUpload": "上傳圖片失敗",
+ "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。",
"uploadSuccess": "自訂圖片上傳成功!",
"errorReading": "讀取檔案時出錯。",
- "invalidFileType": "無效的檔案類型",
- "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。"
+ "failedToUpload": "上傳圖片失敗",
+ "invalidFileType": "無效的檔案類型"
+ },
+ "effects": {
+ "shadow": "陰影",
+ "motion": "動態",
+ "off": "關",
+ "on": "開",
+ "format": "格式",
+ "fitClip": "符合",
+ "fitClipMany": "{{count}} 個片段",
+ "fitClipFew": "{{count}} 個片段",
+ "fitClipOne": "{{count}} 個片段",
+ "frame": "外框",
+ "padding": "內邊距",
+ "title": "畫面合成",
+ "blurBg": "模糊背景",
+ "formatOriginal": "原始",
+ "roundness": "圓角",
+ "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
+ "motionBlur": "動態模糊"
+ },
+ "audioTrack": {
+ "fadeOut": "淡出",
+ "importFailed": "無法新增音訊",
+ "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
+ "loop": "循環",
+ "remove": "刪除軌道",
+ "mute": "靜音",
+ "defaultLabel": "音訊軌道",
+ "add": "新增音訊軌道",
+ "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "fadeIn": "淡入"
},
"transcript": {
- "blankedWord": "已清空",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。將滑鼠移到有標記的字上即可復原。",
+ "noAudio": "此媒體沒有音訊軌道",
+ "insertAria": "新字詞",
+ "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
+ "laneRecording": "錄影",
"editWord": "編輯「{{word}}」",
+ "laneLabel": "轉錄文字讀取自",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
"editorAria": "{{filename}} 的逐字稿",
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "silence": "[靜音 {{duration}} 秒]",
+ "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
"noTranscript": "尚無逐字稿",
+ "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
+ "blankedWord": "已清空",
+ "removeInserted": "刪除「{{word}}」",
"clipLabel": "片段 {{index}}",
- "trimSilence": "修剪靜音({{duration}} 秒)",
- "laneVoiceover": "旁白",
- "restoreSilence": "還原靜音({{duration}} 秒)",
- "transcribeNow": "立即產生逐字稿",
+ "transcribing": "轉錄中…",
"whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
- "removeInserted": "刪除「{{word}}」",
- "insertedWord": "你加入的字詞 — 背後沒有聲音",
- "restoreWord": "還原「{{word}}」",
- "silence": "[靜音 {{duration}} 秒]",
- "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
- "laneRecording": "錄影",
- "noClips": "尚無片段",
- "noAudio": "此媒體沒有音訊軌道",
- "laneLabel": "轉錄文字讀取自",
+ "restoreSilence": "還原靜音({{duration}} 秒)",
"revertWord": "還原為「{{original}}」",
+ "helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
+ "laneVoiceover": "旁白",
+ "noClips": "尚無片段",
+ "trimSilence": "修剪靜音({{duration}} 秒)",
+ "restoreWord": "還原「{{word}}」",
"title": "目前的逐字稿",
- "transcribing": "轉錄中…",
- "insertAria": "新字詞",
- "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。"
+ "transcribeNow": "立即產生逐字稿"
+ },
+ "cursor": {
+ "title": "游標",
+ "show": "顯示游標",
+ "clipToBounds": "裁切至畫布",
+ "motionBlur": "動態模糊",
+ "smoothing": "平滑",
+ "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
+ "themeDefault": "預設",
+ "clickBounce": "點擊彈跳",
+ "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
+ "theme": "游標樣式",
+ "size": "大小"
+ },
+ "zoom": {
+ "position": {
+ "x": "X (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "title": "焦點位置",
+ "y": "Y (%)"
+ },
+ "previewHold": "按住預覽放大效果",
+ "focusMode": {
+ "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
+ "manual": "手動",
+ "auto": "自動",
+ "title": "對焦模式",
+ "autoDescription": "攝影機跟隨錄製時的游標位置"
+ },
+ "threeD": {
+ "preset": {
+ "right": "右",
+ "iso": "Iso",
+ "left": "左"
+ },
+ "none": "無",
+ "title": "3D 旋轉"
+ },
+ "selectRegion": "選擇要調整的縮放區域",
+ "deleteZoom": "刪除縮放",
+ "customScale": "自訂縮放",
+ "level": "縮放級別"
+ },
+ "project": {
+ "save": "儲存專案",
+ "load": "載入專案",
+ "new": "新增專案"
},
"captions": {
- "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
- "distanceFromTop": "距上緣",
- "minWords": "每行最少字數",
- "showBackground": "顯示背景",
- "lineLength": "行長",
- "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
- "translating": "翻譯中…",
- "distanceFromLeft": "距左緣",
- "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
- "position": "位置",
- "translateFailed": "翻譯失敗。",
- "backgroundOpacity": "不透明度",
- "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
- "original": "原文(逐字稿)",
- "font": "字型",
+ "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
"distanceFromRight": "距右緣",
+ "distanceFromBottom": "距下緣",
+ "font": "字型",
+ "distanceFromTop": "距上緣",
+ "alignLeft": "靠左",
+ "removeLegacyAnnotations": "移除舊的字幕註解",
"textColor": "文字顏色",
- "alignCenter": "置中",
- "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
+ "text": "文字",
+ "anchorTop": "上",
"language": "語言",
+ "translate": "翻譯",
"show": "顯示字幕",
- "anchorTop": "上",
- "backgroundColor": "背景顏色",
- "removeLegacyAnnotations": "移除舊的字幕註解",
- "background": "背景",
- "bold": "粗體",
- "distanceFromBottom": "距下緣",
"fontSize": "大小",
+ "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
+ "backgroundOpacity": "不透明度",
+ "translating": "翻譯中…",
"anchorBottom": "下",
- "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
- "maxWords": "每行最多字數",
- "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
- "translate": "翻譯",
"deleteTranslation": "刪除這個翻譯",
+ "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
+ "backgroundColor": "背景顏色",
+ "translateFailed": "翻譯失敗。",
"anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
- "text": "文字",
- "displayLanguage": "顯示",
+ "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
+ "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
"alignRight": "靠右",
- "alignLeft": "靠左"
+ "displayLanguage": "顯示",
+ "distanceFromLeft": "距左緣",
+ "minWords": "每行最少字數",
+ "showBackground": "顯示背景",
+ "maxWords": "每行最多字數",
+ "bold": "粗體",
+ "background": "背景",
+ "alignCenter": "置中",
+ "original": "原文(逐字稿)",
+ "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
+ "lineLength": "行長",
+ "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
+ "position": "位置"
},
- "support": {
- "saveDiagnostics": "儲存診斷資料",
- "reportBug": "回報錯誤",
- "starOnGithub": "在 GitHub 上加星"
+ "textAnimation": {
+ "none": "無",
+ "title": "文字動畫",
+ "pop": "彈出",
+ "slideLeft": "向左滑動",
+ "selectAnimation": "選擇動畫",
+ "fade": "淡入淡出",
+ "rise": "上升",
+ "typewriter": "打字機",
+ "pulse": "脈動"
},
- "customFont": {
- "nameHelp": "這是字體在字體選擇器中顯示的名稱",
- "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
- "urlLabel": "Google Fonts 匯入 URL",
- "addingButton": "新增中...",
- "namePlaceholder": "我的自訂字體",
- "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
- "dialogTitle": "新增 Google 字體",
- "failedToAdd": "新增字體失敗",
- "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
- "addButton": "新增字體",
- "errorEmptyName": "請輸入字體名稱",
- "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
- "nameLabel": "顯示名稱",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorExtractFailed": "無法從 URL 中提取字體系列",
- "successMessage": "字體 \"{{fontName}}\" 新增成功",
- "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。"
+ "gifSettings": {
+ "loop": "循環 GIF",
+ "frameRate": "GIF 影格率",
+ "size": "GIF 尺寸"
},
- "background": {
- "presets": "預設",
- "image": "圖片",
- "title": "背景",
- "custom": "自訂",
- "colorLabel": "顏色 {{color}}",
- "imageLabel": "背景 {{index}}",
- "customWallpaper": "自訂桌布",
- "colorPalette": "調色盤",
- "uploadCustom": "上傳自訂",
- "color": "顏色",
- "imageReadFailed": "無法讀取該圖片檔案。",
- "gradientLabel": "漸層 {{index}}",
- "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
- "gradient": "漸層",
- "colorWheel": "色輪",
- "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。"
+ "exportFormat": {
+ "gifDescription": "可分享的動態圖片",
+ "mp4Description": "高品質影片檔案",
+ "mp4Video": "MP4 影片",
+ "mp4": "MP4",
+ "gif": "GIF",
+ "gifAnimation": "GIF 動畫"
},
"audio": {
+ "title": "音訊",
"reset": "重設音訊",
- "outputGain": "輸出音量",
"help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
- "title": "音訊"
+ "outputGain": "輸出音量"
},
- "textAnimation": {
- "pulse": "脈動",
- "selectAnimation": "選擇動畫",
- "fade": "淡入淡出",
- "typewriter": "打字機",
- "slideLeft": "向左滑動",
- "none": "無",
- "rise": "上升",
- "pop": "彈出",
- "title": "文字動畫"
+ "language": {
+ "title": "語言"
+ },
+ "support": {
+ "saveDiagnostics": "儲存診斷資料",
+ "reportBug": "回報錯誤",
+ "starOnGithub": "在 GitHub 上加星"
},
"crop": {
- "title": "裁剪",
- "unlockAspectRatio": "解鎖長寬比",
+ "cropVideo": "裁剪影片",
"free": "自由",
- "dragInstruction": "拖動每一側來調整裁剪區域",
- "done": "完成",
"lockAspectRatio": "鎖定長寬比",
- "ratio": "比例",
- "cropVideo": "裁剪影片"
+ "done": "完成",
+ "title": "裁剪",
+ "dragInstruction": "拖動每一側來調整裁剪區域",
+ "unlockAspectRatio": "解鎖長寬比",
+ "ratio": "比例"
+ },
+ "speed": {
+ "maxSpeedError": "速度不能超過 {{max}}×",
+ "playbackSpeed": "播放速度",
+ "customPlaybackSpeed": "自訂播放速度",
+ "deleteRegion": "刪除速度區域",
+ "selectRegion": "選擇要調整的速度區域",
+ "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。"
+ },
+ "trim": {
+ "deleteRegion": "刪除剪輯區域"
},
"exportQuality": {
"low": "720p",
@@ -293,59 +344,11 @@
"high": "Source",
"title": "匯出解析度"
},
- "effects": {
- "off": "關",
- "on": "開",
- "fitClip": "符合",
- "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
- "fitClipFew": "{{count}} 個片段",
- "blurBg": "模糊背景",
- "motion": "動態",
- "shadow": "陰影",
- "fitClipOne": "{{count}} 個片段",
- "format": "格式",
- "roundness": "圓角",
- "fitClipMany": "{{count}} 個片段",
- "formatOriginal": "原始",
- "frame": "外框",
- "motionBlur": "動態模糊",
- "padding": "內邊距",
- "title": "畫面合成"
- },
- "language": {
- "title": "語言"
- },
- "gifSettings": {
- "size": "GIF 尺寸",
- "loop": "循環 GIF",
- "frameRate": "GIF 影格率"
+ "facets": {
+ "transcript": "逐字稿",
+ "captions": "字幕"
},
"panes": {
"help": "說明"
- },
- "export": {
- "chooseSaveLocation": "選擇儲存位置",
- "gifButton": "匯出 GIF",
- "videoButton": "匯出影片"
- },
- "exportFormat": {
- "mp4Video": "MP4 影片",
- "mp4Description": "高品質影片檔案",
- "gifAnimation": "GIF 動畫",
- "gifDescription": "可分享的動態圖片",
- "mp4": "MP4",
- "gif": "GIF"
- },
- "project": {
- "save": "儲存專案",
- "new": "新增專案",
- "load": "載入專案"
- },
- "facets": {
- "captions": "字幕",
- "transcript": "逐字稿"
- },
- "trim": {
- "deleteRegion": "刪除剪輯區域"
}
}
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index ce3d4a961..4d86af1ef 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,3 +650,118 @@ describe("translated caption layout", () => {
);
});
});
+
+// ─── Captions across an inserted word's pause ────────────────────
+// Inserting a word SPLITS the clip it lands in — [before · freeze · after] — and a caption
+// line straddling the split was ventilated once per half, each half carrying the whole
+// line. On screen: the caption played, blinked out for the pause, then played again from
+// the top — dark over the one moment the pause exists for.
+
+describe("a caption line over a freeze", () => {
+ /** `clip-1` split at 1.2s, with 0.5s of held frame carrying an inserted word. */
+ function splitDoc(): AxcutDocument {
+ const withInsert = transcript();
+ withInsert.segments[0] = {
+ ...withInsert.segments[0],
+ text: "hello there really friend",
+ wordIds: ["w1", "w2", "synth_1", "w3"],
+ };
+ withInsert.words = [
+ ...withInsert.words,
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 1.2,
+ endSec: 1.2,
+ text: "really",
+ source: "synth",
+ },
+ ];
+ return doc({
+ transcripts: [withInsert],
+ timeline: {
+ ...doc().timeline,
+ clips: [
+ {
+ id: "clip-1_fzA",
+ assetId: "asset-1",
+ sourceStartSec: 0,
+ sourceEndSec: 1.2,
+ timelineStartSec: 0,
+ timelineEndSec: 1.2,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "clip-1_fz",
+ assetId: "asset-1",
+ sourceStartSec: 1.2,
+ sourceEndSec: 1.2,
+ timelineStartSec: 1.2,
+ timelineEndSec: 1.7,
+ wordRefs: [],
+ origin: "user",
+ reason: "Inserted word — held frame",
+ frozenSec: 0.5,
+ },
+ {
+ id: "clip-1_fzB",
+ assetId: "asset-1",
+ sourceStartSec: 1.2,
+ sourceEndSec: 10,
+ timelineStartSec: 1.7,
+ timelineEndSec: 10.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ },
+ });
+ }
+
+ it("plays the line once, straight through the pause", () => {
+ const cues = deriveCaptionCues(splitDoc(), ON, {});
+ const first = cues.filter((cue) => cue.text.includes("really"));
+ expect(first).toHaveLength(1);
+ // It starts before the freeze and is still up after it — no dark stretch.
+ expect(first[0].startMs).toBeLessThan(1200);
+ expect(first[0].endMs).toBeGreaterThan(1700);
+ });
+
+ it("still plays a line twice when one media is genuinely placed twice", () => {
+ // The spans do not touch on the ruler there, so the coalescing must leave them apart.
+ const twice = doc({
+ timeline: {
+ ...doc().timeline,
+ clips: [
+ {
+ id: "clip-1",
+ assetId: "asset-1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "clip-2",
+ assetId: "asset-1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 20,
+ timelineEndSec: 30,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ },
+ });
+ const cues = deriveCaptionCues(twice, ON, {});
+ expect(cues.filter((cue) => cue.text.includes("hello"))).toHaveLength(2);
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index c3287ebcb..8154788a1 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -161,6 +161,17 @@ export function sourceSpanToTimelineSpans(
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
if (clip.assetId !== assetId) continue;
+ // A FREEZE clip holds one source moment for created timeline time — an inserted
+ // word's pause. Its source window is that single point, so the overlap test below
+ // can never match it, and the line the pause exists for went DARK for its whole
+ // duration. A line covering the held moment covers the pause too.
+ if (clip.frozenSec !== undefined) {
+ const held = clip.sourceStartSec;
+ if (held >= startSec && held < endSec) {
+ out.push({ startSec: clip.timelineStartSec, endSec: clip.timelineEndSec });
+ }
+ continue;
+ }
const clipSourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
@@ -170,7 +181,24 @@ export function sourceSpanToTimelineSpans(
endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
- return out;
+
+ // Coalesce what meets on the ruler. Splitting a clip to make room for an inserted word
+ // leaves the line straddling [before · freeze · after]: three spans back to back, each
+ // carrying the WHOLE line's text, so the caption played, blinked out over the pause,
+ // then played again from the top. They are one appearance. A line genuinely played
+ // twice — two clips over one media — does not touch on the ruler and stays two.
+ const EPSILON_SEC = 0.001;
+ const ordered = [...out].sort((a, b) => a.startSec - b.startSec);
+ const merged: Array<{ startSec: number; endSec: number }> = [];
+ for (const span of ordered) {
+ const last = merged[merged.length - 1];
+ if (last && span.startSec <= last.endSec + EPSILON_SEC) {
+ last.endSec = Math.max(last.endSec, span.endSec);
+ continue;
+ }
+ merged.push({ ...span });
+ }
+ return merged;
}
/**
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 987e91dc2..43acfe40a 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -128,6 +128,73 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
});
}
+/**
+ * Split the clip covering `atSec` (source time, `assetId`'s media) into
+ * [before · FREEZE · after], where the freeze holds the frame at `atSec` for
+ * `frozenSec` of timeline time. The pause an inserted word creates: without it the
+ * word only borrows free silence, and a word dropped between two words that run into
+ * each other has no time at all. With it the timeline grows, everything downstream
+ * shifts, and a future TTS voice has a slot to speak in.
+ *
+ * No clip covers `atSec` (gap between clips, boundary of the asset) → unchanged, the
+ * caller decides whether that is acceptable. Trims anchored to the split clip stay on
+ * their id; `trimAppliesToClip` matches by asset when a trim has no clipId, and both
+ * halves keep the asset, so a trim that straddles the freeze point still narrows both
+ * halves exactly as it narrowed the whole clip before.
+ */
+export function insertFreezeInClips(
+ clips: AxcutClip[],
+ assetId: string,
+ atSec: number,
+ frozenSec: number,
+): AxcutClip[] {
+ if (frozenSec <= 0) return clips;
+ const index = clips.findIndex(
+ (clip) =>
+ clip.assetId === assetId &&
+ (clip.frozenSec ?? 0) === 0 &&
+ (clip.sourceEndSec ?? -1) > atSec + 0.001 &&
+ atSec > clip.sourceStartSec,
+ );
+ if (index < 0) return clips;
+ const clip = clips[index];
+ // `resequenceClips` keeps each clip's OWN timeline length, so the halves must not
+ // inherit the un-split clip's — that would double the timeline. Lengths here are
+ // derived from the new source windows; resequence then only relays them.
+ const beforeLen = atSec - clip.sourceStartSec;
+ const afterLen = (clip.sourceEndSec ?? 0) - atSec;
+ const before: AxcutClip = {
+ ...clip,
+ id: `${clip.id}_fzA`,
+ sourceEndSec: atSec,
+ timelineEndSec: clip.timelineStartSec + beforeLen,
+ };
+ const freeze: AxcutClip = {
+ id: `${clip.id}_fz`,
+ assetId: clip.assetId,
+ sourceStartSec: atSec,
+ sourceEndSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: frozenSec,
+ wordRefs: [],
+ origin: "user",
+ reason: "Inserted word — held frame",
+ frozenSec,
+ };
+ const after: AxcutClip | null =
+ afterLen > 0.001
+ ? {
+ ...clip,
+ id: `${clip.id}_fzB`,
+ sourceStartSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: afterLen,
+ }
+ : null;
+ const replaced = after ? [before, freeze, after] : [before, freeze];
+ return resequenceClips([...clips.slice(0, index), ...replaced, ...clips.slice(index + 1)]);
+}
+
export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
const output: Interval[] = [];
for (const interval of intervals) {
@@ -171,7 +238,10 @@ export function resolvePlaybackSegments(
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
- // Duration not probed yet — pass through as a single segment, unchanged.
+ // Either not probed yet, or a FREEZE clip (source window is the point it
+ // holds; `frozenSec` carries its real length). Both pass through as one
+ // segment at their timeline length — for a freeze that KEEPS the created
+ // pause in the compressed stream, which is the whole point.
const dur = clip.timelineEndSec - clip.timelineStartSec;
result.push({
...clip,
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 9e8ace067..03f9e5aae 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -615,6 +615,65 @@ describe("insertDocumentWord / removeDocumentWords", () => {
});
});
+describe("insertDocumentWord freeze", () => {
+ /** One clip covering the whole fixture recording — what the editor starts from. */
+ function makeDocWithClip() {
+ const doc = makeDoc();
+ return {
+ ...doc,
+ timeline: {
+ ...doc.timeline,
+ clips: [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ],
+ },
+ };
+ }
+
+ it("splits the clip and creates held-frame time when the silence is insufficient", () => {
+ // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
+ // gets no silence at all and needs max(0.4, 6/15) = 0.4 s of created time.
+ const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_2", "after", "really");
+ const clips = result.timeline.clips;
+ expect(clips).toHaveLength(3);
+ expect(clips[0]).toMatchObject({ id: "clip_1_fzA", sourceStartSec: 0, sourceEndSec: 3 });
+ expect(clips[1]).toMatchObject({
+ id: "clip_1_fz",
+ sourceStartSec: 3,
+ sourceEndSec: 3,
+ frozenSec: 0.4,
+ });
+ expect(clips[2]).toMatchObject({ id: "clip_1_fzB", sourceStartSec: 3, sourceEndSec: 10 });
+ // The timeline grew by exactly the freeze, laid back-to-back.
+ expect(clips[1].timelineStartSec).toBe(3);
+ expect(clips[1].timelineEndSec).toBeCloseTo(3.4, 5);
+ expect(clips[2].timelineEndSec).toBeCloseTo(10.4, 5);
+ });
+
+ it("does not touch the clips when free silence covers the word", () => {
+ // word_3 ends at 4, word_4 starts at 5: a full second of silence.
+ const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_3", "after", "really");
+ expect(result.timeline.clips).toHaveLength(1);
+ expect(result.timeline.clips[0].id).toBe("clip_1");
+ });
+
+ it("leaves the timeline alone when no clip covers the insertion point", () => {
+ // makeDoc has no clips at all — the word rides the caption line, as before.
+ const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
+ expect(result.timeline.clips).toHaveLength(0);
+ });
+});
+
describe("carryOverWordEdits with inserted words", () => {
const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index dec291474..ae626ee69 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,4 +1,5 @@
import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
+import { insertFreezeInClips } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -302,7 +303,15 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
}
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does. */
+ * reason {@link setDocumentWordText} does.
+ *
+ * When the free silence the word can borrow is shorter than the text needs to be
+ * read, the deficit becomes a FREEZE on the timeline (`insertFreezeInClips`): the clip
+ * is split at the word's edge and a held-frame clip carries the missing time. The
+ * timeline grows, everything downstream shifts, and the word has a real slot a
+ * synthesized voice will later speak in. No document-layer gate on this: it is the
+ * correct document semantics for an inserted word — the product decision of whether
+ * the gesture exists at all lives in the transcript pane (`openInsertion`). */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -314,7 +323,28 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+ const next = withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+
+ // The word `insertWord` just added — the one synth id the old transcript lacked.
+ const inserted = next.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find(
+ (word) => word.source === "synth" && !transcript.words.some((w) => w.id === word.id),
+ );
+ if (!inserted) return next;
+ const deficit = readingSeconds(inserted.text) - (inserted.endSec - inserted.startSec);
+ if (deficit <= 0.05) return next;
+ // The freeze continues the word's slot: after the word for "after" (silence, then
+ // held frame), before it for "before" (held frame, then silence) — one contiguous
+ // stretch of created time the future voice occupies.
+ const atSec = side === "after" ? inserted.endSec : inserted.startSec;
+ return {
+ ...next,
+ timeline: {
+ ...next.timeline,
+ clips: insertFreezeInClips(next.timeline.clips, assetId, atSec, deficit),
+ },
+ };
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index bbf1d6820..0a6f80ab7 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -220,6 +220,14 @@ export const clipSchema = z
// that as the identity region {x:0,y:0,width:1,height:1} rather than
// storing the identity explicitly, so untouched clips stay lean.
cropRegion: clipCropRegionSchema.optional(),
+ // A FREEZE clip: holds the frame at `sourceStartSec` (source window is the
+ // zero-width point [sourceStartSec, sourceStartSec]) for `frozenSec` of
+ // TIMELINE time. The pause an inserted word creates so a future TTS voice has
+ // a slot to speak in — screen and webcam freeze together because both tracks
+ // are derived from the same asset source clock. Absent on every ordinary clip;
+ // `resolvePlaybackSegments`'s un-probed passthrough branch must not be confused
+ // with it (a frozen clip is pushed through unchanged too, see its comment).
+ frozenSec: z.number().positive().optional(),
})
.refine((data) => data.timelineEndSec >= data.timelineStartSec, {
message: "timelineEndSec must be greater than or equal to timelineStartSec",
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index adc70b6cb..60f1fdea6 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -474,3 +474,94 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
+
+// ─── Freeze clips in the pane ────────────────────────────────────
+// An inserted word SPLITS the clip it lands in — [before · freeze · after] — and the word
+// sits exactly on the split. Both the freeze and the half that starts there matched it, so
+// the pane showed the same word twice, in two blocks.
+
+describe("the section a freeze clip projects", () => {
+ const TRANSCRIPT: AxcutTranscript = {
+ assetId: "a1",
+ language: "fr",
+ segments: [
+ { id: "s1", kind: "speech", startSec: 0, endSec: 4, text: "un deux", wordIds: ["w1", "w2"] },
+ ],
+ words: [
+ { id: "w1", segmentId: "s1", startSec: 0, endSec: 2, text: "un" },
+ { id: "w2", segmentId: "s1", startSec: 2, endSec: 4, text: "deux" },
+ { id: "synth_1", segmentId: "s1", startSec: 2, endSec: 2, text: "vraiment", source: "synth" },
+ ],
+ };
+ const ASSET: AxcutAsset = {
+ id: "a1",
+ kind: "video",
+ label: "rec.mp4",
+ originalPath: "/r.mp4",
+ durationSec: 4,
+ cameraTrack: null,
+ };
+ const CLIPS: AxcutClip[] = [
+ {
+ id: "c_fzA",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 2,
+ timelineStartSec: 0,
+ timelineEndSec: 2,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "c_fz",
+ assetId: "a1",
+ sourceStartSec: 2,
+ sourceEndSec: 2,
+ timelineStartSec: 2,
+ timelineEndSec: 2.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ frozenSec: 0.5,
+ },
+ {
+ id: "c_fzB",
+ assetId: "a1",
+ sourceStartSec: 2,
+ sourceEndSec: 4,
+ timelineStartSec: 2.5,
+ timelineEndSec: 4.5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ];
+
+ it("shows the inserted word the freeze exists for", () => {
+ const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
+ expect(sections[1].words.map((cw) => cw.word.text)).toEqual(["vraiment"]);
+ });
+
+ it("shows it exactly once across the whole pane", () => {
+ const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
+ const everywhere = sections.flatMap((section) =>
+ section.words.filter((cw) => cw.word.id === "synth_1"),
+ );
+ expect(everywhere).toHaveLength(1);
+ });
+
+ it("leaves the spoken words where they were", () => {
+ const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
+ expect(sections[0].words.map((cw) => cw.word.text)).toEqual(["un"]);
+ expect(sections[2].words.map((cw) => cw.word.text)).toEqual(["deux"]);
+ });
+
+ // The claim is scoped to freezes: with no freeze in the timeline, a word with no
+ // duration is shown by whichever clip its moment falls in, as before.
+ it("does not withhold an inserted word when no freeze claims it", () => {
+ const whole: AxcutClip[] = [{ ...CLIPS[0], id: "c1", sourceEndSec: 4, timelineEndSec: 4 }];
+ const sections = buildAggregatedSections(whole, [TRANSCRIPT], [ASSET], []);
+ expect(sections[0].words.map((cw) => cw.word.text)).toContain("vraiment");
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 9b59f2bd6..d008867ad 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -197,6 +197,32 @@ export function buildClipSection(
asset: AxcutAsset | null,
trimRanges: AxcutTrimRange[],
): ClipSection {
+ // A FREEZE clip is the created time behind an inserted word: its source window is
+ // the single point it holds, so the ordinary range filter matches nothing — and it
+ // must not. The words it shows are the SYNTH words touching that point: the word
+ // the freeze exists for. While the playhead runs through the freeze the cue
+ // resolves against this section and lights the amber word through the whole pause.
+ if (clip.frozenSec !== undefined) {
+ const atSec = clip.sourceStartSec;
+ const words = transcript
+ ? transcript.words.filter(
+ (word) => word.source === "synth" && word.startSec <= atSec && word.endSec >= atSec,
+ )
+ : [];
+ return {
+ clip,
+ asset,
+ transcript,
+ words: words.map((word) => ({
+ id: clipWordId(clip.id, word.id),
+ word,
+ kept: true,
+ trimId: null,
+ })),
+ trimRuns: [],
+ };
+ }
+
// `trimAppliesToClip` — not a bare `assetId` match — is what keeps a cut on the
// second of two clips over the same media from also greying out the first one's
// words. Same media, same source range: only the clip anchor tells them apart.
@@ -281,7 +307,7 @@ export function buildAggregatedSections(
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
- return clips.map((clip) =>
+ const sections = clips.map((clip) =>
buildClipSection(
clip,
transcriptById.get(clip.assetId) ?? null,
@@ -289,6 +315,23 @@ export function buildAggregatedSections(
trimRanges,
),
);
+
+ // A freeze clip claims the inserted word it was created for, and the clip after the
+ // split starts at the very moment that word sits on — so the word matched BOTH and the
+ // pane showed it twice, in two blocks. The freeze owns it: it is the section the
+ // playhead is inside while the pause plays, and the one whose whole reason to exist is
+ // that word.
+ const claimed = new Set(
+ sections
+ .filter((section) => section.clip.frozenSec !== undefined)
+ .flatMap((section) => section.words.map((cw) => cw.word.id)),
+ );
+ if (claimed.size === 0) return sections;
+ return sections.map((section) =>
+ section.clip.frozenSec !== undefined
+ ? section
+ : { ...section, words: section.words.filter((cw) => !claimed.has(cw.word.id)) },
+ );
}
/**
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ea3f6c61b..ecc81a2d0 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -17,6 +17,7 @@ import {
replacePillSpan,
resolveNativePosition,
resolvePillIds,
+ segmentRawSpanSec,
} from "./timelineMap";
function clip(overrides: Partial & Pick): AxcutClip {
@@ -805,3 +806,63 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
+
+// ─── Freeze clips ────────────────────────────────────────────────
+// The pause an inserted word creates. Its source window is the single point it holds, so
+// every reader that derives a length from `sourceEndSec - sourceStartSec` gets zero for it
+// — and zero is the one answer that makes the playhead skip the pause entirely.
+
+describe("a freeze clip", () => {
+ const clips = [
+ clip({ id: "a", assetId: "m", sourceStartSec: 0, sourceEndSec: 3 }),
+ clip({
+ id: "fz",
+ assetId: "m",
+ sourceStartSec: 3,
+ sourceEndSec: 3,
+ timelineStartSec: 3,
+ timelineEndSec: 3.5,
+ frozenSec: 0.5,
+ }),
+ clip({
+ id: "b",
+ assetId: "m",
+ sourceStartSec: 3,
+ sourceEndSec: 6,
+ timelineStartSec: 3.5,
+ timelineEndSec: 6.5,
+ }),
+ ];
+
+ it("keeps its created time in the playback segments", () => {
+ // It carries no source range to compress, so a naive reader drops it and the pause
+ // vanishes from playback while the ruler still counts it.
+ const segments = resolvePlaybackSegments(clips, []);
+ const freeze = segments.find((segment) => segment.id === "fz");
+ expect(freeze).toBeDefined();
+ expect((freeze?.timelineEndSec ?? 0) - (freeze?.timelineStartSec ?? 0)).toBeCloseTo(0.5, 5);
+ });
+
+ it("spans its frozen time on the raw ruler, not its (zero) source length", () => {
+ const span = segmentRawSpanSec(clips[1], clips);
+ expect(span.endSec - span.startSec).toBeCloseTo(0.5, 5);
+ });
+
+ it("holds the source clock still while the playhead runs through it", () => {
+ // The raw playhead DOES advance through the pause. Letting that delta reach the
+ // decoder would push it past the held frame into the content that belongs after.
+ const segments = resolvePlaybackSegments(clips, []);
+ for (const rawSec of [3.05, 3.25, 3.45]) {
+ const position = resolveNativePosition(rawSec, segments, clips);
+ expect(position?.clip.id).toBe("fz");
+ expect(position?.sourceTimeSec).toBe(3);
+ }
+ });
+
+ it("hands the clip after the pause its own source moment again", () => {
+ const segments = resolvePlaybackSegments(clips, []);
+ const position = resolveNativePosition(4, segments, clips);
+ expect(position?.clip.id).toBe("b");
+ expect(position?.sourceTimeSec).toBeCloseTo(3.5, 5);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index b98601a03..081ede4d7 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -403,7 +403,13 @@ export function segmentRawSpanSec(
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
- const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ // A freeze clip's source window is the point it holds — its RAW span is its created
+ // timeline time, not the (zero) source length, or the playhead could never be
+ // "inside" it and would skip the pause entirely.
+ const lenSec =
+ segment.frozenSec !== undefined
+ ? segment.frozenSec
+ : (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -690,6 +696,20 @@ export function resolveNativePosition(
const seg = visibleSegments[index];
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
+ // Inside a freeze: the source clock does not advance. The whole point of the clip
+ // is created timeline time over one held frame — clamp the offset to zero rather
+ // than letting the raw-playhead delta (which DOES advance through the freeze) push
+ // the decoder past the held frame into content that belongs after the pause.
+ if (seg.frozenSec !== undefined) {
+ return {
+ clip: seg,
+ clipIndex: index,
+ sourceTimeSec: seg.sourceStartSec,
+ };
+ }
+ // No `clampToSegmentStart` any more: this branch used to snap the playhead to the
+ // next kept segment, and main now returns `positionUnderCut` before reaching here
+ // (issue #216), so the flag could never be true.
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
return {
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index 64e877b63..abe2f1c1a 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -41,6 +41,13 @@ export function useNativePlaybackSync(
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
+ // A freeze clip holds ONE frame for `frozenSec` of app-clock time. Free-running the
+ // decoder through it would play the frames after the pause instead; the app clock
+ // (which does traverse the freeze) then re-seeks on drift and stutters. Pausing the
+ // decoder for the duration of the freeze is what makes the pause a pause — the
+ // webcam track freezes with the screen track because both derive from the same
+ // asset source clock the freeze stops advancing.
+ const frozen = activePosition?.clip.frozenSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -49,13 +56,15 @@ export function useNativePlaybackSync(
() => getCurrentNativeViewId() !== null,
);
- // Play/pause → native free-run.
+ // Play/pause → native free-run. Inside a freeze the native side is PAUSED however
+ // the transport is set — the app clock advances through the created time while the
+ // decoder holds the frame.
useEffect(() => {
if (!active) {
return;
}
- setNativePlaying(playing);
- }, [active, playing]);
+ setNativePlaying(playing && !frozen);
+ }, [active, playing, frozen]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -68,6 +77,17 @@ export function useNativePlaybackSync(
}
const now = performance.now();
+ // Inside a freeze while playing: the decoder is paused (see the transport
+ // effect) and parked on the held frame. Refresh the drift refs every run so the
+ // drift check never sees the (correctly) frozen source clock as divergence and
+ // fights itself with repeated seeks.
+ if (playing && frozen) {
+ setNativeTime(sourceTimeSec);
+ lastSyncedSourceTimeRef.current = sourceTimeSec;
+ lastSyncedWallTimeRef.current = now;
+ return;
+ }
+
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -95,5 +115,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, activeClipId, sourceTimeSec]);
+ }, [active, playing, frozen, activeClipId, sourceTimeSec]);
}
From d829afcfdce0ddd217fd378bb4268f231e6cd68c Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 22:36:58 +0200
Subject: [PATCH 058/113] revert(editor): an added word no longer splits the
clip it lands in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Splitting the clip to make room for an inserted word did not survive the rest of
the app. A project saved after two inserts came back with one clip named
`clip_..._fzA_fzA` — split twice, both freezes and both after-halves gone, the
full source range restored onto the mangled id — and zero synthesized words. The
inserted text was lost on save, and the held-frame clips that did exist were
skipped in playback.
That is not a bug to chase. Clip surgery for a caption-only feature puts the
timeline's shape under the transcript's control, and every other writer of
`timeline.clips` — the duration probe, the recording import, resequencing — is
entitled to disagree with it. `frozenSec` goes with it, and so do the readers
that had to special-case a clip whose source window is a single point: the
playback segments, the raw span, the native position clamp, the decoder pause,
the caption ventilation, and the pane's split-clip header run.
What stays is the part that was always true on its own: an inserted word is a
word in the transcript with `source: "synth"`, it borrows whatever silence is
free where it lands, and it reaches the captions. The gesture stays dev-gated —
now for a second reason, since without created time an inserted word can only
speak inside a pause that already exists.
Creating time is still the right answer once there is a voice to put in it. It
belongs in a record of its own, beside `trimRanges`, which is the inverse
operation and the one shape the timeline already threads everywhere.
---
src/components/ai-edition/RightPanes.tsx | 184 ++++++------------
.../TranscriptPane.sharedMedia.test.tsx | 82 --------
src/lib/ai-edition/captions/captions.test.ts | 115 -----------
src/lib/ai-edition/captions/cues.ts | 30 +--
src/lib/ai-edition/document/timeline.ts | 72 +------
.../ai-edition/document/transcript.test.ts | 59 ------
src/lib/ai-edition/document/transcript.ts | 34 +---
src/lib/ai-edition/schema/index.ts | 8 -
.../timeline/aggregated-transcript.test.ts | 91 ---------
.../timeline/aggregated-transcript.ts | 45 +----
.../ai-edition/timeline/timelineMap.test.ts | 61 ------
src/lib/ai-edition/timeline/timelineMap.ts | 22 +--
src/native/useNativePlaybackSync.ts | 28 +--
13 files changed, 73 insertions(+), 758 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index bc2bce966..dfe3d159c 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -68,7 +68,6 @@ import {
findCueWordId,
isInsertedWord,
isSilenceWord,
- placementRawExtent,
type TranscriptLane,
type TrimRun,
voiceoverPlacements,
@@ -1001,8 +1000,6 @@ export function TranscriptPane({
key={section.clip.id}
index={idx}
section={section}
- continuation={continuesPreviousSection(sections[idx - 1], section)}
- runLabel={runLabelFor(sections, idx)}
busy={busyAssetIds.includes(section.clip.assetId)}
busyLabel={
transcriptionBusyLabel(transcriptions?.[section.clip.assetId], transcriptionLabel) ??
@@ -1021,50 +1018,6 @@ export function TranscriptPane({
);
}
-/**
- * Whether this section merely continues the previous one: same media, and the previous
- * clip ends exactly where this one starts, on the source clock and on the ruler alike.
- *
- * Inserting a word SPLITS the clip it lands in — [before · freeze · after] — so one word
- * turned one recording into three headed blocks, each announcing the same filename and a
- * sliver of timecode. They are one continuous read and now render as one: the header
- * appears on the first section of the run, the rest flow straight on from it. Two clips
- * over the same media that are NOT contiguous still get a header each, which is the case
- * the header exists for.
- */
-function continuesPreviousSection(
- previous: ClipSection | undefined,
- section: ClipSection,
-): boolean {
- if (!previous || previous.clip.assetId !== section.clip.assetId) return false;
- const EPSILON_SEC = 0.001;
- const sourceMeets =
- Math.abs((previous.clip.sourceEndSec ?? Number.NaN) - section.clip.sourceStartSec) <
- EPSILON_SEC;
- // A placement's ruler end is derived, not stored: the voiceover lane has no clip behind
- // it to read a `timelineEndSec` from.
- const previousEnd = placementRawExtent(previous.clip)?.endSec;
- const rulerMeets =
- previousEnd !== undefined &&
- Math.abs(previousEnd - section.clip.timelineStartSec) < EPSILON_SEC;
- return sourceMeets && rulerMeets;
-}
-
-/** The source range the whole run covers, for the one header that fronts it. */
-function runLabelFor(sections: ClipSection[], index: number): { start: number; end: number } {
- let last = index;
- while (
- last + 1 < sections.length &&
- continuesPreviousSection(sections[last], sections[last + 1])
- ) {
- last += 1;
- }
- return {
- start: sections[index].clip.sourceStartSec,
- end: sections[last].clip.sourceEndSec ?? sections[last].clip.sourceStartSec,
- };
-}
-
// One contentEditable block per clip — header (vignette + filename +
// range) and a flowing word stream. The stream contains every transcript
// word inside the clip's source range, color-coded by whether the word
@@ -1080,8 +1033,6 @@ function runLabelFor(sections: ClipSection[], index: number): { start: number; e
const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
- continuation,
- runLabel,
busy,
busyLabel,
cueWordId,
@@ -1094,10 +1045,6 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}: {
index: number;
section: ClipSection;
- /** This section reads straight on from the one above — no header, no gap. */
- continuation: boolean;
- /** Source range of the whole contiguous run this section fronts. */
- runLabel: { start: number; end: number };
busy: boolean;
busyLabel?: string;
cueWordId: string | null;
@@ -1117,9 +1064,10 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
[clip.assetId, clip.id],
);
const filename = asset?.label ?? clip.assetId;
- // The run's range, not this clip's: a split clip's own sliver would read as a
- // 0:02.5—0:02.5 recording.
- const sourceRangeLabel = `${formatMs(runLabel.start * 1000)}—${formatMs(runLabel.end * 1000)}`;
+ const sourceRangeLabel =
+ clip.sourceEndSec !== undefined
+ ? `${formatMs(clip.sourceStartSec * 1000)}—${formatMs(clip.sourceEndSec * 1000)}`
+ : `${formatMs(clip.sourceStartSec * 1000)}—`;
const editorRef = useRef(null);
const pendingCaretWordIdRef = useRef(null);
@@ -1396,85 +1344,79 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
return (
- {continuation ? null : (
+ 0 ? 16 : 0,
- marginBottom: 6,
+ justifyContent: "center",
+ background: "var(--accent-soft)",
+ color: "var(--accent)",
+ borderRadius: "var(--r-sm)",
+ font: "700 12px/1 var(--font-mono)",
+ flexShrink: 0,
}}
>
+ {index + 1}
+
+
+
+ {filename}
+
+
+ {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
+
+
+ {/* A block whose transcript is being regenerated is read-only — say it,
+ rather than letting the word stream look live and drop the edits. */}
+ {busy ? (
- {index + 1}
-
-
-
- {filename}
-
-
- {ts("transcript.clipLabel", { index: index + 1 })} · {sourceRangeLabel}
-
+
+ {ts("transcript.transcribing")}
- {/* A block whose transcript is being regenerated is read-only — say it,
- rather than letting the word stream look live and drop the edits. */}
- {busy ? (
-
-
- {ts("transcript.transcribing")}
-
- ) : null}
-
- )}
+ ) : null}
+
{words.length === 0 ? (
{
).toEqual(["clip_2:w2"]);
});
});
-
-// ─── Headers on a clip an inserted word split ────────────────────
-// Inserting a word splits the clip it lands in — [before · freeze · after] — so one word
-// turned one recording into three blocks, each announcing the same filename and a sliver
-// of timecode ("Clip 2 · 0:02.5—0:02.5"). They are one continuous read: one header, and
-// the words flow straight on. The two-copies case above must keep its two headers, which
-// is what tells the split apart from a media genuinely placed twice.
-
-const SPLIT_CLIPS: AxcutClip[] = [
- {
- id: "clip_1_fzA",
- assetId: "asset_1",
- sourceStartSec: 0,
- sourceEndSec: 6,
- timelineStartSec: 0,
- timelineEndSec: 6,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "clip_1_fz",
- assetId: "asset_1",
- sourceStartSec: 6,
- sourceEndSec: 6,
- timelineStartSec: 6,
- timelineEndSec: 6.5,
- wordRefs: [],
- origin: "user",
- reason: "Inserted word — held frame",
- frozenSec: 0.5,
- },
- {
- id: "clip_1_fzB",
- assetId: "asset_1",
- sourceStartSec: 6,
- sourceEndSec: 12,
- timelineStartSec: 6.5,
- timelineEndSec: 12.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
-];
-
-function renderClips(clips: AxcutClip[]) {
- return render(
-
-
- ,
- );
-}
-
-describe("clip headers", () => {
- it("fronts a split clip with one header covering the whole run", () => {
- const view = renderClips(SPLIT_CLIPS);
- const headers = view.container.querySelectorAll("[data-clip-header]");
- expect(headers).toHaveLength(1);
- // The run's range, not the first piece's — and not the freeze's 0:06.0—0:06.0.
- expect(headers[0].textContent).toContain("0:00.0—0:12.0");
- });
-
- it("still gives two headers to one media placed twice", () => {
- const view = renderClips(CLIPS);
- expect(view.container.querySelectorAll("[data-clip-header]")).toHaveLength(2);
- });
-});
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 4d86af1ef..ce3d4a961 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,118 +650,3 @@ describe("translated caption layout", () => {
);
});
});
-
-// ─── Captions across an inserted word's pause ────────────────────
-// Inserting a word SPLITS the clip it lands in — [before · freeze · after] — and a caption
-// line straddling the split was ventilated once per half, each half carrying the whole
-// line. On screen: the caption played, blinked out for the pause, then played again from
-// the top — dark over the one moment the pause exists for.
-
-describe("a caption line over a freeze", () => {
- /** `clip-1` split at 1.2s, with 0.5s of held frame carrying an inserted word. */
- function splitDoc(): AxcutDocument {
- const withInsert = transcript();
- withInsert.segments[0] = {
- ...withInsert.segments[0],
- text: "hello there really friend",
- wordIds: ["w1", "w2", "synth_1", "w3"],
- };
- withInsert.words = [
- ...withInsert.words,
- {
- id: "synth_1",
- segmentId: "seg_1",
- startSec: 1.2,
- endSec: 1.2,
- text: "really",
- source: "synth",
- },
- ];
- return doc({
- transcripts: [withInsert],
- timeline: {
- ...doc().timeline,
- clips: [
- {
- id: "clip-1_fzA",
- assetId: "asset-1",
- sourceStartSec: 0,
- sourceEndSec: 1.2,
- timelineStartSec: 0,
- timelineEndSec: 1.2,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "clip-1_fz",
- assetId: "asset-1",
- sourceStartSec: 1.2,
- sourceEndSec: 1.2,
- timelineStartSec: 1.2,
- timelineEndSec: 1.7,
- wordRefs: [],
- origin: "user",
- reason: "Inserted word — held frame",
- frozenSec: 0.5,
- },
- {
- id: "clip-1_fzB",
- assetId: "asset-1",
- sourceStartSec: 1.2,
- sourceEndSec: 10,
- timelineStartSec: 1.7,
- timelineEndSec: 10.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ],
- },
- });
- }
-
- it("plays the line once, straight through the pause", () => {
- const cues = deriveCaptionCues(splitDoc(), ON, {});
- const first = cues.filter((cue) => cue.text.includes("really"));
- expect(first).toHaveLength(1);
- // It starts before the freeze and is still up after it — no dark stretch.
- expect(first[0].startMs).toBeLessThan(1200);
- expect(first[0].endMs).toBeGreaterThan(1700);
- });
-
- it("still plays a line twice when one media is genuinely placed twice", () => {
- // The spans do not touch on the ruler there, so the coalescing must leave them apart.
- const twice = doc({
- timeline: {
- ...doc().timeline,
- clips: [
- {
- id: "clip-1",
- assetId: "asset-1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "clip-2",
- assetId: "asset-1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 20,
- timelineEndSec: 30,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ],
- },
- });
- const cues = deriveCaptionCues(twice, ON, {});
- expect(cues.filter((cue) => cue.text.includes("hello"))).toHaveLength(2);
- });
-});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 8154788a1..c3287ebcb 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -161,17 +161,6 @@ export function sourceSpanToTimelineSpans(
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
if (clip.assetId !== assetId) continue;
- // A FREEZE clip holds one source moment for created timeline time — an inserted
- // word's pause. Its source window is that single point, so the overlap test below
- // can never match it, and the line the pause exists for went DARK for its whole
- // duration. A line covering the held moment covers the pause too.
- if (clip.frozenSec !== undefined) {
- const held = clip.sourceStartSec;
- if (held >= startSec && held < endSec) {
- out.push({ startSec: clip.timelineStartSec, endSec: clip.timelineEndSec });
- }
- continue;
- }
const clipSourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
@@ -181,24 +170,7 @@ export function sourceSpanToTimelineSpans(
endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
-
- // Coalesce what meets on the ruler. Splitting a clip to make room for an inserted word
- // leaves the line straddling [before · freeze · after]: three spans back to back, each
- // carrying the WHOLE line's text, so the caption played, blinked out over the pause,
- // then played again from the top. They are one appearance. A line genuinely played
- // twice — two clips over one media — does not touch on the ruler and stays two.
- const EPSILON_SEC = 0.001;
- const ordered = [...out].sort((a, b) => a.startSec - b.startSec);
- const merged: Array<{ startSec: number; endSec: number }> = [];
- for (const span of ordered) {
- const last = merged[merged.length - 1];
- if (last && span.startSec <= last.endSec + EPSILON_SEC) {
- last.endSec = Math.max(last.endSec, span.endSec);
- continue;
- }
- merged.push({ ...span });
- }
- return merged;
+ return out;
}
/**
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 43acfe40a..987e91dc2 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -128,73 +128,6 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
});
}
-/**
- * Split the clip covering `atSec` (source time, `assetId`'s media) into
- * [before · FREEZE · after], where the freeze holds the frame at `atSec` for
- * `frozenSec` of timeline time. The pause an inserted word creates: without it the
- * word only borrows free silence, and a word dropped between two words that run into
- * each other has no time at all. With it the timeline grows, everything downstream
- * shifts, and a future TTS voice has a slot to speak in.
- *
- * No clip covers `atSec` (gap between clips, boundary of the asset) → unchanged, the
- * caller decides whether that is acceptable. Trims anchored to the split clip stay on
- * their id; `trimAppliesToClip` matches by asset when a trim has no clipId, and both
- * halves keep the asset, so a trim that straddles the freeze point still narrows both
- * halves exactly as it narrowed the whole clip before.
- */
-export function insertFreezeInClips(
- clips: AxcutClip[],
- assetId: string,
- atSec: number,
- frozenSec: number,
-): AxcutClip[] {
- if (frozenSec <= 0) return clips;
- const index = clips.findIndex(
- (clip) =>
- clip.assetId === assetId &&
- (clip.frozenSec ?? 0) === 0 &&
- (clip.sourceEndSec ?? -1) > atSec + 0.001 &&
- atSec > clip.sourceStartSec,
- );
- if (index < 0) return clips;
- const clip = clips[index];
- // `resequenceClips` keeps each clip's OWN timeline length, so the halves must not
- // inherit the un-split clip's — that would double the timeline. Lengths here are
- // derived from the new source windows; resequence then only relays them.
- const beforeLen = atSec - clip.sourceStartSec;
- const afterLen = (clip.sourceEndSec ?? 0) - atSec;
- const before: AxcutClip = {
- ...clip,
- id: `${clip.id}_fzA`,
- sourceEndSec: atSec,
- timelineEndSec: clip.timelineStartSec + beforeLen,
- };
- const freeze: AxcutClip = {
- id: `${clip.id}_fz`,
- assetId: clip.assetId,
- sourceStartSec: atSec,
- sourceEndSec: atSec,
- timelineStartSec: 0,
- timelineEndSec: frozenSec,
- wordRefs: [],
- origin: "user",
- reason: "Inserted word — held frame",
- frozenSec,
- };
- const after: AxcutClip | null =
- afterLen > 0.001
- ? {
- ...clip,
- id: `${clip.id}_fzB`,
- sourceStartSec: atSec,
- timelineStartSec: 0,
- timelineEndSec: afterLen,
- }
- : null;
- const replaced = after ? [before, freeze, after] : [before, freeze];
- return resequenceClips([...clips.slice(0, index), ...replaced, ...clips.slice(index + 1)]);
-}
-
export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
const output: Interval[] = [];
for (const interval of intervals) {
@@ -238,10 +171,7 @@ export function resolvePlaybackSegments(
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
- // Either not probed yet, or a FREEZE clip (source window is the point it
- // holds; `frozenSec` carries its real length). Both pass through as one
- // segment at their timeline length — for a freeze that KEEPS the created
- // pause in the compressed stream, which is the whole point.
+ // Duration not probed yet — pass through as a single segment, unchanged.
const dur = clip.timelineEndSec - clip.timelineStartSec;
result.push({
...clip,
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 03f9e5aae..9e8ace067 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -615,65 +615,6 @@ describe("insertDocumentWord / removeDocumentWords", () => {
});
});
-describe("insertDocumentWord freeze", () => {
- /** One clip covering the whole fixture recording — what the editor starts from. */
- function makeDocWithClip() {
- const doc = makeDoc();
- return {
- ...doc,
- timeline: {
- ...doc.timeline,
- clips: [
- {
- id: "clip_1",
- assetId: "asset_1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user" as const,
- reason: "",
- },
- ],
- },
- };
- }
-
- it("splits the clip and creates held-frame time when the silence is insufficient", () => {
- // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
- // gets no silence at all and needs max(0.4, 6/15) = 0.4 s of created time.
- const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_2", "after", "really");
- const clips = result.timeline.clips;
- expect(clips).toHaveLength(3);
- expect(clips[0]).toMatchObject({ id: "clip_1_fzA", sourceStartSec: 0, sourceEndSec: 3 });
- expect(clips[1]).toMatchObject({
- id: "clip_1_fz",
- sourceStartSec: 3,
- sourceEndSec: 3,
- frozenSec: 0.4,
- });
- expect(clips[2]).toMatchObject({ id: "clip_1_fzB", sourceStartSec: 3, sourceEndSec: 10 });
- // The timeline grew by exactly the freeze, laid back-to-back.
- expect(clips[1].timelineStartSec).toBe(3);
- expect(clips[1].timelineEndSec).toBeCloseTo(3.4, 5);
- expect(clips[2].timelineEndSec).toBeCloseTo(10.4, 5);
- });
-
- it("does not touch the clips when free silence covers the word", () => {
- // word_3 ends at 4, word_4 starts at 5: a full second of silence.
- const result = insertDocumentWord(makeDocWithClip(), "asset_1", "word_3", "after", "really");
- expect(result.timeline.clips).toHaveLength(1);
- expect(result.timeline.clips[0].id).toBe("clip_1");
- });
-
- it("leaves the timeline alone when no clip covers the insertion point", () => {
- // makeDoc has no clips at all — the word rides the caption line, as before.
- const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
- expect(result.timeline.clips).toHaveLength(0);
- });
-});
-
describe("carryOverWordEdits with inserted words", () => {
const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index ae626ee69..dec291474 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,5 +1,4 @@
import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
-import { insertFreezeInClips } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -303,15 +302,7 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
}
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does.
- *
- * When the free silence the word can borrow is shorter than the text needs to be
- * read, the deficit becomes a FREEZE on the timeline (`insertFreezeInClips`): the clip
- * is split at the word's edge and a held-frame clip carries the missing time. The
- * timeline grows, everything downstream shifts, and the word has a real slot a
- * synthesized voice will later speak in. No document-layer gate on this: it is the
- * correct document semantics for an inserted word — the product decision of whether
- * the gesture exists at all lives in the transcript pane (`openInsertion`). */
+ * reason {@link setDocumentWordText} does. */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -323,28 +314,7 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- const next = withTranscript(document, insertWord(transcript, anchorWordId, side, text));
-
- // The word `insertWord` just added — the one synth id the old transcript lacked.
- const inserted = next.transcripts
- .find((t) => t.assetId === assetId)
- ?.words.find(
- (word) => word.source === "synth" && !transcript.words.some((w) => w.id === word.id),
- );
- if (!inserted) return next;
- const deficit = readingSeconds(inserted.text) - (inserted.endSec - inserted.startSec);
- if (deficit <= 0.05) return next;
- // The freeze continues the word's slot: after the word for "after" (silence, then
- // held frame), before it for "before" (held frame, then silence) — one contiguous
- // stretch of created time the future voice occupies.
- const atSec = side === "after" ? inserted.endSec : inserted.startSec;
- return {
- ...next,
- timeline: {
- ...next.timeline,
- clips: insertFreezeInClips(next.timeline.clips, assetId, atSec, deficit),
- },
- };
+ return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index 0a6f80ab7..bbf1d6820 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -220,14 +220,6 @@ export const clipSchema = z
// that as the identity region {x:0,y:0,width:1,height:1} rather than
// storing the identity explicitly, so untouched clips stay lean.
cropRegion: clipCropRegionSchema.optional(),
- // A FREEZE clip: holds the frame at `sourceStartSec` (source window is the
- // zero-width point [sourceStartSec, sourceStartSec]) for `frozenSec` of
- // TIMELINE time. The pause an inserted word creates so a future TTS voice has
- // a slot to speak in — screen and webcam freeze together because both tracks
- // are derived from the same asset source clock. Absent on every ordinary clip;
- // `resolvePlaybackSegments`'s un-probed passthrough branch must not be confused
- // with it (a frozen clip is pushed through unchanged too, see its comment).
- frozenSec: z.number().positive().optional(),
})
.refine((data) => data.timelineEndSec >= data.timelineStartSec, {
message: "timelineEndSec must be greater than or equal to timelineStartSec",
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index 60f1fdea6..adc70b6cb 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -474,94 +474,3 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
-
-// ─── Freeze clips in the pane ────────────────────────────────────
-// An inserted word SPLITS the clip it lands in — [before · freeze · after] — and the word
-// sits exactly on the split. Both the freeze and the half that starts there matched it, so
-// the pane showed the same word twice, in two blocks.
-
-describe("the section a freeze clip projects", () => {
- const TRANSCRIPT: AxcutTranscript = {
- assetId: "a1",
- language: "fr",
- segments: [
- { id: "s1", kind: "speech", startSec: 0, endSec: 4, text: "un deux", wordIds: ["w1", "w2"] },
- ],
- words: [
- { id: "w1", segmentId: "s1", startSec: 0, endSec: 2, text: "un" },
- { id: "w2", segmentId: "s1", startSec: 2, endSec: 4, text: "deux" },
- { id: "synth_1", segmentId: "s1", startSec: 2, endSec: 2, text: "vraiment", source: "synth" },
- ],
- };
- const ASSET: AxcutAsset = {
- id: "a1",
- kind: "video",
- label: "rec.mp4",
- originalPath: "/r.mp4",
- durationSec: 4,
- cameraTrack: null,
- };
- const CLIPS: AxcutClip[] = [
- {
- id: "c_fzA",
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 2,
- timelineStartSec: 0,
- timelineEndSec: 2,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "c_fz",
- assetId: "a1",
- sourceStartSec: 2,
- sourceEndSec: 2,
- timelineStartSec: 2,
- timelineEndSec: 2.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- frozenSec: 0.5,
- },
- {
- id: "c_fzB",
- assetId: "a1",
- sourceStartSec: 2,
- sourceEndSec: 4,
- timelineStartSec: 2.5,
- timelineEndSec: 4.5,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ];
-
- it("shows the inserted word the freeze exists for", () => {
- const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
- expect(sections[1].words.map((cw) => cw.word.text)).toEqual(["vraiment"]);
- });
-
- it("shows it exactly once across the whole pane", () => {
- const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
- const everywhere = sections.flatMap((section) =>
- section.words.filter((cw) => cw.word.id === "synth_1"),
- );
- expect(everywhere).toHaveLength(1);
- });
-
- it("leaves the spoken words where they were", () => {
- const sections = buildAggregatedSections(CLIPS, [TRANSCRIPT], [ASSET], []);
- expect(sections[0].words.map((cw) => cw.word.text)).toEqual(["un"]);
- expect(sections[2].words.map((cw) => cw.word.text)).toEqual(["deux"]);
- });
-
- // The claim is scoped to freezes: with no freeze in the timeline, a word with no
- // duration is shown by whichever clip its moment falls in, as before.
- it("does not withhold an inserted word when no freeze claims it", () => {
- const whole: AxcutClip[] = [{ ...CLIPS[0], id: "c1", sourceEndSec: 4, timelineEndSec: 4 }];
- const sections = buildAggregatedSections(whole, [TRANSCRIPT], [ASSET], []);
- expect(sections[0].words.map((cw) => cw.word.text)).toContain("vraiment");
- });
-});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index d008867ad..9b59f2bd6 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -197,32 +197,6 @@ export function buildClipSection(
asset: AxcutAsset | null,
trimRanges: AxcutTrimRange[],
): ClipSection {
- // A FREEZE clip is the created time behind an inserted word: its source window is
- // the single point it holds, so the ordinary range filter matches nothing — and it
- // must not. The words it shows are the SYNTH words touching that point: the word
- // the freeze exists for. While the playhead runs through the freeze the cue
- // resolves against this section and lights the amber word through the whole pause.
- if (clip.frozenSec !== undefined) {
- const atSec = clip.sourceStartSec;
- const words = transcript
- ? transcript.words.filter(
- (word) => word.source === "synth" && word.startSec <= atSec && word.endSec >= atSec,
- )
- : [];
- return {
- clip,
- asset,
- transcript,
- words: words.map((word) => ({
- id: clipWordId(clip.id, word.id),
- word,
- kept: true,
- trimId: null,
- })),
- trimRuns: [],
- };
- }
-
// `trimAppliesToClip` — not a bare `assetId` match — is what keeps a cut on the
// second of two clips over the same media from also greying out the first one's
// words. Same media, same source range: only the clip anchor tells them apart.
@@ -307,7 +281,7 @@ export function buildAggregatedSections(
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
- const sections = clips.map((clip) =>
+ return clips.map((clip) =>
buildClipSection(
clip,
transcriptById.get(clip.assetId) ?? null,
@@ -315,23 +289,6 @@ export function buildAggregatedSections(
trimRanges,
),
);
-
- // A freeze clip claims the inserted word it was created for, and the clip after the
- // split starts at the very moment that word sits on — so the word matched BOTH and the
- // pane showed it twice, in two blocks. The freeze owns it: it is the section the
- // playhead is inside while the pause plays, and the one whose whole reason to exist is
- // that word.
- const claimed = new Set(
- sections
- .filter((section) => section.clip.frozenSec !== undefined)
- .flatMap((section) => section.words.map((cw) => cw.word.id)),
- );
- if (claimed.size === 0) return sections;
- return sections.map((section) =>
- section.clip.frozenSec !== undefined
- ? section
- : { ...section, words: section.words.filter((cw) => !claimed.has(cw.word.id)) },
- );
}
/**
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ecc81a2d0..ea3f6c61b 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -17,7 +17,6 @@ import {
replacePillSpan,
resolveNativePosition,
resolvePillIds,
- segmentRawSpanSec,
} from "./timelineMap";
function clip(overrides: Partial & Pick): AxcutClip {
@@ -806,63 +805,3 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
-
-// ─── Freeze clips ────────────────────────────────────────────────
-// The pause an inserted word creates. Its source window is the single point it holds, so
-// every reader that derives a length from `sourceEndSec - sourceStartSec` gets zero for it
-// — and zero is the one answer that makes the playhead skip the pause entirely.
-
-describe("a freeze clip", () => {
- const clips = [
- clip({ id: "a", assetId: "m", sourceStartSec: 0, sourceEndSec: 3 }),
- clip({
- id: "fz",
- assetId: "m",
- sourceStartSec: 3,
- sourceEndSec: 3,
- timelineStartSec: 3,
- timelineEndSec: 3.5,
- frozenSec: 0.5,
- }),
- clip({
- id: "b",
- assetId: "m",
- sourceStartSec: 3,
- sourceEndSec: 6,
- timelineStartSec: 3.5,
- timelineEndSec: 6.5,
- }),
- ];
-
- it("keeps its created time in the playback segments", () => {
- // It carries no source range to compress, so a naive reader drops it and the pause
- // vanishes from playback while the ruler still counts it.
- const segments = resolvePlaybackSegments(clips, []);
- const freeze = segments.find((segment) => segment.id === "fz");
- expect(freeze).toBeDefined();
- expect((freeze?.timelineEndSec ?? 0) - (freeze?.timelineStartSec ?? 0)).toBeCloseTo(0.5, 5);
- });
-
- it("spans its frozen time on the raw ruler, not its (zero) source length", () => {
- const span = segmentRawSpanSec(clips[1], clips);
- expect(span.endSec - span.startSec).toBeCloseTo(0.5, 5);
- });
-
- it("holds the source clock still while the playhead runs through it", () => {
- // The raw playhead DOES advance through the pause. Letting that delta reach the
- // decoder would push it past the held frame into the content that belongs after.
- const segments = resolvePlaybackSegments(clips, []);
- for (const rawSec of [3.05, 3.25, 3.45]) {
- const position = resolveNativePosition(rawSec, segments, clips);
- expect(position?.clip.id).toBe("fz");
- expect(position?.sourceTimeSec).toBe(3);
- }
- });
-
- it("hands the clip after the pause its own source moment again", () => {
- const segments = resolvePlaybackSegments(clips, []);
- const position = resolveNativePosition(4, segments, clips);
- expect(position?.clip.id).toBe("b");
- expect(position?.sourceTimeSec).toBeCloseTo(3.5, 5);
- });
-});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 081ede4d7..b98601a03 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -403,13 +403,7 @@ export function segmentRawSpanSec(
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
- // A freeze clip's source window is the point it holds — its RAW span is its created
- // timeline time, not the (zero) source length, or the playhead could never be
- // "inside" it and would skip the pause entirely.
- const lenSec =
- segment.frozenSec !== undefined
- ? segment.frozenSec
- : (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -696,20 +690,6 @@ export function resolveNativePosition(
const seg = visibleSegments[index];
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
- // Inside a freeze: the source clock does not advance. The whole point of the clip
- // is created timeline time over one held frame — clamp the offset to zero rather
- // than letting the raw-playhead delta (which DOES advance through the freeze) push
- // the decoder past the held frame into content that belongs after the pause.
- if (seg.frozenSec !== undefined) {
- return {
- clip: seg,
- clipIndex: index,
- sourceTimeSec: seg.sourceStartSec,
- };
- }
- // No `clampToSegmentStart` any more: this branch used to snap the playhead to the
- // next kept segment, and main now returns `positionUnderCut` before reaching here
- // (issue #216), so the flag could never be true.
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
return {
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index abe2f1c1a..64e877b63 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -41,13 +41,6 @@ export function useNativePlaybackSync(
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
- // A freeze clip holds ONE frame for `frozenSec` of app-clock time. Free-running the
- // decoder through it would play the frames after the pause instead; the app clock
- // (which does traverse the freeze) then re-seeks on drift and stutters. Pausing the
- // decoder for the duration of the freeze is what makes the pause a pause — the
- // webcam track freezes with the screen track because both derive from the same
- // asset source clock the freeze stops advancing.
- const frozen = activePosition?.clip.frozenSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -56,15 +49,13 @@ export function useNativePlaybackSync(
() => getCurrentNativeViewId() !== null,
);
- // Play/pause → native free-run. Inside a freeze the native side is PAUSED however
- // the transport is set — the app clock advances through the created time while the
- // decoder holds the frame.
+ // Play/pause → native free-run.
useEffect(() => {
if (!active) {
return;
}
- setNativePlaying(playing && !frozen);
- }, [active, playing, frozen]);
+ setNativePlaying(playing);
+ }, [active, playing]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -77,17 +68,6 @@ export function useNativePlaybackSync(
}
const now = performance.now();
- // Inside a freeze while playing: the decoder is paused (see the transport
- // effect) and parked on the held frame. Refresh the drift refs every run so the
- // drift check never sees the (correctly) frozen source clock as divergence and
- // fights itself with repeated seeks.
- if (playing && frozen) {
- setNativeTime(sourceTimeSec);
- lastSyncedSourceTimeRef.current = sourceTimeSec;
- lastSyncedWallTimeRef.current = now;
- return;
- }
-
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -115,5 +95,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, frozen, activeClipId, sourceTimeSec]);
+ }, [active, playing, activeClipId, sourceTimeSec]);
}
From 888f5f1f633905ffd9dd2f126661cdb1c0c87bc8 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 22:51:01 +0200
Subject: [PATCH 059/113] feat(timeline): mark where words were added, without
storing anything new
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An added word was visible only in the transcript pane. On the timeline — where
you decide what the film does — nothing said a moment carried text with no
audio behind it.
Each one now gets a thin amber tick on its clip's own track, at the moment it
sits on, carrying its text in the tooltip and seeking to it on click. Amber is
the colour the pane gives the same word, so the two read as one thing.
Derived, never stored: the mark is computed from the transcript's `synth` words
on every render, so there is no second record to fall out of step with the
first, and nothing for another writer of `timeline.clips` to lose. Positioning
is a percentage inside the clip's own box rather than an absolute ruler offset,
so a mark travels with its clip through a reorder without arithmetic of its own.
---
.../ai-edition/v4/EditorShellV4.module.css | 35 ++++
.../v4/V4Timeline.geometry.test.tsx | 3 +
src/components/ai-edition/v4/V4Timeline.tsx | 47 ++++-
.../v4/V4Timeline.waveform.test.tsx | 2 +
src/i18n/locales/ar/timeline.json | 165 +++++++++---------
src/i18n/locales/en/timeline.json | 165 +++++++++---------
src/i18n/locales/es/timeline.json | 165 +++++++++---------
src/i18n/locales/fr/timeline.json | 165 +++++++++---------
src/i18n/locales/it/timeline.json | 165 +++++++++---------
src/i18n/locales/ja-JP/timeline.json | 165 +++++++++---------
src/i18n/locales/ko-KR/timeline.json | 165 +++++++++---------
src/i18n/locales/pt-BR/timeline.json | 165 +++++++++---------
src/i18n/locales/ru/timeline.json | 165 +++++++++---------
src/i18n/locales/tr/timeline.json | 165 +++++++++---------
src/i18n/locales/vi/timeline.json | 165 +++++++++---------
src/i18n/locales/zh-CN/timeline.json | 165 +++++++++---------
src/i18n/locales/zh-TW/timeline.json | 165 +++++++++---------
src/lib/ai-edition/store/useTimeline.ts | 4 +
18 files changed, 1169 insertions(+), 1067 deletions(-)
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 5347d66f0..56d438a4f 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1744,6 +1744,41 @@
background: var(--danger-soft);
color: var(--danger);
}
+
+/* Where the user has ADDED a word: text with no audio behind it. A thin amber tick
+ over the waveform, at the moment the word sits on, wide enough to hit and no wider
+ — the clip underneath still has to be draggable everywhere else. Amber is the
+ colour the transcript pane gives the same word, so the two read as one thing. */
+.tlClipInsert {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 2;
+ width: 9px;
+ margin-left: -4px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.tlClipInsert::before {
+ content: "";
+ position: absolute;
+ left: 3px;
+ top: 4px;
+ bottom: 4px;
+ width: 3px;
+ border-radius: 2px;
+ background: var(--warn);
+ box-shadow: 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent);
+ transition: box-shadow var(--motion-fast) var(--ease);
+}
+.tlClipInsert:hover::before,
+.tlClipInsert:focus-visible::before {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent),
+ 0 0 0 4px var(--warn-soft);
+}
.tlDropHint {
position: absolute;
inset: 0;
diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
index 5d668f6ff..9a24817dc 100644
--- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
@@ -79,6 +79,9 @@ function renderTimeline(
) {
const tl = {
clips,
+ // Marks for added words are read straight off the transcript (see the pane's
+ // amber words) — no project here has any.
+ transcripts: [],
assets,
annotationRegions: [annotation],
speedRegions: [],
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index c847e7c66..6476d6406 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -41,7 +41,7 @@ import {
} from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe";
-import type { AxcutAudioTrack, AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutAudioTrack, AxcutClip, AxcutWord } from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionStore";
@@ -701,6 +701,32 @@ export function V4Timeline({
label: `${(p.member.customScale ?? ZOOM_DEPTH_SCALES[p.member.depth]).toFixed(2)}×`,
sourceIds: p.ids,
}));
+ // Where the user has ADDED words. Derived from the transcript on every render and
+ // stored nowhere: the word carries `source: "synth"` and its own source time, so a mark
+ // built from it cannot drift from the amber word the transcript pane shows. Grouped by
+ // clip because each mark is positioned inside its clip's own box — it then travels with
+ // the clip through a reorder for free, with no ruler arithmetic of its own.
+ const insertedWordsByClip = useMemo(() => {
+ const byAsset = new Map();
+ for (const transcript of tl.transcripts) {
+ const added = transcript.words.filter((word) => word.source === "synth");
+ if (added.length > 0) byAsset.set(transcript.assetId, added);
+ }
+ if (byAsset.size === 0) return new Map>();
+ const out = new Map>();
+ for (const clip of clips) {
+ const words = byAsset.get(clip.assetId);
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ const span = sourceEnd - clip.sourceStartSec;
+ if (!words || span <= 0) continue;
+ const marks = words
+ .filter((word) => word.startSec >= clip.sourceStartSec && word.startSec <= sourceEnd)
+ .map((word) => ({ word, atPct: ((word.startSec - clip.sourceStartSec) / span) * 100 }));
+ if (marks.length > 0) out.set(clip.id, marks);
+ }
+ return out;
+ }, [tl.transcripts, clips]);
+
// trims: content-free (no per-instance text/settings), so touching rows —
// inevitable once a trim is ventilated across a clip boundary — are
// coalesced into one pill. This is what makes growing a trim across a
@@ -2202,6 +2228,25 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => (
+ e.stopPropagation()}
+ onClick={(e) => {
+ // Jump to the moment the added text sits on. The clip box
+ // underneath would otherwise take this as a selection.
+ e.stopPropagation();
+ setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
+ }}
+ />
+ ))}
{selected ? (
{
function renderBars(atGainDb: number): string[] {
gainDb = atGainDb;
const tl = {
+ // Marks for added words come from the transcript; this project has none.
+ transcripts: [],
clips: [
{
id: "c0",
diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json
index e0e263b43..4ff6524eb 100644
--- a/src/i18n/locales/ar/timeline.json
+++ b/src/i18n/locales/ar/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "إضافة تكبير (Z)",
- "suggestZooms": "اقتراح تكبير من المؤشر",
- "autoZoomOn": "اقتراحات التكبير التلقائي مفعّلة — انقر لإزالة التكبيرات المقترحة",
- "autoZoomOff": "اقتراحات التكبير التلقائي معطّلة — انقر لاقتراح تكبيرات من المؤشر",
- "autoFocusAllOn": "التركيز التلقائي مفعّل لجميع التكبيرات — انقر للتبديل إلى يدوي للجميع",
- "autoFocusAllOff": "تفعيل التركيز التلقائي لجميع التكبيرات (الكاميرا تتبع المؤشر)",
- "addTrim": "إضافة قص (T)",
- "addAnnotation": "إضافة شرح (A)",
- "addSpeed": "إضافة سرعة (S)",
- "addCameraFullscreen": "إضافة كاميرا كاملة الشاشة (C)"
- },
- "hints": {
- "pressZoom": "اضغط Z لإضافة تكبير",
- "pressTrim": "اضغط T لإضافة قص",
- "pressAnnotation": "اضغط A لإضافة شرح",
- "pressAudio": "اضغط M لإضافة صوت، وV لتسجيل تعليق صوتي",
- "pressSpeed": "اضغط S لإضافة سرعة",
- "pressCameraFullscreen": "اضغط C لإضافة مقطع كاميرا كاملة الشاشة"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "لا يحتوي هذا التسجيل على بيانات حركة مؤشر، أو أن التكبيرات الحالية تغطي بالفعل اللحظات المزدحمة.",
+ "smartCutsNoAudio": "لا يحتوي هذا الملف على صوت",
+ "automaticZoomsHint": "من حركة المؤشر المسجلة",
+ "dragToReorderHint": "اسحب لإعادة الترتيب · انقر نقرًا مزدوجًا لتعديل نقطتي البداية والنهاية",
+ "smartCutsNeedsTranscript": "يتطلب نصًا منسوخًا",
+ "addedWord": "كلمة مضافة: \"{{word}}\" — لا صوت خلفها",
+ "smartZoomsAndCuts": "قصات ذكية",
+ "autoZoomFailed": "فشل التكبير التلقائي",
+ "smartCutsWaiting": "جارٍ النسخ… سيكون جاهزًا بعد قليل",
+ "automaticZooms": "تكبيرات تلقائية",
+ "arrangeClipsHint": "اسحب المقاطع أدناه لإعادة ترتيبها أو إسقاط مقاطع جديدة",
+ "comment": "تعليق",
+ "addAudioTooltip": "إضافة صوت",
+ "timelineTools": "أدوات المخطط الزمني",
+ "deleteClip": "حذف المقطع",
+ "arrangeClips": "ترتيب المقاطع",
+ "editInOutPoints": "تعديل نقطتي البداية والنهاية",
+ "smartZoomsAndCutsHint": "باستخدام الذكاء الاصطناعي",
+ "addedAutoZoomPlural": "تمت إضافة {{count}} تكبيرات تلقائية",
+ "noAutoZoomMoments": "لم يتم العثور على لحظات تكبير تلقائي",
+ "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط",
+ "smartCutsNoSpeech": "لم يتم اكتشاف كلام",
+ "newAnnotation": "شرح",
+ "importRecordingFirst": "استورد تسجيلاً أولاً",
+ "addedAutoZoom": "تمت إضافة {{count}} تكبير تلقائي",
+ "dropToAdd": "أفلت للإضافة إلى المخطط الزمني",
+ "aiEnhanceRequested": "طُلب من وكيل الذكاء الاصطناعي قص الأوقات الميتة",
+ "autoEnhance": "تحسين تلقائي"
},
"labels": {
- "pan": "تحريك",
"zoom": "تكبير",
- "trim": "قص",
- "speed": "سرعة",
+ "cameraFullscreenItem": "كاميرا كاملة الشاشة {{index}}",
+ "imageItem": "صورة",
+ "pan": "تحريك",
"zoomItem": "تكبير {{index}}",
+ "cameraFullscreen": "كاميرا كاملة الشاشة",
+ "speed": "سرعة",
+ "emptyText": "نص فارغ",
"trimItem": "قص {{index}}",
- "speedItem": "سرعة {{index}}",
"annotationItem": "شرح",
- "imageItem": "صورة",
- "emptyText": "نص فارغ",
- "cameraFullscreen": "كاميرا كاملة الشاشة",
- "cameraFullscreenItem": "كاميرا كاملة الشاشة {{index}}"
- },
- "emptyState": {
- "noVideo": "لم يتم تحميل أي فيديو",
- "dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
+ "trim": "قص",
+ "speedItem": "سرعة {{index}}"
},
"errors": {
- "cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
- "zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
- "zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
- "noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
+ "noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
"noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.",
+ "cameraFullscreenExistsAtLocation": "يوجد بالفعل مقطع كاميرا كاملة الشاشة في هذا الموقع أو لا توجد مساحة كافية متاحة.",
"noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام",
"noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.",
+ "zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
"noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر",
- "noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
+ "speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
"noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي",
- "noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
"cannotPlaceTrim": "لا يمكن وضع القص هنا",
- "trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
"cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
- "speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
- "cannotPlaceCameraFullscreen": "لا يمكن وضع الكاميرا الكاملة هنا",
- "cameraFullscreenExistsAtLocation": "يوجد بالفعل مقطع كاميرا كاملة الشاشة في هذا الموقع أو لا توجد مساحة كافية متاحة."
- },
- "success": {
- "addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
- "addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
- },
- "toolbar": {
- "autoEnhance": "تحسين تلقائي",
- "automaticZooms": "تكبيرات تلقائية",
- "automaticZoomsHint": "من حركة المؤشر المسجلة",
- "smartZoomsAndCuts": "قصات ذكية",
- "smartZoomsAndCutsHint": "باستخدام الذكاء الاصطناعي",
- "comment": "تعليق",
- "timelineTools": "أدوات المخطط الزمني",
- "arrangeClips": "ترتيب المقاطع",
- "arrangeClipsHint": "اسحب المقاطع أدناه لإعادة ترتيبها أو إسقاط مقاطع جديدة",
- "newAnnotation": "شرح",
- "dragToReorderHint": "اسحب لإعادة الترتيب · انقر نقرًا مزدوجًا لتعديل نقطتي البداية والنهاية",
- "editInOutPoints": "تعديل نقطتي البداية والنهاية",
- "deleteClip": "حذف المقطع",
- "dropToAdd": "أفلت للإضافة إلى المخطط الزمني",
- "importRecordingFirst": "استورد تسجيلاً أولاً",
- "noAutoZoomMoments": "لم يتم العثور على لحظات تكبير تلقائي",
- "noAutoZoomMomentsDescription": "لا يحتوي هذا التسجيل على بيانات حركة مؤشر، أو أن التكبيرات الحالية تغطي بالفعل اللحظات المزدحمة.",
- "addedAutoZoom": "تمت إضافة {{count}} تكبير تلقائي",
- "addedAutoZoomPlural": "تمت إضافة {{count}} تكبيرات تلقائية",
- "autoZoomFailed": "فشل التكبير التلقائي",
- "aiEnhanceRequested": "طُلب من وكيل الذكاء الاصطناعي قص الأوقات الميتة",
- "smartCutsWaiting": "جارٍ النسخ… سيكون جاهزًا بعد قليل",
- "smartCutsNeedsTranscript": "يتطلب نصًا منسوخًا",
- "smartCutsNoAudio": "لا يحتوي هذا الملف على صوت",
- "smartCutsNoSpeech": "لم يتم اكتشاف كلام",
- "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط",
- "addAudioTooltip": "إضافة صوت"
+ "zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
+ "trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "cannotPlaceCameraFullscreen": "لا يمكن وضع الكاميرا الكاملة هنا"
},
"audio": {
- "addVoiceover": "إضافة تعليق صوتي",
- "addVoiceoverHint": "سجّل تعليقًا صوتيًا فوق الفيديو",
+ "micDenied": "تم رفض الوصول إلى الميكروفون",
"subtitle": "ضع تعليقًا صوتيًا أو موسيقى خلفية على المخطط الزمني",
- "record": "تسجيل تعليق صوتي",
- "importFile": "استيراد ملف صوتي",
+ "importFailed": "تعذر استيراد الملف الصوتي",
"importFileHint": "أدرج موسيقى أو ملفًا صوتيًا",
+ "addVoiceoverHint": "سجّل تعليقًا صوتيًا فوق الفيديو",
+ "stop": "إيقاف",
"recording": "جارٍ التسجيل",
+ "saveFailed": "تعذر حفظ التسجيل",
"recordingHint": "علّق صوتيًا مع الفيديو — يعمل أثناء التسجيل",
- "stop": "إيقاف",
- "micDenied": "تم رفض الوصول إلى الميكروفون",
+ "importFile": "استيراد ملف صوتي",
+ "record": "تسجيل تعليق صوتي",
"recordingUnavailable": "التسجيل غير متاح هنا",
- "saveFailed": "تعذر حفظ التسجيل",
- "importFailed": "تعذر استيراد الملف الصوتي"
+ "addVoiceover": "إضافة تعليق صوتي"
+ },
+ "success": {
+ "addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
+ "addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
+ },
+ "hints": {
+ "pressAnnotation": "اضغط A لإضافة شرح",
+ "pressSpeed": "اضغط S لإضافة سرعة",
+ "pressTrim": "اضغط T لإضافة قص",
+ "pressCameraFullscreen": "اضغط C لإضافة مقطع كاميرا كاملة الشاشة",
+ "pressZoom": "اضغط Z لإضافة تكبير",
+ "pressAudio": "اضغط M لإضافة صوت، وV لتسجيل تعليق صوتي"
+ },
+ "buttons": {
+ "autoFocusAllOff": "تفعيل التركيز التلقائي لجميع التكبيرات (الكاميرا تتبع المؤشر)",
+ "autoZoomOff": "اقتراحات التكبير التلقائي معطّلة — انقر لاقتراح تكبيرات من المؤشر",
+ "addAnnotation": "إضافة شرح (A)",
+ "suggestZooms": "اقتراح تكبير من المؤشر",
+ "addSpeed": "إضافة سرعة (S)",
+ "addCameraFullscreen": "إضافة كاميرا كاملة الشاشة (C)",
+ "autoFocusAllOn": "التركيز التلقائي مفعّل لجميع التكبيرات — انقر للتبديل إلى يدوي للجميع",
+ "autoZoomOn": "اقتراحات التكبير التلقائي مفعّلة — انقر لإزالة التكبيرات المقترحة",
+ "addZoom": "إضافة تكبير (Z)",
+ "addTrim": "إضافة قص (T)"
+ },
+ "emptyState": {
+ "noVideo": "لم يتم تحميل أي فيديو",
+ "dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
}
}
diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json
index 886e8ce7a..55b3a541e 100644
--- a/src/i18n/locales/en/timeline.json
+++ b/src/i18n/locales/en/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Add Zoom (Z)",
- "suggestZooms": "Suggest Zooms from Cursor",
- "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms",
- "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor",
- "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual",
- "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)",
- "addTrim": "Add Trim (T)",
- "addAnnotation": "Add Annotation (A)",
- "addSpeed": "Add Speed (S)",
- "addCameraFullscreen": "Add Full Camera (C)"
- },
- "hints": {
- "pressZoom": "Press Z to add zoom",
- "pressTrim": "Press T to add trim",
- "pressAnnotation": "Press A to add annotation",
- "pressAudio": "Press M to add audio, V to record a voiceover",
- "pressSpeed": "Press S to add speed",
- "pressCameraFullscreen": "Press C to add a Full Camera segment"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "This recording has no cursor movement data, or existing zooms already cover the busy moments.",
+ "smartCutsNoAudio": "This media has no audio",
+ "automaticZoomsHint": "From recorded cursor movement",
+ "dragToReorderHint": "Drag to reorder · double-click to edit in/out points",
+ "smartCutsNeedsTranscript": "Needs a transcript",
+ "addedWord": "Added word: \"{{word}}\" — no audio behind it",
+ "smartZoomsAndCuts": "Smart cuts",
+ "autoZoomFailed": "Auto-zoom failed",
+ "smartCutsWaiting": "Transcribing… ready in a moment",
+ "automaticZooms": "Automatic zooms",
+ "arrangeClipsHint": "Drag clips below to reorder or drop new ones in",
+ "comment": "Comment",
+ "addAudioTooltip": "Add audio",
+ "timelineTools": "Timeline tools",
+ "deleteClip": "Delete clip",
+ "arrangeClips": "Arrange clips",
+ "editInOutPoints": "Edit in/out points",
+ "smartZoomsAndCutsHint": "With AI",
+ "addedAutoZoomPlural": "Added {{count}} automatic zooms",
+ "noAutoZoomMoments": "No auto-zoom moments found",
+ "smartCutsFailed": "Transcription failed — retry it from Media",
+ "smartCutsNoSpeech": "No speech detected",
+ "newAnnotation": "Annotation",
+ "importRecordingFirst": "Import a recording first",
+ "addedAutoZoom": "Added {{count}} automatic zoom",
+ "dropToAdd": "Drop to add to timeline",
+ "aiEnhanceRequested": "Asked the AI agent to cut the dead time",
+ "autoEnhance": "Auto-enhance"
},
"labels": {
- "pan": "Pan",
"zoom": "Zoom",
- "trim": "Trim",
- "speed": "Speed",
+ "cameraFullscreenItem": "Full Camera {{index}}",
+ "imageItem": "Image",
+ "pan": "Pan",
"zoomItem": "Zoom {{index}}",
+ "cameraFullscreen": "Full Camera",
+ "speed": "Speed",
+ "emptyText": "Empty text",
"trimItem": "Trim {{index}}",
- "speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
- "imageItem": "Image",
- "emptyText": "Empty text",
- "cameraFullscreen": "Full Camera",
- "cameraFullscreenItem": "Full Camera {{index}}"
- },
- "emptyState": {
- "noVideo": "No Video Loaded",
- "dragAndDrop": "Drag and drop a video to start editing"
+ "trim": "Trim",
+ "speedItem": "Speed {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Cannot place zoom here",
- "zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
- "zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
- "noCursorTelemetry": "No cursor telemetry available",
+ "noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
+ "cameraFullscreenExistsAtLocation": "A Full Camera segment already exists at this location or not enough space available.",
"noUsableTelemetry": "No usable cursor telemetry",
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
+ "zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
"noDwellMoments": "No clear cursor dwell moments found",
- "noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
+ "speedExistsAtLocation": "Speed region already exists at this location or not enough space available.",
+ "noCursorTelemetry": "No cursor telemetry available",
"noAutoZoomSlots": "No auto-zoom slots available",
- "noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
"cannotPlaceTrim": "Cannot place trim here",
- "trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
+ "cannotPlaceZoom": "Cannot place zoom here",
"cannotPlaceSpeed": "Cannot place speed here",
- "speedExistsAtLocation": "Speed region already exists at this location or not enough space available.",
- "cannotPlaceCameraFullscreen": "Cannot place Full Camera here",
- "cameraFullscreenExistsAtLocation": "A Full Camera segment already exists at this location or not enough space available."
- },
- "success": {
- "addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
- "addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
- },
- "toolbar": {
- "autoEnhance": "Auto-enhance",
- "automaticZooms": "Automatic zooms",
- "automaticZoomsHint": "From recorded cursor movement",
- "smartZoomsAndCuts": "Smart cuts",
- "smartZoomsAndCutsHint": "With AI",
- "comment": "Comment",
- "timelineTools": "Timeline tools",
- "arrangeClips": "Arrange clips",
- "arrangeClipsHint": "Drag clips below to reorder or drop new ones in",
- "newAnnotation": "Annotation",
- "dragToReorderHint": "Drag to reorder · double-click to edit in/out points",
- "editInOutPoints": "Edit in/out points",
- "deleteClip": "Delete clip",
- "dropToAdd": "Drop to add to timeline",
- "importRecordingFirst": "Import a recording first",
- "noAutoZoomMoments": "No auto-zoom moments found",
- "noAutoZoomMomentsDescription": "This recording has no cursor movement data, or existing zooms already cover the busy moments.",
- "addedAutoZoom": "Added {{count}} automatic zoom",
- "addedAutoZoomPlural": "Added {{count}} automatic zooms",
- "autoZoomFailed": "Auto-zoom failed",
- "aiEnhanceRequested": "Asked the AI agent to cut the dead time",
- "smartCutsWaiting": "Transcribing… ready in a moment",
- "smartCutsNeedsTranscript": "Needs a transcript",
- "smartCutsNoAudio": "This media has no audio",
- "smartCutsNoSpeech": "No speech detected",
- "smartCutsFailed": "Transcription failed — retry it from Media",
- "addAudioTooltip": "Add audio"
+ "zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
+ "noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
+ "trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
+ "cannotPlaceCameraFullscreen": "Cannot place Full Camera here"
},
"audio": {
- "addVoiceover": "Add Voiceover",
- "addVoiceoverHint": "Record narration over your video",
+ "micDenied": "Microphone access was denied",
"subtitle": "Place a voiceover or background music layer on the timeline",
- "record": "Record voiceover",
- "importFile": "Import audio file",
+ "importFailed": "Could not import the audio file",
"importFileHint": "Bring in music or an audio file",
+ "addVoiceoverHint": "Record narration over your video",
+ "stop": "Stop",
"recording": "Recording",
+ "saveFailed": "Could not save the recording",
"recordingHint": "Narrate along with the video — it plays while you record",
- "stop": "Stop",
- "micDenied": "Microphone access was denied",
+ "importFile": "Import audio file",
+ "record": "Record voiceover",
"recordingUnavailable": "Recording is not available here",
- "saveFailed": "Could not save the recording",
- "importFailed": "Could not import the audio file"
+ "addVoiceover": "Add Voiceover"
+ },
+ "success": {
+ "addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
+ "addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
+ },
+ "hints": {
+ "pressAnnotation": "Press A to add annotation",
+ "pressSpeed": "Press S to add speed",
+ "pressTrim": "Press T to add trim",
+ "pressCameraFullscreen": "Press C to add a Full Camera segment",
+ "pressZoom": "Press Z to add zoom",
+ "pressAudio": "Press M to add audio, V to record a voiceover"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)",
+ "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor",
+ "addAnnotation": "Add Annotation (A)",
+ "suggestZooms": "Suggest Zooms from Cursor",
+ "addSpeed": "Add Speed (S)",
+ "addCameraFullscreen": "Add Full Camera (C)",
+ "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual",
+ "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms",
+ "addZoom": "Add Zoom (Z)",
+ "addTrim": "Add Trim (T)"
+ },
+ "emptyState": {
+ "noVideo": "No Video Loaded",
+ "dragAndDrop": "Drag and drop a video to start editing"
}
}
diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json
index 8d6f3974b..5b7298398 100644
--- a/src/i18n/locales/es/timeline.json
+++ b/src/i18n/locales/es/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Agregar zoom (Z)",
- "suggestZooms": "Sugerir zooms desde el cursor",
- "autoZoomOn": "Sugerencias de zoom automático activadas — haz clic para quitar los zooms sugeridos",
- "autoZoomOff": "Sugerencias de zoom automático desactivadas — haz clic para sugerir zooms desde el cursor",
- "autoFocusAllOn": "Enfoque automático activado para todos los zooms — haz clic para pasar todos a manual",
- "autoFocusAllOff": "Activar enfoque automático para todos los zooms (la cámara sigue el cursor)",
- "addTrim": "Agregar recorte (T)",
- "addAnnotation": "Agregar anotación (A)",
- "addSpeed": "Agregar velocidad (S)",
- "addCameraFullscreen": "Agregar cámara a pantalla completa (C)"
- },
- "hints": {
- "pressZoom": "Presiona Z para agregar zoom",
- "pressTrim": "Presiona T para agregar recorte",
- "pressAnnotation": "Presiona A para agregar anotación",
- "pressAudio": "Pulsa M para añadir audio, V para grabar una voz en off",
- "pressSpeed": "Presiona S para agregar velocidad",
- "pressCameraFullscreen": "Presiona C para agregar un segmento de cámara a pantalla completa"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "Esta grabación no tiene datos de movimiento del cursor, o los zooms existentes ya cubren los momentos con actividad.",
+ "smartCutsNoAudio": "Este medio no tiene audio",
+ "automaticZoomsHint": "Del movimiento del cursor grabado",
+ "dragToReorderHint": "Arrastra para reordenar · doble clic para editar los puntos de entrada/salida",
+ "smartCutsNeedsTranscript": "Requiere una transcripción",
+ "addedWord": "Palabra añadida: «{{word}}» — sin audio detrás",
+ "smartZoomsAndCuts": "Cortes inteligentes",
+ "autoZoomFailed": "Error en el zoom automático",
+ "smartCutsWaiting": "Transcribiendo… disponible en un momento",
+ "automaticZooms": "Zooms automáticos",
+ "arrangeClipsHint": "Arrastra los clips de abajo para reordenarlos o suelta otros nuevos",
+ "comment": "Comentario",
+ "addAudioTooltip": "Añadir audio",
+ "timelineTools": "Herramientas de la línea de tiempo",
+ "deleteClip": "Eliminar clip",
+ "arrangeClips": "Organizar clips",
+ "editInOutPoints": "Editar puntos de entrada/salida",
+ "smartZoomsAndCutsHint": "Con IA",
+ "addedAutoZoomPlural": "Se añadieron {{count}} zooms automáticos",
+ "noAutoZoomMoments": "No se encontraron momentos para zoom automático",
+ "smartCutsFailed": "La transcripción falló: reinténtala desde Medios",
+ "smartCutsNoSpeech": "No se detectó voz",
+ "newAnnotation": "Anotación",
+ "importRecordingFirst": "Importa una grabación primero",
+ "addedAutoZoom": "Se añadió {{count}} zoom automático",
+ "dropToAdd": "Suelta para añadir a la línea de tiempo",
+ "aiEnhanceRequested": "Se pidió al agente de IA que corte los tiempos muertos",
+ "autoEnhance": "Mejora automática"
},
"labels": {
- "pan": "Desplazar",
"zoom": "Zoom",
- "trim": "Recortar",
- "speed": "Velocidad",
+ "cameraFullscreenItem": "Cámara a pantalla completa {{index}}",
+ "imageItem": "Imagen",
+ "pan": "Desplazar",
"zoomItem": "Zoom {{index}}",
+ "cameraFullscreen": "Cámara a pantalla completa",
+ "speed": "Velocidad",
+ "emptyText": "Texto vacío",
"trimItem": "Recorte {{index}}",
- "speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
- "imageItem": "Imagen",
- "emptyText": "Texto vacío",
- "cameraFullscreen": "Cámara a pantalla completa",
- "cameraFullscreenItem": "Cámara a pantalla completa {{index}}"
- },
- "emptyState": {
- "noVideo": "No hay video cargado",
- "dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
+ "trim": "Recortar",
+ "speedItem": "Velocidad {{index}}"
},
"errors": {
- "cannotPlaceZoom": "No se puede colocar el zoom aquí",
- "zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
- "zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
- "noCursorTelemetry": "No hay telemetría de cursor disponible",
+ "noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
+ "cameraFullscreenExistsAtLocation": "Ya existe un segmento de cámara a pantalla completa en esta ubicación o no hay suficiente espacio disponible.",
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
+ "zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
- "noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
+ "speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible.",
+ "noCursorTelemetry": "No hay telemetría de cursor disponible",
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
- "noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
- "trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
+ "cannotPlaceZoom": "No se puede colocar el zoom aquí",
"cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
- "speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible.",
- "cannotPlaceCameraFullscreen": "No se puede colocar la cámara a pantalla completa aquí",
- "cameraFullscreenExistsAtLocation": "Ya existe un segmento de cámara a pantalla completa en esta ubicación o no hay suficiente espacio disponible."
- },
- "success": {
- "addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
- "addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
- },
- "toolbar": {
- "autoEnhance": "Mejora automática",
- "automaticZooms": "Zooms automáticos",
- "automaticZoomsHint": "Del movimiento del cursor grabado",
- "smartZoomsAndCuts": "Cortes inteligentes",
- "smartZoomsAndCutsHint": "Con IA",
- "comment": "Comentario",
- "timelineTools": "Herramientas de la línea de tiempo",
- "arrangeClips": "Organizar clips",
- "arrangeClipsHint": "Arrastra los clips de abajo para reordenarlos o suelta otros nuevos",
- "newAnnotation": "Anotación",
- "dragToReorderHint": "Arrastra para reordenar · doble clic para editar los puntos de entrada/salida",
- "editInOutPoints": "Editar puntos de entrada/salida",
- "deleteClip": "Eliminar clip",
- "dropToAdd": "Suelta para añadir a la línea de tiempo",
- "importRecordingFirst": "Importa una grabación primero",
- "noAutoZoomMoments": "No se encontraron momentos para zoom automático",
- "noAutoZoomMomentsDescription": "Esta grabación no tiene datos de movimiento del cursor, o los zooms existentes ya cubren los momentos con actividad.",
- "addedAutoZoom": "Se añadió {{count}} zoom automático",
- "addedAutoZoomPlural": "Se añadieron {{count}} zooms automáticos",
- "autoZoomFailed": "Error en el zoom automático",
- "aiEnhanceRequested": "Se pidió al agente de IA que corte los tiempos muertos",
- "smartCutsWaiting": "Transcribiendo… disponible en un momento",
- "smartCutsNeedsTranscript": "Requiere una transcripción",
- "smartCutsNoAudio": "Este medio no tiene audio",
- "smartCutsNoSpeech": "No se detectó voz",
- "smartCutsFailed": "La transcripción falló: reinténtala desde Medios",
- "addAudioTooltip": "Añadir audio"
+ "zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
+ "noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
+ "trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
+ "cannotPlaceCameraFullscreen": "No se puede colocar la cámara a pantalla completa aquí"
},
"audio": {
- "addVoiceover": "Añadir voz en off",
- "addVoiceoverHint": "Graba una narración sobre tu vídeo",
+ "micDenied": "Se denegó el acceso al micrófono",
"subtitle": "Coloca una capa de voz en off o de música de fondo en la línea de tiempo",
- "record": "Grabar voz en off",
- "importFile": "Importar archivo de audio",
+ "importFailed": "No se pudo importar el archivo de audio",
"importFileHint": "Importa música o un archivo de audio",
+ "addVoiceoverHint": "Graba una narración sobre tu vídeo",
+ "stop": "Detener",
"recording": "Grabando",
+ "saveFailed": "No se pudo guardar la grabación",
"recordingHint": "Narra junto al vídeo: se reproduce mientras grabas",
- "stop": "Detener",
- "micDenied": "Se denegó el acceso al micrófono",
+ "importFile": "Importar archivo de audio",
+ "record": "Grabar voz en off",
"recordingUnavailable": "La grabación no está disponible aquí",
- "saveFailed": "No se pudo guardar la grabación",
- "importFailed": "No se pudo importar el archivo de audio"
+ "addVoiceover": "Añadir voz en off"
+ },
+ "success": {
+ "addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
+ "addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
+ },
+ "hints": {
+ "pressAnnotation": "Presiona A para agregar anotación",
+ "pressSpeed": "Presiona S para agregar velocidad",
+ "pressTrim": "Presiona T para agregar recorte",
+ "pressCameraFullscreen": "Presiona C para agregar un segmento de cámara a pantalla completa",
+ "pressZoom": "Presiona Z para agregar zoom",
+ "pressAudio": "Pulsa M para añadir audio, V para grabar una voz en off"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Activar enfoque automático para todos los zooms (la cámara sigue el cursor)",
+ "autoZoomOff": "Sugerencias de zoom automático desactivadas — haz clic para sugerir zooms desde el cursor",
+ "addAnnotation": "Agregar anotación (A)",
+ "suggestZooms": "Sugerir zooms desde el cursor",
+ "addSpeed": "Agregar velocidad (S)",
+ "addCameraFullscreen": "Agregar cámara a pantalla completa (C)",
+ "autoFocusAllOn": "Enfoque automático activado para todos los zooms — haz clic para pasar todos a manual",
+ "autoZoomOn": "Sugerencias de zoom automático activadas — haz clic para quitar los zooms sugeridos",
+ "addZoom": "Agregar zoom (Z)",
+ "addTrim": "Agregar recorte (T)"
+ },
+ "emptyState": {
+ "noVideo": "No hay video cargado",
+ "dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
}
}
diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json
index 52de88c6c..de5b2d06f 100644
--- a/src/i18n/locales/fr/timeline.json
+++ b/src/i18n/locales/fr/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Ajouter un zoom (Z)",
- "suggestZooms": "Suggérer des zooms depuis le curseur",
- "autoZoomOn": "Suggestions de zoom automatique activées — cliquez pour retirer les zooms suggérés",
- "autoZoomOff": "Suggestions de zoom automatique désactivées — cliquez pour suggérer des zooms depuis le curseur",
- "autoFocusAllOn": "Mise au point automatique activée pour tous les zooms — cliquez pour tout passer en manuel",
- "autoFocusAllOff": "Activer la mise au point automatique pour tous les zooms (la caméra suit le curseur)",
- "addTrim": "Ajouter une coupe (T)",
- "addAnnotation": "Ajouter une annotation (A)",
- "addSpeed": "Ajouter une vitesse (S)",
- "addCameraFullscreen": "Ajouter Caméra plein écran (C)"
- },
- "hints": {
- "pressZoom": "Appuyez sur Z pour ajouter un zoom",
- "pressTrim": "Appuyez sur T pour ajouter une coupe",
- "pressAnnotation": "Appuyez sur A pour ajouter une annotation",
- "pressAudio": "Appuyez sur M pour ajouter un audio, V pour enregistrer une voix off",
- "pressSpeed": "Appuyez sur S pour ajouter une vitesse",
- "pressCameraFullscreen": "Appuyez sur C pour ajouter un segment Caméra plein écran"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "Cet enregistrement n'a pas de données de mouvement du curseur, ou les zooms existants couvrent déjà les moments importants.",
+ "smartCutsNoAudio": "Ce média n'a pas d'audio",
+ "automaticZoomsHint": "Basé sur le mouvement du curseur enregistré",
+ "dragToReorderHint": "Glissez pour réorganiser · double-cliquez pour modifier les points d'entrée/sortie",
+ "smartCutsNeedsTranscript": "Nécessite une transcription",
+ "addedWord": "Mot ajouté : « {{word}} » — aucun son derrière",
+ "smartZoomsAndCuts": "Coupes intelligentes",
+ "autoZoomFailed": "Échec du zoom automatique",
+ "smartCutsWaiting": "Transcription en cours… disponible dans un instant",
+ "automaticZooms": "Zooms automatiques",
+ "arrangeClipsHint": "Glissez les clips ci-dessous pour les réorganiser ou en déposer de nouveaux",
+ "comment": "Commentaire",
+ "addAudioTooltip": "Ajouter un audio",
+ "timelineTools": "Outils de la timeline",
+ "deleteClip": "Supprimer le clip",
+ "arrangeClips": "Organiser les clips",
+ "editInOutPoints": "Modifier les points d'entrée/sortie",
+ "smartZoomsAndCutsHint": "Avec l'IA",
+ "addedAutoZoomPlural": "{{count}} zooms automatiques ajoutés",
+ "noAutoZoomMoments": "Aucun moment de zoom automatique trouvé",
+ "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias",
+ "smartCutsNoSpeech": "Aucune parole détectée",
+ "newAnnotation": "Annotation",
+ "importRecordingFirst": "Importez d'abord un enregistrement",
+ "addedAutoZoom": "{{count}} zoom automatique ajouté",
+ "dropToAdd": "Déposez pour ajouter à la timeline",
+ "aiEnhanceRequested": "Demandé à l'agent IA de couper les temps morts",
+ "autoEnhance": "Amélioration auto"
},
"labels": {
- "pan": "Panoramique",
"zoom": "Zoom",
- "trim": "Couper",
- "speed": "Vitesse",
+ "cameraFullscreenItem": "Caméra plein écran {{index}}",
+ "imageItem": "Image",
+ "pan": "Panoramique",
"zoomItem": "Zoom {{index}}",
+ "cameraFullscreen": "Caméra plein écran",
+ "speed": "Vitesse",
+ "emptyText": "Texte vide",
"trimItem": "Coupe {{index}}",
- "speedItem": "Vitesse {{index}}",
"annotationItem": "Annotation",
- "imageItem": "Image",
- "emptyText": "Texte vide",
- "cameraFullscreen": "Caméra plein écran",
- "cameraFullscreenItem": "Caméra plein écran {{index}}"
- },
- "emptyState": {
- "noVideo": "Aucune vidéo chargée",
- "dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
+ "trim": "Couper",
+ "speedItem": "Vitesse {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Impossible de placer le zoom ici",
- "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
- "zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
- "noCursorTelemetry": "Aucune télémétrie de curseur disponible",
+ "noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.",
+ "cameraFullscreenExistsAtLocation": "Un segment Caméra plein écran existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
+ "zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
- "noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
+ "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
+ "noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
- "noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
- "trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
+ "cannotPlaceZoom": "Impossible de placer le zoom ici",
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
- "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
- "cannotPlaceCameraFullscreen": "Impossible de placer la caméra plein écran ici",
- "cameraFullscreenExistsAtLocation": "Un segment Caméra plein écran existe déjà à cet emplacement ou l'espace disponible est insuffisant."
- },
- "success": {
- "addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
- "addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
- },
- "toolbar": {
- "autoEnhance": "Amélioration auto",
- "automaticZooms": "Zooms automatiques",
- "automaticZoomsHint": "Basé sur le mouvement du curseur enregistré",
- "smartZoomsAndCuts": "Coupes intelligentes",
- "smartZoomsAndCutsHint": "Avec l'IA",
- "comment": "Commentaire",
- "timelineTools": "Outils de la timeline",
- "arrangeClips": "Organiser les clips",
- "arrangeClipsHint": "Glissez les clips ci-dessous pour les réorganiser ou en déposer de nouveaux",
- "newAnnotation": "Annotation",
- "dragToReorderHint": "Glissez pour réorganiser · double-cliquez pour modifier les points d'entrée/sortie",
- "editInOutPoints": "Modifier les points d'entrée/sortie",
- "deleteClip": "Supprimer le clip",
- "dropToAdd": "Déposez pour ajouter à la timeline",
- "importRecordingFirst": "Importez d'abord un enregistrement",
- "noAutoZoomMoments": "Aucun moment de zoom automatique trouvé",
- "noAutoZoomMomentsDescription": "Cet enregistrement n'a pas de données de mouvement du curseur, ou les zooms existants couvrent déjà les moments importants.",
- "addedAutoZoom": "{{count}} zoom automatique ajouté",
- "addedAutoZoomPlural": "{{count}} zooms automatiques ajoutés",
- "autoZoomFailed": "Échec du zoom automatique",
- "aiEnhanceRequested": "Demandé à l'agent IA de couper les temps morts",
- "smartCutsWaiting": "Transcription en cours… disponible dans un instant",
- "smartCutsNeedsTranscript": "Nécessite une transcription",
- "smartCutsNoAudio": "Ce média n'a pas d'audio",
- "smartCutsNoSpeech": "Aucune parole détectée",
- "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias",
- "addAudioTooltip": "Ajouter un audio"
+ "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
+ "noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
+ "trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
+ "cannotPlaceCameraFullscreen": "Impossible de placer la caméra plein écran ici"
},
"audio": {
- "addVoiceover": "Ajouter une voix off",
- "addVoiceoverHint": "Enregistrez une narration par-dessus votre vidéo",
+ "micDenied": "L'accès au micro a été refusé",
"subtitle": "Placez une couche de voix off ou de musique de fond sur la timeline",
- "record": "Enregistrer une voix off",
- "importFile": "Importer un fichier audio",
+ "importFailed": "Impossible d'importer le fichier audio",
"importFileHint": "Importez une musique ou un fichier audio",
+ "addVoiceoverHint": "Enregistrez une narration par-dessus votre vidéo",
+ "stop": "Arrêter",
"recording": "Enregistrement",
+ "saveFailed": "Impossible d'enregistrer la capture",
"recordingHint": "Commentez en même temps que la vidéo — elle joue pendant l'enregistrement",
- "stop": "Arrêter",
- "micDenied": "L'accès au micro a été refusé",
+ "importFile": "Importer un fichier audio",
+ "record": "Enregistrer une voix off",
"recordingUnavailable": "L'enregistrement n'est pas disponible ici",
- "saveFailed": "Impossible d'enregistrer la capture",
- "importFailed": "Impossible d'importer le fichier audio"
+ "addVoiceover": "Ajouter une voix off"
+ },
+ "success": {
+ "addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
+ "addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
+ },
+ "hints": {
+ "pressAnnotation": "Appuyez sur A pour ajouter une annotation",
+ "pressSpeed": "Appuyez sur S pour ajouter une vitesse",
+ "pressTrim": "Appuyez sur T pour ajouter une coupe",
+ "pressCameraFullscreen": "Appuyez sur C pour ajouter un segment Caméra plein écran",
+ "pressZoom": "Appuyez sur Z pour ajouter un zoom",
+ "pressAudio": "Appuyez sur M pour ajouter un audio, V pour enregistrer une voix off"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Activer la mise au point automatique pour tous les zooms (la caméra suit le curseur)",
+ "autoZoomOff": "Suggestions de zoom automatique désactivées — cliquez pour suggérer des zooms depuis le curseur",
+ "addAnnotation": "Ajouter une annotation (A)",
+ "suggestZooms": "Suggérer des zooms depuis le curseur",
+ "addSpeed": "Ajouter une vitesse (S)",
+ "addCameraFullscreen": "Ajouter Caméra plein écran (C)",
+ "autoFocusAllOn": "Mise au point automatique activée pour tous les zooms — cliquez pour tout passer en manuel",
+ "autoZoomOn": "Suggestions de zoom automatique activées — cliquez pour retirer les zooms suggérés",
+ "addZoom": "Ajouter un zoom (Z)",
+ "addTrim": "Ajouter une coupe (T)"
+ },
+ "emptyState": {
+ "noVideo": "Aucune vidéo chargée",
+ "dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
}
}
diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json
index 014041e4b..2eb958dd9 100644
--- a/src/i18n/locales/it/timeline.json
+++ b/src/i18n/locales/it/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Aggiungi zoom (Z)",
- "suggestZooms": "Suggerisci zoom dal cursore",
- "autoZoomOn": "Suggerimenti di zoom automatico attivi — clicca per rimuovere gli zoom suggeriti",
- "autoZoomOff": "Suggerimenti di zoom automatico disattivi — clicca per suggerire zoom dal cursore",
- "autoFocusAllOn": "Messa a fuoco automatica attiva per tutti gli zoom — clicca per passare tutti a manuale",
- "autoFocusAllOff": "Attiva la messa a fuoco automatica per tutti gli zoom (la fotocamera segue il cursore)",
- "addTrim": "Aggiungi taglio (T)",
- "addAnnotation": "Aggiungi annotazione (A)",
- "addSpeed": "Aggiungi velocità (S)",
- "addCameraFullscreen": "Aggiungi Camera a schermo intero (C)"
- },
- "hints": {
- "pressZoom": "Premi Z per aggiungere zoom",
- "pressTrim": "Premi T per aggiungere taglio",
- "pressAnnotation": "Premi A per aggiungere annotazione",
- "pressAudio": "Premi M per aggiungere audio, V per registrare una voce fuori campo",
- "pressSpeed": "Premi S per aggiungere velocità",
- "pressCameraFullscreen": "Premi C per aggiungere un segmento Camera a schermo intero"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "Questa registrazione non ha dati sul movimento del cursore, oppure gli zoom esistenti coprono già i momenti principali.",
+ "smartCutsNoAudio": "Questo contenuto non ha audio",
+ "automaticZoomsHint": "Dal movimento del cursore registrato",
+ "dragToReorderHint": "Trascina per riordinare · doppio clic per modificare i punti di entrata/uscita",
+ "smartCutsNeedsTranscript": "Richiede una trascrizione",
+ "addedWord": "Parola aggiunta: «{{word}}» — nessun audio dietro",
+ "smartZoomsAndCuts": "Tagli intelligenti",
+ "autoZoomFailed": "Zoom automatico non riuscito",
+ "smartCutsWaiting": "Trascrizione in corso… disponibile a breve",
+ "automaticZooms": "Zoom automatici",
+ "arrangeClipsHint": "Trascina le clip qui sotto per riordinarle o rilasciane di nuove",
+ "comment": "Commento",
+ "addAudioTooltip": "Aggiungi audio",
+ "timelineTools": "Strumenti della timeline",
+ "deleteClip": "Elimina clip",
+ "arrangeClips": "Organizza clip",
+ "editInOutPoints": "Modifica punti di entrata/uscita",
+ "smartZoomsAndCutsHint": "Con l'IA",
+ "addedAutoZoomPlural": "Aggiunti {{count}} zoom automatici",
+ "noAutoZoomMoments": "Nessun momento per lo zoom automatico trovato",
+ "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali",
+ "smartCutsNoSpeech": "Nessun parlato rilevato",
+ "newAnnotation": "Annotazione",
+ "importRecordingFirst": "Importa prima una registrazione",
+ "addedAutoZoom": "Aggiunto {{count}} zoom automatico",
+ "dropToAdd": "Rilascia per aggiungere alla timeline",
+ "aiEnhanceRequested": "Chiesto all'agente IA di tagliare i tempi morti",
+ "autoEnhance": "Miglioramento automatico"
},
"labels": {
- "pan": "Panoramica",
"zoom": "Zoom",
- "trim": "Taglio",
- "speed": "Velocità",
+ "cameraFullscreenItem": "Camera a schermo intero {{index}}",
+ "imageItem": "Immagine",
+ "pan": "Panoramica",
"zoomItem": "Zoom {{index}}",
+ "cameraFullscreen": "Camera a schermo intero",
+ "speed": "Velocità",
+ "emptyText": "Testo vuoto",
"trimItem": "Taglio {{index}}",
- "speedItem": "Velocità {{index}}",
"annotationItem": "Annotazione",
- "imageItem": "Immagine",
- "emptyText": "Testo vuoto",
- "cameraFullscreen": "Camera a schermo intero",
- "cameraFullscreenItem": "Camera a schermo intero {{index}}"
- },
- "emptyState": {
- "noVideo": "Nessun video caricato",
- "dragAndDrop": "Trascina e rilascia un video per iniziare a modificare"
+ "trim": "Taglio",
+ "speedItem": "Velocità {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Impossibile posizionare lo zoom qui",
- "zoomExistsAtLocation": "Lo zoom esiste già in questa posizione o non c'è spazio sufficiente.",
- "zoomSuggestionUnavailable": "Gestore suggerimenti zoom non disponibile",
- "noCursorTelemetry": "Nessuna telemetria del cursore disponibile",
+ "noAutoZoomSlotsDescription": "I punti di sosta rilevati si sovrappongono alle regioni zoom esistenti.",
"noCursorTelemetryDescription": "Registra prima uno screencast per generare suggerimenti basati sul cursore.",
+ "cameraFullscreenExistsAtLocation": "Un segmento Camera a schermo intero esiste già in questa posizione o non c'è spazio sufficiente.",
"noUsableTelemetry": "Nessuna telemetria del cursore utilizzabile",
"noUsableTelemetryDescription": "La registrazione non include dati sufficienti sul movimento del cursore.",
+ "zoomSuggestionUnavailable": "Gestore suggerimenti zoom non disponibile",
"noDwellMoments": "Nessun momento di sosta del cursore trovato",
- "noDwellMomentsDescription": "Prova una registrazione con pause del cursore più lente sulle azioni importanti.",
+ "speedExistsAtLocation": "La regione velocità esiste già in questa posizione o non c'è spazio sufficiente.",
+ "noCursorTelemetry": "Nessuna telemetria del cursore disponibile",
"noAutoZoomSlots": "Nessuno slot di zoom automatico disponibile",
- "noAutoZoomSlotsDescription": "I punti di sosta rilevati si sovrappongono alle regioni zoom esistenti.",
"cannotPlaceTrim": "Impossibile posizionare il taglio qui",
- "trimExistsAtLocation": "Il taglio esiste già in questa posizione o non c'è spazio sufficiente.",
+ "cannotPlaceZoom": "Impossibile posizionare lo zoom qui",
"cannotPlaceSpeed": "Impossibile posizionare la velocità qui",
- "speedExistsAtLocation": "La regione velocità esiste già in questa posizione o non c'è spazio sufficiente.",
- "cannotPlaceCameraFullscreen": "Impossibile posizionare la Camera a schermo intero qui",
- "cameraFullscreenExistsAtLocation": "Un segmento Camera a schermo intero esiste già in questa posizione o non c'è spazio sufficiente."
- },
- "success": {
- "addedZoomSuggestions": "Aggiunto {{count}} suggerimento zoom basato sul cursore",
- "addedZoomSuggestionsPlural": "Aggiunti {{count}} suggerimenti zoom basati sul cursore"
- },
- "toolbar": {
- "autoEnhance": "Miglioramento automatico",
- "automaticZooms": "Zoom automatici",
- "automaticZoomsHint": "Dal movimento del cursore registrato",
- "smartZoomsAndCuts": "Tagli intelligenti",
- "smartZoomsAndCutsHint": "Con l'IA",
- "comment": "Commento",
- "timelineTools": "Strumenti della timeline",
- "arrangeClips": "Organizza clip",
- "arrangeClipsHint": "Trascina le clip qui sotto per riordinarle o rilasciane di nuove",
- "newAnnotation": "Annotazione",
- "dragToReorderHint": "Trascina per riordinare · doppio clic per modificare i punti di entrata/uscita",
- "editInOutPoints": "Modifica punti di entrata/uscita",
- "deleteClip": "Elimina clip",
- "dropToAdd": "Rilascia per aggiungere alla timeline",
- "importRecordingFirst": "Importa prima una registrazione",
- "noAutoZoomMoments": "Nessun momento per lo zoom automatico trovato",
- "noAutoZoomMomentsDescription": "Questa registrazione non ha dati sul movimento del cursore, oppure gli zoom esistenti coprono già i momenti principali.",
- "addedAutoZoom": "Aggiunto {{count}} zoom automatico",
- "addedAutoZoomPlural": "Aggiunti {{count}} zoom automatici",
- "autoZoomFailed": "Zoom automatico non riuscito",
- "aiEnhanceRequested": "Chiesto all'agente IA di tagliare i tempi morti",
- "smartCutsWaiting": "Trascrizione in corso… disponibile a breve",
- "smartCutsNeedsTranscript": "Richiede una trascrizione",
- "smartCutsNoAudio": "Questo contenuto non ha audio",
- "smartCutsNoSpeech": "Nessun parlato rilevato",
- "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali",
- "addAudioTooltip": "Aggiungi audio"
+ "zoomExistsAtLocation": "Lo zoom esiste già in questa posizione o non c'è spazio sufficiente.",
+ "noDwellMomentsDescription": "Prova una registrazione con pause del cursore più lente sulle azioni importanti.",
+ "trimExistsAtLocation": "Il taglio esiste già in questa posizione o non c'è spazio sufficiente.",
+ "cannotPlaceCameraFullscreen": "Impossibile posizionare la Camera a schermo intero qui"
},
"audio": {
- "addVoiceover": "Aggiungi voce fuori campo",
- "addVoiceoverHint": "Registra una narrazione sopra il video",
+ "micDenied": "Accesso al microfono negato",
"subtitle": "Posiziona un livello di voce fuori campo o di musica di sottofondo sulla timeline",
- "record": "Registra voce fuori campo",
- "importFile": "Importa file audio",
+ "importFailed": "Impossibile importare il file audio",
"importFileHint": "Importa musica o un file audio",
+ "addVoiceoverHint": "Registra una narrazione sopra il video",
+ "stop": "Ferma",
"recording": "Registrazione",
+ "saveFailed": "Impossibile salvare la registrazione",
"recordingHint": "Racconta insieme al video: continua a riprodursi mentre registri",
- "stop": "Ferma",
- "micDenied": "Accesso al microfono negato",
+ "importFile": "Importa file audio",
+ "record": "Registra voce fuori campo",
"recordingUnavailable": "La registrazione non è disponibile qui",
- "saveFailed": "Impossibile salvare la registrazione",
- "importFailed": "Impossibile importare il file audio"
+ "addVoiceover": "Aggiungi voce fuori campo"
+ },
+ "success": {
+ "addedZoomSuggestions": "Aggiunto {{count}} suggerimento zoom basato sul cursore",
+ "addedZoomSuggestionsPlural": "Aggiunti {{count}} suggerimenti zoom basati sul cursore"
+ },
+ "hints": {
+ "pressAnnotation": "Premi A per aggiungere annotazione",
+ "pressSpeed": "Premi S per aggiungere velocità",
+ "pressTrim": "Premi T per aggiungere taglio",
+ "pressCameraFullscreen": "Premi C per aggiungere un segmento Camera a schermo intero",
+ "pressZoom": "Premi Z per aggiungere zoom",
+ "pressAudio": "Premi M per aggiungere audio, V per registrare una voce fuori campo"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Attiva la messa a fuoco automatica per tutti gli zoom (la fotocamera segue il cursore)",
+ "autoZoomOff": "Suggerimenti di zoom automatico disattivi — clicca per suggerire zoom dal cursore",
+ "addAnnotation": "Aggiungi annotazione (A)",
+ "suggestZooms": "Suggerisci zoom dal cursore",
+ "addSpeed": "Aggiungi velocità (S)",
+ "addCameraFullscreen": "Aggiungi Camera a schermo intero (C)",
+ "autoFocusAllOn": "Messa a fuoco automatica attiva per tutti gli zoom — clicca per passare tutti a manuale",
+ "autoZoomOn": "Suggerimenti di zoom automatico attivi — clicca per rimuovere gli zoom suggeriti",
+ "addZoom": "Aggiungi zoom (Z)",
+ "addTrim": "Aggiungi taglio (T)"
+ },
+ "emptyState": {
+ "noVideo": "Nessun video caricato",
+ "dragAndDrop": "Trascina e rilascia un video per iniziare a modificare"
}
}
diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json
index bf21fd025..f87c88b7f 100644
--- a/src/i18n/locales/ja-JP/timeline.json
+++ b/src/i18n/locales/ja-JP/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "ズームを追加 (Z)",
- "suggestZooms": "カーソル位置からズームを提案",
- "autoZoomOn": "自動ズーム提案がオン — クリックすると提案されたズームを削除します",
- "autoZoomOff": "自動ズーム提案がオフ — クリックするとカーソル位置からズームを提案します",
- "autoFocusAllOn": "すべてのズームでオートフォーカスがオン — クリックするとすべて手動に切り替わります",
- "autoFocusAllOff": "すべてのズームでオートフォーカスをオンにする(カメラがカーソルに追従)",
- "addTrim": "トリムを追加 (T)",
- "addAnnotation": "注釈を追加 (A)",
- "addSpeed": "再生速度を追加 (S)",
- "addCameraFullscreen": "フルスクリーンカメラを追加 (C)"
- },
- "hints": {
- "pressZoom": "Zキーを押してズームを追加",
- "pressTrim": "Tキーを押してトリムを追加",
- "pressAnnotation": "Aキーを押して注釈を追加",
- "pressAudio": "M キーで音声を追加、V キーでナレーションを録音",
- "pressSpeed": "Sキーを押して再生速度を追加",
- "pressCameraFullscreen": "Cキーを押してフルスクリーンカメラのセグメントを追加"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "この録画にはカーソルの動きのデータがないか、既存のズームがすでに重要な瞬間をカバーしています。",
+ "smartCutsNoAudio": "このメディアには音声がありません",
+ "automaticZoomsHint": "記録されたカーソルの動きから",
+ "dragToReorderHint": "ドラッグして並べ替え・ダブルクリックでイン/アウトポイントを編集",
+ "smartCutsNeedsTranscript": "文字起こしが必要です",
+ "addedWord": "追加した単語:「{{word}}」— 音声はありません",
+ "smartZoomsAndCuts": "スマートカット",
+ "autoZoomFailed": "自動ズームに失敗しました",
+ "smartCutsWaiting": "文字起こし中… まもなく使えます",
+ "automaticZooms": "自動ズーム",
+ "arrangeClipsHint": "下のクリップをドラッグして並べ替えるか、新しいクリップをドロップします",
+ "comment": "コメント",
+ "addAudioTooltip": "音声を追加",
+ "timelineTools": "タイムラインツール",
+ "deleteClip": "クリップを削除",
+ "arrangeClips": "クリップを配置",
+ "editInOutPoints": "イン/アウトポイントを編集",
+ "smartZoomsAndCutsHint": "AIを使用",
+ "addedAutoZoomPlural": "自動ズームを {{count}} 件追加しました",
+ "noAutoZoomMoments": "自動ズームの瞬間が見つかりません",
+ "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください",
+ "smartCutsNoSpeech": "音声が検出されませんでした",
+ "newAnnotation": "注釈",
+ "importRecordingFirst": "先に録画をインポートしてください",
+ "addedAutoZoom": "自動ズームを {{count}} 件追加しました",
+ "dropToAdd": "ドロップしてタイムラインに追加",
+ "aiEnhanceRequested": "AIエージェントに無音部分のカットを依頼しました",
+ "autoEnhance": "自動強化"
},
"labels": {
- "pan": "移動",
"zoom": "ズーム",
- "trim": "トリム",
- "speed": "再生速度",
+ "cameraFullscreenItem": "フルスクリーンカメラ {{index}}",
+ "imageItem": "画像",
+ "pan": "移動",
"zoomItem": "ズーム {{index}}",
+ "cameraFullscreen": "フルスクリーンカメラ",
+ "speed": "再生速度",
+ "emptyText": "空のテキスト",
"trimItem": "トリム {{index}}",
- "speedItem": "再生速度 {{index}}",
"annotationItem": "注釈",
- "imageItem": "画像",
- "emptyText": "空のテキスト",
- "cameraFullscreen": "フルスクリーンカメラ",
- "cameraFullscreenItem": "フルスクリーンカメラ {{index}}"
- },
- "emptyState": {
- "noVideo": "ビデオが読み込まれていません",
- "dragAndDrop": "ビデオをドラッグアンドドロップして編集を開始してください"
+ "trim": "トリム",
+ "speedItem": "再生速度 {{index}}"
},
"errors": {
- "cannotPlaceZoom": "ここにズームを配置できません",
- "zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
- "zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
- "noCursorTelemetry": "カーソルの動きが記録されていません",
+ "noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
"noCursorTelemetryDescription": "まず画面録画を行い、カーソルに基づく提案を生成してください。",
+ "cameraFullscreenExistsAtLocation": "この場所にはすでにフルスクリーンカメラのセグメントが存在するか、十分なスペースがありません。",
"noUsableTelemetry": "使用可能なカーソルの動きデータがありません",
"noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。",
+ "zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
"noDwellMoments": "カーソルが静止したポイントが見つかりません",
- "noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
+ "speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。",
+ "noCursorTelemetry": "カーソルの動きが記録されていません",
"noAutoZoomSlots": "自動ズームを適用できる箇所がありません",
- "noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
"cannotPlaceTrim": "ここにトリムを配置できません",
- "trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。",
+ "cannotPlaceZoom": "ここにズームを配置できません",
"cannotPlaceSpeed": "ここに再生速度を配置できません",
- "speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。",
- "cannotPlaceCameraFullscreen": "ここにフルスクリーンカメラを配置できません",
- "cameraFullscreenExistsAtLocation": "この場所にはすでにフルスクリーンカメラのセグメントが存在するか、十分なスペースがありません。"
- },
- "success": {
- "addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
- "addedZoomSuggestionsPlural": "カーソルに基づくズーム提案を {{count}} 件追加しました"
- },
- "toolbar": {
- "autoEnhance": "自動強化",
- "automaticZooms": "自動ズーム",
- "automaticZoomsHint": "記録されたカーソルの動きから",
- "smartZoomsAndCuts": "スマートカット",
- "smartZoomsAndCutsHint": "AIを使用",
- "comment": "コメント",
- "timelineTools": "タイムラインツール",
- "arrangeClips": "クリップを配置",
- "arrangeClipsHint": "下のクリップをドラッグして並べ替えるか、新しいクリップをドロップします",
- "newAnnotation": "注釈",
- "dragToReorderHint": "ドラッグして並べ替え・ダブルクリックでイン/アウトポイントを編集",
- "editInOutPoints": "イン/アウトポイントを編集",
- "deleteClip": "クリップを削除",
- "dropToAdd": "ドロップしてタイムラインに追加",
- "importRecordingFirst": "先に録画をインポートしてください",
- "noAutoZoomMoments": "自動ズームの瞬間が見つかりません",
- "noAutoZoomMomentsDescription": "この録画にはカーソルの動きのデータがないか、既存のズームがすでに重要な瞬間をカバーしています。",
- "addedAutoZoom": "自動ズームを {{count}} 件追加しました",
- "addedAutoZoomPlural": "自動ズームを {{count}} 件追加しました",
- "autoZoomFailed": "自動ズームに失敗しました",
- "aiEnhanceRequested": "AIエージェントに無音部分のカットを依頼しました",
- "smartCutsWaiting": "文字起こし中… まもなく使えます",
- "smartCutsNeedsTranscript": "文字起こしが必要です",
- "smartCutsNoAudio": "このメディアには音声がありません",
- "smartCutsNoSpeech": "音声が検出されませんでした",
- "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください",
- "addAudioTooltip": "音声を追加"
+ "zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
+ "noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
+ "trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。",
+ "cannotPlaceCameraFullscreen": "ここにフルスクリーンカメラを配置できません"
},
"audio": {
- "addVoiceover": "ナレーションを追加",
- "addVoiceoverHint": "動画にナレーションを録音",
+ "micDenied": "マイクへのアクセスが拒否されました",
"subtitle": "タイムラインにナレーションまたは BGM のレイヤーを配置します",
- "record": "ナレーションを録音",
- "importFile": "音声ファイルを読み込む",
+ "importFailed": "音声ファイルを読み込めませんでした",
"importFileHint": "音楽やオーディオファイルを読み込む",
+ "addVoiceoverHint": "動画にナレーションを録音",
+ "stop": "停止",
"recording": "録音中",
+ "saveFailed": "録音を保存できませんでした",
"recordingHint": "動画に合わせて話してください — 録音中も再生されます",
- "stop": "停止",
- "micDenied": "マイクへのアクセスが拒否されました",
+ "importFile": "音声ファイルを読み込む",
+ "record": "ナレーションを録音",
"recordingUnavailable": "ここでは録音できません",
- "saveFailed": "録音を保存できませんでした",
- "importFailed": "音声ファイルを読み込めませんでした"
+ "addVoiceover": "ナレーションを追加"
+ },
+ "success": {
+ "addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
+ "addedZoomSuggestionsPlural": "カーソルに基づくズーム提案を {{count}} 件追加しました"
+ },
+ "hints": {
+ "pressAnnotation": "Aキーを押して注釈を追加",
+ "pressSpeed": "Sキーを押して再生速度を追加",
+ "pressTrim": "Tキーを押してトリムを追加",
+ "pressCameraFullscreen": "Cキーを押してフルスクリーンカメラのセグメントを追加",
+ "pressZoom": "Zキーを押してズームを追加",
+ "pressAudio": "M キーで音声を追加、V キーでナレーションを録音"
+ },
+ "buttons": {
+ "autoFocusAllOff": "すべてのズームでオートフォーカスをオンにする(カメラがカーソルに追従)",
+ "autoZoomOff": "自動ズーム提案がオフ — クリックするとカーソル位置からズームを提案します",
+ "addAnnotation": "注釈を追加 (A)",
+ "suggestZooms": "カーソル位置からズームを提案",
+ "addSpeed": "再生速度を追加 (S)",
+ "addCameraFullscreen": "フルスクリーンカメラを追加 (C)",
+ "autoFocusAllOn": "すべてのズームでオートフォーカスがオン — クリックするとすべて手動に切り替わります",
+ "autoZoomOn": "自動ズーム提案がオン — クリックすると提案されたズームを削除します",
+ "addZoom": "ズームを追加 (Z)",
+ "addTrim": "トリムを追加 (T)"
+ },
+ "emptyState": {
+ "noVideo": "ビデオが読み込まれていません",
+ "dragAndDrop": "ビデオをドラッグアンドドロップして編集を開始してください"
}
}
diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json
index 1a0242f10..4abe0c760 100644
--- a/src/i18n/locales/ko-KR/timeline.json
+++ b/src/i18n/locales/ko-KR/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "줌 추가 (Z)",
- "suggestZooms": "커서 기반 줌 제안",
- "autoZoomOn": "자동 줌 제안 켜짐 — 클릭하면 제안된 줌을 제거합니다",
- "autoZoomOff": "자동 줌 제안 꺼짐 — 클릭하면 커서 기반으로 줌을 제안합니다",
- "autoFocusAllOn": "모든 줌에 자동 초점 켜짐 — 클릭하면 모두 수동으로 전환합니다",
- "autoFocusAllOff": "모든 줌에 자동 초점 켜기 (카메라가 커서를 따라갑니다)",
- "addTrim": "트림 추가 (T)",
- "addAnnotation": "주석 추가 (A)",
- "addSpeed": "속도 추가 (S)",
- "addCameraFullscreen": "전체 화면 카메라 추가 (C)"
- },
- "hints": {
- "pressZoom": "Z를 눌러 줌 추가",
- "pressTrim": "T를 눌러 트림 추가",
- "pressAnnotation": "A를 눌러 주석 추가",
- "pressAudio": "M 키로 오디오 추가, V 키로 보이스오버 녹음",
- "pressSpeed": "S를 눌러 속도 추가",
- "pressCameraFullscreen": "C를 눌러 전체 화면 카메라 구간을 추가하세요"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "이 녹화에는 커서 이동 데이터가 없거나 기존 줌이 이미 주요 순간을 다루고 있습니다.",
+ "smartCutsNoAudio": "이 미디어에는 오디오가 없습니다",
+ "automaticZoomsHint": "녹화된 커서 움직임 기반",
+ "dragToReorderHint": "드래그하여 순서 변경 · 더블클릭하여 시작/종료 지점 편집",
+ "smartCutsNeedsTranscript": "받아쓰기가 필요합니다",
+ "addedWord": "추가한 단어: \"{{word}}\" — 뒤에 오디오가 없습니다",
+ "smartZoomsAndCuts": "스마트 컷",
+ "autoZoomFailed": "자동 줌 실패",
+ "smartCutsWaiting": "받아쓰는 중… 곧 사용할 수 있습니다",
+ "automaticZooms": "자동 줌",
+ "arrangeClipsHint": "아래 클립을 드래그하여 순서를 바꾸거나 새 클립을 놓으세요",
+ "comment": "코멘트",
+ "addAudioTooltip": "오디오 추가",
+ "timelineTools": "타임라인 도구",
+ "deleteClip": "클립 삭제",
+ "arrangeClips": "클립 정리",
+ "editInOutPoints": "시작/종료 지점 편집",
+ "smartZoomsAndCutsHint": "AI 사용",
+ "addedAutoZoomPlural": "자동 줌 {{count}}개가 추가되었습니다",
+ "noAutoZoomMoments": "자동 줌 순간을 찾을 수 없음",
+ "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요",
+ "smartCutsNoSpeech": "음성이 감지되지 않음",
+ "newAnnotation": "주석",
+ "importRecordingFirst": "먼저 녹화 파일을 가져오세요",
+ "addedAutoZoom": "자동 줌 {{count}}개가 추가되었습니다",
+ "dropToAdd": "타임라인에 추가하려면 놓으세요",
+ "aiEnhanceRequested": "AI 에이전트에 빈 구간 컷을 요청했습니다",
+ "autoEnhance": "자동 향상"
},
"labels": {
- "pan": "이동",
"zoom": "줌",
- "trim": "트림",
- "speed": "속도",
+ "cameraFullscreenItem": "전체 화면 카메라 {{index}}",
+ "imageItem": "이미지",
+ "pan": "이동",
"zoomItem": "줌 {{index}}",
+ "cameraFullscreen": "전체 화면 카메라",
+ "speed": "속도",
+ "emptyText": "빈 텍스트",
"trimItem": "트림 {{index}}",
- "speedItem": "속도 {{index}}",
"annotationItem": "주석",
- "imageItem": "이미지",
- "emptyText": "빈 텍스트",
- "cameraFullscreen": "전체 화면 카메라",
- "cameraFullscreenItem": "전체 화면 카메라 {{index}}"
- },
- "emptyState": {
- "noVideo": "불러온 비디오 없음",
- "dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
+ "trim": "트림",
+ "speedItem": "속도 {{index}}"
},
"errors": {
- "cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
- "zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
- "zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
- "noCursorTelemetry": "커서 데이터가 없습니다",
+ "noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
"noCursorTelemetryDescription": "커서 기반 제안을 생성하려면 먼저 화면을 녹화해 주세요.",
+ "cameraFullscreenExistsAtLocation": "이 위치에 이미 전체 화면 카메라 구간이 있거나 공간이 부족합니다.",
"noUsableTelemetry": "사용 가능한 커서 데이터가 없습니다",
"noUsableTelemetryDescription": "녹화에 충분한 커서 이동 데이터가 포함되어 있지 않습니다.",
+ "zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
"noDwellMoments": "명확한 커서 정지 구간을 찾을 수 없습니다",
- "noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
+ "speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다.",
+ "noCursorTelemetry": "커서 데이터가 없습니다",
"noAutoZoomSlots": "자동 줌 슬롯이 없습니다",
- "noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
"cannotPlaceTrim": "이 위치에 트림을 추가할 수 없습니다",
- "trimExistsAtLocation": "이 위치에 이미 트림이 있거나 공간이 부족합니다.",
+ "cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
"cannotPlaceSpeed": "이 위치에 속도를 추가할 수 없습니다",
- "speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다.",
- "cannotPlaceCameraFullscreen": "이 위치에 전체 화면 카메라를 추가할 수 없습니다",
- "cameraFullscreenExistsAtLocation": "이 위치에 이미 전체 화면 카메라 구간이 있거나 공간이 부족합니다."
- },
- "success": {
- "addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
- "addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
- },
- "toolbar": {
- "autoEnhance": "자동 향상",
- "automaticZooms": "자동 줌",
- "automaticZoomsHint": "녹화된 커서 움직임 기반",
- "smartZoomsAndCuts": "스마트 컷",
- "smartZoomsAndCutsHint": "AI 사용",
- "comment": "코멘트",
- "timelineTools": "타임라인 도구",
- "arrangeClips": "클립 정리",
- "arrangeClipsHint": "아래 클립을 드래그하여 순서를 바꾸거나 새 클립을 놓으세요",
- "newAnnotation": "주석",
- "dragToReorderHint": "드래그하여 순서 변경 · 더블클릭하여 시작/종료 지점 편집",
- "editInOutPoints": "시작/종료 지점 편집",
- "deleteClip": "클립 삭제",
- "dropToAdd": "타임라인에 추가하려면 놓으세요",
- "importRecordingFirst": "먼저 녹화 파일을 가져오세요",
- "noAutoZoomMoments": "자동 줌 순간을 찾을 수 없음",
- "noAutoZoomMomentsDescription": "이 녹화에는 커서 이동 데이터가 없거나 기존 줌이 이미 주요 순간을 다루고 있습니다.",
- "addedAutoZoom": "자동 줌 {{count}}개가 추가되었습니다",
- "addedAutoZoomPlural": "자동 줌 {{count}}개가 추가되었습니다",
- "autoZoomFailed": "자동 줌 실패",
- "aiEnhanceRequested": "AI 에이전트에 빈 구간 컷을 요청했습니다",
- "smartCutsWaiting": "받아쓰는 중… 곧 사용할 수 있습니다",
- "smartCutsNeedsTranscript": "받아쓰기가 필요합니다",
- "smartCutsNoAudio": "이 미디어에는 오디오가 없습니다",
- "smartCutsNoSpeech": "음성이 감지되지 않음",
- "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요",
- "addAudioTooltip": "오디오 추가"
+ "zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
+ "noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
+ "trimExistsAtLocation": "이 위치에 이미 트림이 있거나 공간이 부족합니다.",
+ "cannotPlaceCameraFullscreen": "이 위치에 전체 화면 카메라를 추가할 수 없습니다"
},
"audio": {
- "addVoiceover": "내레이션 추가",
- "addVoiceoverHint": "영상 위에 내레이션을 녹음",
+ "micDenied": "마이크 접근이 거부되었습니다",
"subtitle": "타임라인에 내레이션 또는 배경 음악 레이어를 배치합니다",
- "record": "내레이션 녹음",
- "importFile": "오디오 파일 가져오기",
+ "importFailed": "오디오 파일을 가져오지 못했습니다",
"importFileHint": "음악이나 오디오 파일 가져오기",
+ "addVoiceoverHint": "영상 위에 내레이션을 녹음",
+ "stop": "중지",
"recording": "녹음 중",
+ "saveFailed": "녹음을 저장하지 못했습니다",
"recordingHint": "영상에 맞춰 말하세요 — 녹음하는 동안 재생됩니다",
- "stop": "중지",
- "micDenied": "마이크 접근이 거부되었습니다",
+ "importFile": "오디오 파일 가져오기",
+ "record": "내레이션 녹음",
"recordingUnavailable": "여기에서는 녹음할 수 없습니다",
- "saveFailed": "녹음을 저장하지 못했습니다",
- "importFailed": "오디오 파일을 가져오지 못했습니다"
+ "addVoiceover": "내레이션 추가"
+ },
+ "success": {
+ "addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
+ "addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
+ },
+ "hints": {
+ "pressAnnotation": "A를 눌러 주석 추가",
+ "pressSpeed": "S를 눌러 속도 추가",
+ "pressTrim": "T를 눌러 트림 추가",
+ "pressCameraFullscreen": "C를 눌러 전체 화면 카메라 구간을 추가하세요",
+ "pressZoom": "Z를 눌러 줌 추가",
+ "pressAudio": "M 키로 오디오 추가, V 키로 보이스오버 녹음"
+ },
+ "buttons": {
+ "autoFocusAllOff": "모든 줌에 자동 초점 켜기 (카메라가 커서를 따라갑니다)",
+ "autoZoomOff": "자동 줌 제안 꺼짐 — 클릭하면 커서 기반으로 줌을 제안합니다",
+ "addAnnotation": "주석 추가 (A)",
+ "suggestZooms": "커서 기반 줌 제안",
+ "addSpeed": "속도 추가 (S)",
+ "addCameraFullscreen": "전체 화면 카메라 추가 (C)",
+ "autoFocusAllOn": "모든 줌에 자동 초점 켜짐 — 클릭하면 모두 수동으로 전환합니다",
+ "autoZoomOn": "자동 줌 제안 켜짐 — 클릭하면 제안된 줌을 제거합니다",
+ "addZoom": "줌 추가 (Z)",
+ "addTrim": "트림 추가 (T)"
+ },
+ "emptyState": {
+ "noVideo": "불러온 비디오 없음",
+ "dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
}
}
diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json
index c1e6d3c27..96630e222 100644
--- a/src/i18n/locales/pt-BR/timeline.json
+++ b/src/i18n/locales/pt-BR/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Adicionar Zoom (Z)",
- "suggestZooms": "Sugerir Zooms a partir do Cursor",
- "autoZoomOn": "Sugestões de zoom automático ativadas — clique para remover os zooms sugeridos",
- "autoZoomOff": "Sugestões de zoom automático desativadas — clique para sugerir zooms a partir do cursor",
- "autoFocusAllOn": "Foco automático ativado para todos os zooms — clique para mudar todos para manual",
- "autoFocusAllOff": "Ativar foco automático para todos os zooms (a câmera segue o cursor)",
- "addTrim": "Adicionar Recorte (T)",
- "addAnnotation": "Adicionar Anotação (A)",
- "addSpeed": "Adicionar Velocidade (S)",
- "addCameraFullscreen": "Adicionar Câmera em Tela Cheia (C)"
- },
- "hints": {
- "pressZoom": "Pressione Z para adicionar zoom",
- "pressTrim": "Pressione T para adicionar recorte",
- "pressAnnotation": "Pressione A para adicionar anotação",
- "pressAudio": "Pressione M para adicionar áudio, V para gravar uma narração",
- "pressSpeed": "Pressione S para adicionar velocidade",
- "pressCameraFullscreen": "Pressione C para adicionar um segmento de Câmera em Tela Cheia"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "Esta gravação não tem dados de movimento do cursor, ou os zooms existentes já cobrem os momentos de destaque.",
+ "smartCutsNoAudio": "Esta mídia não tem áudio",
+ "automaticZoomsHint": "Do movimento do cursor gravado",
+ "dragToReorderHint": "Arraste para reordenar · clique duas vezes para editar os pontos de entrada/saída",
+ "smartCutsNeedsTranscript": "Requer uma transcrição",
+ "addedWord": "Palavra adicionada: \"{{word}}\" — sem áudio por trás",
+ "smartZoomsAndCuts": "Cortes inteligentes",
+ "autoZoomFailed": "Falha no zoom automático",
+ "smartCutsWaiting": "Transcrevendo… disponível em instantes",
+ "automaticZooms": "Zooms automáticos",
+ "arrangeClipsHint": "Arraste os clipes abaixo para reordená-los ou solte novos",
+ "comment": "Comentário",
+ "addAudioTooltip": "Adicionar áudio",
+ "timelineTools": "Ferramentas da linha do tempo",
+ "deleteClip": "Excluir clipe",
+ "arrangeClips": "Organizar clipes",
+ "editInOutPoints": "Editar pontos de entrada/saída",
+ "smartZoomsAndCutsHint": "Com IA",
+ "addedAutoZoomPlural": "{{count}} zooms automáticos adicionados",
+ "noAutoZoomMoments": "Nenhum momento de zoom automático encontrado",
+ "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia",
+ "smartCutsNoSpeech": "Nenhuma fala detectada",
+ "newAnnotation": "Anotação",
+ "importRecordingFirst": "Importe uma gravação primeiro",
+ "addedAutoZoom": "{{count}} zoom automático adicionado",
+ "dropToAdd": "Solte para adicionar à linha do tempo",
+ "aiEnhanceRequested": "Pedido ao agente de IA para cortar os tempos mortos",
+ "autoEnhance": "Melhoria automática"
},
"labels": {
- "pan": "Mover",
"zoom": "Zoom",
- "trim": "Recorte",
- "speed": "Velocidade",
+ "cameraFullscreenItem": "Câmera em Tela Cheia {{index}}",
+ "imageItem": "Imagem",
+ "pan": "Mover",
"zoomItem": "Zoom {{index}}",
+ "cameraFullscreen": "Câmera em Tela Cheia",
+ "speed": "Velocidade",
+ "emptyText": "Texto vazio",
"trimItem": "Recorte {{index}}",
- "speedItem": "Velocidade {{index}}",
"annotationItem": "Anotação",
- "imageItem": "Imagem",
- "emptyText": "Texto vazio",
- "cameraFullscreen": "Câmera em Tela Cheia",
- "cameraFullscreenItem": "Câmera em Tela Cheia {{index}}"
- },
- "emptyState": {
- "noVideo": "Nenhum Vídeo Carregado",
- "dragAndDrop": "Arraste e solte um vídeo para começar a editar"
+ "trim": "Recorte",
+ "speedItem": "Velocidade {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Não é possível colocar zoom aqui",
- "zoomExistsAtLocation": "Já existe um zoom neste local ou não há espaço suficiente disponível.",
- "zoomSuggestionUnavailable": "Sugestão de zoom não disponível",
- "noCursorTelemetry": "Nenhuma telemetria de cursor disponível",
+ "noAutoZoomSlotsDescription": "Pontos de parada detectados sobrepõem regiões de zoom existentes.",
"noCursorTelemetryDescription": "Grave um screencast primeiro para gerar sugestões baseadas no cursor.",
+ "cameraFullscreenExistsAtLocation": "Já existe um segmento de Câmera em Tela Cheia neste local ou não há espaço suficiente disponível.",
"noUsableTelemetry": "Nenhuma telemetria de cursor utilizável",
"noUsableTelemetryDescription": "A gravação não inclui dados suficientes de movimento do cursor.",
+ "zoomSuggestionUnavailable": "Sugestão de zoom não disponível",
"noDwellMoments": "Nenhum momento claro de parada do cursor encontrado",
- "noDwellMomentsDescription": "Tente uma gravação com pausas mais lentas do cursor em ações importantes.",
+ "speedExistsAtLocation": "Já existe uma região de velocidade neste local ou não há espaço suficiente disponível.",
+ "noCursorTelemetry": "Nenhuma telemetria de cursor disponível",
"noAutoZoomSlots": "Nenhum slot de zoom automático disponível",
- "noAutoZoomSlotsDescription": "Pontos de parada detectados sobrepõem regiões de zoom existentes.",
"cannotPlaceTrim": "Não é possível colocar recorte aqui",
- "trimExistsAtLocation": "Já existe um recorte neste local ou não há espaço suficiente disponível.",
+ "cannotPlaceZoom": "Não é possível colocar zoom aqui",
"cannotPlaceSpeed": "Não é possível colocar velocidade aqui",
- "speedExistsAtLocation": "Já existe uma região de velocidade neste local ou não há espaço suficiente disponível.",
- "cannotPlaceCameraFullscreen": "Não é possível colocar Câmera em Tela Cheia aqui",
- "cameraFullscreenExistsAtLocation": "Já existe um segmento de Câmera em Tela Cheia neste local ou não há espaço suficiente disponível."
- },
- "success": {
- "addedZoomSuggestions": "Adicionada {{count}} sugestão de zoom baseada no cursor",
- "addedZoomSuggestionsPlural": "Adicionadas {{count}} sugestões de zoom baseadas no cursor"
- },
- "toolbar": {
- "autoEnhance": "Melhoria automática",
- "automaticZooms": "Zooms automáticos",
- "automaticZoomsHint": "Do movimento do cursor gravado",
- "smartZoomsAndCuts": "Cortes inteligentes",
- "smartZoomsAndCutsHint": "Com IA",
- "comment": "Comentário",
- "timelineTools": "Ferramentas da linha do tempo",
- "arrangeClips": "Organizar clipes",
- "arrangeClipsHint": "Arraste os clipes abaixo para reordená-los ou solte novos",
- "newAnnotation": "Anotação",
- "dragToReorderHint": "Arraste para reordenar · clique duas vezes para editar os pontos de entrada/saída",
- "editInOutPoints": "Editar pontos de entrada/saída",
- "deleteClip": "Excluir clipe",
- "dropToAdd": "Solte para adicionar à linha do tempo",
- "importRecordingFirst": "Importe uma gravação primeiro",
- "noAutoZoomMoments": "Nenhum momento de zoom automático encontrado",
- "noAutoZoomMomentsDescription": "Esta gravação não tem dados de movimento do cursor, ou os zooms existentes já cobrem os momentos de destaque.",
- "addedAutoZoom": "{{count}} zoom automático adicionado",
- "addedAutoZoomPlural": "{{count}} zooms automáticos adicionados",
- "autoZoomFailed": "Falha no zoom automático",
- "aiEnhanceRequested": "Pedido ao agente de IA para cortar os tempos mortos",
- "smartCutsWaiting": "Transcrevendo… disponível em instantes",
- "smartCutsNeedsTranscript": "Requer uma transcrição",
- "smartCutsNoAudio": "Esta mídia não tem áudio",
- "smartCutsNoSpeech": "Nenhuma fala detectada",
- "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia",
- "addAudioTooltip": "Adicionar áudio"
+ "zoomExistsAtLocation": "Já existe um zoom neste local ou não há espaço suficiente disponível.",
+ "noDwellMomentsDescription": "Tente uma gravação com pausas mais lentas do cursor em ações importantes.",
+ "trimExistsAtLocation": "Já existe um recorte neste local ou não há espaço suficiente disponível.",
+ "cannotPlaceCameraFullscreen": "Não é possível colocar Câmera em Tela Cheia aqui"
},
"audio": {
- "addVoiceover": "Adicionar narração",
- "addVoiceoverHint": "Grave uma narração sobre o seu vídeo",
+ "micDenied": "Acesso ao microfone negado",
"subtitle": "Coloque uma camada de narração ou de música de fundo na linha do tempo",
- "record": "Gravar narração",
- "importFile": "Importar arquivo de áudio",
+ "importFailed": "Não foi possível importar o arquivo de áudio",
"importFileHint": "Importe música ou um arquivo de áudio",
+ "addVoiceoverHint": "Grave uma narração sobre o seu vídeo",
+ "stop": "Parar",
"recording": "Gravando",
+ "saveFailed": "Não foi possível salvar a gravação",
"recordingHint": "Narre junto com o vídeo — ele continua tocando enquanto você grava",
- "stop": "Parar",
- "micDenied": "Acesso ao microfone negado",
+ "importFile": "Importar arquivo de áudio",
+ "record": "Gravar narração",
"recordingUnavailable": "A gravação não está disponível aqui",
- "saveFailed": "Não foi possível salvar a gravação",
- "importFailed": "Não foi possível importar o arquivo de áudio"
+ "addVoiceover": "Adicionar narração"
+ },
+ "success": {
+ "addedZoomSuggestions": "Adicionada {{count}} sugestão de zoom baseada no cursor",
+ "addedZoomSuggestionsPlural": "Adicionadas {{count}} sugestões de zoom baseadas no cursor"
+ },
+ "hints": {
+ "pressAnnotation": "Pressione A para adicionar anotação",
+ "pressSpeed": "Pressione S para adicionar velocidade",
+ "pressTrim": "Pressione T para adicionar recorte",
+ "pressCameraFullscreen": "Pressione C para adicionar um segmento de Câmera em Tela Cheia",
+ "pressZoom": "Pressione Z para adicionar zoom",
+ "pressAudio": "Pressione M para adicionar áudio, V para gravar uma narração"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Ativar foco automático para todos os zooms (a câmera segue o cursor)",
+ "autoZoomOff": "Sugestões de zoom automático desativadas — clique para sugerir zooms a partir do cursor",
+ "addAnnotation": "Adicionar Anotação (A)",
+ "suggestZooms": "Sugerir Zooms a partir do Cursor",
+ "addSpeed": "Adicionar Velocidade (S)",
+ "addCameraFullscreen": "Adicionar Câmera em Tela Cheia (C)",
+ "autoFocusAllOn": "Foco automático ativado para todos os zooms — clique para mudar todos para manual",
+ "autoZoomOn": "Sugestões de zoom automático ativadas — clique para remover os zooms sugeridos",
+ "addZoom": "Adicionar Zoom (Z)",
+ "addTrim": "Adicionar Recorte (T)"
+ },
+ "emptyState": {
+ "noVideo": "Nenhum Vídeo Carregado",
+ "dragAndDrop": "Arraste e solte um vídeo para começar a editar"
}
}
diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json
index 7a89391c4..1f715065f 100644
--- a/src/i18n/locales/ru/timeline.json
+++ b/src/i18n/locales/ru/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Добавить масштабирование (Z)",
- "suggestZooms": "Предложить масштабирование на основе курсора",
- "autoZoomOn": "Автоматические предложения масштабирования включены — нажмите, чтобы убрать предложенные зумы",
- "autoZoomOff": "Автоматические предложения масштабирования выключены — нажмите, чтобы предложить зумы по курсору",
- "autoFocusAllOn": "Автофокус включён для всех зумов — нажмите, чтобы переключить все в ручной режим",
- "autoFocusAllOff": "Включить автофокус для всех зумов (камера следует за курсором)",
- "addTrim": "Добавить обрезку (T)",
- "addAnnotation": "Добавить аннотацию (A)",
- "addSpeed": "Изменить скорость (S)",
- "addCameraFullscreen": "Добавить камеру на весь экран (C)"
- },
- "hints": {
- "pressZoom": "Нажмите Z для добавления масштабирования",
- "pressTrim": "Нажмите T для добавления обрезки",
- "pressAnnotation": "Нажмите A для добавления аннотации",
- "pressAudio": "Нажмите M, чтобы добавить аудио, V — чтобы записать закадровый голос",
- "pressSpeed": "Нажмите S для изменения скорости",
- "pressCameraFullscreen": "Нажмите C, чтобы добавить сегмент камеры на весь экран"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "В этой записи нет данных о движении курсора, либо существующие зумы уже покрывают активные моменты.",
+ "smartCutsNoAudio": "В этом медиафайле нет звука",
+ "automaticZoomsHint": "На основе записанного движения курсора",
+ "dragToReorderHint": "Перетащите для изменения порядка · дважды щёлкните для редактирования точек входа/выхода",
+ "smartCutsNeedsTranscript": "Нужна расшифровка",
+ "addedWord": "Добавленное слово: «{{word}}» — за ним нет звука",
+ "smartZoomsAndCuts": "Умные вырезки",
+ "autoZoomFailed": "Не удалось выполнить автозум",
+ "smartCutsWaiting": "Идёт расшифровка… скоро будет готово",
+ "automaticZooms": "Автоматические зумы",
+ "arrangeClipsHint": "Перетащите клипы ниже, чтобы изменить порядок, или добавьте новые",
+ "comment": "Комментарий",
+ "addAudioTooltip": "Добавить аудио",
+ "timelineTools": "Инструменты таймлайна",
+ "deleteClip": "Удалить клип",
+ "arrangeClips": "Упорядочить клипы",
+ "editInOutPoints": "Редактировать точки входа/выхода",
+ "smartZoomsAndCutsHint": "С помощью ИИ",
+ "addedAutoZoomPlural": "Добавлено {{count}} автоматических зумов",
+ "noAutoZoomMoments": "Моменты для автозума не найдены",
+ "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»",
+ "smartCutsNoSpeech": "Речь не обнаружена",
+ "newAnnotation": "Аннотация",
+ "importRecordingFirst": "Сначала импортируйте запись",
+ "addedAutoZoom": "Добавлен {{count}} автоматический зум",
+ "dropToAdd": "Отпустите, чтобы добавить на таймлайн",
+ "aiEnhanceRequested": "Агенту ИИ поручено вырезать паузы",
+ "autoEnhance": "Авто-улучшение"
},
"labels": {
- "pan": "Панорамирование",
"zoom": "Масштабирование",
- "trim": "Обрезка",
- "speed": "Скорость воспроизведения",
+ "cameraFullscreenItem": "Камера на весь экран {{index}}",
+ "imageItem": "Изображение",
+ "pan": "Панорамирование",
"zoomItem": "Масштабирование {{index}}",
+ "cameraFullscreen": "Камера на весь экран",
+ "speed": "Скорость воспроизведения",
+ "emptyText": "Пустой текст",
"trimItem": "Обрезка {{index}}",
- "speedItem": "Скорость воспроизведения {{index}}",
"annotationItem": "Аннотация",
- "imageItem": "Изображение",
- "emptyText": "Пустой текст",
- "cameraFullscreen": "Камера на весь экран",
- "cameraFullscreenItem": "Камера на весь экран {{index}}"
- },
- "emptyState": {
- "noVideo": "Видео не загружено",
- "dragAndDrop": "Перетащите видео для начала редактирования"
+ "trim": "Обрезка",
+ "speedItem": "Скорость воспроизведения {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
- "zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
- "zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
- "noCursorTelemetry": "Нет данных телеметрии курсора",
+ "noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
"noCursorTelemetryDescription": "Сначала запишите screencast для генерации предложений на основе курсора.",
+ "cameraFullscreenExistsAtLocation": "Сегмент камеры на весь экран уже существует в этом месте или недостаточно свободного места.",
"noUsableTelemetry": "Нет пригодной телеметрии курсора",
"noUsableTelemetryDescription": "Запись не содержит достаточно данных о движении курсора.",
+ "zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
"noDwellMoments": "Не найдено чётких моментов задержки курсора",
- "noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
+ "speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места.",
+ "noCursorTelemetry": "Нет данных телеметрии курсора",
"noAutoZoomSlots": "Нет доступных слотов авто-масштабирования",
- "noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
"cannotPlaceTrim": "Невозможно разместить обрезку здесь",
- "trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
+ "cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
"cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
- "speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места.",
- "cannotPlaceCameraFullscreen": "Невозможно разместить камеру на весь экран здесь",
- "cameraFullscreenExistsAtLocation": "Сегмент камеры на весь экран уже существует в этом месте или недостаточно свободного места."
- },
- "success": {
- "addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
- "addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
- },
- "toolbar": {
- "autoEnhance": "Авто-улучшение",
- "automaticZooms": "Автоматические зумы",
- "automaticZoomsHint": "На основе записанного движения курсора",
- "smartZoomsAndCuts": "Умные вырезки",
- "smartZoomsAndCutsHint": "С помощью ИИ",
- "comment": "Комментарий",
- "timelineTools": "Инструменты таймлайна",
- "arrangeClips": "Упорядочить клипы",
- "arrangeClipsHint": "Перетащите клипы ниже, чтобы изменить порядок, или добавьте новые",
- "newAnnotation": "Аннотация",
- "dragToReorderHint": "Перетащите для изменения порядка · дважды щёлкните для редактирования точек входа/выхода",
- "editInOutPoints": "Редактировать точки входа/выхода",
- "deleteClip": "Удалить клип",
- "dropToAdd": "Отпустите, чтобы добавить на таймлайн",
- "importRecordingFirst": "Сначала импортируйте запись",
- "noAutoZoomMoments": "Моменты для автозума не найдены",
- "noAutoZoomMomentsDescription": "В этой записи нет данных о движении курсора, либо существующие зумы уже покрывают активные моменты.",
- "addedAutoZoom": "Добавлен {{count}} автоматический зум",
- "addedAutoZoomPlural": "Добавлено {{count}} автоматических зумов",
- "autoZoomFailed": "Не удалось выполнить автозум",
- "aiEnhanceRequested": "Агенту ИИ поручено вырезать паузы",
- "smartCutsWaiting": "Идёт расшифровка… скоро будет готово",
- "smartCutsNeedsTranscript": "Нужна расшифровка",
- "smartCutsNoAudio": "В этом медиафайле нет звука",
- "smartCutsNoSpeech": "Речь не обнаружена",
- "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»",
- "addAudioTooltip": "Добавить аудио"
+ "zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
+ "noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
+ "trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
+ "cannotPlaceCameraFullscreen": "Невозможно разместить камеру на весь экран здесь"
},
"audio": {
- "addVoiceover": "Добавить озвучку",
- "addVoiceoverHint": "Запишите закадровый голос поверх видео",
+ "micDenied": "Доступ к микрофону запрещён",
"subtitle": "Разместите слой озвучки или фоновой музыки на таймлайне",
- "record": "Записать озвучку",
- "importFile": "Импортировать аудиофайл",
+ "importFailed": "Не удалось импортировать аудиофайл",
"importFileHint": "Импортируйте музыку или аудиофайл",
+ "addVoiceoverHint": "Запишите закадровый голос поверх видео",
+ "stop": "Остановить",
"recording": "Запись",
+ "saveFailed": "Не удалось сохранить запись",
"recordingHint": "Говорите под видео — оно продолжает играть во время записи",
- "stop": "Остановить",
- "micDenied": "Доступ к микрофону запрещён",
+ "importFile": "Импортировать аудиофайл",
+ "record": "Записать озвучку",
"recordingUnavailable": "Запись здесь недоступна",
- "saveFailed": "Не удалось сохранить запись",
- "importFailed": "Не удалось импортировать аудиофайл"
+ "addVoiceover": "Добавить озвучку"
+ },
+ "success": {
+ "addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
+ "addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
+ },
+ "hints": {
+ "pressAnnotation": "Нажмите A для добавления аннотации",
+ "pressSpeed": "Нажмите S для изменения скорости",
+ "pressTrim": "Нажмите T для добавления обрезки",
+ "pressCameraFullscreen": "Нажмите C, чтобы добавить сегмент камеры на весь экран",
+ "pressZoom": "Нажмите Z для добавления масштабирования",
+ "pressAudio": "Нажмите M, чтобы добавить аудио, V — чтобы записать закадровый голос"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Включить автофокус для всех зумов (камера следует за курсором)",
+ "autoZoomOff": "Автоматические предложения масштабирования выключены — нажмите, чтобы предложить зумы по курсору",
+ "addAnnotation": "Добавить аннотацию (A)",
+ "suggestZooms": "Предложить масштабирование на основе курсора",
+ "addSpeed": "Изменить скорость (S)",
+ "addCameraFullscreen": "Добавить камеру на весь экран (C)",
+ "autoFocusAllOn": "Автофокус включён для всех зумов — нажмите, чтобы переключить все в ручной режим",
+ "autoZoomOn": "Автоматические предложения масштабирования включены — нажмите, чтобы убрать предложенные зумы",
+ "addZoom": "Добавить масштабирование (Z)",
+ "addTrim": "Добавить обрезку (T)"
+ },
+ "emptyState": {
+ "noVideo": "Видео не загружено",
+ "dragAndDrop": "Перетащите видео для начала редактирования"
}
}
diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json
index 9f3d70d36..db20ec28e 100644
--- a/src/i18n/locales/tr/timeline.json
+++ b/src/i18n/locales/tr/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Yakınlaştırma Ekle (Z)",
- "suggestZooms": "İmleçten Yakınlaştırma Öner",
- "autoZoomOn": "Otomatik yakınlaştırma önerileri açık — önerilen yakınlaştırmaları kaldırmak için tıklayın",
- "autoZoomOff": "Otomatik yakınlaştırma önerileri kapalı — imleçten yakınlaştırma önermek için tıklayın",
- "autoFocusAllOn": "Tüm yakınlaştırmalarda Otomatik Odak açık — hepsini manuel yapmak için tıklayın",
- "autoFocusAllOff": "Tüm yakınlaştırmalarda Otomatik Odağı aç (kamera imleci takip eder)",
- "addTrim": "Kırpma Ekle (T)",
- "addAnnotation": "Açıklama Ekle (A)",
- "addSpeed": "Hız Ekle (S)",
- "addCameraFullscreen": "Tam Ekran Kamera Ekle (C)"
- },
- "hints": {
- "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
- "pressTrim": "Kırpma eklemek için T tuşuna basın",
- "pressAnnotation": "Açıklama eklemek için A tuşuna basın",
- "pressAudio": "Ses eklemek için M, seslendirme kaydetmek için V tuşuna basın",
- "pressSpeed": "Hız eklemek için S tuşuna basın",
- "pressCameraFullscreen": "Tam Ekran Kamera bölümü eklemek için C tuşuna basın"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "Bu kayıtta imleç hareket verisi yok veya mevcut yakınlaştırmalar zaten yoğun anları kapsıyor.",
+ "smartCutsNoAudio": "Bu medyada ses yok",
+ "automaticZoomsHint": "Kaydedilen imleç hareketinden",
+ "dragToReorderHint": "Yeniden sıralamak için sürükleyin · giriş/çıkış noktalarını düzenlemek için çift tıklayın",
+ "smartCutsNeedsTranscript": "Bir döküm gerekiyor",
+ "addedWord": "Eklenen kelime: \"{{word}}\" — arkasında ses yok",
+ "smartZoomsAndCuts": "Akıllı kırpma",
+ "autoZoomFailed": "Otomatik yakınlaştırma başarısız oldu",
+ "smartCutsWaiting": "Metne dökülüyor… birazdan hazır",
+ "automaticZooms": "Otomatik yakınlaştırmalar",
+ "arrangeClipsHint": "Yeniden sıralamak için aşağıdaki klipleri sürükleyin veya yenilerini bırakın",
+ "comment": "Yorum",
+ "addAudioTooltip": "Ses ekle",
+ "timelineTools": "Zaman çizelgesi araçları",
+ "deleteClip": "Klibi sil",
+ "arrangeClips": "Klipleri düzenle",
+ "editInOutPoints": "Giriş/çıkış noktalarını düzenle",
+ "smartZoomsAndCutsHint": "Yapay zeka ile",
+ "addedAutoZoomPlural": "{{count}} otomatik yakınlaştırma eklendi",
+ "noAutoZoomMoments": "Otomatik yakınlaştırma anı bulunamadı",
+ "smartCutsFailed": "Metne dökme başarısız — Medya bölümünden yeniden deneyin",
+ "smartCutsNoSpeech": "Konuşma algılanmadı",
+ "newAnnotation": "Açıklama",
+ "importRecordingFirst": "Önce bir kayıt içe aktarın",
+ "addedAutoZoom": "{{count}} otomatik yakınlaştırma eklendi",
+ "dropToAdd": "Zaman çizelgesine eklemek için bırakın",
+ "aiEnhanceRequested": "Yapay zeka aracısından ölü zamanları kırpması istendi",
+ "autoEnhance": "Otomatik iyileştirme"
},
"labels": {
- "pan": "Kaydır",
"zoom": "Yakınlaştır",
- "trim": "Kırp",
- "speed": "Hız",
+ "cameraFullscreenItem": "Tam Ekran Kamera {{index}}",
+ "imageItem": "Görüntü",
+ "pan": "Kaydır",
"zoomItem": "Yakınlaştırma {{index}}",
+ "cameraFullscreen": "Tam Ekran Kamera",
+ "speed": "Hız",
+ "emptyText": "Boş metin",
"trimItem": "Kırpma {{index}}",
- "speedItem": "Hız {{index}}",
"annotationItem": "Açıklama",
- "imageItem": "Görüntü",
- "emptyText": "Boş metin",
- "cameraFullscreen": "Tam Ekran Kamera",
- "cameraFullscreenItem": "Tam Ekran Kamera {{index}}"
- },
- "emptyState": {
- "noVideo": "Video Yüklenmedi",
- "dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
+ "trim": "Kırp",
+ "speedItem": "Hız {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
- "zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
- "zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
- "noCursorTelemetry": "İmleç telemetrisi mevcut değil",
+ "noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
"noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
+ "cameraFullscreenExistsAtLocation": "Bu konumda zaten bir Tam Ekran Kamera bölümü var veya yeterli alan yok.",
"noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
"noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
+ "zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
"noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
- "noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
+ "speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok.",
+ "noCursorTelemetry": "İmleç telemetrisi mevcut değil",
"noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
- "noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
"cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
- "trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
+ "cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
"cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
- "speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok.",
- "cannotPlaceCameraFullscreen": "Buraya Tam Ekran Kamera yerleştirilemiyor",
- "cameraFullscreenExistsAtLocation": "Bu konumda zaten bir Tam Ekran Kamera bölümü var veya yeterli alan yok."
- },
- "success": {
- "addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
- "addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
- },
- "toolbar": {
- "autoEnhance": "Otomatik iyileştirme",
- "automaticZooms": "Otomatik yakınlaştırmalar",
- "automaticZoomsHint": "Kaydedilen imleç hareketinden",
- "smartZoomsAndCuts": "Akıllı kırpma",
- "smartZoomsAndCutsHint": "Yapay zeka ile",
- "comment": "Yorum",
- "timelineTools": "Zaman çizelgesi araçları",
- "arrangeClips": "Klipleri düzenle",
- "arrangeClipsHint": "Yeniden sıralamak için aşağıdaki klipleri sürükleyin veya yenilerini bırakın",
- "newAnnotation": "Açıklama",
- "dragToReorderHint": "Yeniden sıralamak için sürükleyin · giriş/çıkış noktalarını düzenlemek için çift tıklayın",
- "editInOutPoints": "Giriş/çıkış noktalarını düzenle",
- "deleteClip": "Klibi sil",
- "dropToAdd": "Zaman çizelgesine eklemek için bırakın",
- "importRecordingFirst": "Önce bir kayıt içe aktarın",
- "noAutoZoomMoments": "Otomatik yakınlaştırma anı bulunamadı",
- "noAutoZoomMomentsDescription": "Bu kayıtta imleç hareket verisi yok veya mevcut yakınlaştırmalar zaten yoğun anları kapsıyor.",
- "addedAutoZoom": "{{count}} otomatik yakınlaştırma eklendi",
- "addedAutoZoomPlural": "{{count}} otomatik yakınlaştırma eklendi",
- "autoZoomFailed": "Otomatik yakınlaştırma başarısız oldu",
- "aiEnhanceRequested": "Yapay zeka aracısından ölü zamanları kırpması istendi",
- "smartCutsWaiting": "Metne dökülüyor… birazdan hazır",
- "smartCutsNeedsTranscript": "Bir döküm gerekiyor",
- "smartCutsNoAudio": "Bu medyada ses yok",
- "smartCutsNoSpeech": "Konuşma algılanmadı",
- "smartCutsFailed": "Metne dökme başarısız — Medya bölümünden yeniden deneyin",
- "addAudioTooltip": "Ses ekle"
+ "zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
+ "noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
+ "trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
+ "cannotPlaceCameraFullscreen": "Buraya Tam Ekran Kamera yerleştirilemiyor"
},
"audio": {
- "addVoiceover": "Seslendirme ekle",
- "addVoiceoverHint": "Videonuzun üzerine anlatım kaydedin",
+ "micDenied": "Mikrofon erişimi reddedildi",
"subtitle": "Zaman çizelgesine seslendirme veya fon müziği katmanı yerleştirin",
- "record": "Seslendirme kaydet",
- "importFile": "Ses dosyası içe aktar",
+ "importFailed": "Ses dosyası içe aktarılamadı",
"importFileHint": "Müzik veya ses dosyası içe aktarın",
+ "addVoiceoverHint": "Videonuzun üzerine anlatım kaydedin",
+ "stop": "Durdur",
"recording": "Kaydediliyor",
+ "saveFailed": "Kayıt kaydedilemedi",
"recordingHint": "Videoyla birlikte anlatın — kayıt sırasında oynamaya devam eder",
- "stop": "Durdur",
- "micDenied": "Mikrofon erişimi reddedildi",
+ "importFile": "Ses dosyası içe aktar",
+ "record": "Seslendirme kaydet",
"recordingUnavailable": "Burada kayıt kullanılamıyor",
- "saveFailed": "Kayıt kaydedilemedi",
- "importFailed": "Ses dosyası içe aktarılamadı"
+ "addVoiceover": "Seslendirme ekle"
+ },
+ "success": {
+ "addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
+ "addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
+ },
+ "hints": {
+ "pressAnnotation": "Açıklama eklemek için A tuşuna basın",
+ "pressSpeed": "Hız eklemek için S tuşuna basın",
+ "pressTrim": "Kırpma eklemek için T tuşuna basın",
+ "pressCameraFullscreen": "Tam Ekran Kamera bölümü eklemek için C tuşuna basın",
+ "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
+ "pressAudio": "Ses eklemek için M, seslendirme kaydetmek için V tuşuna basın"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Tüm yakınlaştırmalarda Otomatik Odağı aç (kamera imleci takip eder)",
+ "autoZoomOff": "Otomatik yakınlaştırma önerileri kapalı — imleçten yakınlaştırma önermek için tıklayın",
+ "addAnnotation": "Açıklama Ekle (A)",
+ "suggestZooms": "İmleçten Yakınlaştırma Öner",
+ "addSpeed": "Hız Ekle (S)",
+ "addCameraFullscreen": "Tam Ekran Kamera Ekle (C)",
+ "autoFocusAllOn": "Tüm yakınlaştırmalarda Otomatik Odak açık — hepsini manuel yapmak için tıklayın",
+ "autoZoomOn": "Otomatik yakınlaştırma önerileri açık — önerilen yakınlaştırmaları kaldırmak için tıklayın",
+ "addZoom": "Yakınlaştırma Ekle (Z)",
+ "addTrim": "Kırpma Ekle (T)"
+ },
+ "emptyState": {
+ "noVideo": "Video Yüklenmedi",
+ "dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
}
}
diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json
index e585a7dce..da7c84a40 100644
--- a/src/i18n/locales/vi/timeline.json
+++ b/src/i18n/locales/vi/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "Thêm Thu phóng (Z)",
- "suggestZooms": "Đề xuất Thu phóng từ Con trỏ",
- "autoZoomOn": "Đề xuất thu phóng tự động đang bật — nhấp để loại bỏ các thu phóng được đề xuất",
- "autoZoomOff": "Đề xuất thu phóng tự động đang tắt — nhấp để đề xuất thu phóng từ con trỏ",
- "autoFocusAllOn": "Lấy nét tự động đang bật cho tất cả các thu phóng — nhấp để chuyển tất cả sang thủ công",
- "autoFocusAllOff": "Bật lấy nét tự động cho tất cả các thu phóng (máy ảnh theo dõi con trỏ)",
- "addTrim": "Thêm Cắt (T)",
- "addAnnotation": "Thêm Chú thích (A)",
- "addSpeed": "Thêm Tốc độ (S)",
- "addCameraFullscreen": "Thêm Camera Toàn màn hình (C)"
- },
- "hints": {
- "pressZoom": "Nhấn Z để thêm thu phóng",
- "pressTrim": "Nhấn T để thêm cắt",
- "pressAnnotation": "Nhấn A để thêm chú thích",
- "pressAudio": "Nhấn M để thêm âm thanh, V để ghi âm lời thuyết minh",
- "pressSpeed": "Nhấn S để thêm tốc độ",
- "pressCameraFullscreen": "Nhấn C để thêm một đoạn Camera Toàn màn hình"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "Bản ghi này không có dữ liệu chuyển động con trỏ, hoặc các thu phóng hiện có đã bao phủ các khoảnh khắc bận rộn.",
+ "smartCutsNoAudio": "Media này không có âm thanh",
+ "automaticZoomsHint": "Từ chuyển động con trỏ đã ghi",
+ "dragToReorderHint": "Kéo để sắp xếp lại · nhấp đúp để chỉnh sửa điểm vào/ra",
+ "smartCutsNeedsTranscript": "Cần có bản phiên âm",
+ "addedWord": "Từ đã thêm: \"{{word}}\" — không có âm thanh phía sau",
+ "smartZoomsAndCuts": "Cắt thông minh",
+ "autoZoomFailed": "Thu phóng tự động thất bại",
+ "smartCutsWaiting": "Đang phiên âm… sẵn sàng trong giây lát",
+ "automaticZooms": "Thu phóng tự động",
+ "arrangeClipsHint": "Kéo các clip bên dưới để sắp xếp lại hoặc thả clip mới vào",
+ "comment": "Bình luận",
+ "addAudioTooltip": "Thêm âm thanh",
+ "timelineTools": "Công cụ dòng thời gian",
+ "deleteClip": "Xóa clip",
+ "arrangeClips": "Sắp xếp clip",
+ "editInOutPoints": "Chỉnh sửa điểm vào/ra",
+ "smartZoomsAndCutsHint": "Với AI",
+ "addedAutoZoomPlural": "Đã thêm {{count}} thu phóng tự động",
+ "noAutoZoomMoments": "Không tìm thấy khoảnh khắc thu phóng tự động",
+ "smartCutsFailed": "Phiên âm thất bại — thử lại trong Media",
+ "smartCutsNoSpeech": "Không phát hiện giọng nói",
+ "newAnnotation": "Chú thích",
+ "importRecordingFirst": "Hãy nhập một bản ghi trước",
+ "addedAutoZoom": "Đã thêm {{count}} thu phóng tự động",
+ "dropToAdd": "Thả để thêm vào dòng thời gian",
+ "aiEnhanceRequested": "Đã yêu cầu tác nhân AI cắt thời gian chết",
+ "autoEnhance": "Tự động nâng cao"
},
"labels": {
- "pan": "Xoay",
"zoom": "Thu phóng",
- "trim": "Cắt",
- "speed": "Tốc độ",
+ "cameraFullscreenItem": "Camera Toàn màn hình {{index}}",
+ "imageItem": "Hình ảnh",
+ "pan": "Xoay",
"zoomItem": "Thu phóng {{index}}",
+ "cameraFullscreen": "Camera Toàn màn hình",
+ "speed": "Tốc độ",
+ "emptyText": "Văn bản trống",
"trimItem": "Cắt {{index}}",
- "speedItem": "Tốc độ {{index}}",
"annotationItem": "Chú thích",
- "imageItem": "Hình ảnh",
- "emptyText": "Văn bản trống",
- "cameraFullscreen": "Camera Toàn màn hình",
- "cameraFullscreenItem": "Camera Toàn màn hình {{index}}"
- },
- "emptyState": {
- "noVideo": "Chưa tải video",
- "dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa"
+ "trim": "Cắt",
+ "speedItem": "Tốc độ {{index}}"
},
"errors": {
- "cannotPlaceZoom": "Không thể đặt thu phóng ở đây",
- "zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.",
- "zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng",
- "noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ",
+ "noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.",
"noCursorTelemetryDescription": "Ghi hình màn hình trước để tạo các đề xuất dựa trên con trỏ.",
+ "cameraFullscreenExistsAtLocation": "Đoạn Camera Toàn màn hình đã tồn tại ở vị trí này hoặc không có đủ không gian.",
"noUsableTelemetry": "Không có dữ liệu từ xa của con trỏ có thể sử dụng",
"noUsableTelemetryDescription": "Bản ghi không chứa đủ dữ liệu chuyển động của con trỏ.",
+ "zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng",
"noDwellMoments": "Không tìm thấy khoảnh khắc dừng con trỏ rõ ràng",
- "noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.",
+ "speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian.",
+ "noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ",
"noAutoZoomSlots": "Không có khe thu phóng tự động nào",
- "noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.",
"cannotPlaceTrim": "Không thể đặt cắt ở đây",
- "trimExistsAtLocation": "Cắt đã tồn tại ở vị trí này hoặc không có đủ không gian.",
+ "cannotPlaceZoom": "Không thể đặt thu phóng ở đây",
"cannotPlaceSpeed": "Không thể đặt tốc độ ở đây",
- "speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian.",
- "cannotPlaceCameraFullscreen": "Không thể đặt Camera Toàn màn hình ở đây",
- "cameraFullscreenExistsAtLocation": "Đoạn Camera Toàn màn hình đã tồn tại ở vị trí này hoặc không có đủ không gian."
- },
- "success": {
- "addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ",
- "addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ"
- },
- "toolbar": {
- "autoEnhance": "Tự động nâng cao",
- "automaticZooms": "Thu phóng tự động",
- "automaticZoomsHint": "Từ chuyển động con trỏ đã ghi",
- "smartZoomsAndCuts": "Cắt thông minh",
- "smartZoomsAndCutsHint": "Với AI",
- "comment": "Bình luận",
- "timelineTools": "Công cụ dòng thời gian",
- "arrangeClips": "Sắp xếp clip",
- "arrangeClipsHint": "Kéo các clip bên dưới để sắp xếp lại hoặc thả clip mới vào",
- "newAnnotation": "Chú thích",
- "dragToReorderHint": "Kéo để sắp xếp lại · nhấp đúp để chỉnh sửa điểm vào/ra",
- "editInOutPoints": "Chỉnh sửa điểm vào/ra",
- "deleteClip": "Xóa clip",
- "dropToAdd": "Thả để thêm vào dòng thời gian",
- "importRecordingFirst": "Hãy nhập một bản ghi trước",
- "noAutoZoomMoments": "Không tìm thấy khoảnh khắc thu phóng tự động",
- "noAutoZoomMomentsDescription": "Bản ghi này không có dữ liệu chuyển động con trỏ, hoặc các thu phóng hiện có đã bao phủ các khoảnh khắc bận rộn.",
- "addedAutoZoom": "Đã thêm {{count}} thu phóng tự động",
- "addedAutoZoomPlural": "Đã thêm {{count}} thu phóng tự động",
- "autoZoomFailed": "Thu phóng tự động thất bại",
- "aiEnhanceRequested": "Đã yêu cầu tác nhân AI cắt thời gian chết",
- "smartCutsWaiting": "Đang phiên âm… sẵn sàng trong giây lát",
- "smartCutsNeedsTranscript": "Cần có bản phiên âm",
- "smartCutsNoAudio": "Media này không có âm thanh",
- "smartCutsNoSpeech": "Không phát hiện giọng nói",
- "smartCutsFailed": "Phiên âm thất bại — thử lại trong Media",
- "addAudioTooltip": "Thêm âm thanh"
+ "zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.",
+ "noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.",
+ "trimExistsAtLocation": "Cắt đã tồn tại ở vị trí này hoặc không có đủ không gian.",
+ "cannotPlaceCameraFullscreen": "Không thể đặt Camera Toàn màn hình ở đây"
},
"audio": {
- "addVoiceover": "Thêm thuyết minh",
- "addVoiceoverHint": "Ghi âm lời thuyết minh trên video của bạn",
+ "micDenied": "Quyền truy cập micrô bị từ chối",
"subtitle": "Đặt lớp thuyết minh hoặc nhạc nền lên dòng thời gian",
- "record": "Ghi âm thuyết minh",
- "importFile": "Nhập tệp âm thanh",
+ "importFailed": "Không thể nhập tệp âm thanh",
"importFileHint": "Nhập nhạc hoặc tệp âm thanh",
+ "addVoiceoverHint": "Ghi âm lời thuyết minh trên video của bạn",
+ "stop": "Dừng",
"recording": "Đang ghi",
+ "saveFailed": "Không thể lưu bản ghi",
"recordingHint": "Thuyết minh cùng video — video vẫn phát trong khi bạn ghi âm",
- "stop": "Dừng",
- "micDenied": "Quyền truy cập micrô bị từ chối",
+ "importFile": "Nhập tệp âm thanh",
+ "record": "Ghi âm thuyết minh",
"recordingUnavailable": "Không thể ghi âm ở đây",
- "saveFailed": "Không thể lưu bản ghi",
- "importFailed": "Không thể nhập tệp âm thanh"
+ "addVoiceover": "Thêm thuyết minh"
+ },
+ "success": {
+ "addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ",
+ "addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ"
+ },
+ "hints": {
+ "pressAnnotation": "Nhấn A để thêm chú thích",
+ "pressSpeed": "Nhấn S để thêm tốc độ",
+ "pressTrim": "Nhấn T để thêm cắt",
+ "pressCameraFullscreen": "Nhấn C để thêm một đoạn Camera Toàn màn hình",
+ "pressZoom": "Nhấn Z để thêm thu phóng",
+ "pressAudio": "Nhấn M để thêm âm thanh, V để ghi âm lời thuyết minh"
+ },
+ "buttons": {
+ "autoFocusAllOff": "Bật lấy nét tự động cho tất cả các thu phóng (máy ảnh theo dõi con trỏ)",
+ "autoZoomOff": "Đề xuất thu phóng tự động đang tắt — nhấp để đề xuất thu phóng từ con trỏ",
+ "addAnnotation": "Thêm Chú thích (A)",
+ "suggestZooms": "Đề xuất Thu phóng từ Con trỏ",
+ "addSpeed": "Thêm Tốc độ (S)",
+ "addCameraFullscreen": "Thêm Camera Toàn màn hình (C)",
+ "autoFocusAllOn": "Lấy nét tự động đang bật cho tất cả các thu phóng — nhấp để chuyển tất cả sang thủ công",
+ "autoZoomOn": "Đề xuất thu phóng tự động đang bật — nhấp để loại bỏ các thu phóng được đề xuất",
+ "addZoom": "Thêm Thu phóng (Z)",
+ "addTrim": "Thêm Cắt (T)"
+ },
+ "emptyState": {
+ "noVideo": "Chưa tải video",
+ "dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa"
}
}
diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json
index 1513e4f39..ff397771b 100644
--- a/src/i18n/locales/zh-CN/timeline.json
+++ b/src/i18n/locales/zh-CN/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "添加缩放 (Z)",
- "suggestZooms": "根据光标建议缩放",
- "autoZoomOn": "自动缩放建议已开启 — 点击可移除建议的缩放",
- "autoZoomOff": "自动缩放建议已关闭 — 点击可根据光标建议缩放",
- "autoFocusAllOn": "所有缩放的自动对焦已开启 — 点击可将全部切换为手动",
- "autoFocusAllOff": "为所有缩放开启自动对焦(摄像头跟随光标)",
- "addTrim": "添加剪辑 (T)",
- "addAnnotation": "添加标注 (A)",
- "addSpeed": "添加速度 (S)",
- "addCameraFullscreen": "添加全屏摄像头 (C)"
- },
- "hints": {
- "pressZoom": "按 Z 添加缩放",
- "pressTrim": "按 T 添加剪辑",
- "pressAnnotation": "按 A 添加标注",
- "pressAudio": "按 M 添加音频,按 V 录制配音",
- "pressSpeed": "按 S 添加速度",
- "pressCameraFullscreen": "按 C 添加一个全屏摄像头片段"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "此录制没有光标移动数据,或现有缩放已覆盖繁忙时刻。",
+ "smartCutsNoAudio": "此媒体没有音频",
+ "automaticZoomsHint": "基于录制的光标移动",
+ "dragToReorderHint": "拖动以重新排序 · 双击以编辑入点/出点",
+ "smartCutsNeedsTranscript": "需要转录文本",
+ "addedWord": "已添加的词:“{{word}}” — 背后没有声音",
+ "smartZoomsAndCuts": "智能剪切",
+ "autoZoomFailed": "自动缩放失败",
+ "smartCutsWaiting": "正在转录…稍后可用",
+ "automaticZooms": "自动缩放",
+ "arrangeClipsHint": "拖动下方片段以重新排序,或拖入新片段",
+ "comment": "评论",
+ "addAudioTooltip": "添加音频",
+ "timelineTools": "时间轴工具",
+ "deleteClip": "删除片段",
+ "arrangeClips": "排列片段",
+ "editInOutPoints": "编辑入点/出点",
+ "smartZoomsAndCutsHint": "使用 AI",
+ "addedAutoZoomPlural": "已添加 {{count}} 个自动缩放",
+ "noAutoZoomMoments": "未找到自动缩放时刻",
+ "smartCutsFailed": "转录失败 — 请在“媒体”中重试",
+ "smartCutsNoSpeech": "未检测到语音",
+ "newAnnotation": "标注",
+ "importRecordingFirst": "请先导入录制内容",
+ "addedAutoZoom": "已添加 {{count}} 个自动缩放",
+ "dropToAdd": "拖放以添加到时间轴",
+ "aiEnhanceRequested": "已请求 AI 代理剪除空白片段",
+ "autoEnhance": "自动增强"
},
"labels": {
- "pan": "平移",
"zoom": "缩放",
- "trim": "剪辑",
- "speed": "速度",
+ "cameraFullscreenItem": "全屏摄像头 {{index}}",
+ "imageItem": "图片",
+ "pan": "平移",
"zoomItem": "缩放 {{index}}",
+ "cameraFullscreen": "全屏摄像头",
+ "speed": "速度",
+ "emptyText": "空文本",
"trimItem": "剪辑 {{index}}",
- "speedItem": "速度 {{index}}",
"annotationItem": "标注",
- "imageItem": "图片",
- "emptyText": "空文本",
- "cameraFullscreen": "全屏摄像头",
- "cameraFullscreenItem": "全屏摄像头 {{index}}"
- },
- "emptyState": {
- "noVideo": "未加载视频",
- "dragAndDrop": "拖放视频以开始编辑"
+ "trim": "剪辑",
+ "speedItem": "速度 {{index}}"
},
"errors": {
- "cannotPlaceZoom": "无法在此处放置缩放",
- "zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
- "zoomSuggestionUnavailable": "缩放建议处理器不可用",
- "noCursorTelemetry": "无可用的光标遥测数据",
+ "noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
"noCursorTelemetryDescription": "请先录制一段屏幕录像以生成基于光标的建议。",
+ "cameraFullscreenExistsAtLocation": "此位置已存在全屏摄像头片段或没有足够的空间。",
"noUsableTelemetry": "无可用的光标遥测数据",
"noUsableTelemetryDescription": "录制内容没有包含足够的光标移动数据。",
+ "zoomSuggestionUnavailable": "缩放建议处理器不可用",
"noDwellMoments": "未找到明确的光标停留时刻",
- "noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
+ "speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。",
+ "noCursorTelemetry": "无可用的光标遥测数据",
"noAutoZoomSlots": "无可用的自动缩放位置",
- "noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
"cannotPlaceTrim": "无法在此处放置剪辑",
- "trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
+ "cannotPlaceZoom": "无法在此处放置缩放",
"cannotPlaceSpeed": "无法在此处放置速度",
- "speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。",
- "cannotPlaceCameraFullscreen": "无法在此处放置全屏摄像头",
- "cameraFullscreenExistsAtLocation": "此位置已存在全屏摄像头片段或没有足够的空间。"
- },
- "success": {
- "addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
- "addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
- },
- "toolbar": {
- "autoEnhance": "自动增强",
- "automaticZooms": "自动缩放",
- "automaticZoomsHint": "基于录制的光标移动",
- "smartZoomsAndCuts": "智能剪切",
- "smartZoomsAndCutsHint": "使用 AI",
- "comment": "评论",
- "timelineTools": "时间轴工具",
- "arrangeClips": "排列片段",
- "arrangeClipsHint": "拖动下方片段以重新排序,或拖入新片段",
- "newAnnotation": "标注",
- "dragToReorderHint": "拖动以重新排序 · 双击以编辑入点/出点",
- "editInOutPoints": "编辑入点/出点",
- "deleteClip": "删除片段",
- "dropToAdd": "拖放以添加到时间轴",
- "importRecordingFirst": "请先导入录制内容",
- "noAutoZoomMoments": "未找到自动缩放时刻",
- "noAutoZoomMomentsDescription": "此录制没有光标移动数据,或现有缩放已覆盖繁忙时刻。",
- "addedAutoZoom": "已添加 {{count}} 个自动缩放",
- "addedAutoZoomPlural": "已添加 {{count}} 个自动缩放",
- "autoZoomFailed": "自动缩放失败",
- "aiEnhanceRequested": "已请求 AI 代理剪除空白片段",
- "smartCutsWaiting": "正在转录…稍后可用",
- "smartCutsNeedsTranscript": "需要转录文本",
- "smartCutsNoAudio": "此媒体没有音频",
- "smartCutsNoSpeech": "未检测到语音",
- "smartCutsFailed": "转录失败 — 请在“媒体”中重试",
- "addAudioTooltip": "添加音频"
+ "zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
+ "noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
+ "trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
+ "cannotPlaceCameraFullscreen": "无法在此处放置全屏摄像头"
},
"audio": {
- "addVoiceover": "添加配音",
- "addVoiceoverHint": "为视频录制旁白",
+ "micDenied": "麦克风访问被拒绝",
"subtitle": "在时间轴上放置配音或背景音乐图层",
- "record": "录制配音",
- "importFile": "导入音频文件",
+ "importFailed": "无法导入音频文件",
"importFileHint": "导入音乐或音频文件",
+ "addVoiceoverHint": "为视频录制旁白",
+ "stop": "停止",
"recording": "正在录制",
+ "saveFailed": "无法保存录音",
"recordingHint": "跟着视频讲解 — 录制时视频会继续播放",
- "stop": "停止",
- "micDenied": "麦克风访问被拒绝",
+ "importFile": "导入音频文件",
+ "record": "录制配音",
"recordingUnavailable": "此处无法录音",
- "saveFailed": "无法保存录音",
- "importFailed": "无法导入音频文件"
+ "addVoiceover": "添加配音"
+ },
+ "success": {
+ "addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
+ "addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
+ },
+ "hints": {
+ "pressAnnotation": "按 A 添加标注",
+ "pressSpeed": "按 S 添加速度",
+ "pressTrim": "按 T 添加剪辑",
+ "pressCameraFullscreen": "按 C 添加一个全屏摄像头片段",
+ "pressZoom": "按 Z 添加缩放",
+ "pressAudio": "按 M 添加音频,按 V 录制配音"
+ },
+ "buttons": {
+ "autoFocusAllOff": "为所有缩放开启自动对焦(摄像头跟随光标)",
+ "autoZoomOff": "自动缩放建议已关闭 — 点击可根据光标建议缩放",
+ "addAnnotation": "添加标注 (A)",
+ "suggestZooms": "根据光标建议缩放",
+ "addSpeed": "添加速度 (S)",
+ "addCameraFullscreen": "添加全屏摄像头 (C)",
+ "autoFocusAllOn": "所有缩放的自动对焦已开启 — 点击可将全部切换为手动",
+ "autoZoomOn": "自动缩放建议已开启 — 点击可移除建议的缩放",
+ "addZoom": "添加缩放 (Z)",
+ "addTrim": "添加剪辑 (T)"
+ },
+ "emptyState": {
+ "noVideo": "未加载视频",
+ "dragAndDrop": "拖放视频以开始编辑"
}
}
diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json
index e5e5f8d25..01bbc2373 100644
--- a/src/i18n/locales/zh-TW/timeline.json
+++ b/src/i18n/locales/zh-TW/timeline.json
@@ -1,107 +1,108 @@
{
- "buttons": {
- "addZoom": "新增縮放 (Z)",
- "suggestZooms": "根據游標建議縮放",
- "autoZoomOn": "自動縮放建議已開啟 — 點擊可移除建議的縮放",
- "autoZoomOff": "自動縮放建議已關閉 — 點擊可根據游標建議縮放",
- "autoFocusAllOn": "所有縮放的自動對焦已開啟 — 點擊可將全部切換為手動",
- "autoFocusAllOff": "為所有縮放開啟自動對焦(攝影機跟隨游標)",
- "addTrim": "新增剪輯 (T)",
- "addAnnotation": "新增標註 (A)",
- "addSpeed": "新增速度 (S)",
- "addCameraFullscreen": "新增全螢幕攝影機 (C)"
- },
- "hints": {
- "pressZoom": "按 Z 新增縮放",
- "pressTrim": "按 T 新增剪輯",
- "pressAnnotation": "按 A 新增標註",
- "pressAudio": "按 M 新增音訊,按 V 錄製配音",
- "pressSpeed": "按 S 新增速度",
- "pressCameraFullscreen": "按 C 新增一個全螢幕攝影機片段"
+ "toolbar": {
+ "noAutoZoomMomentsDescription": "此錄製內容沒有游標移動資料,或現有縮放已涵蓋忙碌時刻。",
+ "smartCutsNoAudio": "此媒體沒有音訊",
+ "automaticZoomsHint": "根據錄製的游標移動",
+ "dragToReorderHint": "拖曳以重新排序 · 按兩下以編輯入點/出點",
+ "smartCutsNeedsTranscript": "需要轉錄文字",
+ "addedWord": "已加入的字詞:「{{word}}」— 背後沒有聲音",
+ "smartZoomsAndCuts": "智慧剪輯",
+ "autoZoomFailed": "自動縮放失敗",
+ "smartCutsWaiting": "正在轉錄…稍後可用",
+ "automaticZooms": "自動縮放",
+ "arrangeClipsHint": "拖曳下方片段以重新排序,或拖曳新片段至此",
+ "comment": "留言",
+ "addAudioTooltip": "新增音訊",
+ "timelineTools": "時間軸工具",
+ "deleteClip": "刪除片段",
+ "arrangeClips": "排列片段",
+ "editInOutPoints": "編輯入點/出點",
+ "smartZoomsAndCutsHint": "使用 AI",
+ "addedAutoZoomPlural": "已新增 {{count}} 個自動縮放",
+ "noAutoZoomMoments": "找不到自動縮放時刻",
+ "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試",
+ "smartCutsNoSpeech": "未偵測到語音",
+ "newAnnotation": "註解",
+ "importRecordingFirst": "請先匯入錄製內容",
+ "addedAutoZoom": "已新增 {{count}} 個自動縮放",
+ "dropToAdd": "拖放以新增至時間軸",
+ "aiEnhanceRequested": "已請求 AI 代理剪除空白片段",
+ "autoEnhance": "自動加強"
},
"labels": {
- "pan": "平移",
"zoom": "縮放",
- "trim": "剪輯",
- "speed": "速度",
+ "cameraFullscreenItem": "全螢幕攝影機 {{index}}",
+ "imageItem": "圖片",
+ "pan": "平移",
"zoomItem": "縮放 {{index}}",
+ "cameraFullscreen": "全螢幕攝影機",
+ "speed": "速度",
+ "emptyText": "空文字",
"trimItem": "剪輯 {{index}}",
- "speedItem": "速度 {{index}}",
"annotationItem": "標註",
- "imageItem": "圖片",
- "emptyText": "空文字",
- "cameraFullscreen": "全螢幕攝影機",
- "cameraFullscreenItem": "全螢幕攝影機 {{index}}"
- },
- "emptyState": {
- "noVideo": "未載入影片",
- "dragAndDrop": "拖放影片以開始編輯"
+ "trim": "剪輯",
+ "speedItem": "速度 {{index}}"
},
"errors": {
- "cannotPlaceZoom": "無法在此處放置縮放",
- "zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
- "zoomSuggestionUnavailable": "縮放建議處理器不可用",
- "noCursorTelemetry": "無可用的游標遙測資料",
+ "noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
"noCursorTelemetryDescription": "請先錄製一段螢幕錄影以產生基於游標的建議。",
+ "cameraFullscreenExistsAtLocation": "此位置已存在全螢幕攝影機片段或沒有足夠的空間。",
"noUsableTelemetry": "無可用的游標遙測資料",
"noUsableTelemetryDescription": "錄製內容沒有包含足夠的游標移動資料。",
+ "zoomSuggestionUnavailable": "縮放建議處理器不可用",
"noDwellMoments": "未找到明確的游標停留時刻",
- "noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
+ "speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。",
+ "noCursorTelemetry": "無可用的游標遙測資料",
"noAutoZoomSlots": "無可用的自動縮放位置",
- "noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
"cannotPlaceTrim": "無法在此處放置剪輯",
- "trimExistsAtLocation": "此位置已存在剪輯或沒有足夠的空間。",
+ "cannotPlaceZoom": "無法在此處放置縮放",
"cannotPlaceSpeed": "無法在此處放置速度",
- "speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。",
- "cannotPlaceCameraFullscreen": "無法在此處放置全螢幕攝影機",
- "cameraFullscreenExistsAtLocation": "此位置已存在全螢幕攝影機片段或沒有足夠的空間。"
- },
- "success": {
- "addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
- "addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
- },
- "toolbar": {
- "autoEnhance": "自動加強",
- "automaticZooms": "自動縮放",
- "automaticZoomsHint": "根據錄製的游標移動",
- "smartZoomsAndCuts": "智慧剪輯",
- "smartZoomsAndCutsHint": "使用 AI",
- "comment": "留言",
- "timelineTools": "時間軸工具",
- "arrangeClips": "排列片段",
- "arrangeClipsHint": "拖曳下方片段以重新排序,或拖曳新片段至此",
- "newAnnotation": "註解",
- "dragToReorderHint": "拖曳以重新排序 · 按兩下以編輯入點/出點",
- "editInOutPoints": "編輯入點/出點",
- "deleteClip": "刪除片段",
- "dropToAdd": "拖放以新增至時間軸",
- "importRecordingFirst": "請先匯入錄製內容",
- "noAutoZoomMoments": "找不到自動縮放時刻",
- "noAutoZoomMomentsDescription": "此錄製內容沒有游標移動資料,或現有縮放已涵蓋忙碌時刻。",
- "addedAutoZoom": "已新增 {{count}} 個自動縮放",
- "addedAutoZoomPlural": "已新增 {{count}} 個自動縮放",
- "autoZoomFailed": "自動縮放失敗",
- "aiEnhanceRequested": "已請求 AI 代理剪除空白片段",
- "smartCutsWaiting": "正在轉錄…稍後可用",
- "smartCutsNeedsTranscript": "需要轉錄文字",
- "smartCutsNoAudio": "此媒體沒有音訊",
- "smartCutsNoSpeech": "未偵測到語音",
- "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試",
- "addAudioTooltip": "新增音訊"
+ "zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
+ "noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
+ "trimExistsAtLocation": "此位置已存在剪輯或沒有足夠的空間。",
+ "cannotPlaceCameraFullscreen": "無法在此處放置全螢幕攝影機"
},
"audio": {
- "addVoiceover": "新增旁白",
- "addVoiceoverHint": "為影片錄製旁白",
+ "micDenied": "麥克風存取遭拒絕",
"subtitle": "在時間軸上放置旁白或背景音樂圖層",
- "record": "錄製旁白",
- "importFile": "匯入音訊檔案",
+ "importFailed": "無法匯入音訊檔案",
"importFileHint": "匯入音樂或音訊檔案",
+ "addVoiceoverHint": "為影片錄製旁白",
+ "stop": "停止",
"recording": "錄製中",
+ "saveFailed": "無法儲存錄音",
"recordingHint": "跟著影片講解 — 錄製時影片會繼續播放",
- "stop": "停止",
- "micDenied": "麥克風存取遭拒絕",
+ "importFile": "匯入音訊檔案",
+ "record": "錄製旁白",
"recordingUnavailable": "此處無法錄音",
- "saveFailed": "無法儲存錄音",
- "importFailed": "無法匯入音訊檔案"
+ "addVoiceover": "新增旁白"
+ },
+ "success": {
+ "addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
+ "addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
+ },
+ "hints": {
+ "pressAnnotation": "按 A 新增標註",
+ "pressSpeed": "按 S 新增速度",
+ "pressTrim": "按 T 新增剪輯",
+ "pressCameraFullscreen": "按 C 新增一個全螢幕攝影機片段",
+ "pressZoom": "按 Z 新增縮放",
+ "pressAudio": "按 M 新增音訊,按 V 錄製配音"
+ },
+ "buttons": {
+ "autoFocusAllOff": "為所有縮放開啟自動對焦(攝影機跟隨游標)",
+ "autoZoomOff": "自動縮放建議已關閉 — 點擊可根據游標建議縮放",
+ "addAnnotation": "新增標註 (A)",
+ "suggestZooms": "根據游標建議縮放",
+ "addSpeed": "新增速度 (S)",
+ "addCameraFullscreen": "新增全螢幕攝影機 (C)",
+ "autoFocusAllOn": "所有縮放的自動對焦已開啟 — 點擊可將全部切換為手動",
+ "autoZoomOn": "自動縮放建議已開啟 — 點擊可移除建議的縮放",
+ "addZoom": "新增縮放 (Z)",
+ "addTrim": "新增剪輯 (T)"
+ },
+ "emptyState": {
+ "noVideo": "未載入影片",
+ "dragAndDrop": "拖放影片以開始編輯"
}
}
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 3119869d6..d00d8e59f 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -1448,6 +1448,10 @@ export function useTimeline() {
cameraFullscreenRegions,
clips: document?.timeline.clips ?? [],
assets: document?.assets ?? [],
+ // The timeline marks where the user has ADDED words — text with no audio behind it.
+ // Read straight off the transcript: the word is the only record of an insert, and a
+ // mark derived from it can never disagree with the pane that shows the same word.
+ transcripts: document?.transcripts ?? [],
hasDoc,
selection,
multiSelection,
From b2fce8cd44de9685a481b733a4b94c19613c48e2 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Mon, 31 Aug 2026 23:00:12 +0200
Subject: [PATCH 060/113] feat(ai): let the chat correct a word it heard wrong
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The agent could read the transcript and cut it, and that was all. Asked to fix a
misheard name — the request the whole transcript-editing feature exists for — it
had exactly one tool that touched a word, `addTrim`, which removes the audio
along with it. It would either do the destructive thing or say it had no tool,
while the app had had the operation for three commits.
`setWordText` writes `transcript.words[].text` through the same document
function the pane uses, so the captions follow and the timeline does not move.
Empty text blanks the word, which is how a junk token leaves the captions
without cutting the speech around it. It is registered as a mutating tool: it
writes the document, so it passes the consent gate like every other edit.
It needed a read to address anything. `getTranscript` answers in SEGMENTS, whose
ids live in a different namespace than the words and are refused by name — the
trap a model would fall into first, so the refusal says which read hands out the
right ones. `getTranscriptWords` is that read: id, text, span, and — only when
the word is not plain transcription — where it came from and what the
transcriber had originally heard. It takes a span, because a half-hour
transcript is ~70k tokens and fixing one name should cost one phrase.
Inserting a word is deliberately NOT exposed. The gesture is dev-gated in the UI
until a voice can be synthesized for it, and handing the model a tool for
something a release build refuses to do would be the same dead affordance the
pane was careful not to advertise.
---
electron/ai-edition/agent-tools.test.ts | 157 ++++++++++++++++++
electron/ai-edition/agent-tools.ts | 108 ++++++++++++
.../ai-edition/deep-agent/service.test.ts | 16 +-
electron/ai-edition/deep-agent/service.ts | 8 +
4 files changed, 287 insertions(+), 2 deletions(-)
diff --git a/electron/ai-edition/agent-tools.test.ts b/electron/ai-edition/agent-tools.test.ts
index 57c65eed0..f39510487 100644
--- a/electron/ai-edition/agent-tools.test.ts
+++ b/electron/ai-edition/agent-tools.test.ts
@@ -190,6 +190,7 @@ describe("the mutating-tool table", () => {
"setClipRange",
"setSpeed",
"setTrim",
+ "setWordText",
"setZoom",
].sort(),
);
@@ -2186,3 +2187,159 @@ describe("addAudio / setAudio", () => {
expect((result.document as AxcutDocument).audioTracks).toEqual([]);
});
});
+
+// ─── Correcting a word from the chat ─────────────────────────────
+// The model could READ the transcript and CUT it, and that was all. Asked to fix a
+// misheard name it had exactly one tool that touched a word — addTrim — which removes the
+// audio with it. These two close that: one read that hands out word ids, one write that
+// changes text and nothing else.
+
+/** A transcript with real words, one of them already corrected by the user. */
+function documentWithWords(): AxcutDocument {
+ const base = fixtureDocument();
+ return {
+ ...base,
+ transcripts: [
+ {
+ assetId: "asset_1",
+ language: "en",
+ segments: [
+ {
+ id: "seg_1",
+ kind: "speech",
+ startSec: 0,
+ endSec: 3,
+ text: "I use Cuber Nettes",
+ wordIds: ["word_1", "word_2", "word_3"],
+ },
+ ],
+ words: [
+ { id: "word_1", segmentId: "seg_1", startSec: 0, endSec: 1, text: "I" },
+ { id: "word_2", segmentId: "seg_1", startSec: 1, endSec: 2, text: "use" },
+ {
+ id: "word_3",
+ segmentId: "seg_1",
+ startSec: 2,
+ endSec: 3,
+ text: "Cuber Nettes",
+ },
+ ],
+ },
+ ],
+ };
+}
+
+function run(document: AxcutDocument, name: string, args: unknown) {
+ return executeAgentTool(document, name, JSON.stringify(args), { editsAllowed: true });
+}
+
+describe("getTranscriptWords", () => {
+ it("hands out the ids setWordText takes", () => {
+ const result = run(documentWithWords(), "getTranscriptWords", {});
+ const payload = JSON.parse(result.resultJson) as {
+ words: Array<{ id: string; text: string }>;
+ total: number;
+ };
+ expect(result.ok).toBe(true);
+ expect(payload.total).toBe(3);
+ expect(payload.words.map((w) => w.id)).toEqual(["word_1", "word_2", "word_3"]);
+ });
+
+ // A half-hour transcript is ~70k tokens. Fixing one name should cost one phrase.
+ it("returns only the words touching the span it is given", () => {
+ const result = run(documentWithWords(), "getTranscriptWords", { startSec: 2, endSec: 3 });
+ const payload = JSON.parse(result.resultJson) as {
+ words: Array<{ id: string }>;
+ total: number;
+ };
+ // Touching counts: `word_2` ends exactly where the span begins. Inclusive on
+ // purpose — a word with no duration at all (one the user typed in) sits on a
+ // single point, and a strict overlap would drop it from every span it meets.
+ expect(payload.words.map((w) => w.id)).toEqual(["word_2", "word_3"]);
+ // `total` still reports the whole transcript, so a filtered read never reads as
+ // the entire thing.
+ expect(payload.total).toBe(3);
+ });
+
+ it("says nothing about provenance for a plainly transcribed word", () => {
+ const result = run(documentWithWords(), "getTranscriptWords", {});
+ const payload = JSON.parse(result.resultJson) as { words: Array> };
+ expect(payload.words[0]).not.toHaveProperty("source");
+ expect(payload.words[0]).not.toHaveProperty("originalText");
+ });
+
+ it("names what the transcriber had heard, once a word is corrected", () => {
+ const corrected = run(documentWithWords(), "setWordText", {
+ wordId: "word_3",
+ text: "Kubernetes",
+ });
+ const result = run(corrected.document as AxcutDocument, "getTranscriptWords", {});
+ const payload = JSON.parse(result.resultJson) as {
+ words: Array<{ id: string; source?: string; originalText?: string }>;
+ };
+ expect(payload.words.find((w) => w.id === "word_3")).toMatchObject({
+ source: "user",
+ originalText: "Cuber Nettes",
+ });
+ });
+
+ it("refuses an asset with no transcript instead of answering with nothing", () => {
+ const result = run({ ...fixtureDocument(), transcripts: [] }, "getTranscriptWords", {});
+ expect(result.ok).toBe(false);
+ expect(result.resultJson).toContain("No transcript");
+ });
+});
+
+describe("setWordText", () => {
+ it("changes the text and leaves the timeline alone", () => {
+ const before = documentWithWords();
+ const result = run(before, "setWordText", { wordId: "word_3", text: "Kubernetes" });
+ expect(result.ok).toBe(true);
+ const next = result.document as AxcutDocument;
+ expect(next.transcripts[0].words.find((w) => w.id === "word_3")?.text).toBe("Kubernetes");
+ expect(next.timeline).toEqual(before.timeline);
+ expect(next.transcripts[0].segments[0].text).toBe("I use Kubernetes");
+ });
+
+ // The document carries the transcript twice; a write that reaches only one leaves the
+ // legacy mirror serving the old text forever.
+ it("writes the legacy mirror too", () => {
+ const result = run(documentWithWords(), "setWordText", {
+ wordId: "word_3",
+ text: "Kubernetes",
+ });
+ const next = result.document as AxcutDocument;
+ expect(next.transcript).toBe(next.transcripts.find((t) => t.assetId === "asset_1"));
+ });
+
+ it("empties a word without cutting the speech around it", () => {
+ const result = run(documentWithWords(), "setWordText", { wordId: "word_2", text: "" });
+ const next = result.document as AxcutDocument;
+ expect(next.transcripts[0].words.find((w) => w.id === "word_2")?.text).toBe("");
+ expect(next.transcripts[0].segments[0].text).toBe("I Cuber Nettes");
+ expect(JSON.parse(result.resultJson)).toMatchObject({ blanked: true });
+ });
+
+ it("points an unknown id at the read that hands them out", () => {
+ const result = run(documentWithWords(), "setWordText", { wordId: "seg_1", text: "x" });
+ expect(result.ok).toBe(false);
+ // `seg_1` is a real id — of a SEGMENT. The two namespaces are the trap.
+ expect(result.resultJson).toContain("getTranscriptWords");
+ });
+
+ it("refuses a write that would change nothing", () => {
+ const result = run(documentWithWords(), "setWordText", { wordId: "word_2", text: "use" });
+ expect(result.ok).toBe(false);
+ expect(result.document).toBeUndefined();
+ });
+
+ it("is a consented edit, not a read", () => {
+ const result = executeAgentTool(
+ documentWithWords(),
+ "setWordText",
+ JSON.stringify({ wordId: "word_3", text: "Kubernetes" }),
+ { editsAllowed: false },
+ );
+ expect(result.document).toBeUndefined();
+ });
+});
diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts
index 329346003..18ed32437 100644
--- a/electron/ai-edition/agent-tools.ts
+++ b/electron/ai-edition/agent-tools.ts
@@ -32,6 +32,7 @@ import {
replaceTimeline,
setClipSourceRange,
} from "../../src/lib/ai-edition/document/timeline";
+import { setDocumentWordText } from "../../src/lib/ai-edition/document/transcript";
import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
import { hasAnyClipWithCamera } from "../../src/lib/ai-edition/timeline/camera";
import {
@@ -521,6 +522,18 @@ export const setCameraFullscreenArgs = z.object({
endSec: secondsSchema.optional(),
});
+export const getTranscriptWordsArgs = z.object({
+ assetId: z.string().min(1).optional(),
+ startSec: secondsSchema.optional(),
+ endSec: secondsSchema.optional(),
+});
+
+export const setWordTextArgs = z.object({
+ wordId: z.string().min(1),
+ text: z.string(),
+ assetId: z.string().min(1).optional(),
+});
+
export const removeTrimArgs = z.object({
trimRangeId: z.string().min(1),
});
@@ -556,7 +569,9 @@ export const removeClipArgs = z.object({
export const OPENSCREEN_TOOL_NAMES = [
"getCurrentDocument",
"getTranscript",
+ "getTranscriptWords",
"getCursorTrack",
+ "setWordText",
"addTrim",
"addTrims",
"setTrim",
@@ -625,6 +640,9 @@ export const PHANTOM_TOOL_NAMES = [
* remaining surfaces (descriptions, built tools, executor cases) to each other.
*/
export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([
+ // Writes the transcript, not the timeline — but it writes the document, so it is a
+ // consented edit like any other.
+ "setWordText",
"addTrim",
"addTrims",
"addZooms",
@@ -1260,6 +1278,96 @@ export function executeAgentTool(
};
}
+ // The word-level read. `getTranscript` answers in SEGMENTS, whose ids belong to a
+ // different namespace than the words — so on its own it cannot address anything
+ // `setWordText` takes. This is the one that can. It is separate rather than folded
+ // in because a whole transcript is already ~70k tokens and most turns never touch a
+ // word; the span filter is there so fixing one name costs one phrase, not the film.
+ case "getTranscriptWords": {
+ const parsed = getTranscriptWordsArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const assetId =
+ parsed.data.assetId ?? document.project.primaryAssetId ?? document.assets[0]?.id;
+ const transcript =
+ document.transcripts.find((t) => t.assetId === assetId) ??
+ (document.transcript?.assetId === assetId ? document.transcript : null);
+ if (!transcript) {
+ return failure(`No transcript for asset ${assetId ?? "(none)"}.`);
+ }
+ const from = parsed.data.startSec ?? Number.NEGATIVE_INFINITY;
+ const to = parsed.data.endSec ?? Number.POSITIVE_INFINITY;
+ const words = transcript.words
+ .filter((word) => word.endSec >= from && word.startSec <= to)
+ .map((word) => ({
+ id: word.id,
+ text: word.text,
+ startSec: word.startSec,
+ endSec: word.endSec,
+ // Only the words that are NOT plain transcription say so, so the common
+ // case costs nothing to read.
+ ...(word.source ? { source: word.source } : {}),
+ ...(word.originalText !== undefined ? { originalText: word.originalText } : {}),
+ }));
+ return {
+ ok: true,
+ resultJson: JSON.stringify({
+ assetId,
+ language: transcript.language,
+ total: transcript.words.length,
+ returned: words.length,
+ words,
+ }),
+ };
+ }
+
+ // Correcting what the transcriber HEARD. This writes text and nothing else: the
+ // captions follow it, the film does not move. The tool for making a spoken word go
+ // away is addTrim, which removes its audio with it.
+ case "setWordText": {
+ const parsed = setWordTextArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const assetId =
+ parsed.data.assetId ?? document.project.primaryAssetId ?? document.assets[0]?.id;
+ if (!assetId) return failure("Project has no assets — nothing to correct.");
+ const { wordId, text } = parsed.data;
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ const before = transcript?.words.find((word) => word.id === wordId);
+ if (!before) {
+ return failure(
+ `No word ${wordId} in the transcript for asset ${assetId}. ` +
+ `Call getTranscriptWords to read the ids.`,
+ );
+ }
+ if (before.text === text) {
+ return failure(`Word ${wordId} already reads "${text}" — nothing to change.`);
+ }
+ let next: AxcutDocument;
+ try {
+ next = setDocumentWordText(document, assetId, wordId, text);
+ } catch (error) {
+ return failure(error instanceof Error ? error.message : String(error));
+ }
+ const after = next.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find((word) => word.id === wordId);
+ return {
+ ok: true,
+ document: next,
+ resultJson: JSON.stringify({
+ wordId,
+ assetId,
+ text: after?.text ?? text,
+ was: before.text,
+ // Absent once the word is back to what the transcriber said — the pair is
+ // cleared on that round trip, and the model should be able to see it.
+ originalText: after?.originalText,
+ blanked: text.trim().length === 0,
+ }),
+ summary:
+ text.trim().length === 0 ? `blanked "${before.text}"` : `"${before.text}" → "${text}"`,
+ };
+ }
+
case "addTrim": {
const parsed = addTrimArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
diff --git a/electron/ai-edition/deep-agent/service.test.ts b/electron/ai-edition/deep-agent/service.test.ts
index f23970bc0..5aebb9a33 100644
--- a/electron/ai-edition/deep-agent/service.test.ts
+++ b/electron/ai-edition/deep-agent/service.test.ts
@@ -57,7 +57,9 @@ const PHANTOM_TOOLS: readonly string[] = PHANTOM_TOOL_NAMES;
const ARGS: Record = {
getCurrentDocument: {},
getTranscript: {},
+ getTranscriptWords: {},
getCursorTrack: {},
+ setWordText: { wordId: "word_1", text: "Hullo" },
addTrim: { startSec: 1, endSec: 2 },
addTrims: { ranges: [{ startSec: 1, endSec: 2 }] },
setTrim: { trimRangeId: "trim_1", startSec: 1, endSec: 2 },
@@ -113,9 +115,18 @@ function fixtureDocument(): AxcutDocument {
assetId: "asset_1",
language: "en",
segments: [
- { id: "seg_1", kind: "speech", startSec: 0, endSec: 5, text: "Hello", wordIds: [] },
+ {
+ id: "seg_1",
+ kind: "speech",
+ startSec: 0,
+ endSec: 5,
+ text: "Hello",
+ // A real word, so `setWordText` lands on its WRITE branch in the table
+ // below — a tool refused for an unknown id would look non-mutating.
+ wordIds: ["word_1"],
+ },
],
- words: [],
+ words: [{ id: "word_1", segmentId: "seg_1", startSec: 0, endSec: 5, text: "Hello" }],
},
],
timeline: {
@@ -355,6 +366,7 @@ describe("one description of the tools, not two", () => {
expect(OPENSCREEN_TOOLS.filter((n) => !isMutatingTool(n))).toEqual([
"getCurrentDocument",
"getTranscript",
+ "getTranscriptWords",
"getCursorTrack",
]);
});
diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts
index a67a1da2a..7ddc55bfc 100644
--- a/electron/ai-edition/deep-agent/service.ts
+++ b/electron/ai-edition/deep-agent/service.ts
@@ -38,6 +38,7 @@ import {
executeAgentTool,
getCursorTrackArgs,
getTranscriptArgs,
+ getTranscriptWordsArgs,
isMutatingTool,
moveClipArgs,
removeClipArgs,
@@ -51,6 +52,7 @@ import {
setClipRangeArgs,
setSpeedArgs,
setTrimArgs,
+ setWordTextArgs,
setZoomArgs,
} from "../agent-tools";
import {
@@ -146,6 +148,10 @@ export const TOOL_DESCRIPTIONS: Record = {
"Read the transcript segments (speech and silence, with start/end seconds and text) for an asset. Omit assetId to read the primary asset's transcript.",
getCursorTrack:
"Read the recorded pointer track for an asset: where the cursor was over time, downsampled to a readable rate. Each point carries atSec (the asset's own source clock), virtualSec (the same instant on the edited timeline — the coordinate addZoom takes, null when no clip carries it), cx/cy as 0–1 fractions of the frame, and `shape`, an index into the pointer bitmaps the recording used (equal values are the same pointer; a change means the pointer changed, e.g. arrow to text caret). Points that are not plain moves carry `kind`; points a trim cuts out of playback carry `trimmed`. These are real samples, not a summary — reading what the pointer was doing is yours. Omit assetId for the primary asset. It answers `available:false` in two DIFFERENT ways you must not confuse: reason 'no-sidecar' means this asset was checked and genuinely has no telemetry, while reason 'unavailable' means it could not be read from here.",
+ getTranscriptWords:
+ 'Read the transcript one WORD at a time for an asset: each word\'s id, text, start/end seconds, and — only when it is not plain transcription — `source` ("user" for a word the user corrected, "synth" for one they typed in) and `originalText` (what the transcriber had heard before the correction). This is the ONLY read that gives you the ids setWordText takes; getTranscript answers in segments, whose ids belong to a different namespace and are not accepted there. A whole transcript is large, so pass startSec/endSec to read just the passage you mean to fix. Omit assetId for the primary asset.',
+ setWordText:
+ "Correct ONE word's text, by the id getTranscriptWords returns. This changes the TRANSCRIPT and nothing else: the captions follow it, the film is untouched and no audio is cut. Use it when the transcriber misheard something — a name, a technical term — and the user asks for it to read correctly. Passing an empty string BLANKS the word: it keeps its place in the media but leaves the captions, which is how a junk token like \"(inaudible)\" is removed without cutting the speech around it. Writing the transcriber's own text back clears the correction. This is NOT how you make a spoken word go away — that removes only the label and leaves the film saying it; use addTrim, which cuts the audio with it.",
addTrim:
"Add ONE trim range: a cut of a span inside a clip (this source-time span will not be played or exported) that does NOT split the clip. Times are in seconds of the asset's source time. This is the preferred (and for 'remove silences' requests, the only) way to handle silences; it preserves the user's placed clips and only adds a cut. When you have several cuts to make, use addTrims and send them together — this one is for a single cut or a later correction. A cut belongs to ONE clip: `clipId` is inferred when a single clip covers the range, but when several clips draw on the same asset over it the call FAILS and lists them — pass the `clipId` you mean (ids come from getCurrentDocument).",
addTrims:
@@ -330,7 +336,9 @@ export function buildTools(
return [
build("getCurrentDocument", z.object({})),
build("getTranscript", getTranscriptArgs),
+ build("getTranscriptWords", getTranscriptWordsArgs),
build("getCursorTrack", getCursorTrackArgs),
+ build("setWordText", setWordTextArgs),
build("addTrim", addTrimArgs),
build("addTrims", addTrimsArgs),
build("setTrim", setTrimArgs),
From 90280dae9aa8f38173a3e89e0673357efa1eb99e Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Tue, 1 Sep 2026 09:05:36 +0200
Subject: [PATCH 061/113] feat(document): store the pause an added word needs,
as a region
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adding a word only ever borrowed the silence that happened to be free where it
landed, so a word dropped between two words that run into each other got no time
at all — nothing to see on the timeline, nothing for a voice to speak into.
The pause is now a record of its own: `timeline.insertRanges`, anchored in
source time, shaped like a trim and stored for the same reason. It is the
inverse operation, and a region is the one shape this timeline already carries
safely from end to end. The first attempt made CLIPS for it and lost both the
pauses and the words they belonged to, because every other writer of
`timeline.clips` — the duration probe, the recording import, resequencing — is
entitled to disagree with a clip it did not make.
Stored, but with one writer and one invariant. `withInsertRangesForWords` runs
after every word write and is the only thing that touches the array: it adds the
pause an added word needs, resizes one whose text changed length, and drops the
ones whose word is gone. `insertRangesMatchWords` is that same rule read back,
so a test holds the writer to it rather than trusting it. A pause under 50ms is
not stored at all — a few frames of held image is a stutter, not a slot.
`timeline/inserted-time.ts` is the arithmetic the readers will need, pure and on
its own: where a pause lands on the ruler once projected through the clip that
plays its moment, and the pair that converts between stored raw seconds and the
seconds the user actually scrubs. They are inverses everywhere except inside a
pause, where a stretch of ruler stands for one held source moment — so the
collapse answers with that moment AND says it is being held, which is what a
caller driving a decoder needs to park rather than seek.
Nothing reads it yet. The ruler, playback and the captions come next; this is
the record and the arithmetic they will share.
---
.../ai-edition/EditorEmptyState.test.tsx | 1 +
.../ExportDialog.showInFolder.test.tsx | 1 +
.../ai-edition/ExportDialog.test.ts | 1 +
.../ai-edition/WebcamOverlay.test.tsx | 1 +
.../ai-edition/document/outputFormat.test.ts | 1 +
src/lib/ai-edition/document/timeline.test.ts | 9 +
.../ai-edition/document/transcribe.test.ts | 1 +
.../ai-edition/document/transcript.test.ts | 100 ++++++++++-
src/lib/ai-edition/document/transcript.ts | 130 ++++++++++++++-
src/lib/ai-edition/schema/index.ts | 34 ++++
.../ai-edition/store/editorSettings.test.ts | 1 +
src/lib/ai-edition/store/projectStore.test.ts | 1 +
.../ai-edition/store/undo.modalGuard.test.tsx | 1 +
src/lib/ai-edition/store/useCaptions.test.ts | 1 +
.../store/useEditorSettings.test.ts | 1 +
src/lib/ai-edition/store/useTimeline.test.ts | 1 +
.../ai-edition/timeline/inserted-time.test.ts | 157 ++++++++++++++++++
src/lib/ai-edition/timeline/inserted-time.ts | 108 ++++++++++++
.../ai-edition/transcription/status.test.ts | 1 +
src/native/sceneDescription.test.ts | 1 +
20 files changed, 544 insertions(+), 8 deletions(-)
create mode 100644 src/lib/ai-edition/timeline/inserted-time.test.ts
create mode 100644 src/lib/ai-edition/timeline/inserted-time.ts
diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx
index be7f130b7..0756bc0dd 100644
--- a/src/components/ai-edition/EditorEmptyState.test.tsx
+++ b/src/components/ai-edition/EditorEmptyState.test.tsx
@@ -47,6 +47,7 @@ const sampleDoc = vi.hoisted(
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
index 52eb7b2ad..d2ec1adbd 100644
--- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
+++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
@@ -71,6 +71,7 @@ const DOC: AxcutDocument = {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts
index ed2f1ab1a..1fb005750 100644
--- a/src/components/ai-edition/ExportDialog.test.ts
+++ b/src/components/ai-edition/ExportDialog.test.ts
@@ -51,6 +51,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx
index 0e5575b6a..ffd13f30a 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -68,6 +68,7 @@ function makeDocument(): AxcutDocument {
clips: [CLIP_WITH_CAMERA, CLIP_WITHOUT_CAMERA],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts
index c7e75ab40..2a614edd0 100644
--- a/src/lib/ai-edition/document/outputFormat.test.ts
+++ b/src/lib/ai-edition/document/outputFormat.test.ts
@@ -61,6 +61,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index 06f83943d..02b3d5244 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -52,6 +52,7 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -212,6 +213,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -309,6 +311,7 @@ describe("timeline pure functions", () => {
clips: [],
gaps: [],
trimRanges: [makeTrim({ id: "trim_other", assetId: "asset_2", startSec: 1, endSec: 2 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -410,6 +413,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -535,6 +539,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -611,6 +616,7 @@ describe("timeline pure functions", () => {
trimRanges: [
{ id: "s1", assetId: "asset_1", startSec: 10, endSec: 20, origin: "user", reason: "" },
],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -648,6 +654,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -963,6 +970,7 @@ describe("duplicateClip / moveClip", () => {
...makeDoc().timeline,
clips: [makeClip({ id: "clip_a", sourceStartSec: 0, sourceEndSec: 10 })],
trimRanges: [makeTrim({ id: "t1", clipId: "clip_a", startSec: 2, endSec: 4 })],
+ insertRanges: [],
},
});
const next = duplicateClip(doc, "clip_a");
@@ -1316,6 +1324,7 @@ describe("removeRegion — the one shared region-delete mutator", () => {
timeline: {
...makeDoc().timeline,
trimRanges: [makeTrim({ id: "trim_1" }), makeTrim({ id: "trim_2" })],
+ insertRanges: [],
},
});
const next = removeRegion(doc, "trim", "trim_1");
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 0ca221a8c..6a0656c23 100644
--- a/src/lib/ai-edition/document/transcribe.test.ts
+++ b/src/lib/ai-edition/document/transcribe.test.ts
@@ -51,6 +51,7 @@ function makeDoc(): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 9e8ace067..2f531542e 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
-import { type AxcutTranscript, createEmptyDocument } from "../schema";
+import { type AxcutTranscript, createEmptyDocument, documentSchema } from "../schema";
import {
carryOverWordEdits,
insertDocumentWord,
+ insertRangesMatchWords,
insertWord,
removeDocumentWords,
removeWord,
@@ -667,3 +668,100 @@ describe("carryOverWordEdits with inserted words", () => {
expect(result.transcript.words.some((w) => w.text === "really")).toBe(true);
});
});
+
+// ─── The pause an added word needs ───────────────────────────────
+// Created time is STORED, as a region beside the trims. Something has to keep those
+// records true against the words they belong to, and `withInsertRangesForWords` is the one
+// writer — these hold it to the invariant it maintains. The first attempt at this made
+// CLIPS instead, and every other writer of `timeline.clips` disagreed with them.
+
+describe("insert ranges", () => {
+ function docWithClip() {
+ const doc = makeDoc();
+ return {
+ ...doc,
+ timeline: {
+ ...doc.timeline,
+ clips: [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ],
+ },
+ };
+ }
+
+ it("stores a pause when the free silence does not cover the word", () => {
+ // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
+ // borrows nothing and needs its whole reading time — max(0.4, 6/15) = 0.4s.
+ const result = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ expect(result.timeline.insertRanges).toHaveLength(1);
+ expect(result.timeline.insertRanges[0]).toMatchObject({
+ assetId: "asset_1",
+ wordId: "synth_1",
+ atSec: 3,
+ durationSec: 0.4,
+ origin: "user",
+ });
+ expect(insertRangesMatchWords(result)).toBe(true);
+ });
+
+ // The clips are the thing the first attempt broke. Nothing here may touch them.
+ it("leaves the clips exactly as they were", () => {
+ const before = docWithClip();
+ const result = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
+ expect(result.timeline.clips).toEqual(before.timeline.clips);
+ });
+
+ it("stores nothing when the word fits in silence that is already there", () => {
+ // word_3 ends at 4 and word_4 starts at 5: a full second, more than "really" needs.
+ const result = insertDocumentWord(docWithClip(), "asset_1", "word_3", "after", "really");
+ expect(result.timeline.insertRanges).toEqual([]);
+ expect(insertRangesMatchWords(result)).toBe(true);
+ });
+
+ it("resizes the pause when the word is rewritten longer", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const longer = setDocumentWordText(added, "asset_1", "synth_1", "really quite genuinely so");
+ const [range] = longer.timeline.insertRanges;
+ expect(range.durationSec).toBeCloseTo(25 / 15, 5);
+ expect(range.id).toBe(added.timeline.insertRanges[0].id);
+ expect(insertRangesMatchWords(longer)).toBe(true);
+ });
+
+ it("drops the pause with the word", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const gone = removeDocumentWords(added, "asset_1", ["synth_1"]);
+ expect(gone.timeline.insertRanges).toEqual([]);
+ expect(insertRangesMatchWords(gone)).toBe(true);
+ });
+
+ it("keeps one pause per added word, and no more", () => {
+ let doc = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ doc = insertDocumentWord(doc, "asset_1", "word_1", "after", "personally");
+ expect(doc.timeline.insertRanges).toHaveLength(2);
+ expect(new Set(doc.timeline.insertRanges.map((r) => r.wordId)).size).toBe(2);
+ expect(insertRangesMatchWords(doc)).toBe(true);
+ });
+
+ // Correcting a SPOKEN word must not invent a pause: it has audio behind it already.
+ it("stores nothing for an ordinary correction", () => {
+ const result = setDocumentWordText(docWithClip(), "asset_1", "word_3", "OpenScreenApp");
+ expect(result.timeline.insertRanges).toEqual([]);
+ });
+
+ it("survives the document schema", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const parsed = documentSchema.parse(JSON.parse(JSON.stringify(added)));
+ expect(parsed.timeline.insertRanges).toHaveLength(1);
+ expect(insertRangesMatchWords(parsed)).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index dec291474..331c2d7ec 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,4 +1,5 @@
-import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
+import type { AxcutDocument, AxcutInsertRange, AxcutTranscript, AxcutWord } from "../schema";
+import { createId } from "./ids";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -144,7 +145,12 @@ export function setDocumentWordText(
if (!transcript) {
throw new Error(`Cannot edit a word of asset "${assetId}": it has no transcript`);
}
- return withTranscript(document, setWordText(transcript, wordId, text));
+ // Rewriting an added word changes how long it takes to read, so its pause is resized
+ // here too — the one writer, whatever the edit was.
+ return withInsertRangesForWords(
+ withTranscript(document, setWordText(transcript, wordId, text)),
+ assetId,
+ );
}
/** Where a new word goes relative to the word the caret was resting on. */
@@ -301,8 +307,112 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
};
}
+/**
+ * How much created time an added word still needs, on top of the silence it borrowed.
+ *
+ * Zero when the pause it landed in was already long enough — an added word between two
+ * sentences costs the film nothing.
+ */
+function pauseDeficitSec(word: AxcutWord): number {
+ const borrowed = word.endSec - word.startSec;
+ return Math.max(0, readingSeconds(word.text) - borrowed);
+}
+
+/** Below this, a pause is not worth a record — a few milliseconds of held frame is a
+ * stutter, not a slot to speak in. */
+const MIN_PAUSE_SEC = 0.05;
+
+/**
+ * Bring the document's insert ranges back in line with its words.
+ *
+ * The ranges are STORED, so something has to keep them true; this is that something, and
+ * it is the only writer. Called after every word write, it adds the pause an added word
+ * needs, resizes one whose text changed length, and drops the ones whose word is gone —
+ * so no caller has to remember any of the three. `insertRangesMatchWords` is the same rule
+ * read back, for a test to hold this to.
+ */
+function withInsertRangesForWords(document: AxcutDocument, assetId: string): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ const words = transcript?.words ?? [];
+ const wanted = new Map();
+ for (const word of words) {
+ if (word.source !== "synth") continue;
+ const deficit = pauseDeficitSec(word);
+ if (deficit >= MIN_PAUSE_SEC) wanted.set(word.id, deficit);
+ }
+
+ const existing = document.timeline.insertRanges;
+ const kept: AxcutInsertRange[] = [];
+ const seen = new Set();
+ for (const range of existing) {
+ // Ranges for OTHER assets are none of this call's business.
+ if (range.assetId !== assetId) {
+ kept.push(range);
+ continue;
+ }
+ const durationSec = wanted.get(range.wordId);
+ if (durationSec === undefined) continue; // its word is gone, or needs no pause now
+ seen.add(range.wordId);
+ const word = words.find((w) => w.id === range.wordId);
+ const atSec = word?.endSec ?? range.atSec;
+ kept.push(
+ durationSec === range.durationSec && atSec === range.atSec
+ ? range
+ : { ...range, atSec, durationSec },
+ );
+ }
+ for (const [wordId, durationSec] of wanted) {
+ if (seen.has(wordId)) continue;
+ const word = words.find((w) => w.id === wordId);
+ if (!word) continue;
+ kept.push({
+ id: createId("insert"),
+ assetId,
+ atSec: word.endSec,
+ durationSec,
+ wordId,
+ reason: `Held frame for the added word "${word.text}".`,
+ origin: "user",
+ });
+ }
+
+ if (kept.length === existing.length && kept.every((range, i) => range === existing[i])) {
+ return document;
+ }
+ return { ...document, timeline: { ...document.timeline, insertRanges: kept } };
+}
+
+/**
+ * The invariant {@link withInsertRangesForWords} maintains, read back: every stored pause
+ * belongs to an added word that still needs one, sits where that word ends, and lasts what
+ * its text needs. Exported for the test that holds the writer to it.
+ */
+export function insertRangesMatchWords(document: AxcutDocument): boolean {
+ const byAsset = new Map(document.transcripts.map((t) => [t.assetId, t]));
+ const expected = new Set();
+ for (const transcript of document.transcripts) {
+ for (const word of transcript.words) {
+ if (word.source === "synth" && pauseDeficitSec(word) >= MIN_PAUSE_SEC) {
+ expected.add(`${transcript.assetId}::${word.id}`);
+ }
+ }
+ }
+ const seen = new Set();
+ for (const range of document.timeline.insertRanges) {
+ const key = `${range.assetId}::${range.wordId}`;
+ if (!expected.has(key) || seen.has(key)) return false;
+ seen.add(key);
+ const word = byAsset.get(range.assetId)?.words.find((w) => w.id === range.wordId);
+ if (!word) return false;
+ if (range.atSec !== word.endSec) return false;
+ if (Math.abs(range.durationSec - pauseDeficitSec(word)) > 1e-9) return false;
+ }
+ return seen.size === expected.size;
+}
+
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does. */
+ * reason {@link setDocumentWordText} does, and leaves behind the pause the new word
+ * needs — see {@link withInsertRangesForWords}. */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -314,7 +424,10 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+ return withInsertRangesForWords(
+ withTranscript(document, insertWord(transcript, anchorWordId, side, text)),
+ assetId,
+ );
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
@@ -329,9 +442,12 @@ export function removeDocumentWords(
if (!transcript) {
throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
}
- return withTranscript(
- document,
- wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ return withInsertRangesForWords(
+ withTranscript(
+ document,
+ wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ ),
+ assetId,
);
}
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index bbf1d6820..fcfecebd3 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -274,6 +274,34 @@ export const trimRangeSchema = endGteStart(
"startSec",
);
+/**
+ * Time the film does NOT have: the pause an added word needs so a synthesized voice will
+ * have somewhere to speak. The film holds the frame at `atSec` for `durationSec`, screen
+ * and webcam together, and everything after it shifts.
+ *
+ * The exact inverse of a trim, and stored the same way and for the same reason. The first
+ * attempt created CLIPS for this; every other writer of `timeline.clips` — the duration
+ * probe, the recording import, resequencing — is entitled to disagree with a clip it did
+ * not make, and they did: a project came back split twice, both pauses gone, and the words
+ * they belonged to with them. A region is the shape this timeline already carries safely.
+ *
+ * `wordId` is what makes it derived-in-spirit while stored in fact: `document/transcript.ts`
+ * is the only writer, it creates the range with the word and drops it with the word, and
+ * `insertRangesMatchWords` is the invariant a test holds it to. Nothing else may write one.
+ */
+export const insertRangeSchema = z.object({
+ id: z.string().min(1),
+ assetId: z.string().min(1),
+ /** Source moment the film holds on. */
+ atSec: z.number().nonnegative(),
+ /** Timeline time created. Always positive — a pause of zero is simply not stored. */
+ durationSec: z.number().positive(),
+ /** The transcript word this pause exists for. */
+ wordId: z.string().min(1),
+ reason: z.string().default(""),
+ origin: z.enum(["system", "agent", "user"]),
+});
+
export const timelineSchema = z.preprocess(
// Back-compat: the field was renamed skipRanges → trimRanges. Old persisted
// documents (disk + browser-shim localStorage) still carry `skipRanges`;
@@ -291,6 +319,10 @@ export const timelineSchema = z.preprocess(
clips: z.array(clipSchema).default([]),
gaps: z.array(gapSchema).default([]),
trimRanges: z.array(trimRangeSchema).default([]),
+ // Additive, like every optional field before it: absent on every document written
+ // before this, so no schema bump — an older build simply drops the key on save, and
+ // the words it belonged to keep their text and lose only their pause.
+ insertRanges: z.array(insertRangeSchema).default([]),
muteRanges: z.array(rangeSchema).default([]),
speedRanges: z.array(rangeSchema).default([]),
captionRanges: z.array(rangeSchema).default([]),
@@ -586,6 +618,7 @@ const documentSchemaShape = z.object({
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -1033,6 +1066,7 @@ export type AxcutClip = z.infer;
export type AxcutClipCropRegion = z.infer;
export type AxcutGap = z.infer;
export type AxcutTrimRange = z.infer;
+export type AxcutInsertRange = z.infer;
export type AxcutTimeline = z.infer;
export type AxcutTimelineOperation = z.infer;
export type AxcutAnnotationRegion = z.infer;
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts
index ec37d7176..1e8cc43de 100644
--- a/src/lib/ai-edition/store/editorSettings.test.ts
+++ b/src/lib/ai-edition/store/editorSettings.test.ts
@@ -23,6 +23,7 @@ const baseDoc: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts
index 2b75f0a04..18df17063 100644
--- a/src/lib/ai-edition/store/projectStore.test.ts
+++ b/src/lib/ai-edition/store/projectStore.test.ts
@@ -67,6 +67,7 @@ const sampleDoc = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/undo.modalGuard.test.tsx b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
index 46e083b0b..834103e60 100644
--- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx
+++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
@@ -29,6 +29,7 @@ function doc(title: string): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts
index 0cd641328..84694923c 100644
--- a/src/lib/ai-edition/store/useCaptions.test.ts
+++ b/src/lib/ai-edition/store/useCaptions.test.ts
@@ -65,6 +65,7 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useEditorSettings.test.ts b/src/lib/ai-edition/store/useEditorSettings.test.ts
index 4122c05bf..fe433f070 100644
--- a/src/lib/ai-edition/store/useEditorSettings.test.ts
+++ b/src/lib/ai-edition/store/useEditorSettings.test.ts
@@ -68,6 +68,7 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index 7b77e9c46..ecb5dced9 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -106,6 +106,7 @@ const sampleDoc: AxcutDocument = {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
new file mode 100644
index 000000000..4dd217209
--- /dev/null
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -0,0 +1,157 @@
+// The ruler arithmetic behind an added word's pause.
+//
+// The one thing these have to pin: stored raw seconds and the seconds the user scrubs stop
+// being the same number the moment a pause exists, and every reader that confuses the two
+// puts a region, a playhead or a caption in the wrong place. The pair is an inverse
+// everywhere except inside a pause — which is not a gap in the model, it is the pause.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+import {
+ collapseRawSec,
+ expandRawSec,
+ type RulerInsert,
+ rulerInserts,
+ totalInsertedSec,
+} from "./inserted-time";
+
+function clip(overrides: Partial & Pick): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...overrides,
+ };
+}
+
+function insert(overrides: Partial = {}): AxcutInsertRange {
+ return {
+ id: "ins_1",
+ assetId: "a1",
+ atSec: 4,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user",
+ ...overrides,
+ };
+}
+
+describe("rulerInserts", () => {
+ it("projects a pause through the clip that plays its moment", () => {
+ // The clip plays source 4–10 starting at ruler 20, so source 6 is ruler 22.
+ const clips = [clip({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 })];
+ expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([
+ { id: "ins_1", wordId: "synth_1", atRawSec: 22, durationSec: 0.5 },
+ ]);
+ });
+
+ // The word is not on the timeline, so its pause has no place on the ruler and adds
+ // nothing — the same rule a caption line follows when no clip covers it.
+ it("drops a pause no clip plays", () => {
+ const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 })];
+ expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([]);
+ });
+
+ it("counts a pause sitting exactly on a clip's edge", () => {
+ // A pause sits at the END of the word it follows, which is routinely the boundary.
+ const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 })];
+ expect(rulerInserts([insert({ atSec: 4 })], clips)).toHaveLength(1);
+ });
+
+ it("returns them in ruler order, whatever order they were stored in", () => {
+ const clips = [clip({ id: "c1" })];
+ const placed = rulerInserts(
+ [insert({ id: "b", atSec: 8 }), insert({ id: "a", atSec: 2 })],
+ clips,
+ );
+ expect(placed.map((p) => p.id)).toEqual(["a", "b"]);
+ });
+
+ it("places a pause only once when two clips could play its moment", () => {
+ const clips = [
+ clip({ id: "c1" }),
+ clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
+ ];
+ expect(rulerInserts([insert()], clips)).toHaveLength(1);
+ });
+});
+
+describe("the expanded ruler", () => {
+ const INSERTS: RulerInsert[] = [
+ { id: "a", wordId: "w_a", atRawSec: 2, durationSec: 0.5 },
+ { id: "b", wordId: "w_b", atRawSec: 6, durationSec: 1 },
+ ];
+
+ it("leaves everything before the first pause where it was", () => {
+ expect(expandRawSec(0, INSERTS)).toBe(0);
+ expect(expandRawSec(1.9, INSERTS)).toBe(1.9);
+ });
+
+ // The frame about to be held keeps its own instant; the pause opens after it.
+ it("keeps the held moment itself in place", () => {
+ expect(expandRawSec(2, INSERTS)).toBe(2);
+ });
+
+ it("shifts everything after a pause by what it added", () => {
+ expect(expandRawSec(3, INSERTS)).toBe(3.5);
+ expect(expandRawSec(6, INSERTS)).toBe(6.5);
+ expect(expandRawSec(7, INSERTS)).toBe(8.5);
+ });
+
+ it("grows the ruler by the pauses' total", () => {
+ expect(totalInsertedSec(INSERTS)).toBe(1.5);
+ expect(expandRawSec(10, INSERTS)).toBe(10 + totalInsertedSec(INSERTS));
+ });
+
+ it("round-trips every moment that is not inside a pause", () => {
+ for (const sec of [0, 1.9, 3, 5.99, 7, 10]) {
+ const back = collapseRawSec(expandRawSec(sec, INSERTS), INSERTS);
+ expect(back.sec).toBeCloseTo(sec, 9);
+ expect(back.heldBy).toBeNull();
+ }
+ });
+
+ // The held moment is the one place the pair is not a clean inverse, and it is not
+ // meant to be: source 2 occupies the WHOLE of ruler [2, 2.5) — it is what the pause
+ // shows. Expanding picks the start of that stretch; collapsing it back answers with
+ // the same source moment and says it is being held, which is the honest reading of a
+ // moment that is on screen for half a second.
+ it("says the held moment is held, and still names the right source moment", () => {
+ const back = collapseRawSec(expandRawSec(2, INSERTS), INSERTS);
+ expect(back.sec).toBe(2);
+ expect(back.heldBy?.id).toBe("a");
+ });
+
+ // Not a gap in the model — this IS the pause. A stretch of ruler stands for one held
+ // source moment, and the caller is told which pause is holding it so it parks the
+ // decoder instead of seeking through content that belongs after.
+ it("collapses a moment inside a pause onto the frame being held", () => {
+ for (const sec of [2.01, 2.25, 2.49]) {
+ const back = collapseRawSec(sec, INSERTS);
+ expect(back.sec).toBe(2);
+ expect(back.heldBy?.id).toBe("a");
+ }
+ });
+
+ it("resumes on the far side of a pause", () => {
+ const back = collapseRawSec(2.5, INSERTS);
+ expect(back.sec).toBe(2);
+ expect(back.heldBy).toBeNull();
+ });
+
+ it("counts every earlier pause when collapsing a later moment", () => {
+ // Ruler 8.5 is source 7: 0.5s from the first pause and 1s from the second.
+ expect(collapseRawSec(8.5, INSERTS)).toEqual({ sec: 7, heldBy: null });
+ });
+
+ it("is the identity when there are no pauses", () => {
+ expect(expandRawSec(4, [])).toBe(4);
+ expect(collapseRawSec(4, [])).toEqual({ sec: 4, heldBy: null });
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
new file mode 100644
index 000000000..a7afcf27b
--- /dev/null
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -0,0 +1,108 @@
+// Time the film does not have.
+//
+// An added word needs somewhere to be spoken. Where the transcript has free silence it
+// borrows it; where it does not, the film holds its frame and everything after it moves
+// along the ruler. That created time is stored as an `AxcutInsertRange` — the inverse of a
+// trim, and deliberately the same shape, because a region is what this timeline already
+// carries safely from end to end. (An earlier attempt made CLIPS for it; see the schema's
+// note on `insertRangeSchema` for how that ended.)
+//
+// This module is the arithmetic, and nothing else: pure, no document, no React. It answers
+// two questions.
+//
+// • Where does a pause land on the RULER? A range is anchored in SOURCE time, so it has
+// to be projected through whichever clip plays that moment — `rulerInserts`.
+// • What does the ruler look like once the pauses are counted? Stored raw seconds and
+// the seconds the user actually scrubs are no longer the same number, and
+// `expandRawSec` / `collapseRawSec` are the one place that difference is resolved.
+//
+// The two are inverses everywhere except INSIDE a pause, where they cannot be: a stretch
+// of ruler maps to the single source moment being held. `collapseRawSec` returns that
+// moment, which is exactly what a decoder parked on a held frame should be told.
+
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+
+/** A pause placed on the raw ruler, ready to be counted. */
+export interface RulerInsert {
+ id: string;
+ wordId: string;
+ /** Where the pause begins, in STORED raw seconds — before any pause is counted. */
+ atRawSec: number;
+ durationSec: number;
+}
+
+/**
+ * Project each insert onto the raw ruler through the clip that plays its source moment.
+ *
+ * A range whose moment no clip plays yields nothing: the pause exists for a word that is
+ * not on the timeline, so there is no ruler position for it and nothing to add. Same rule
+ * the captions follow for a line no clip covers.
+ *
+ * Ordered by ruler position, which is what lets the accumulation below be a single pass.
+ */
+export function rulerInserts(
+ inserts: readonly AxcutInsertRange[],
+ clips: readonly AxcutClip[],
+): RulerInsert[] {
+ const placed: RulerInsert[] = [];
+ for (const insert of inserts) {
+ for (const clip of clips) {
+ if (clip.assetId !== insert.assetId) continue;
+ const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
+ // Inclusive at both edges: a pause sits at the END of the word it follows, which
+ // is routinely a clip's own boundary.
+ if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
+ placed.push({
+ id: insert.id,
+ wordId: insert.wordId,
+ atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec),
+ durationSec: insert.durationSec,
+ });
+ break;
+ }
+ }
+ return placed.sort((a, b) => a.atRawSec - b.atRawSec);
+}
+
+/** How much time the pauses add in total — what the ruler grows by. */
+export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
+ return inserts.reduce((sum, insert) => sum + insert.durationSec, 0);
+}
+
+/**
+ * Stored raw seconds → the ruler the user sees.
+ *
+ * Monotone and total: every stored moment has exactly one place on the expanded ruler.
+ * A moment sitting exactly ON a pause maps to where the pause BEGINS, so the frame that
+ * is about to be held keeps its own instant and the pause opens after it.
+ */
+export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): number {
+ let out = sec;
+ for (const insert of inserts) {
+ if (insert.atRawSec < sec) out += insert.durationSec;
+ }
+ return out;
+}
+
+/**
+ * The ruler the user sees → stored raw seconds.
+ *
+ * The inverse of {@link expandRawSec} outside a pause. Inside one it cannot be an inverse
+ * — a whole stretch of ruler stands for a single held moment — and it returns that moment,
+ * flagged, so a caller driving a decoder knows to hold rather than to seek.
+ */
+export function collapseRawSec(
+ sec: number,
+ inserts: readonly RulerInsert[],
+): { sec: number; heldBy: RulerInsert | null } {
+ let offset = 0;
+ for (const insert of inserts) {
+ const startsAt = insert.atRawSec + offset;
+ if (sec < startsAt) break;
+ if (sec < startsAt + insert.durationSec) {
+ return { sec: insert.atRawSec, heldBy: insert };
+ }
+ offset += insert.durationSec;
+ }
+ return { sec: sec - offset, heldBy: null };
+}
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index d453936bf..b37b71ece 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -274,6 +274,7 @@ function doc(assetIds: string[], clipAssetIds: string[]): AxcutDocument {
})),
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index 01c656967..32afda410 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -84,6 +84,7 @@ function makeDoc(
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
From a8faaad78a9ae869ce073c3ce507dbe04c437fda Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Tue, 1 Sep 2026 10:17:26 +0200
Subject: [PATCH 062/113] feat(editor): the readers count the pause an added
word bought
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The record and the arithmetic existed; nothing consulted them, so the added time
was real in the document and invisible everywhere else. Now every reader that
measures the timeline measures the same expanded ruler.
Playback holds the frame. `resolvePlaybackSegments` cuts the kept span open at
each pause's moment and puts a held segment between the halves — the stream
plays up to that frame, stays on it, then carries on, which is what makes the
film longer. It carries `heldSec` on `PlaybackSegment`, a type that exists only
on the derived shape: nothing can write the field to a stored clip, which is the
whole structural difference from the attempt that made clips for this. A pause
whose moment a trim removed is never emitted — that moment is not in the film
any more, so neither is its pause.
The decoder holds with it. `resolveNativePosition` clamps the source clock
inside a held segment, and the transport pauses the native side for its
duration; free-running would play what comes after while the app clock, which
does traverse the pause, re-seeks on the drift and stutters. Screen and webcam
hold together because both derive from the one asset source clock the pause
stops advancing.
The ruler counts it. Total, ticks, clip boxes, lane pills and the playhead are
all placed through `expandRawSec`, so nothing drifts from anything else by the
added time. The amber mark on the clip becomes a BAND exactly as wide as the
time it bought — a word that fitted in silence already there adds nothing and
stays the hairline it was. Stored clip geometry is never rewritten for any of
this; only what is drawn moves.
The captions follow. Expanding both ends of a line's ruler span does the whole
job: a line after a pause slides along by it, and a line covering the held
moment has only its end pushed out, so it stays on screen through the pause
instead of going dark over the one moment an added word exists for.
---
src/components/ai-edition/NewEditorShell.tsx | 1 +
src/components/ai-edition/Preview.tsx | 4 +
src/components/ai-edition/PreviewCanvas.tsx | 2 +
src/components/ai-edition/VirtualPreview.tsx | 8 +-
src/components/ai-edition/v4/V4Timeline.tsx | 101 ++++++++++++++-----
src/lib/ai-edition/captions/captions.test.ts | 49 +++++++++
src/lib/ai-edition/captions/cues.ts | 23 ++++-
src/lib/ai-edition/document/timeline.test.ts | 87 ++++++++++++++++
src/lib/ai-edition/document/timeline.ts | 97 +++++++++++++++---
src/lib/ai-edition/store/useTimeline.ts | 3 +
src/lib/ai-edition/timeline/timelineMap.ts | 22 +++-
src/native/sceneDescription.ts | 6 +-
src/native/useNativePlaybackSync.ts | 22 +++-
13 files changed, 373 insertions(+), 52 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index dea5a52d1..84133cfa7 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -1474,6 +1474,7 @@ export function NewEditorShell() {
speedRegions={tl.speedRegions}
cameraFullscreenRegions={tl.cameraFullscreenRegions}
trimRanges={tl.trimRanges}
+ insertRanges={document?.timeline?.insertRanges ?? []}
selectedZoomRegionId={tl.selection?.kind === "zoom" ? tl.selection.id : null}
onZoomFocusChange={tl.updateZoomFocusLive}
onZoomFocusCommit={() => void tl.commitZoomFocus()}
diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx
index cd6868e75..2753f9635 100644
--- a/src/components/ai-edition/Preview.tsx
+++ b/src/components/ai-edition/Preview.tsx
@@ -5,6 +5,7 @@ import type {
AxcutAnnotationRegion,
AxcutAudioTrack,
AxcutClip,
+ AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -33,6 +34,7 @@ interface PreviewProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -66,6 +68,7 @@ export function Preview({
speedRegions,
cameraFullscreenRegions,
trimRanges,
+ insertRanges,
selectedZoomRegionId,
onZoomFocusChange,
onZoomFocusCommit,
@@ -194,6 +197,7 @@ export function Preview({
speedRegions={speedRegions}
cameraFullscreenRegions={cameraFullscreenRegions}
trimRanges={trimRanges}
+ insertRanges={insertRanges}
selectedZoomRegionId={selectedZoomRegionId}
onZoomFocusChange={onZoomFocusChange}
onZoomFocusCommit={onZoomFocusCommit}
diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 150012b95..2fcd15d86 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -36,6 +36,7 @@ import type {
AxcutAnnotationRegion,
AxcutAudioTrack,
AxcutClip,
+ AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -75,6 +76,7 @@ interface PreviewCanvasProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 326bbd642..aed97cc4c 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -12,6 +12,7 @@ import {
import type {
AxcutAudioTrack,
AxcutClip,
+ AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -201,6 +202,8 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
+ /** The pauses added words created — they lengthen playback, they do not cut it. */
+ insertRanges?: AxcutInsertRange[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -247,6 +250,7 @@ export function VirtualPreview({
zoomRegions = [],
speedRegions = [],
trimRanges = [],
+ insertRanges = [],
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -554,8 +558,8 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- () => resolvePlaybackSegments(clips, trimRanges),
- [clips, trimRanges],
+ () => resolvePlaybackSegments(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 6476d6406..4a8f642fd 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -50,6 +50,12 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
+import {
+ expandRawSec,
+ type RulerInsert,
+ rulerInserts,
+ totalInsertedSec,
+} from "@/lib/ai-edition/timeline/inserted-time";
import {
newRegionDurationSec,
setTimelineScale,
@@ -219,6 +225,10 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
+ /** The pauses added words created. `currentTimeSec` is a STORED second; the ruler it is
+ * drawn on counts the pauses, so it has to be placed through them or it drifts from
+ * the clips by the whole added time. */
+ inserts: readonly RulerInsert[];
/** Live scrub position, when a drag is in flight. Takes precedence over the store. */
overrideTimeSec: number | null;
canvasStyle: React.CSSProperties;
@@ -244,13 +254,14 @@ interface PlayheadOverlayProps {
*/
const PlayheadOverlay = memo(function PlayheadOverlay({
totalSec,
+ inserts,
overrideTimeSec,
canvasStyle,
onPointerDown,
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const pct = ((overrideTimeSec ?? storeTimeSec) / totalSec) * 100;
+ const pct = (expandRawSec(overrideTimeSec ?? storeTimeSec, inserts) / totalSec) * 100;
return (
@@ -631,15 +642,26 @@ export function V4Timeline({
// clicked instead of looking like it worked. Same question, same helper as the Layout
// pane: is a camera attached anywhere on this timeline?
const hasAnyCamera = useMemo(() => hasAnyClipWithCamera(tl.assets, clips), [tl.assets, clips]);
+ // The pauses added words created, placed on the ruler. Everything below measures the
+ // EXPANDED ruler — stored clip geometry plus the time those pauses add — because that
+ // is the film's real length and the one the playhead runs along. Stored geometry is
+ // never rewritten for this: only what is drawn moves.
+ // `?? []` because the key is additive: a document written before it has no pauses.
+ const inserts = useMemo(
+ () => rulerInserts(tl.insertRanges ?? [], clips),
+ [tl.insertRanges, clips],
+ );
const total = useMemo(
() =>
Math.max(
1,
- clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0),
+ clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0) + totalInsertedSec(inserts),
),
- [clips],
+ [clips, inserts],
);
const pctOf = useCallback((sec: number) => (sec / total) * 100, [total]);
+ /** Stored raw seconds → a percentage of the expanded ruler. */
+ const pctAt = useCallback((sec: number) => pctOf(expandRawSec(sec, inserts)), [pctOf, inserts]);
const showLanes = variant === "edit";
// The visible fraction of the timeline, and what one second is worth on screen
@@ -1594,8 +1616,10 @@ export function V4Timeline({
compact ? ` ${styles.lanePillCompact}` : ""
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
- left: `${pctOf(seg.segStart)}%`,
- width: `${pctOf(durSec)}%`,
+ left: `${pctAt(seg.segStart)}%`,
+ // Measured on the expanded ruler at BOTH ends: a region straddling a pause
+ // covers it, so its box has to grow by that pause and not merely slide.
+ width: `${pctOf(expandRawSec(seg.segEnd, inserts) - expandRawSec(seg.segStart, inserts))}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
? undefined
@@ -2006,7 +2030,7 @@ export function V4Timeline({
{tick.major ? (
{fmtTick(tick.sec, rulerTicks.step)}
@@ -2153,6 +2177,12 @@ export function V4Timeline({
>
{clips.map((c, i) => {
const dur = c.timelineEndSec - c.timelineStartSec;
+ // On the expanded ruler the box also carries whatever pauses fall
+ // inside it — the film really does stay on this clip's frame for
+ // them, so they belong to its box rather than between boxes.
+ const boxStart = expandRawSec(c.timelineStartSec, inserts);
+ const boxEnd = expandRawSec(c.timelineEndSec, inserts);
+ const boxLen = boxEnd - boxStart;
const asset = tl.assets.find((a) => a.id === c.assetId);
const clipVideoUrl = videoSources.find((v) => v.id === c.assetId)?.src;
const selected = tl.clipSelection === c.id;
@@ -2180,12 +2210,12 @@ export function V4Timeline({
dragging ? ` ${styles.tlClipDragging}` : ""
}`}
style={{
- left: `${pctOf(c.timelineStartSec)}%`,
+ left: `${pctOf(boxStart)}%`,
// Minus the gutter that separates two cards (it used to be the
// flex row's `gap`). A clip shorter than the gutter lands on
// .tlClip's 1px min-width instead of collapsing — same rule as
// the lane pills above.
- width: `calc(${pctOf(dur)}% - ${CLIP_GUTTER_PX}px)`,
+ width: `calc(${pctOf(boxLen)}% - ${CLIP_GUTTER_PX}px)`,
transform: clipTransform,
}}
onPointerDown={(e) => startClipDrag(e, c)}
@@ -2228,25 +2258,41 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
- {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => (
- e.stopPropagation()}
- onClick={(e) => {
- // Jump to the moment the added text sits on. The clip box
- // underneath would otherwise take this as a selection.
- e.stopPropagation();
- setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
- }}
- />
- ))}
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => {
+ // A word whose pause the film actually holds gets a BAND as wide
+ // as the time it adds — that width is the added time, drawn. One
+ // that fitted in silence already there adds nothing and stays the
+ // hairline it was: there is nothing to show.
+ const pause = inserts.find((ins) => ins.wordId === word.id);
+ const left = pause
+ ? ((expandRawSec(pause.atRawSec, inserts) - boxStart) / boxLen) * 100
+ : atPct;
+ const width = pause ? (pause.durationSec / boxLen) * 100 : 0;
+ return (
+ 0
+ ? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
+ : { left: `${left}%` }
+ }
+ title={t("toolbar.addedWord", { word: word.text })}
+ aria-label={t("toolbar.addedWord", { word: word.text })}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => {
+ // Jump to the moment the added text sits on. The clip box
+ // underneath would otherwise take this as a selection.
+ e.stopPropagation();
+ setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
+ }}
+ />
+ );
+ })}
{selected ? (
{
);
});
});
+
+// ─── Captions across an added word's pause ───────────────────────
+// The pause lengthens the ruler, so every line after it slides — and the line the pause
+// exists FOR has to stay on screen through it rather than going dark over the one moment
+// an added word is there for.
+
+describe("captions and a pause", () => {
+ function withPause(): AxcutDocument {
+ const base = doc();
+ return {
+ ...base,
+ timeline: {
+ ...base.timeline,
+ insertRanges: [
+ {
+ id: "ins_1",
+ assetId: "asset-1",
+ // Inside "hello there friend" (0–2s), so the line covers it.
+ atSec: 1.2,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user" as const,
+ },
+ ],
+ },
+ };
+ }
+
+ it("keeps the covering line up through the pause instead of cutting it short", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withPause(), ON, {});
+ const line = (cues: typeof before) => cues.find((cue) => cue.text.includes("hello"));
+ expect(line(after)?.startMs).toBe(line(before)?.startMs);
+ // Half a second longer: exactly the pause it now spans.
+ expect((line(after)?.endMs ?? 0) - (line(before)?.endMs ?? 0)).toBe(500);
+ });
+
+ it("slides everything after the pause along by it", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withPause(), ON, {});
+ const later = (cues: typeof before) => cues.find((cue) => cue.text.includes("goodbye"));
+ expect((later(after)?.startMs ?? 0) - (later(before)?.startMs ?? 0)).toBe(500);
+ });
+
+ it("is unchanged when the project has no pauses", () => {
+ expect(deriveCaptionCues(doc(), ON, {})).toEqual(deriveCaptionCues(doc(), ON, {}));
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index c3287ebcb..b4dfa7677 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -23,6 +23,7 @@ import {
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
import type { AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
+import { expandRawSec, type RulerInsert, rulerInserts } from "../timeline/inserted-time";
import {
type CaptionAnchorV,
type CaptionSettings,
@@ -157,6 +158,7 @@ export function sourceSpanToTimelineSpans(
startSec: number,
endSec: number,
clips: AxcutClip[],
+ inserts: readonly RulerInsert[] = [],
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -170,7 +172,15 @@ export function sourceSpanToTimelineSpans(
endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
- return out;
+ // Onto the ruler the viewer actually sees. Expanding BOTH ends does the whole job:
+ // a line after a pause slides along by it, and a line that covers the held moment
+ // has only its end pushed out — so it stays on screen through the pause instead of
+ // going dark over the one moment an added word exists for.
+ if (inserts.length === 0) return out;
+ return out.map((span) => ({
+ startSec: expandRawSec(span.startSec, inserts),
+ endSec: expandRawSec(span.endSec, inserts),
+ }));
}
/**
@@ -189,6 +199,9 @@ export function deriveCaptionCues(
if (clips.length === 0) return [];
const transcripts = new Map(document.transcripts.map((t) => [t.assetId, t]));
+ // `?? []` because the key is additive: a document written before it — or a hand-built
+ // one that never went through the schema — simply has no pauses.
+ const inserts = rulerInserts(document.timeline.insertRanges ?? [], clips);
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
const linesByAsset = new Map();
@@ -205,7 +218,13 @@ export function deriveCaptionCues(
for (const line of lines) {
const text = line.text.trim();
if (!text) continue;
- for (const span of sourceSpanToTimelineSpans(assetId, line.startSec, line.endSec, clips)) {
+ for (const span of sourceSpanToTimelineSpans(
+ assetId,
+ line.startSec,
+ line.endSec,
+ clips,
+ inserts,
+ )) {
const startMs = Math.round(span.startSec * 1000);
const endMs = Math.max(Math.round(span.endSec * 1000), startMs + 1);
cues.push({ id: `caption-${n++}`, startMs, endMs, text });
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index 02b3d5244..4774c6517 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
type AxcutClip,
type AxcutDocument,
+ type AxcutInsertRange,
type AxcutTrimRange,
axcutSchemaVersion,
} from "../schema";
@@ -1745,3 +1746,89 @@ describe("projectRawTimelineSecToPlayback with speed regions", () => {
expect(projectRawTimelineSecToPlayback([clip], [], 4, speed)).toBeCloseTo(4, 6);
});
});
+
+// ─── The pause an added word bought ──────────────────────────────
+// Created time only exists once playback honours it. These pin the one thing the record
+// is for: the stream really does stay on the held frame, and the film really is longer.
+
+describe("resolvePlaybackSegments with insert ranges", () => {
+ const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ];
+ const insert = (overrides: Partial = {}): AxcutInsertRange => ({
+ id: "ins_1",
+ assetId: "a1",
+ atSec: 10,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "held",
+ origin: "user",
+ ...overrides,
+ });
+
+ it("holds the frame where the pause sits, and lengthens the stream by it", () => {
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
+ expect(segments).toHaveLength(2);
+ expect(segments[1]).toMatchObject({
+ sourceStartSec: 10,
+ sourceEndSec: 10,
+ heldSec: 0.5,
+ timelineStartSec: 10,
+ timelineEndSec: 10.5,
+ });
+ });
+
+ it("changes nothing when there is no pause", () => {
+ expect(resolvePlaybackSegments(CLIPS, [], [])).toHaveLength(1);
+ });
+
+ // The usual case, and the one the first cut of this missed: a pause sits at the end of
+ // the word it follows, which is almost never a boundary a trim happened to leave.
+ it("cuts the clip open where a pause falls in the MIDDLE of it", () => {
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert({ atSec: 2.5 })]);
+ expect(segments.map((s) => [s.sourceStartSec, s.sourceEndSec, s.heldSec])).toEqual([
+ [0, 2.5, undefined],
+ [2.5, 2.5, 0.5],
+ [2.5, 10, undefined],
+ ]);
+ // 10s of film plus half a second of held frame.
+ expect(segments[2].timelineEndSec).toBeCloseTo(10.5, 5);
+ });
+
+ // The moment the pause holds is not in the film any more, so neither is the pause.
+ it("drops a pause whose moment a trim removed", () => {
+ const trims: AxcutTrimRange[] = [
+ { id: "t1", assetId: "a1", startSec: 4, endSec: 10, origin: "user", reason: "" },
+ ];
+ const segments = resolvePlaybackSegments(CLIPS, trims, [insert()]);
+ expect(segments.some((s) => s.heldSec !== undefined)).toBe(false);
+ });
+
+ it("places a pause inside a clip between the halves a trim left", () => {
+ const trims: AxcutTrimRange[] = [
+ { id: "t1", assetId: "a1", startSec: 4, endSec: 6, origin: "user", reason: "" },
+ ];
+ const segments = resolvePlaybackSegments(CLIPS, trims, [insert({ atSec: 4 })]);
+ expect(segments.map((s) => s.heldSec)).toEqual([undefined, 0.5, undefined]);
+ // The stream is the kept film plus the pause: 4s + 0.5s + 4s.
+ expect(segments[segments.length - 1].timelineEndSec).toBeCloseTo(8.5, 5);
+ });
+
+ it("never writes the held flag onto a stored clip", () => {
+ // The field lives on the derived segment only; that is the whole difference from
+ // the attempt that made clips for it.
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
+ expect(CLIPS[0]).not.toHaveProperty("heldSec");
+ expect(segments[0]).not.toHaveProperty("heldSec");
+ });
+});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 987e91dc2..ee46a6cc2 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -3,7 +3,24 @@
// (store, exporter, agent) feeds an AxcutDocument and gets back intervals
// or a new document with updated clips.
-import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "../schema";
+import type {
+ AxcutClip,
+ AxcutDocument,
+ AxcutInsertRange,
+ AxcutTranscript,
+ AxcutTrimRange,
+} from "../schema";
+
+/**
+ * What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
+ * one thing a stored clip can never carry — `heldSec`, the pause an added word created.
+ *
+ * A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
+ * The field lives only on this derived shape, never on `clipSchema`, so nothing can write
+ * one to disk — which is the whole difference from the attempt that made clips for it.
+ */
+export type PlaybackSegment = AxcutClip & { heldSec?: number };
+
import {
anchoredToRawSpanSec,
anchorRegionsWithDerivedMs,
@@ -164,10 +181,32 @@ export function subtractInterval(intervals: Interval[], cut: Interval): Interval
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
-): AxcutClip[] {
+ insertRanges: readonly AxcutInsertRange[] = [],
+): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const result: AxcutClip[] = [];
+ const result: PlaybackSegment[] = [];
let timelineCursor = 0;
+ // The pauses added words need, in the order they will be met. Consumed as the walk
+ // passes each one's moment, so a pause inside a span a trim removed is never reached —
+ // which is right: the moment it holds is not in the film any more.
+ const pending = [...insertRanges].sort((a, b) => a.atSec - b.atSec);
+ const holdAt = (clip: AxcutClip, atSec: number): PlaybackSegment | null => {
+ const insert = pending.find(
+ (range) => range.assetId === clip.assetId && Math.abs(range.atSec - atSec) < 1e-6,
+ );
+ if (!insert) return null;
+ pending.splice(pending.indexOf(insert), 1);
+ return {
+ ...clip,
+ id: `${clip.id}__hold_${insert.id}`,
+ sourceStartSec: atSec,
+ sourceEndSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: 0,
+ heldSec: insert.durationSec,
+ reason: insert.reason,
+ };
+ };
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
@@ -186,18 +225,52 @@ export function resolvePlaybackSegments(
if (!trimAppliesToClip(trim, clip)) continue;
kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
}
- kept.forEach((iv, i) => {
- const dur = iv.endSec - iv.startSec;
- if (dur <= 0) return;
+ // A pause sits at the END of the word it follows, which is almost never a boundary a
+ // trim happened to leave. So each kept span is cut at the moments it holds, and the
+ // held frame goes between the halves: the stream plays up to that frame, stays on it
+ // for the pause, then carries on — which is what makes the film longer.
+ const pieces: Array<{ startSec: number; endSec: number; holdAtEnd: boolean }> = [];
+ for (const iv of kept) {
+ const moments = pending
+ .filter(
+ (range) =>
+ range.assetId === clip.assetId &&
+ range.atSec > iv.startSec + 1e-6 &&
+ range.atSec <= iv.endSec + 1e-6,
+ )
+ .map((range) => range.atSec)
+ .sort((a, b) => a - b);
+ let from = iv.startSec;
+ for (const at of moments) {
+ pieces.push({ startSec: from, endSec: Math.min(at, iv.endSec), holdAtEnd: true });
+ from = Math.min(at, iv.endSec);
+ }
+ if (iv.endSec - from > 1e-6 || pieces.length === 0) {
+ pieces.push({ startSec: from, endSec: iv.endSec, holdAtEnd: false });
+ }
+ }
+ pieces.forEach((piece, i) => {
+ const dur = piece.endSec - piece.startSec;
+ if (dur > 0) {
+ result.push({
+ ...clip,
+ id: pieces.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
+ sourceStartSec: piece.startSec,
+ sourceEndSec: piece.endSec,
+ timelineStartSec: timelineCursor,
+ timelineEndSec: timelineCursor + dur,
+ });
+ timelineCursor += dur;
+ }
+ if (!piece.holdAtEnd) return;
+ const hold = holdAt(clip, piece.endSec);
+ if (!hold) return;
result.push({
- ...clip,
- id: kept.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
- sourceStartSec: iv.startSec,
- sourceEndSec: iv.endSec,
+ ...hold,
timelineStartSec: timelineCursor,
- timelineEndSec: timelineCursor + dur,
+ timelineEndSec: timelineCursor + (hold.heldSec ?? 0),
});
- timelineCursor += dur;
+ timelineCursor += hold.heldSec ?? 0;
});
}
return result;
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index d00d8e59f..cb514d15b 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -1443,6 +1443,9 @@ export function useTimeline() {
zoomRegions: document?.zoomRanges ?? [],
trimRanges: document?.timeline.trimRanges ?? [],
audioTracks: document?.audioTracks ?? [],
+ // The pauses added words created. The ruler counts them; nothing else in the
+ // timeline store writes them (see `document/transcript.ts`).
+ insertRanges: document?.timeline.insertRanges ?? [],
annotationRegions: (document?.annotations ?? []) as unknown as AnnotationRegion[],
speedRegions,
cameraFullscreenRegions,
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index b98601a03..507126f5c 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -17,6 +17,7 @@
// against the COMPRESSED segment layout, which slips every region after a trim
// forward by the trimmed duration.
+import type { PlaybackSegment } from "../document/timeline";
import type { AxcutClip } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
@@ -399,11 +400,15 @@ export function anchorRegionsWithDerivedMs<
* the gap.
*/
export function segmentRawSpanSec(
- segment: AxcutClip,
+ segment: PlaybackSegment,
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
- const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ // A held segment's source window is the single frame it shows, so its source length
+ // is zero — its RAW span is the pause it carries. Without this the playhead could
+ // never be inside it and would step straight over the pause.
+ const lenSec =
+ segment.heldSec ?? (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -559,7 +564,7 @@ export function projectRegionsToSource<
T extends { id: string; startMs: number; endMs: number } & RegionClipAnchor,
>(
regions: T[],
- visibleSegments: AxcutClip[],
+ visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
makeId: () => string,
): (T & { clipIndex?: number; underTrim?: boolean })[] {
@@ -639,7 +644,7 @@ export function projectRegionsToSource<
export interface NativePosition {
/** The trim-narrowed playback segment (from `visibleSegments`) that is active. */
- clip: AxcutClip;
+ clip: PlaybackSegment;
/** Its index in `visibleSegments`, matching `SceneDescription.clips` / native `clip_index`. */
clipIndex: number;
/** Screen-source seconds the native decoder should present for this segment. */
@@ -675,7 +680,7 @@ const NATIVE_EOF_MARGIN_SEC = 0.033;
*/
export function resolveNativePosition(
rawSec: number,
- visibleSegments: AxcutClip[],
+ visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
@@ -689,6 +694,13 @@ export function resolveNativePosition(
if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips);
const seg = visibleSegments[index];
+ // Inside a pause the source clock does not advance: the whole point of the segment
+ // is created time over one held frame. Clamping here is what stops the raw-playhead
+ // delta — which DOES advance through the pause — from pushing the decoder past the
+ // held frame into the content that belongs after it.
+ if (seg.heldSec !== undefined) {
+ return { clip: seg, clipIndex: index, sourceTimeSec: seg.sourceStartSec };
+ }
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 0d214831b..69bfa1016 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -514,7 +514,11 @@ function clipAssetIsResolvable(
export function resolveVisibleClips(document: AxcutDocument): AxcutClip[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
- return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
+ return resolvePlaybackSegments(
+ document.timeline.clips,
+ document.timeline.trimRanges,
+ document.timeline.insertRanges,
+ )
.sort((a, b) => a.timelineStartSec - b.timelineStartSec)
.filter((clip) => clipAssetIsResolvable(clip, assetById));
}
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index 64e877b63..f1ab884ed 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -41,6 +41,12 @@ export function useNativePlaybackSync(
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
+ // A pause holds ONE frame for its whole length. Free-running the decoder through it
+ // would play what comes after instead, and the app clock — which does traverse the
+ // pause — would then re-seek on the drift and stutter. Pausing the decoder is what
+ // makes the pause a pause; the webcam holds with the screen because both derive
+ // from the one asset source clock the pause stops advancing.
+ const held = activePosition?.clip.heldSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -54,8 +60,8 @@ export function useNativePlaybackSync(
if (!active) {
return;
}
- setNativePlaying(playing);
- }, [active, playing]);
+ setNativePlaying(playing && !held);
+ }, [active, playing, held]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -68,6 +74,16 @@ export function useNativePlaybackSync(
}
const now = performance.now();
+ // Inside a pause while playing: the decoder is parked on the held frame (see the
+ // transport effect). Refresh the drift refs every run so the check never reads a
+ // correctly-frozen source clock as divergence and fights itself with seeks.
+ if (playing && held) {
+ setNativeTime(sourceTimeSec);
+ lastSyncedSourceTimeRef.current = sourceTimeSec;
+ lastSyncedWallTimeRef.current = now;
+ return;
+ }
+
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -95,5 +111,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, activeClipId, sourceTimeSec]);
+ }, [active, playing, held, activeClipId, sourceTimeSec]);
}
From 1dbf94a284b34df0242d6badb73a2160fcf49f94 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Wed, 2 Sep 2026 18:36:06 +0200
Subject: [PATCH 063/113] feat(timeline): one answer to whether a raw moment is
in the film
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Everything that reads the timeline answered that question its own way, and the
answers disagreed. The transcript pane asked it by IDENTITY — does a trim name
this clip — which is a question a voiceover placement can never answer yes to:
it carries an audio fragment id and an audio asset, while every trim carries a
video clip. So the voiceover lane read every word as kept, including words whose
moment had been cut out of the film, and a cut authored from that lane removed
nothing at all.
The fix is not a better identity test. A trim is a removed span of the RAW
RULER, and both lanes lie on that one ruler: a word is removed if and only if
the raw moment it occupies is. `programme-time.ts` is that reading —
`keptRawSpans` / `removedRawSpans` / `removalAt` / `subtractRemoved`, derived and
never stored. Storage does not move: a trim stays source-anchored to a clip.
`keptRawSpans` is LIFTED out of `projectRawTimelineSecToPlayback`, which now
calls it, rather than reimplemented beside it — so agreement with playback is by
construction. Deriving it from `trimToTimelineSpan` instead would have been the
trap: that function's un-anchored branch resolves a pre-v7 trim through the
first clip whose source range contains its START, while the playback walk cuts on
OVERLAP across every clip of the asset. A primitive built on it would have left
the second clip's words reading kept over film that is gone — a new bug on the
lane that is correct today. There is a test named after exactly that.
Two boundaries do not follow from the definition and are pinned:
- The programme ends at the last CLIP's raw end, not the last KEPT span's. A
trimmed tail is genuinely removed; raw time PAST every clip is not removed
but unfilmed, because the projection is the identity there. That is what
lets a voiceover hang off the end and keep playing.
- A gap between clips is removed with NO trim ids. Nothing plays there, so a
word over it is not in the film — but there is no pill to restore, and a
caller offering that affordance must key it on `trimIds` being non-empty.
`Interval` and `subtractInterval` move to `timeline/intervals.ts` and are
re-exported, because the dependency runs document/ → timeline/ and importing
back would close a cycle. A second copy of those twelve lines was the
alternative, and two implementations of "what survives a cut" is the shape of
bug this change exists to remove.
The randomised fixture checks the SUM against `resolvePlaybackSegments`, which is
blind to order — so it also projects every span's head and expects the output
length of everything before it, which only holds if the walk yields them in
playback order. Reversing the output fails three assertions.
Refs #560. Step 1 of 7.
---
src/lib/ai-edition/document/timeline.ts | 76 ++---
src/lib/ai-edition/timeline/intervals.ts | 37 +++
.../timeline/programme-time.test.ts | 276 ++++++++++++++++++
src/lib/ai-edition/timeline/programme-time.ts | 225 ++++++++++++++
4 files changed, 557 insertions(+), 57 deletions(-)
create mode 100644 src/lib/ai-edition/timeline/intervals.ts
create mode 100644 src/lib/ai-edition/timeline/programme-time.test.ts
create mode 100644 src/lib/ai-edition/timeline/programme-time.ts
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index ee46a6cc2..87670128c 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -21,6 +21,8 @@ import type {
*/
export type PlaybackSegment = AxcutClip & { heldSec?: number };
+import { type Interval, subtractInterval } from "../timeline/intervals";
+import { keptRawSpans } from "../timeline/programme-time";
import {
anchoredToRawSpanSec,
anchorRegionsWithDerivedMs,
@@ -46,10 +48,10 @@ export function byStart(a: { startSec: number }, b: { startSec: number }): numbe
return a.startSec - b.startSec;
}
-export interface Interval {
- startSec: number;
- endSec: number;
-}
+// Re-exported, not redefined: `programme-time.ts` needs the same subtraction and cannot
+// import it from here without closing a dependency cycle (this module already imports from
+// `../timeline`). Callers of `Interval` / `subtractInterval` from this module are unaffected.
+export { type Interval, subtractInterval } from "../timeline/intervals";
export function normalizeIntervals(durationSec: number, intervals: Interval[]): Interval[] {
const bounded = intervals
@@ -145,23 +147,6 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
});
}
-export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
- const output: Interval[] = [];
- for (const interval of intervals) {
- if (cut.endSec <= interval.startSec || cut.startSec >= interval.endSec) {
- output.push(interval);
- continue;
- }
- if (cut.startSec > interval.startSec) {
- output.push({ startSec: interval.startSec, endSec: cut.startSec });
- }
- if (cut.endSec < interval.endSec) {
- output.push({ startSec: cut.endSec, endSec: interval.endSec });
- }
- }
- return output;
-}
-
/**
* Derived, ephemeral clip list for playback/native/export — never written back to
* `document.timeline.clips`. Each clip's own `[sourceStartSec, sourceEndSec]` (its media
@@ -358,43 +343,20 @@ export function projectRawTimelineSecToPlayback(
let lastRawEnd = 0; // raw end of the last kept segment, for the trailing overhang
let landed: number | null = null; // output(rawSec), once it falls in/before a kept segment
- // Each kept segment as a raw extent `{ rawStart, dur }`. Trims only REMOVE, so a kept
- // segment's raw length survives here; how long it takes to PLAY is a separate question
- // that `outputDurationOfRawSpan` answers, because a speed region scales it.
- const keptSegments = (clip: AxcutClip): Array<{ rawStart: number; dur: number }> => {
- const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
- if (sourceEnd <= clip.sourceStartSec) {
- // Duration not probed yet — the whole raw clip passes through unnarrowed, matching
- // `resolvePlaybackSegments`' own pass-through branch.
- return [
- { rawStart: clip.timelineStartSec, dur: clip.timelineEndSec - clip.timelineStartSec },
- ];
- }
- let ivs: Interval[] = [{ startSec: clip.sourceStartSec, endSec: sourceEnd }];
- for (const trim of trimRanges) {
- if (!trimAppliesToClip(trim, clip)) continue;
- ivs = subtractInterval(ivs, { startSec: trim.startSec, endSec: trim.endSec });
- }
- // Source time `s` sits at `timelineStartSec + (s − sourceStartSec)` on the raw ruler.
- return ivs.map((iv) => ({
- rawStart: clip.timelineStartSec + (iv.startSec - clip.sourceStartSec),
- dur: iv.endSec - iv.startSec,
- }));
- };
-
- for (const clip of ordered) {
- for (const seg of keptSegments(clip)) {
- if (seg.dur <= 0) continue;
- const rawEnd = seg.rawStart + seg.dur;
- if (landed === null && rawSec < rawEnd) {
- // `rawSec` is inside this segment, or before it in a trimmed/gap region (then
- // the span clamps to nothing → the output edge just before the gap).
- const within = Math.min(Math.max(rawSec, seg.rawStart), rawEnd);
- landed = outCursor + outputDurationOfRawSpan(seg.rawStart, within, speedRegions);
- }
- outCursor += outputDurationOfRawSpan(seg.rawStart, rawEnd, speedRegions);
- lastRawEnd = rawEnd;
+ // The kept stretches come from `keptRawSpans`, which is this walk — it was lifted out of
+ // here so the transcript lanes and the audio mix could ask the same question and get the
+ // same answer (issue #560). Trims only REMOVE, so a kept span's RAW length is what
+ // survives; how long it takes to PLAY is a separate question `outputDurationOfRawSpan`
+ // answers, because a speed region scales it.
+ for (const seg of keptRawSpans(ordered, trimRanges)) {
+ if (landed === null && rawSec < seg.endSec) {
+ // `rawSec` is inside this segment, or before it in a trimmed/gap region (then
+ // the span clamps to nothing → the output edge just before the gap).
+ const within = Math.min(Math.max(rawSec, seg.startSec), seg.endSec);
+ landed = outCursor + outputDurationOfRawSpan(seg.startSec, within, speedRegions);
}
+ outCursor += outputDurationOfRawSpan(seg.startSec, seg.endSec, speedRegions);
+ lastRawEnd = seg.endSec;
}
// Past every kept frame: programme end plus whatever raw time hangs off the end (identity when
// there are no clips at all). A value ≥ programme length just means the mixer skips the track.
diff --git a/src/lib/ai-edition/timeline/intervals.ts b/src/lib/ai-edition/timeline/intervals.ts
new file mode 100644
index 000000000..ecd6daad2
--- /dev/null
+++ b/src/lib/ai-edition/timeline/intervals.ts
@@ -0,0 +1,37 @@
+// Interval arithmetic, with no opinion about what the numbers mean.
+//
+// Extracted from `document/timeline.ts` so `programme-time.ts` can reuse the very
+// subtraction that `resolvePlaybackSegments` runs. It could not import it from there:
+// the dependency runs `document/` → `timeline/` (document/timeline.ts already imports
+// `trimAppliesToClip` from this layer), so importing back would close a cycle. A second
+// copy of the same twelve lines was the alternative, and two implementations of "what
+// survives a cut" is exactly the shape of bug this whole change exists to remove.
+//
+// `document/timeline.ts` re-exports both names, so its existing callers are unaffected.
+
+export interface Interval {
+ startSec: number;
+ endSec: number;
+}
+
+/**
+ * `intervals` minus `cut`. An interval straddling the cut splits in two; one wholly
+ * inside it disappears. Inputs are not required to be sorted or disjoint, and the
+ * output preserves the order it was given.
+ */
+export function subtractInterval(intervals: Interval[], cut: Interval): Interval[] {
+ const output: Interval[] = [];
+ for (const interval of intervals) {
+ if (cut.endSec <= interval.startSec || cut.startSec >= interval.endSec) {
+ output.push(interval);
+ continue;
+ }
+ if (cut.startSec > interval.startSec) {
+ output.push({ startSec: interval.startSec, endSec: cut.startSec });
+ }
+ if (cut.endSec < interval.endSec) {
+ output.push({ startSec: cut.endSec, endSec: interval.endSec });
+ }
+ }
+ return output;
+}
diff --git a/src/lib/ai-edition/timeline/programme-time.test.ts b/src/lib/ai-edition/timeline/programme-time.test.ts
new file mode 100644
index 000000000..5d6faa4ef
--- /dev/null
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -0,0 +1,276 @@
+// Issue #560. These hold the one claim the whole change rests on: that
+// `programme-time.ts` and `resolvePlaybackSegments` answer "is this raw moment in the
+// film" the same way. They are the same walk now, so the interesting assertions are the
+// ones that would catch it drifting apart again — and the two boundary rules that do NOT
+// follow from the definition (a trimmed tail is removed, unfilmed time past the last clip
+// is not).
+
+import { describe, expect, it } from "vitest";
+import { projectRawTimelineSecToPlayback, resolvePlaybackSegments } from "../document/timeline";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
+import { keptRawSpans, removalAt, removedRawSpans, subtractRemoved } from "./programme-time";
+
+function clip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutClip;
+}
+
+function trim(over: Partial & { id: string }): AxcutTrimRange {
+ return {
+ assetId: "a1",
+ startSec: 0,
+ endSec: 1,
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutTrimRange;
+}
+
+/** Two clips laid end to end over one 20s asset, cut at source 10. */
+function twoClips(): AxcutClip[] {
+ return [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 10,
+ sourceEndSec: 20,
+ timelineStartSec: 10,
+ timelineEndSec: 20,
+ }),
+ ];
+}
+
+const total = (spans: Array<{ startSec: number; endSec: number }>) =>
+ spans.reduce((sum, s) => sum + (s.endSec - s.startSec), 0);
+
+/** Deterministic LCG — a failure here has to be reproducible, so no Math.random. */
+function lcg(seed: number) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 4294967296;
+ };
+}
+
+describe("keptRawSpans agrees with playback", () => {
+ it("keeps exactly what resolvePlaybackSegments plays, over randomised fixtures", () => {
+ for (let seed = 1; seed <= 40; seed++) {
+ const rand = lcg(seed);
+ const clipCount = 1 + Math.floor(rand() * 3);
+ const clips: AxcutClip[] = [];
+ let cursor = 0;
+ for (let i = 0; i < clipCount; i++) {
+ const len = 4 + Math.floor(rand() * 8);
+ const sourceStart = Math.floor(rand() * 5);
+ clips.push(
+ clip({
+ id: `c${i}`,
+ // Two clips over one asset on purpose: it is the case that separates a
+ // per-clip walk from a per-asset one.
+ assetId: rand() < 0.5 ? "a1" : "a2",
+ sourceStartSec: sourceStart,
+ sourceEndSec: sourceStart + len,
+ timelineStartSec: cursor,
+ timelineEndSec: cursor + len,
+ }),
+ );
+ // Sometimes a gap before the next clip.
+ cursor += len + (rand() < 0.3 ? 1 + Math.floor(rand() * 3) : 0);
+ }
+ const trims: AxcutTrimRange[] = [];
+ const trimCount = Math.floor(rand() * 4);
+ for (let i = 0; i < trimCount; i++) {
+ const host = clips[Math.floor(rand() * clips.length)];
+ const start = host.sourceStartSec + rand() * 4;
+ trims.push(
+ trim({
+ id: `t${i}`,
+ assetId: host.assetId,
+ // Half anchored, half pre-v7 style, so both branches of
+ // `trimAppliesToClip` are exercised.
+ ...(rand() < 0.5 ? { clipId: host.id } : {}),
+ startSec: start,
+ endSec: start + 0.5 + rand() * 3,
+ }),
+ );
+ }
+
+ const played = resolvePlaybackSegments(clips, trims).reduce(
+ (sum, seg) => sum + ((seg.sourceEndSec ?? seg.sourceStartSec) - seg.sourceStartSec),
+ 0,
+ );
+ const kept = keptRawSpans(clips, trims);
+ expect(total(kept), `seed ${seed} total`).toBeCloseTo(played, 6);
+
+ // The sum alone is blind to ORDER, and order is the whole reason this walk was
+ // lifted rather than reimplemented: `projectRawTimelineSecToPlayback` accumulates
+ // one output cursor across the spans in the order they arrive. So check each
+ // span's head projects to the output length of everything before it — which is
+ // only true if the walk yields them in playback order.
+ let before = 0;
+ for (const [i, span] of kept.entries()) {
+ expect(
+ projectRawTimelineSecToPlayback(clips, trims, span.startSec),
+ `seed ${seed} span ${i}`,
+ ).toBeCloseTo(before, 6);
+ before += span.endSec - span.startSec;
+ }
+ }
+ });
+
+ it("is caught out when the spans arrive in the wrong order", () => {
+ // Guards the guard: if `keptRawSpans` ever returned globally sorted spans instead of
+ // playback-ordered ones, the assertion above has to fail. Two clips whose ruler order
+ // is the reverse of their array order make the two orderings differ.
+ const clips = [
+ clip({
+ id: "late",
+ sourceStartSec: 0,
+ sourceEndSec: 4,
+ timelineStartSec: 6,
+ timelineEndSec: 10,
+ }),
+ clip({
+ id: "early",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ }),
+ ];
+ expect(keptRawSpans(clips, []).map((s) => s.startSec)).toEqual([0, 6]);
+ });
+
+ it("leaves the projection identical to what it produced before the lift", () => {
+ const clips = twoClips();
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 })];
+ // Raw 2..4 is gone, so everything after it plays 2s earlier; inside the cut the
+ // playhead lands on the output edge just before it.
+ expect(projectRawTimelineSecToPlayback(clips, trims, 1)).toBeCloseTo(1, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 3)).toBeCloseTo(2, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 6)).toBeCloseTo(4, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 20)).toBeCloseTo(18, 6);
+ // Past the programme the projection is the identity, which is what lets a voiceover
+ // hang off the end and keep playing.
+ expect(projectRawTimelineSecToPlayback(clips, trims, 25)).toBeCloseTo(23, 6);
+ });
+});
+
+describe("removedRawSpans", () => {
+ it("partitions the programme with no overlap and no hole", () => {
+ const clips = twoClips();
+ const trims = [
+ trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 }),
+ trim({ id: "t2", clipId: "c2", startSec: 15, endSec: 16 }),
+ ];
+ const kept = [...keptRawSpans(clips, trims)].sort((a, b) => a.startSec - b.startSec);
+ const removed = removedRawSpans(clips, trims);
+ const all = [...kept, ...removed].sort((a, b) => a.startSec - b.startSec);
+
+ let cursor = 0;
+ for (const span of all) {
+ expect(span.startSec).toBeCloseTo(cursor, 6); // no hole, no overlap
+ cursor = span.endSec;
+ }
+ expect(cursor).toBeCloseTo(20, 6); // the last clip's raw end
+ });
+
+ it("reports an inter-clip gap as removed by nothing", () => {
+ const clips = [
+ twoClips()[0],
+ clip({
+ id: "c2",
+ sourceStartSec: 10,
+ sourceEndSec: 20,
+ timelineStartSec: 13,
+ timelineEndSec: 23,
+ }),
+ ];
+ const gap = removedRawSpans(clips, []).find((s) => s.startSec === 10);
+ expect(gap).toMatchObject({ startSec: 10, endSec: 13 });
+ // No trim took it, so the pane must not offer a restore.
+ expect(gap?.trimIds).toEqual([]);
+ });
+
+ it("removes a trimmed tail of the last clip but never the time past it", () => {
+ const clips = [twoClips()[0]];
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 8, endSec: 10 })];
+ const removed = removedRawSpans(clips, trims);
+ expect(removed).toEqual([{ startSec: 8, endSec: 10, trimIds: ["t1"] }]);
+ // Raw 12 is unfilmed, not removed — the distinction a voiceover overhanging the
+ // programme depends on.
+ expect(removalAt(removed, 12)).toBeNull();
+ expect(removalAt(removed, 9)).toMatchObject({ trimIds: ["t1"] });
+ });
+
+ it("covers BOTH clips of an asset for a pre-v7 un-anchored trim", () => {
+ // The regression guard. `trimToTimelineSpan`'s un-anchored branch resolves such a
+ // trim through the FIRST clip whose source range contains its start, so a primitive
+ // built on it would leave c2's words reading kept over film that is gone. The
+ // playback walk cuts on overlap, per clip, and this must match it.
+ const clips = twoClips();
+ const trims = [trim({ id: "t1", startSec: 5, endSec: 15 })]; // no clipId
+ const removed = removedRawSpans(clips, trims);
+ expect(removalAt(removed, 6)).toMatchObject({ trimIds: ["t1"] }); // inside c1
+ expect(removalAt(removed, 12)).toMatchObject({ trimIds: ["t1"] }); // inside c2
+ expect(removalAt(removed, 2)).toBeNull();
+ expect(removalAt(removed, 18)).toBeNull();
+ });
+
+ it("names every overlapping trim that took a stretch", () => {
+ const clips = [twoClips()[0]];
+ const trims = [
+ trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 5 }),
+ trim({ id: "t2", clipId: "c1", startSec: 4, endSec: 7 }),
+ ];
+ // `subtractInterval` merges the two into one hole; both ids come with it, so
+ // restoring from the pane can drop the whole pill.
+ expect(removedRawSpans(clips, trims)).toEqual([
+ { startSec: 2, endSec: 7, trimIds: ["t1", "t2"] },
+ ]);
+ });
+
+ it("returns nothing for a document with no clips", () => {
+ expect(removedRawSpans([], [trim({ id: "t1" })])).toEqual([]);
+ });
+});
+
+describe("subtractRemoved", () => {
+ it("splits a span that crosses a cut into the pieces that survive", () => {
+ const clips = twoClips();
+ const removed = removedRawSpans(clips, [
+ trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 5 }),
+ ]);
+ // A voiceover from raw 1 to raw 8 plays as two pieces, not as one take cut short.
+ expect(subtractRemoved(1, 8, removed)).toEqual([
+ { startSec: 1, endSec: 3 },
+ { startSec: 5, endSec: 8 },
+ ]);
+ });
+
+ it("yields nothing for a span buried inside a cut, and the whole span when untouched", () => {
+ const clips = twoClips();
+ const removed = removedRawSpans(clips, [
+ trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 8 }),
+ ]);
+ expect(subtractRemoved(4, 6, removed)).toEqual([]);
+ expect(subtractRemoved(10, 14, removed)).toEqual([{ startSec: 10, endSec: 14 }]);
+ // Past the programme is not removed, so an overhanging take keeps its tail.
+ expect(subtractRemoved(18, 25, removed)).toEqual([{ startSec: 18, endSec: 25 }]);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
new file mode 100644
index 000000000..60c23f032
--- /dev/null
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -0,0 +1,225 @@
+// One answer to "is this raw ruler moment in the film" (issue #560).
+//
+// Everything that reads the timeline used to answer that question its own way, and the
+// answers disagreed. The transcript pane asked it by IDENTITY — does a trim name this
+// clip — which is a question a voiceover placement can never answer yes to, since it
+// carries an audio fragment id and an audio asset while every trim carries a video clip.
+// So the voiceover lane read every word as kept, including words whose moment had been
+// cut out of the film, and a cut authored from that lane removed nothing at all.
+//
+// The fix is not a better identity test. It is to stop asking about identity: a trim is a
+// removed span of the RAW RULER, and both lanes lie on that one ruler. A word — from the
+// recording or from a voiceover — is removed if and only if the raw moment it occupies is.
+//
+// `keptRawSpans` is therefore lifted verbatim out of `projectRawTimelineSecToPlayback`,
+// which now calls it, rather than reimplemented beside it. Agreement with playback is by
+// construction; `programme-time.test.ts` holds the two to it on randomised fixtures.
+//
+// Storage does not change: a trim stays source-time anchored to a clip. This is the
+// derived READING of those rows, computed on demand and never written back.
+
+import type { AxcutClip, AxcutTrimRange } from "../schema";
+import { type Interval, subtractInterval } from "./intervals";
+import { trimAppliesToClip } from "./trim-mapping";
+
+/** A stretch of the raw ruler, in seconds. */
+export interface RawSpan {
+ startSec: number;
+ endSec: number;
+}
+
+/** A stretch the film does not contain, and the trims that took it away. */
+export interface RemovedRawSpan extends RawSpan {
+ /**
+ * The trims covering this stretch — several when they overlap, and EMPTY for a gap
+ * between two clips, which is missing from the film without anything having removed
+ * it. Callers offering a restore affordance must key it on this being non-empty:
+ * there is no pill to click for a gap.
+ */
+ trimIds: string[];
+}
+
+/**
+ * The clip's own extent on the raw ruler.
+ *
+ * Source second `s` sits at `timelineStartSec + (s − sourceStartSec)`, so the extent runs
+ * to the source length past the head. An UNPROBED clip (no real `sourceEndSec` yet) has no
+ * source length to measure, and falls back to the ruler geometry it was given — matching
+ * the pass-through branch `resolvePlaybackSegments` takes for the same clips.
+ */
+function clipRawExtent(clip: AxcutClip): RawSpan {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) {
+ return { startSec: clip.timelineStartSec, endSec: clip.timelineEndSec };
+ }
+ return {
+ startSec: clip.timelineStartSec,
+ endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec),
+ };
+}
+
+/** Source interval → raw, through the clip that carries it. */
+function sourceToRaw(clip: AxcutClip, interval: Interval): RawSpan {
+ return {
+ startSec: clip.timelineStartSec + (interval.startSec - clip.sourceStartSec),
+ endSec: clip.timelineStartSec + (interval.endSec - clip.sourceStartSec),
+ };
+}
+
+/** What survives the trims inside one clip, in source order. */
+function keptSourceIntervals(clip: AxcutClip, trimRanges: AxcutTrimRange[]): Interval[] {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) return [];
+ let ivs: Interval[] = [{ startSec: clip.sourceStartSec, endSec: sourceEnd }];
+ for (const trim of trimRanges) {
+ if (!trimAppliesToClip(trim, clip)) continue;
+ ivs = subtractInterval(ivs, { startSec: trim.startSec, endSec: trim.endSec });
+ }
+ return ivs;
+}
+
+/**
+ * Every stretch of raw ruler the film actually contains, in PLAYBACK ORDER — clips by
+ * `timelineStartSec`, and within a clip by source time.
+ *
+ * Not globally sorted, on purpose: `projectRawTimelineSecToPlayback` walks these with a
+ * single output cursor, so the order has to be the order they play. Two clips that overlap
+ * on the ruler (which the model does not produce, but nothing forbids) therefore come back
+ * interleaved rather than merged, exactly as the projection has always treated them.
+ *
+ * Zero-length spans are dropped, so a caller can trust `endSec > startSec`.
+ */
+export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]): RawSpan[] {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ const spans: RawSpan[] = [];
+ for (const clip of ordered) {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) {
+ // Duration not probed yet — the whole raw clip passes through unnarrowed.
+ const extent = clipRawExtent(clip);
+ if (extent.endSec > extent.startSec) spans.push(extent);
+ continue;
+ }
+ for (const iv of keptSourceIntervals(clip, trimRanges)) {
+ const span = sourceToRaw(clip, iv);
+ if (span.endSec > span.startSec) spans.push(span);
+ }
+ }
+ return spans;
+}
+
+/**
+ * The complement of {@link keptRawSpans} over `[0, lastClipRawEnd]`, sorted, each stretch
+ * carrying the ids of the trims that took it.
+ *
+ * Two boundaries decide what this does and do not follow from the definition:
+ *
+ * It stops at the last CLIP's raw end, not the last KEPT span's. A trimmed tail of the
+ * last clip is inside the programme's extent and so is genuinely removed; raw time PAST
+ * every clip is not removed but simply unfilmed, because `projectRawTimelineSecToPlayback`
+ * is the identity there. That is what lets a voiceover hang off the end of the programme
+ * and keep playing, its words still reading kept, instead of being silently swallowed.
+ *
+ * Gaps count as removed, with no trim ids. Nothing plays there, so a word over a gap is
+ * not in the film — but there is no trim to restore, and the pane must not offer one.
+ */
+export function removedRawSpans(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[],
+): RemovedRawSpan[] {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ if (ordered.length === 0) return [];
+
+ const removed: RemovedRawSpan[] = [];
+ let cursor = 0; // raw end of the programme walked so far
+
+ for (const clip of ordered) {
+ const extent = clipRawExtent(clip);
+ // The unfilmed stretch before this clip. `max` rather than a bare subtraction so
+ // two clips overlapping on the ruler contribute no negative gap.
+ if (extent.startSec > cursor) {
+ removed.push({ startSec: cursor, endSec: extent.startSec, trimIds: [] });
+ }
+ cursor = Math.max(cursor, extent.endSec);
+
+ if (extent.endSec <= extent.startSec) continue;
+ const kept = keptSourceIntervals(clip, trimRanges);
+ // An unprobed clip has no source interval to cut, and passes through whole.
+ if (kept.length === 0 && (clip.sourceEndSec ?? clip.sourceStartSec) <= clip.sourceStartSec) {
+ continue;
+ }
+
+ // The trims that reach this clip, in raw, so a removed piece can name them.
+ const applicable = trimRanges
+ .filter((trim) => trimAppliesToClip(trim, clip))
+ .map((trim) => ({
+ id: trim.id,
+ ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }),
+ }));
+
+ let holeStart = extent.startSec;
+ for (const iv of kept) {
+ const span = sourceToRaw(clip, iv);
+ if (span.startSec > holeStart) {
+ removed.push(taggedHole(holeStart, span.startSec, applicable));
+ }
+ holeStart = Math.max(holeStart, span.endSec);
+ }
+ if (extent.endSec > holeStart) {
+ removed.push(taggedHole(holeStart, extent.endSec, applicable));
+ }
+ }
+
+ return removed.sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec);
+}
+
+function taggedHole(
+ startSec: number,
+ endSec: number,
+ applicable: Array<{ id: string; startSec: number; endSec: number }>,
+): RemovedRawSpan {
+ return {
+ startSec,
+ endSec,
+ trimIds: applicable
+ .filter((trim) => trim.endSec > startSec && trim.startSec < endSec)
+ .map((trim) => trim.id),
+ };
+}
+
+/**
+ * The stretch removing `rawSec`, or null when the moment is in the film.
+ *
+ * Half-open: a moment exactly on a removed span's end belongs to what follows, so a word
+ * whose centre lands on the far edge of a cut reads as kept.
+ */
+export function removalAt(removed: RemovedRawSpan[], rawSec: number): RemovedRawSpan | null {
+ for (const span of removed) {
+ if (rawSec < span.startSec) break; // sorted, so nothing later can contain it
+ if (rawSec < span.endSec) return span;
+ }
+ return null;
+}
+
+/**
+ * `[startSec, endSec]` with every removed stretch taken out — the pieces of a span that
+ * survive into the film, in order.
+ *
+ * This is what turns one audio track into the several mix entries a cut underneath it
+ * demands: a voiceover crossing a trim plays as two pieces, not as one take shortened at
+ * the tail.
+ */
+export function subtractRemoved(
+ startSec: number,
+ endSec: number,
+ removed: RemovedRawSpan[],
+): RawSpan[] {
+ if (endSec <= startSec) return [];
+ let pieces: Interval[] = [{ startSec, endSec }];
+ for (const span of removed) {
+ if (span.startSec >= endSec) break; // sorted; nothing later overlaps
+ if (span.endSec <= startSec) continue;
+ pieces = subtractInterval(pieces, { startSec: span.startSec, endSec: span.endSec });
+ }
+ return pieces.filter((piece) => piece.endSec > piece.startSec);
+}
From ca66c1de61af8cfe306d22eb618fd2aefe80bac0 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Wed, 2 Sep 2026 21:58:05 +0200
Subject: [PATCH 064/113] feat(editor): decide a word by the ruler, so both
lanes agree
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The transcript pane decided kept-or-removed by asking whether a trim NAMED the
placement — `trimAppliesToClip`, matching a clip id, else an asset id. A
voiceover placement carries an audio fragment id and an audio asset; every trim
carries a video clip. They never matched, so the voiceover lane read every word
as kept, including words whose moment had been cut out of the film.
`buildClipSection` now takes the removed set from `removedRawSpans` instead of
the trim rows, and tags each word by the RAW moment its centre occupies. Centre,
not overlap — mirroring the rule the identity filter used, so the recording
lane's answers do not shift. Both lanes read one set, so a cut on either greys
the other, and the existing shared-media guarantees are unchanged: the same
per-clip walk decides both.
`ClipWord.trimId` and `TrimRun.trimId` become `trimIds: string[]`. Two
consequences worth naming:
- Overlapping trims merge into one hole carrying both ids, where they used to
split into one run per attributed trim. Restoring still takes one click per
trim, as before — step 4 replaces the single-id op with one that drops the
whole pill.
- A gap between clips is removed with an EMPTY set. It is missing from the
film, so its words are struck through; nothing took it, so there is no bin
icon to click. The affordance is keyed on the set being non-empty.
`findCueWordId` takes a raw second instead of a clip id plus a source second. A
clip id is something only the recording lane has, so the voiceover lane never
highlighted anything at all. Raw time also settles the case the clip id was
introduced for — two clips over one media have identical source ranges but
different raw extents — and `locateVirtualPosition` drops out of the pane.
A LOOPING voiceover now contributes no placement. `anchorAudioTrackFragments`
deliberately does not advance `offsetMs` across a looping track's fragments, so
their words map to raw moments the words do not occupy: the lane would read
kept-or-removed on false evidence and, after step 4, author a cut in the wrong
place.
Forcing `removalAt` to answer null fails 8 assertions across the two aggregator
suites, so the fixtures are exercising the rule rather than agreeing with it by
construction.
Refs #560. Step 2 of 7.
---
src/components/ai-edition/RightPanes.tsx | 63 +++---
.../aggregated-transcript.lanes.test.ts | 160 ++++++++++++++
.../timeline/aggregated-transcript.test.ts | 196 ++++++++++++------
.../timeline/aggregated-transcript.ts | 169 ++++++++-------
.../timeline/sharedMediaTrim.test.ts | 3 +-
5 files changed, 419 insertions(+), 172 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index dfe3d159c..d9c2fae4d 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -74,10 +74,10 @@ import {
} from "@/lib/ai-edition/timeline/aggregated-transcript";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatMs } from "@/lib/ai-edition/timeline/format";
-import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview";
-import {
- type AssetTranscriptionView,
- type TranscriptGateReason,
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
+import type {
+ AssetTranscriptionView,
+ TranscriptGateReason,
} from "@/lib/ai-edition/transcription/status";
import { getAssetPath } from "@/lib/assetPath";
import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/compositeLayout";
@@ -875,29 +875,24 @@ export function TranscriptPane({
lane === "voiceover" && voiceover.length === 0 ? "recording" : lane;
const placements = activeLane === "voiceover" ? voiceover : clips;
+ // From the RECORDING clips and the whole trim set, never from `placements`: the
+ // programme is one thing, and the voiceover lane is asking whether the film still
+ // contains a moment — not whether some trim happens to name an audio fragment.
+ const removed = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
const sections = useMemo(
- () => buildAggregatedSections(placements, transcripts, assets, trimRanges),
- [placements, transcripts, assets, trimRanges],
+ () => buildAggregatedSections(placements, transcripts, assets, removed),
+ [placements, transcripts, assets, removed],
);
- // the cue position is the playback head's location in the current clip's source time.
// `currentTimeSec` is the RAW/document timeline (same referential as the ruler, see
- // NewEditorShell) — looked up against the raw `clips`, matching that referential.
- // `clipId` is what `findCueWordId` keys on — do NOT drop it as unused: source time is
- // per asset, so without it the resolver falls back to the first section of the asset
- // and the cue tracks clip 1 forever on a timeline that plays one media twice.
- const cue = useMemo(() => {
- if (clips.length === 0) return null;
- const position = locateVirtualPosition(clips, currentTimeSec);
- if (!position) return null;
- return {
- assetId: position.clip.assetId,
- clipId: position.clip.id,
- sourceTimeSec: position.sourceTimeSec,
- };
- }, [clips, currentTimeSec]);
-
- const cueWordId = useMemo(() => findCueWordId(sections, cue), [sections, cue]);
+ // NewEditorShell), which is exactly what `findCueWordId` now takes. It used to be
+ // resolved through `locateVirtualPosition` into a clip id + source second, and a clip
+ // id is something only the recording lane has — so the voiceover lane never
+ // highlighted. Raw seconds are the coordinate both lanes share.
+ const cueWordId = useMemo(
+ () => findCueWordId(sections, currentTimeSec),
+ [sections, currentTimeSec],
+ );
const laneSwitch =
voiceover.length > 0 ? : null;
@@ -1152,8 +1147,12 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
const removeTrimRun = useCallback(
(run: TrimRun) => {
- if (busy || !run.trimId) return;
- onRemoveTrimRange(run.trimId);
+ // An empty set is a gap between clips: removed from the film, but by nothing
+ // there is a pill for. Step 4 of #560 replaces this with a call that drops every
+ // row of the pill at once; today's op takes one id, so overlapping cuts still
+ // need a second click, exactly as before.
+ if (busy || run.trimIds.length === 0) return;
+ onRemoveTrimRange(run.trimIds[0]);
},
[busy, onRemoveTrimRange],
);
@@ -1598,7 +1597,7 @@ const TranscriptWord = memo(function TranscriptWord({
onClick={(e) => {
e.stopPropagation();
onRestore({
- trimId: cw.trimId ?? "",
+ trimIds: cw.trimIds,
assetId: "",
startWordIndex: 0,
endWordIndex: 0,
@@ -1725,7 +1724,7 @@ const TranscriptWord = memo(function TranscriptWord({
data-start-sec={cw.word.startSec}
data-end-sec={cw.word.endSec}
data-inserted="true"
- data-skip-id={cw.trimId ?? undefined}
+ data-skip-id={cw.trimIds[0] ?? undefined}
style={{ display: "inline", opacity: removed ? 0.6 : 1 }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
@@ -1815,7 +1814,7 @@ const TranscriptWord = memo(function TranscriptWord({
data-word-id={cw.id}
data-start-sec={cw.word.startSec}
data-end-sec={cw.word.endSec}
- data-skip-id={cw.trimId ?? undefined}
+ data-skip-id={cw.trimIds[0] ?? undefined}
data-corrected={corrected ? "true" : undefined}
data-cue={isCue ? "true" : undefined}
title={corrected ? ts("transcript.correctedWord", { original }) : undefined}
@@ -1848,7 +1847,7 @@ const TranscriptWord = memo(function TranscriptWord({
the LLM is the only place that names a word a filler (via the
filler_or_hesitation reason when generating suggestions). */}
{cw.word.text}{" "}
- {removed && hover && cw.trimId ? (
+ {removed && hover && cw.trimIds.length > 0 ? (
{
e.stopPropagation();
- // build a minimal TrimRun stub — only trimId is
+ // build a minimal TrimRun stub — only the ids are
// read by onRestore.
onRestore({
- trimId: cw.trimId ?? "",
+ trimIds: cw.trimIds,
assetId: "",
startWordIndex: 0,
endWordIndex: 0,
@@ -2041,7 +2040,7 @@ function findCollapsedDeletionWordId(
): string | null {
// read the kept/skip state from the words array, not the
// DOM's data-skip-id. The DOM may be lagging a render behind (its
- // trimId is only set on the next React commit), so a DOM check would
+ // skip id is only set on the next React commit), so a DOM check would
// re-trim an already-trimmed word. The words array is the React state
// captured at the call site — always current.
const skippedIds = new Set(words.filter((w) => !w.kept).map((w) => w.id));
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
index 81db5c0eb..0a458c36a 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest";
import type { AxcutAudioTrack } from "../schema";
import {
buildAggregatedSections,
+ findCueWordId,
lanePlacements,
+ placementRawSec,
voiceoverPlacements,
} from "./aggregated-transcript";
+import { removedRawSpans } from "./programme-time";
function track(over: Partial & { id: string }): AxcutAudioTrack {
return {
@@ -121,3 +124,160 @@ describe("lanePlacements", () => {
expect(sections[0].words[0].id.startsWith("t1:")).toBe(true);
});
});
+
+// ─── The bug this parameterisation shipped with ──────────────────────────────
+// `b9e0f1ff` decided kept-or-removed by asking whether a trim NAMED the placement. A
+// voiceover placement carries an audio fragment id and an audio asset; every trim carries
+// a video clip. They never matched, so the voiceover lane read every word as kept — over
+// film that had been cut away — and a cut authored from it removed nothing at all. These
+// hold the ruler-based answer that replaced it.
+
+describe("one programme, two lanes", () => {
+ const CLIPS_2 = [
+ {
+ id: "clip_1",
+ assetId: "asset_rec",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ {
+ id: "clip_2",
+ assetId: "asset_rec",
+ sourceStartSec: 6,
+ sourceEndSec: 12,
+ timelineStartSec: 6,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ];
+
+ /** A cut over raw 2..4, anchored the way the transcript pane writes one. */
+ const TRIM = {
+ id: "trim_1",
+ assetId: "asset_rec",
+ clipId: "clip_1",
+ startSec: 2,
+ endSec: 4,
+ origin: "user" as const,
+ reason: "",
+ };
+
+ /** Words at one per second, so a word's index is its second. */
+ function secondsTranscript(assetId: string, count: number, from = 0) {
+ return {
+ assetId,
+ language: "en",
+ segments: [],
+ words: Array.from({ length: count }, (_, i) => ({
+ id: `w${from + i}`,
+ segmentId: "s",
+ text: `w${from + i}`,
+ startSec: from + i + 0.1,
+ endSec: from + i + 0.9,
+ })),
+ };
+ }
+
+ /** A voiceover laid over the whole programme, reading its own file from the head. */
+ const VO = track({ id: "vo_1", startMs: 0, endMs: 12000, offsetMs: 0, durationSec: 12 });
+
+ function lanes(trims: (typeof TRIM)[]) {
+ const removed = removedRawSpans(CLIPS_2, trims);
+ const transcripts = [secondsTranscript("asset_rec", 12), secondsTranscript("asset_vo", 12)];
+ const build = (lane: "recording" | "voiceover") =>
+ buildAggregatedSections(
+ lanePlacements(lane, CLIPS_2, [VO]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixtures, not a schema exercise
+ transcripts as any,
+ [],
+ removed,
+ );
+ return { recording: build("recording"), voiceover: build("voiceover") };
+ }
+
+ const cutWords = (sections: ReturnType["recording"]) =>
+ sections
+ .flatMap((s) => s.words)
+ .filter((w) => !w.kept && !w.word.id.startsWith("silence_"))
+ .map((w) => w.word.text);
+
+ it("marks a voiceover word removed when the film under it was cut", () => {
+ // THE bug. Before this, the voiceover lane returned every word kept.
+ const { voiceover } = lanes([TRIM]);
+ expect(cutWords(voiceover)).toEqual(["w2", "w3"]);
+ const w2 = voiceover.flatMap((s) => s.words).find((w) => w.word.id === "w2");
+ expect(w2?.trimIds).toEqual(["trim_1"]);
+ });
+
+ it("greys the same moment on whichever lane you read", () => {
+ const { recording, voiceover } = lanes([TRIM]);
+ expect(cutWords(recording)).toEqual(["w2", "w3"]);
+ expect(cutWords(voiceover)).toEqual(cutWords(recording));
+ });
+
+ it("leaves both lanes whole when nothing is cut", () => {
+ const { recording, voiceover } = lanes([]);
+ expect(cutWords(recording)).toEqual([]);
+ expect(cutWords(voiceover)).toEqual([]);
+ });
+
+ it("removes a word over an inter-clip gap, with nothing to restore", () => {
+ const gapped = [CLIPS_2[0], { ...CLIPS_2[1], timelineStartSec: 8, timelineEndSec: 14 }];
+ const removed = removedRawSpans(gapped, []);
+ const sections = buildAggregatedSections(
+ voiceoverPlacements([track({ id: "vo_1", startMs: 0, endMs: 14000, durationSec: 14 })]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ [secondsTranscript("asset_vo", 14)] as any,
+ [],
+ removed,
+ );
+ const w6 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w6"); // raw 6..7
+ expect(w6?.kept).toBe(false);
+ // Nothing took it, so the pane must offer no bin: a gap is not a pill.
+ expect(w6?.trimIds).toEqual([]);
+ const run = sections.flatMap((s) => s.trimRuns).find((r) => r.trimIds.length === 0);
+ expect(run).toBeDefined();
+ });
+
+ it("keeps a word that hangs past the end of the programme", () => {
+ // The projection is the identity there, so the narration still plays.
+ const over = track({ id: "vo_1", startMs: 0, endMs: 20000, durationSec: 20 });
+ const sections = buildAggregatedSections(
+ voiceoverPlacements([over]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ [secondsTranscript("asset_vo", 20)] as any,
+ [],
+ removedRawSpans(CLIPS_2, []),
+ );
+ const w15 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w15");
+ expect(w15?.kept).toBe(true);
+ });
+
+ it("highlights the voiceover lane from a raw second", () => {
+ // The cue used to be resolved into a clip id, which only the recording lane has —
+ // so this returned null for every moment of every voiceover.
+ const { voiceover } = lanes([]);
+ expect(findCueWordId(voiceover, 4.5)).toBe("vo_1:w4");
+ expect(findCueWordId(voiceover, 0.5)).toBe("vo_1:w0");
+ });
+
+ it("reads a word's raw moment through its own placement", () => {
+ // A take starting 3s along the ruler, 5s into its file: its source 6 is raw 4.
+ const placement = { id: "p", assetId: "a", sourceStartSec: 5, timelineStartSec: 3 };
+ expect(placementRawSec(placement, 6)).toBe(4);
+ });
+
+ it("contributes no placement for a looping take", () => {
+ // `anchorAudioTrackFragments` does not advance `offsetMs` under loop, so a looping
+ // take's later fragments map their words to raw moments the words do not occupy.
+ expect(voiceoverPlacements([{ ...VO, loop: true }])).toEqual([]);
+ expect(voiceoverPlacements([VO])).toHaveLength(1);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index adc70b6cb..1a62d7e4c 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -7,6 +7,7 @@ import {
findCueWordId,
isSilenceWord,
} from "./aggregated-transcript";
+import { removedRawSpans } from "./programme-time";
function makeClip(overrides: Partial = {}): AxcutClip {
return {
@@ -78,18 +79,23 @@ describe("buildClipSection", () => {
]);
const trim = makeTrim({ id: "trim_a", startSec: 1, endSec: 4 });
- const section = buildClipSection(clip, transcript, makeAsset(), [trim]);
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], [trim]),
+ );
expect(section.words.map((cw) => cw.kept)).toEqual([true, false, false, false, true]);
- expect(section.words.map((cw) => cw.trimId)).toEqual([
- null,
- "trim_a",
- "trim_a",
- "trim_a",
- null,
+ expect(section.words.map((cw) => cw.trimIds)).toEqual([
+ [],
+ ["trim_a"],
+ ["trim_a"],
+ ["trim_a"],
+ [],
]);
expect(section.trimRuns).toHaveLength(1);
expect(section.trimRuns[0]).toMatchObject({
- trimId: "trim_a",
+ trimIds: ["trim_a"],
startWordIndex: 1,
endWordIndex: 3,
durationSec: 3,
@@ -111,15 +117,15 @@ describe("buildClipSection", () => {
makeTrim({ id: "trim_b", startSec: 3, endSec: 4 }),
];
- const section = buildClipSection(clip, transcript, makeAsset(), trims);
+ const section = buildClipSection(clip, transcript, makeAsset(), removedRawSpans([clip], trims));
expect(section.trimRuns).toHaveLength(2);
expect(section.trimRuns[0]).toMatchObject({
- trimId: "trim_a",
+ trimIds: ["trim_a"],
startWordIndex: 1,
endWordIndex: 1,
});
expect(section.trimRuns[1]).toMatchObject({
- trimId: "trim_b",
+ trimIds: ["trim_b"],
startWordIndex: 3,
endWordIndex: 3,
});
@@ -146,29 +152,31 @@ describe("buildClipSection", () => {
it("marks the words removed only in the clip the trim is anchored to", () => {
const trim = makeTrim({ id: "trim_c2", clipId: "clip_2", startSec: 1, endSec: 2 });
+ const clips = [clip1(), clip2()];
const sections = buildAggregatedSections(
- [clip1(), clip2()],
+ clips,
[makeTranscript(words())],
[makeAsset()],
- [trim],
+ removedRawSpans(clips, [trim]),
);
expect(sections[0].words.map((cw) => cw.kept)).toEqual([true, true, true]);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].words.map((cw) => cw.kept)).toEqual([true, false, true]);
expect(sections[1].trimRuns).toHaveLength(1);
- expect(sections[1].trimRuns[0]).toMatchObject({ trimId: "trim_c2", startWordIndex: 1 });
+ expect(sections[1].trimRuns[0]).toMatchObject({ trimIds: ["trim_c2"], startWordIndex: 1 });
});
it("still marks both clips for a pre-v7 trim that names no clip", () => {
// Back-compat: an un-anchored row keeps the asset-wide meaning it had, so an
// existing document reads exactly as it did before the anchor was introduced.
const trim = makeTrim({ id: "trim_legacy", startSec: 1, endSec: 2 });
+ const clips = [clip1(), clip2()];
const sections = buildAggregatedSections(
- [clip1(), clip2()],
+ clips,
[makeTranscript(words())],
[makeAsset()],
- [trim],
+ removedRawSpans(clips, [trim]),
);
expect(sections[0].trimRuns).toHaveLength(1);
expect(sections[1].trimRuns).toHaveLength(1);
@@ -183,7 +191,12 @@ describe("buildClipSection", () => {
]);
const trim = makeTrim({ id: "trim_x", assetId: "asset_2", startSec: 0.5, endSec: 2.5 });
- const section = buildClipSection(clip, transcript, makeAsset(), [trim]);
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], [trim]),
+ );
// Trailing gap 2s→3s is a silence — the different-asset trim doesn't
// cover any of the three entries, so all stay kept.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -201,7 +214,7 @@ describe("buildClipSection", () => {
// ponytail: the LLM (not the renderer) decides what is a filler. Every
// word renders as plain text in the right pane.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
- expect(section.words.map((cw) => cw.trimId)).toEqual([null, null, null]);
+ expect(section.words.map((cw) => cw.trimIds)).toEqual([[], [], []]);
});
it("returns an empty words list when the clip has no matching transcript", () => {
@@ -261,10 +274,15 @@ describe("silence gaps", () => {
]);
const trim = makeTrim({ id: "trim_silence", startSec: 1, endSec: 2 });
- const section = buildClipSection(clip, transcript, makeAsset(), [trim]);
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], [trim]),
+ );
const silence = section.words.find((cw) => isSilenceWord(cw.word));
expect(silence?.kept).toBe(false);
- expect(silence?.trimId).toBe("trim_silence");
+ expect(silence?.trimIds).toEqual(["trim_silence"]);
});
});
@@ -323,114 +341,164 @@ describe("buildAggregatedSections", () => {
});
describe("findCueWordId", () => {
+ // Takes a RAW ruler second. It used to take a clip id plus a source second, which only
+ // the recording lane could ever produce — so the voiceover lane never highlighted a
+ // word at all. Raw time is the coordinate both lanes share, and it settles the
+ // duplicated-clip case the clip id was introduced for: two sections over one media
+ // have identical source ranges but different raw extents.
function makeSection(
clipId: string,
assetId: string,
wordTimes: Array<[string, number, number]>,
+ clipOverrides: Partial = {},
) {
return {
- clip: makeClip({ id: clipId, assetId, sourceStartSec: 0, sourceEndSec: 100 }),
+ clip: makeClip({
+ id: clipId,
+ assetId,
+ sourceStartSec: 0,
+ sourceEndSec: 100,
+ timelineStartSec: 0,
+ timelineEndSec: 100,
+ ...clipOverrides,
+ }),
asset: makeAsset({ id: assetId }),
transcript: null,
words: wordTimes.map(([id, start, end]) => ({
id: clipWordId(clipId, id),
word: { id, segmentId: "s1", startSec: start, endSec: end, text: id },
kept: true,
- trimId: null,
+ trimIds: [],
})),
trimRuns: [],
};
}
- it("returns null when cue is null", () => {
+ it("returns null when there is no playhead", () => {
const section = makeSection("c1", "asset_1", [["w1", 0, 1]]);
expect(findCueWordId([section], null)).toBeNull();
});
- it("returns null when no section matches the cue asset", () => {
- const section = makeSection("c1", "asset_1", [["w1", 0, 1]]);
- const cue = { assetId: "asset_2", sourceTimeSec: 0.5 };
- expect(findCueWordId([section], cue)).toBeNull();
+ it("returns null when the head is before every section", () => {
+ const section = makeSection("c1", "asset_1", [["w1", 0, 1]], {
+ timelineStartSec: 10,
+ timelineEndSec: 110,
+ });
+ expect(findCueWordId([section], 2)).toBeNull();
});
- it("returns the word containing the cue time", () => {
+ it("returns the word containing the head", () => {
const section = makeSection("c1", "asset_1", [
["w1", 0, 1],
["w2", 1, 2],
["w3", 2, 3],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 1.5 })).toBe("c1:w2");
+ expect(findCueWordId([section], 1.5)).toBe("c1:w2");
});
- it("returns the previous word when the cue is between two words", () => {
+ it("returns the previous word when the head is between two words", () => {
const section = makeSection("c1", "asset_1", [
["w1", 0, 1],
["w2", 2, 3],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 1.5 })).toBe("c1:w1");
+ expect(findCueWordId([section], 1.5)).toBe("c1:w1");
});
- it("returns the previous word when the cue is before the first word", () => {
+ it("returns null when the head is before the first word", () => {
const section = makeSection("c1", "asset_1", [
["w1", 5, 6],
["w2", 7, 8],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 0.5 })).toBeNull();
+ expect(findCueWordId([section], 0.5)).toBeNull();
});
- it("returns the last word when the cue is after the last word", () => {
+ it("returns the last word when the head is past the last word", () => {
const section = makeSection("c1", "asset_1", [
["w1", 0, 1],
["w2", 1, 2],
]);
- expect(findCueWordId([section], { assetId: "asset_1", sourceTimeSec: 99 })).toBe("c1:w2");
+ expect(findCueWordId([section], 99)).toBe("c1:w2");
+ });
+
+ it("reads the head through the section's own source clock", () => {
+ // A clip that starts 20s along the ruler and 5s into its media: raw 22 is source 7.
+ const section = makeSection("c1", "asset_1", [["w1", 6, 8]], {
+ sourceStartSec: 5,
+ sourceEndSec: 15,
+ timelineStartSec: 20,
+ timelineEndSec: 30,
+ });
+ expect(findCueWordId([section], 22)).toBe("c1:w1");
+ expect(findCueWordId([section], 2)).toBeNull();
});
// Two clips over the same media project the SAME transcript words twice, so the cue
- // has to be resolved against the clip that is actually playing. Matching on assetId
- // alone always returned the first section — the highlight tracked clip 1 forever.
+ // has to be resolved against the one that is actually playing.
describe("two clips over the same media", () => {
const sections = () => [
- makeSection("c1", "asset_1", [
- ["w1", 0, 1],
- ["w2", 1, 2],
- ]),
- makeSection("c2", "asset_1", [
- ["w1", 0, 1],
- ["w2", 1, 2],
- ]),
+ makeSection(
+ "c1",
+ "asset_1",
+ [
+ ["w1", 0, 1],
+ ["w2", 1, 2],
+ ],
+ { sourceEndSec: 3, timelineStartSec: 0, timelineEndSec: 3 },
+ ),
+ makeSection(
+ "c2",
+ "asset_1",
+ [
+ ["w1", 0, 1],
+ ["w2", 1, 2],
+ ],
+ { sourceEndSec: 3, timelineStartSec: 3, timelineEndSec: 6 },
+ ),
];
- it("resolves the cue against the clip that is playing", () => {
- expect(
- findCueWordId(sections(), { assetId: "asset_1", clipId: "c2", sourceTimeSec: 1.5 }),
- ).toBe("c2:w2");
- expect(
- findCueWordId(sections(), { assetId: "asset_1", clipId: "c1", sourceTimeSec: 1.5 }),
- ).toBe("c1:w2");
+ it("resolves the head against the clip that is playing", () => {
+ // Source 1.5 in both, but raw 4.5 is only inside c2.
+ expect(findCueWordId(sections(), 4.5)).toBe("c2:w2");
+ expect(findCueWordId(sections(), 1.5)).toBe("c1:w2");
});
it("returns an id that cannot match the other clip's copy of the same word", () => {
- const cue = findCueWordId(sections(), {
- assetId: "asset_1",
- clipId: "c2",
- sourceTimeSec: 1.5,
- });
+ const cue = findCueWordId(sections(), 4.5);
// The whole point: `word.id` is "w2" in BOTH sections, so a bare word id lit up
// both blocks. Exactly one rendered word may claim the cue.
const claiming = sections().flatMap((s) => s.words.filter((cw) => cw.id === cue));
expect(claiming).toHaveLength(1);
});
- it("falls back to the asset when the caller names no clip", () => {
- expect(findCueWordId(sections(), { assetId: "asset_1", sourceTimeSec: 1.5 })).toBe("c1:w2");
+ it("returns null rather than another clip's words when the playing clip has none", () => {
+ const withEmptyC2 = [
+ sections()[0],
+ makeSection("c2", "asset_1", [], {
+ sourceEndSec: 3,
+ timelineStartSec: 3,
+ timelineEndSec: 6,
+ }),
+ ];
+ // c2 has no words, and borrowing c1's would point at the wrong text.
+ expect(findCueWordId(withEmptyC2, 4.5)).toBeNull();
});
- it("returns null rather than another clip's words when the playing clip has none", () => {
- const withEmptyC2 = [sections()[0], makeSection("c2", "asset_1", [])];
- expect(
- findCueWordId(withEmptyC2, { assetId: "asset_1", clipId: "c2", sourceTimeSec: 1.5 }),
- ).toBeNull();
+ it("runs an open-ended placement up to the next one", () => {
+ // An unprobed clip has no raw extent of its own; it ends where the next begins.
+ const open = [
+ makeSection("c1", "asset_1", [["w1", 0, 1]], {
+ sourceEndSec: undefined,
+ timelineStartSec: 0,
+ timelineEndSec: 3,
+ }),
+ makeSection("c2", "asset_1", [["w1", 0, 1]], {
+ sourceEndSec: 3,
+ timelineStartSec: 3,
+ timelineEndSec: 6,
+ }),
+ ];
+ expect(findCueWordId(open, 2)).toBe("c1:w1");
+ expect(findCueWordId(open, 3.5)).toBe("c2:w1");
});
});
});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 9b59f2bd6..9ecc0d5b5 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -14,15 +14,8 @@
// names a word a filler. The transcript view shows plain text for every
// kept word; the user or the LLM decides what to mark as skipped.
-import type {
- AxcutAsset,
- AxcutAudioTrack,
- AxcutClip,
- AxcutTranscript,
- AxcutTrimRange,
- AxcutWord,
-} from "../schema";
-import { trimAppliesToClip } from "./trim-mapping";
+import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
+import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
/**
* The unit the aggregation actually runs over: one stretch of ONE asset's source
@@ -53,6 +46,26 @@ export interface TranscriptPlacement {
/** Which lane's speech the transcript tab is reading. */
export type TranscriptLane = "recording" | "voiceover";
+/**
+ * A source second of this placement's asset, as a moment on the RAW ruler.
+ *
+ * The one coordinate both lanes share. Source time is per asset, so it cannot say
+ * whether two things coincide; raw time can, which is why kept-or-removed is asked here
+ * and not in source time (issue #560).
+ */
+export function placementRawSec(placement: TranscriptPlacement, sourceSec: number): number {
+ return placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
+}
+
+/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
+export function placementRawExtent(placement: TranscriptPlacement): RawSpan | null {
+ if (placement.sourceEndSec === undefined) return null;
+ return {
+ startSec: placement.timelineStartSec,
+ endSec: placementRawSec(placement, placement.sourceEndSec),
+ };
+}
+
/** Gaps between words at least this long are surfaced as a `[silence]` token. */
export const SILENCE_THRESHOLD_SEC = 0.2;
@@ -116,8 +129,13 @@ function withSilenceGaps(
/** A contiguous run of removed words inside one clip's source range. */
export interface TrimRun {
- /** Id of the trim range this run came from (used by the bin-icon restore). */
- trimId: string;
+ /**
+ * The trims that took this run — SEVERAL when they overlap, and EMPTY when the run
+ * sits in a gap between clips, which is missing from the film without anything having
+ * removed it. A restore affordance must be keyed on this being non-empty: there is no
+ * pill to click for a gap.
+ */
+ trimIds: string[];
/** Index of the first removed word in `words`. */
startWordIndex: number;
/** Inclusive index of the last removed word in `words`. */
@@ -148,10 +166,10 @@ export interface ClipWord {
/** {@link clipWordId} — the word's identity *in this clip*, unique across the pane. */
id: string;
word: AxcutWord;
- /** Whether the word is inside a trimRange for this clip's asset. */
+ /** Whether the raw moment this word occupies is still in the film. */
kept: boolean;
- /** Id of the trim range that removed this word, if any. */
- trimId: string | null;
+ /** The trims that took it — empty when kept, and empty for a word over a gap. */
+ trimIds: string[];
}
/** One placement's contribution to the aggregated flow. */
@@ -176,37 +194,25 @@ function wordsInRange(transcript: AxcutTranscript, startSec: number, endSec: num
);
}
-/** Find the trim range covering this word's center (returns the deepest match). */
-function findCoveringTrim(word: AxcutWord, trimRanges: AxcutTrimRange[]): AxcutTrimRange | null {
- const center = (word.startSec + word.endSec) / 2;
- for (const trim of trimRanges) {
- if (center >= trim.startSec && center <= trim.endSec) return trim;
- }
- return null;
-}
-
/**
- * Build one clip section. Words inside the clip's source range that fall
- * inside any trim range for the same asset are marked removed; the rest
- * are kept. Contiguous removed words from the same trim range group into
- * one `TrimRun` (for the trim-duration pill + bin-icon restore).
+ * Build one placement's section. A word is removed when the RAW moment it occupies is not
+ * in the film; the rest are kept. Contiguous removed words taken by the same trims group
+ * into one `TrimRun` (for the trim-duration pill + bin-icon restore).
+ *
+ * Takes the precomputed removed set, not the trim rows. Filtering rows by identity —
+ * `trimAppliesToClip`, which is what this did — is a question a voiceover placement can
+ * never answer yes to: it carries an audio fragment id and an audio asset, while every
+ * trim carries a video clip. That is what left the voiceover lane reading every word as
+ * kept over film that had been cut away (issue #560). Asking the ruler instead makes both
+ * lanes agree by construction, and keeps the recording lane's answers identical: the same
+ * per-clip walk decides both.
*/
export function buildClipSection(
clip: TranscriptPlacement,
transcript: AxcutTranscript | null,
asset: AxcutAsset | null,
- trimRanges: AxcutTrimRange[],
+ removed: RemovedRawSpan[],
): ClipSection {
- // `trimAppliesToClip` — not a bare `assetId` match — is what keeps a cut on the
- // second of two clips over the same media from also greying out the first one's
- // words. Same media, same source range: only the clip anchor tells them apart.
- const clipTrims = trimRanges.filter(
- (trim) =>
- trimAppliesToClip(trim, clip) &&
- trim.endSec > clip.sourceStartSec &&
- trim.startSec < (clip.sourceEndSec ?? Infinity),
- );
-
const words = transcript
? withSilenceGaps(
wordsInRange(transcript, clip.sourceStartSec, clip.sourceEndSec ?? Infinity),
@@ -215,25 +221,27 @@ export function buildClipSection(
)
: [];
const tagged: ClipWord[] = words.map((word) => {
- const covering = findCoveringTrim(word, clipTrims);
+ // The word's CENTRE, mirroring the rule the identity filter used, so the recording
+ // lane's tagging does not shift under this change.
+ const covering = removalAt(removed, placementRawSec(clip, (word.startSec + word.endSec) / 2));
return {
id: clipWordId(clip.id, word.id),
word,
kept: covering === null,
- trimId: covering?.id ?? null,
+ trimIds: covering?.trimIds ?? [],
};
});
const trimRuns: TrimRun[] = [];
let runStart = -1;
let runEnd = -1;
- let runTrimId = "";
+ let runTrimIds: string[] = [];
let runMinStart = 0;
let runMaxEnd = 0;
const flush = () => {
if (runStart >= 0) {
trimRuns.push({
- trimId: runTrimId,
+ trimIds: runTrimIds,
assetId: clip.assetId,
startWordIndex: runStart,
endWordIndex: runEnd,
@@ -242,23 +250,26 @@ export function buildClipSection(
}
runStart = -1;
runEnd = -1;
- runTrimId = "";
+ runTrimIds = [];
runMinStart = 0;
runMaxEnd = 0;
};
+ const key = (ids: string[]) => ids.join("|");
tagged.forEach((cw, i) => {
if (cw.kept) {
flush();
return;
}
- // Split the run if the trim range id changes (overlapping trims).
- if (runStart >= 0 && cw.trimId !== runTrimId) {
+ // Split the run when the SET of trims changes, so two cuts meeting at a word
+ // boundary stay two pills. A run whose set is empty is a gap between clips: still
+ // removed, still one run, but with nothing to restore.
+ if (runStart >= 0 && key(cw.trimIds) !== key(runTrimIds)) {
flush();
}
if (runStart < 0) {
runStart = i;
runMinStart = cw.word.startSec;
- runTrimId = cw.trimId ?? "";
+ runTrimIds = cw.trimIds;
}
runEnd = i;
runMaxEnd = Math.max(runMaxEnd, cw.word.endSec);
@@ -277,7 +288,7 @@ export function buildAggregatedSections(
clips: TranscriptPlacement[],
transcripts: AxcutTranscript[],
assets: AxcutAsset[],
- trimRanges: AxcutTrimRange[],
+ removed: RemovedRawSpan[],
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
@@ -286,7 +297,7 @@ export function buildAggregatedSections(
clip,
transcriptById.get(clip.assetId) ?? null,
assetById.get(clip.assetId) ?? null,
- trimRanges,
+ removed,
),
);
}
@@ -304,13 +315,14 @@ export function buildAggregatedSections(
* already carry exactly the source windows this needs, and collapsing them back
* into one pill here would re-read the file from its head at every cut.
*
- * `loop` is ignored on purpose. A looping voiceover would repeat its words, and a
- * transcript that says the same sentence three times is not a transcript of
- * anything — the source window is what was said, however many times it plays.
+ * A LOOPING take contributes nothing at all. `anchorAudioTrackFragments` deliberately
+ * does not advance `offsetMs` across the fragments of a looping track, so their words map
+ * to raw moments the words do not occupy — a placement built from them would read
+ * kept-or-removed on false evidence, and would author a cut in the wrong place.
*/
export function voiceoverPlacements(audioTracks: AxcutAudioTrack[]): TranscriptPlacement[] {
return audioTracks
- .filter((track) => track.kind === "voiceover")
+ .filter((track) => track.kind === "voiceover" && !track.loop)
.slice()
.sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id))
.map((track) => {
@@ -334,16 +346,6 @@ export function lanePlacements(
return lane === "voiceover" ? voiceoverPlacements(audioTracks) : clips;
}
-/** Where the playback head currently is, in source time. */
-export interface CuePosition {
- assetId: string;
- /** Which clip is playing — the primary selector for the cue's section. Source time is
- * per asset, so `assetId` cannot separate two clips over one media; pass this whenever
- * the caller knows it (the transcript pane always does). */
- clipId?: string;
- sourceTimeSec: number;
-}
-
/**
* Find the word in `sections` that the playback head is currently inside, as a
* {@link clipWordId} — NOT a bare `word.id`, which would name the same moment in every
@@ -356,22 +358,39 @@ export interface CuePosition {
* - Silence tokens (id starts with `silence_`) are skipped over so a
* long pause doesn't surface a fake cue word.
*
- * The section is chosen by `cue.clipId` when the caller knows which clip is playing.
- * Matching on `assetId` alone always resolved to the FIRST section of that asset, so with
- * a clip duplicated on the timeline the cue tracked clip 1 while clip 2 played. `assetId`
- * stays as the fallback for callers that have no clip in hand.
+ * Takes a RAW ruler second. It used to take a clip id resolved from the playhead, which
+ * only ever named a video clip — so the voiceover lane never highlighted anything at all.
+ * Raw time is what both lanes have in common, and it also settles the case the clip id was
+ * introduced for: with one clip duplicated on the timeline, the two sections occupy
+ * different raw extents even though their source ranges are identical.
+ *
+ * The section is the one whose raw extent contains the head. An open-ended placement (a
+ * clip whose media has not been probed) has no extent of its own and runs to the next
+ * section's head, then to the end of time.
*/
-export function findCueWordId(sections: ClipSection[], cue: CuePosition | null): string | null {
- if (!cue) return null;
- const withWords = sections.filter((s) => s.words.length > 0);
- // No fallback when `clipId` is given but that clip has no transcript: the playing clip
- // simply has no cue word, and borrowing another clip's would point at the wrong text.
- const match = cue.clipId
- ? withWords.find((s) => s.clip.id === cue.clipId)
- : withWords.find((s) => s.clip.assetId === cue.assetId);
+export function findCueWordId(sections: ClipSection[], rawSec: number | null): string | null {
+ if (rawSec === null || !Number.isFinite(rawSec)) return null;
+ // No fallback to a neighbouring section: a placement with no transcript simply has no
+ // cue word, and borrowing another's would point at the wrong text.
+ const withWords = sections
+ .filter((s) => s.words.length > 0)
+ .sort((a, b) => a.clip.timelineStartSec - b.clip.timelineStartSec);
+
+ let match: ClipSection | null = null;
+ for (const [i, section] of withWords.entries()) {
+ if (rawSec < section.clip.timelineStartSec) break;
+ const extent = placementRawExtent(section.clip);
+ const endSec =
+ extent?.endSec ?? withWords[i + 1]?.clip.timelineStartSec ?? Number.POSITIVE_INFINITY;
+ if (rawSec < endSec) {
+ match = section;
+ break;
+ }
+ }
if (!match) return null;
- const t = cue.sourceTimeSec;
+ // Back to the placement's own source clock, which is what the words are stamped in.
+ const t = match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
index 2789dd5e5..5bbe7ac44 100644
--- a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
+++ b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
@@ -12,6 +12,7 @@ import { applyTimelineOperation } from "@/lib/ai-edition/document/operations";
import { resolvePlaybackSegments } from "@/lib/ai-edition/document/timeline";
import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema";
import { buildAggregatedSections } from "@/lib/ai-edition/timeline/aggregated-transcript";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { coalescedTrimGroups } from "@/lib/ai-edition/timeline/trim-mapping";
function doc(): AxcutDocument {
@@ -73,7 +74,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
next.timeline.clips,
next.transcripts,
next.assets,
- next.timeline.trimRanges,
+ removedRawSpans(next.timeline.clips, next.timeline.trimRanges),
);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].trimRuns).toHaveLength(1);
From c1633cd36ffd7ce2ae8e0afdb67f76c795c91855 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Wed, 2 Sep 2026 23:37:30 +0200
Subject: [PATCH 065/113] feat(audio): a cut under a voiceover takes the words,
not the take's tail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 2 made the transcript pane strike through a voiceover word whose moment the
film had lost. The mix went on playing it anyway — shifted earlier, because an
audio track is laid on the finished programme as one contiguous block — so the
red was a lie in both the preview and the export.
A voiceover is now SLICED by the cuts: `subtractRemoved` over its raw span gives
one mix entry per surviving piece, each reading the source seconds it actually
covers, so the two seconds a cut took are never heard. Fades stay on the TAKE's
outer edges rather than reappearing at every piece.
Music deliberately keeps the old path, byte for byte. A bed plays through a cut
and ends early — slicing it at every edit is a musical regression the current
code avoids on purpose (see the comment it already carries), and a bed has no
words whose redness has to be true. This kind-dependence is the one place the
design departs from a single shared projection, and it is the reason the
departure is worth it.
A LOOPING voiceover also keeps the old path. Step 6 refuses that combination
outright, and inventing semantics for something about to be banned would be the
worse answer.
`resolveVoiceoverPlayback` is extracted next to `resolveTimelineAudioPlayback`
rather than left inline in the rAF, because a decision that has to agree with the
export is a decision worth testing. It asks the question in RAW seconds, which is
both simpler and exact: no projection to invert, and the same question
`removedRawSpans` answers for the words themselves, so the two cannot drift.
Preview and export are held to each other by walking the take frame by frame and
collecting the contiguous runs of source time the preview would play, then
asserting they are the entries the scene description emits, piece for piece.
Letting the take play through the cut fails two of those assertions.
Every existing audio fixture is `kind: "music"`, which is why none of them moved.
Refs #560. Step 3 of 7.
---
.../ai-edition/VirtualPreview.audio.test.ts | 97 +++++++++++++++
src/components/ai-edition/VirtualPreview.tsx | 58 ++++++++-
src/native/sceneDescription.test.ts | 113 ++++++++++++++++++
src/native/sceneDescription.ts | 42 +++++++
4 files changed, 304 insertions(+), 6 deletions(-)
diff --git a/src/components/ai-edition/VirtualPreview.audio.test.ts b/src/components/ai-edition/VirtualPreview.audio.test.ts
index e1d6fec6d..39fea8528 100644
--- a/src/components/ai-edition/VirtualPreview.audio.test.ts
+++ b/src/components/ai-edition/VirtualPreview.audio.test.ts
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import { projectRawTimelineSecToPlayback } from "@/lib/ai-edition/document/timeline";
import type { AxcutAudioTrack, AxcutClip, AxcutTrimRange } from "@/lib/ai-edition/schema";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import {
applyPreviewAudioSettings,
type PreviewAudioGraph,
resolveAudioTrackPlayback,
resolveTimelineAudioPlayback,
+ resolveVoiceoverPlayback,
timelineAudioFadeAt,
} from "./VirtualPreview";
@@ -308,3 +310,98 @@ describe("timelineAudioFadeAt", () => {
expect(timelineAudioFadeAt(long, 2, 2)).toBe(1);
});
});
+
+// ─── A cut under a voiceover ──────────────────────────────────────────────────
+// Issue #560. The preview and the export have to agree about this, or a word the
+// transcript pane shows struck through is still audible in one of them.
+
+describe("resolveVoiceoverPlayback", () => {
+ const CLIPS = [
+ {
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ];
+ /** Raw 4..6 is out of the film. */
+ const TRIMS = [
+ {
+ id: "t1",
+ assetId: "scr",
+ clipId: "c1",
+ startSec: 4,
+ endSec: 6,
+ reason: "",
+ origin: "user" as const,
+ },
+ ];
+ const removed = removedRawSpans(CLIPS, TRIMS);
+
+ const voice = {
+ id: "vo",
+ assetId: "aud",
+ kind: "voiceover" as const,
+ startMs: 0,
+ endMs: 10_000,
+ durationSec: 30,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user" as const,
+ } as unknown as AxcutAudioTrack;
+
+ it("goes silent exactly where the film lost its moment", () => {
+ expect(resolveVoiceoverPlayback(voice, 3.9, removed).shouldPlay).toBe(true);
+ expect(resolveVoiceoverPlayback(voice, 4.5, removed).shouldPlay).toBe(false);
+ expect(resolveVoiceoverPlayback(voice, 6.1, removed).shouldPlay).toBe(true);
+ });
+
+ it("keeps the take's own clock running through the cut", () => {
+ // It does NOT rewind or skip: raw 7 is second 7 of the take either way, which is
+ // what makes the words after the cut still line up with the ones on screen.
+ expect(resolveVoiceoverPlayback(voice, 7, removed).targetTimeSec).toBeCloseTo(7, 6);
+ expect(
+ resolveVoiceoverPlayback({ ...voice, offsetMs: 2000 }, 7, removed).targetTimeSec,
+ ).toBeCloseTo(9, 6);
+ });
+
+ it("stays silent outside its own span, and past the end of its file", () => {
+ expect(resolveVoiceoverPlayback({ ...voice, startMs: 2000 }, 1, removed).shouldPlay).toBe(
+ false,
+ );
+ expect(resolveVoiceoverPlayback(voice, 11, removed).shouldPlay).toBe(false);
+ // A 3s file under a 10s span: silent after its own end rather than seeking past it.
+ const short = { ...voice, durationSec: 3 } as AxcutAudioTrack;
+ expect(resolveVoiceoverPlayback(short, 2.5, removed).shouldPlay).toBe(true);
+ expect(resolveVoiceoverPlayback(short, 3.5, removed).shouldPlay).toBe(false);
+ });
+
+ it("schedules the same source seconds the export writes into the mix", () => {
+ // Walk the take frame by frame and collect the contiguous runs of source time the
+ // preview would play; they must be the export's entries, piece for piece.
+ const runs: Array<{ from: number; to: number }> = [];
+ for (let raw = 0; raw < 10; raw += 0.05) {
+ const at = resolveVoiceoverPlayback(voice, raw, removed);
+ if (!at.shouldPlay) continue;
+ const last = runs.at(-1);
+ if (last && Math.abs(at.targetTimeSec - last.to) < 0.06) last.to = at.targetTimeSec;
+ else runs.push({ from: at.targetTimeSec, to: at.targetTimeSec });
+ }
+ expect(runs).toHaveLength(2);
+ expect(runs[0].from).toBeCloseTo(0, 1);
+ expect(runs[0].to).toBeCloseTo(4, 1);
+ // The second run resumes at source 6 — the two seconds the cut took are never heard.
+ expect(runs[1].from).toBeCloseTo(6, 1);
+ expect(runs[1].to).toBeCloseTo(10, 1);
+ });
+});
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index aed97cc4c..075446e01 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -19,6 +19,11 @@ import type {
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
+import {
+ type RemovedRawSpan,
+ removalAt,
+ removedRawSpans,
+} from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
import {
clampVirtualTime,
@@ -127,6 +132,42 @@ export function resolveTimelineAudioPlayback(
};
}
+/**
+ * Where a VOICEOVER should be, asked in RAW ruler seconds (issue #560).
+ *
+ * A cut under a voiceover removes the words that were said there, not the tail of the
+ * take: the transcript pane has already struck those words through, so a mix that went on
+ * playing them — shifted earlier, as a contiguous block does — would make the red a lie.
+ *
+ * Raw is the simpler question and the exact one. `resolveTimelineAudioPlayback` works in
+ * output seconds because a bed is one contiguous block laid on the finished programme, and
+ * getting there means projecting the playhead through the trims. A sliced take needs no
+ * projection at all: its source position is its own offset plus however far raw time has
+ * carried it, and it falls silent wherever the film did. That is the same question
+ * `removedRawSpans` answers for the words themselves, so the two cannot drift.
+ *
+ * Music does NOT come through here. A bed plays through a cut and ends early, on purpose.
+ */
+export function resolveVoiceoverPlayback(
+ track: AxcutAudioTrack,
+ rawSec: number,
+ removed: RemovedRawSpan[],
+) {
+ const startSec = track.startMs / 1000;
+ const offset = Math.max(0, track.offsetMs / 1000);
+ const sourceEnd = track.durationSec > 0 ? track.durationSec : Number.POSITIVE_INFINITY;
+ const local = rawSec - startSec;
+ const targetTimeSec = Math.min(Math.max(offset, offset + local), sourceEnd);
+ return {
+ targetTimeSec,
+ shouldPlay:
+ rawSec >= startSec &&
+ rawSec < track.endMs / 1000 &&
+ offset + local < sourceEnd &&
+ removalAt(removed, rawSec) === null,
+ };
+}
+
/** Fraction 0..1 of a track's volume `localSec` into its span, applying the
* ramps. Shares `resolveFadeSecs` with the export so a fade too long for its
* span is reduced the same way on both sides. */
@@ -547,6 +588,10 @@ export function VirtualPreview({
// must see the live trims, not the set captured when the loop was created.
const trimRangesRef = useRef(trimRanges);
trimRangesRef.current = trimRanges;
+ // What the film no longer contains, recomputed only when the cuts move — the rAF asks
+ // it once per voiceover per frame, and walking every trim there would be wasteful.
+ const removedRef = useRef(removedRawSpans(clips, trimRanges));
+ removedRef.current = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
// Trim-narrowed (`resolvePlaybackSegments`) — used ONLY to detect "has the
- {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => {
- // A word whose pause the film actually holds gets a BAND as wide
- // as the time it adds — that width is the added time, drawn. One
- // that fitted in silence already there adds nothing and stays the
- // hairline it was: there is nothing to show.
- const pause = inserts.find((ins) => ins.wordId === word.id);
- const left = pause
- ? ((expandRawSec(pause.atRawSec, inserts) - boxStart) / boxLen) * 100
- : atPct;
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ wordId, text, atRawSec }) => {
+ // A word whose pause the film holds gets a BAND as wide as the time
+ // it adds — that width IS the added time, drawn. One that fitted in
+ // silence already there adds nothing and stays a hairline.
+ //
+ // Both ends on ONE clock. The mark used to place a paused word on
+ // the expanded ruler and an unpaused one at a fraction of the clip's
+ // SOURCE span, in the same ternary — two clocks, one of which the
+ // box is not drawn in.
+ const pause = inserts.find((ins) => ins.wordId === wordId);
+ const left = ((expandRawSec(atRawSec, inserts) - boxStart) / boxLen) * 100;
const width = pause ? (pause.durationSec / boxLen) * 100 : 0;
return (
e.stopPropagation()}
onClick={(e) => {
// Jump to the moment the added text sits on. The clip box
// underneath would otherwise take this as a selection.
e.stopPropagation();
- setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
+ setCurrentTime(atRawSec);
}}
/>
);
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index fc16f564f..4abae2859 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "صورة GIF متحركة",
+ "mp4Description": "ملف فيديو عالي الجودة",
+ "mp4": "MP4",
+ "mp4Video": "فيديو MP4",
+ "gif": "GIF",
+ "gifDescription": "صورة متحركة للمشاركة"
+ },
"customFont": {
- "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
- "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
- "urlLabel": "رابط استيراد خطوط Google",
- "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "إضافة خط Google",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
+ "nameLabel": "اسم العرض",
"failedToAdd": "فشل في إضافة الخط",
- "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
+ "urlLabel": "رابط استيراد خطوط Google",
+ "namePlaceholder": "خطي المخصص",
+ "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
"addingButton": "جاري الإضافة...",
- "dialogTitle": "إضافة خط Google",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
"successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
- "addButton": "إضافة خط",
- "namePlaceholder": "خطي المخصص",
+ "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
"errorEmptyName": "يرجى إدخال اسم الخط",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
- "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
- "nameLabel": "اسم العرض"
+ "addButton": "إضافة خط",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google"
},
"annotation": {
- "defaultText": "مرحبا",
- "size": "الحجم",
+ "colorWheel": "عجلة الألوان",
"imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
+ "typeArrow": "سهم",
+ "selectStyle": "حدد النمط",
+ "blurColorWhite": "أبيض",
"blurType": "نوع التمويه",
- "mosaicBlockSize": "حجم كتلة الفسيفساء",
- "colorWheel": "عجلة الألوان",
- "typeImage": "صورة",
- "textContent": "محتوى النص",
- "clearBackground": "مسح الخلفية",
- "invalidImageType": "نوع ملف غير صالح",
- "shortcutsAndTips": "اختصارات ونصائح",
- "colorPalette": "لوحة الألوان",
+ "blurShapeRectangle": "مستطيل",
+ "blurColor": "لون التمويه",
"arrowColor": "لون السهم",
- "strokeWidth": "عرض الخط: {{width}}px",
- "blurColorWhite": "أبيض",
+ "textContent": "محتوى النص",
"background": "الخلفية",
- "blurColor": "لون التمويه",
- "typeArrow": "سهم",
- "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "فسيفساء",
- "textPlaceholder": "أدخل النص هنا...",
+ "clearBackground": "مسح الخلفية",
+ "blurShapeFreehand": "رسم حر",
"blurIntensity": "كثافة التمويه",
- "textColor": "لون النص",
- "blurShapeRectangle": "مستطيل",
- "deleteAnnotation": "حذف الشرح",
"tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
- "blurShapeFreehand": "رسم حر",
- "arrowDirection": "اتجاه السهم",
- "selectStyle": "حدد النمط",
- "fontStyle": "نمط الخط",
- "imageUploadSuccess": "تم رفع الصورة بنجاح!",
"active": "نشط",
- "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
- "blurShapeOval": "بيضاوي",
+ "size": "الحجم",
"blurColorBlack": "أسود",
- "color": "لون",
+ "typeImage": "صورة",
+ "mosaicBlockSize": "حجم كتلة الفسيفساء",
+ "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
+ "strokeWidth": "عرض الخط: {{width}}px",
+ "textColor": "لون النص",
+ "defaultText": "مرحبا",
"blurShape": "شكل التمويه",
- "customFonts": "خطوط مخصصة",
- "typeBlur": "تمويه",
- "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
"type": "النوع",
+ "typeText": "نص",
+ "textPlaceholder": "أدخل النص هنا...",
+ "fontStyle": "نمط الخط",
+ "imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "colorPalette": "لوحة الألوان",
+ "color": "لون",
+ "shortcutsAndTips": "اختصارات ونصائح",
+ "none": "بدون",
+ "invalidImageType": "نوع ملف غير صالح",
+ "arrowDirection": "اتجاه السهم",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
"uploadImage": "رفع صورة",
+ "customFonts": "خطوط مخصصة",
+ "blurTypeMosaic": "فسيفساء",
"blurTypeBlur": "غاوسي",
- "none": "بدون",
+ "typeBlur": "تمويه",
"title": "إعدادات الشروح",
- "typeText": "نص"
+ "deleteAnnotation": "حذف الشرح",
+ "blurShapeOval": "بيضاوي"
+ },
+ "transcript": {
+ "restoreSilence": "استعادة الصمت ({{duration}} ث)",
+ "editWord": "تحرير \"{{word}}\"",
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
+ "insertAria": "كلمة جديدة",
+ "transcribing": "جارٍ التفريغ…",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "noTranscript": "لا يوجد نص بعد",
+ "silence": "[صمت {{duration}} ث]",
+ "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
+ "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
+ "noClips": "لا توجد مقاطع بعد",
+ "laneVoiceover": "التعليق الصوتي",
+ "laneLabel": "اقرأ النص من",
+ "revertWord": "استعادة \"{{original}}\"",
+ "helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
+ "blankedWord": "مُفرَّغة",
+ "clipLabel": "المقطع {{index}}",
+ "title": "النص الحالي",
+ "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
+ "transcribeNow": "فرّغ النص الآن",
+ "laneRecording": "التسجيل",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "removeInserted": "حذف \"{{word}}\"",
+ "trimSilence": "قص الصمت ({{duration}} ث)",
+ "editorAria": "نص {{filename}}",
+ "restoreWord": "استعادة \"{{word}}\""
},
"effects": {
- "fitClipMany": "{{count}} مقاطع",
+ "shadow": "ظل",
+ "fitClipOne": "مقطع واحد",
+ "blurBg": "تمويه الخلفية",
+ "fitClip": "ملاءمة",
+ "formatOriginal": "الأصلي",
+ "title": "التركيب",
"format": "التنسيق",
"motionBlur": "ضبابية الحركة",
+ "fitClipMany": "{{count}} مقاطع",
+ "roundness": "الاستدارة",
"fitClipFew": "{{count}} مقاطع",
- "fitClip": "ملاءمة",
+ "motion": "الحركة",
"on": "تشغيل",
- "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
- "formatOriginal": "الأصلي",
- "blurBg": "تمويه الخلفية",
"frame": "الإطار",
"padding": "المسافة البادئة",
- "shadow": "ظل",
- "off": "إيقاف",
- "title": "التركيب",
- "motion": "الحركة",
- "fitClipOne": "مقطع واحد",
- "roundness": "الاستدارة"
+ "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
+ "off": "إيقاف"
+ },
+ "audioTrack": {
+ "mute": "كتم",
+ "fadeIn": "تلاشٍ للداخل",
+ "loop": "تكرار",
+ "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
+ "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "importFailed": "تعذّر إضافة الصوت",
+ "fadeOut": "تلاشٍ للخارج",
+ "add": "إضافة مسار صوتي",
+ "defaultLabel": "مسار صوتي",
+ "remove": "حذف المسار"
},
"layout": {
- "noWebcam": "بدون كاميرا",
- "bgModes": {
- "none": "الأصلي",
- "transparent": "تفريغ",
- "blur": "تمويه",
- "custom": "مخصص"
- },
- "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
- "webcamFraming": "تأطير كاميرا الويب",
- "webcamCropX": "تحريك أفقي",
- "preset": "الإعداد المسبق",
- "webcamSize": "حجم كاميرا الويب",
"shapes": {
"circle": "دائرة",
"square": "مربع",
"rectangle": "مستطيل",
"rounded": "زوايا مستديرة"
},
- "dualFrame": "إطار مزدوج",
"verticalStack": "تكدس عمودي",
- "webcamCropZoom": "تكبير الاقتصاص",
+ "webcamSize": "حجم كاميرا الويب",
+ "preset": "الإعداد المسبق",
+ "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
+ "dualFrame": "إطار مزدوج",
"title": "تخطيط الكاميرا",
- "webcamCropY": "تحريك عمودي",
"reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
+ "bgModes": {
+ "transparent": "تفريغ",
+ "custom": "مخصص",
+ "none": "الأصلي",
+ "blur": "تمويه"
+ },
+ "webcamCropZoom": "تكبير الاقتصاص",
+ "selectPreset": "حدد إعدادًا مسبقًا",
+ "webcamCropY": "تحريك عمودي",
+ "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
"pictureInPicture": "صورة داخل صورة",
- "mirrorWebcam": "عكس كاميرا الويب",
+ "reactiveWebcam": "تصغير عند التكبير",
+ "webcamBackground": "خلفية الكاميرا",
"webcamBlurIntensity": "شدة الضبابية",
+ "mirrorWebcam": "عكس كاميرا الويب",
"webcamShape": "شكل الكاميرا",
- "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
- "selectPreset": "حدد إعدادًا مسبقًا",
- "reactiveWebcam": "تصغير عند التكبير",
- "webcamBackground": "خلفية الكاميرا"
- },
- "background": {
- "colorPalette": "لوحة الألوان",
- "gradient": "تدرج لوني",
- "imageLabel": "الخلفية {{index}}",
- "custom": "مخصص",
- "image": "صورة",
- "title": "الخلفية",
- "colorWheel": "عجلة الألوان",
- "customWallpaper": "خلفية مخصصة",
- "uploadCustom": "رفع صورة مخصصة",
- "colorLabel": "اللون {{color}}",
- "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
- "gradientLabel": "تدرج لوني {{index}}",
- "imageReadFailed": "تعذّر قراءة ملف الصورة.",
- "presets": "إعدادات مسبقة",
- "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
- "color": "لون"
+ "noWebcam": "بدون كاميرا",
+ "webcamCropX": "تحريك أفقي",
+ "webcamFraming": "تأطير كاميرا الويب"
},
- "support": {
- "saveDiagnostics": "حفظ التشخيصات",
- "starOnGithub": "إعطاء نجمة على GitHub",
- "reportBug": "الإبلاغ عن خطأ"
+ "imageUpload": {
+ "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
+ "failedToUpload": "فشل رفع الصورة",
+ "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
+ "errorReading": "حدث خطأ أثناء قراءة الملف.",
+ "invalidFileType": "نوع ملف غير صالح"
},
- "audio": {
- "outputGain": "ضبط مستوى الإخراج",
- "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
- "reset": "إعادة ضبط الصوت",
- "title": "الصوت"
+ "cursor": {
+ "themeDefault": "افتراضي",
+ "motionBlur": "ضبابية الحركة",
+ "smoothing": "التنعيم",
+ "title": "المؤشر",
+ "theme": "نمط المؤشر",
+ "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
+ "show": "إظهار المؤشر",
+ "clipToBounds": "القص ضمن اللوحة",
+ "size": "الحجم",
+ "clickBounce": "ارتداد النقر",
+ "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر."
},
"captions": {
- "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
- "alignCenter": "توسيط",
- "backgroundColor": "لون الخلفية",
"original": "الأصل (النص المفرّغ)",
- "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
- "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
- "displayLanguage": "العرض",
"alignRight": "يمين",
+ "backgroundOpacity": "العتامة",
"anchorBottom": "أسفل",
- "anchorTop": "أعلى",
- "text": "النص",
- "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
- "textColor": "لون النص",
+ "minWords": "أقل عدد كلمات في السطر",
+ "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
+ "translate": "ترجمة",
"maxWords": "أكثر عدد كلمات في السطر",
- "show": "إظهار الترجمة",
- "translateFailed": "فشلت الترجمة.",
+ "lineLength": "طول السطر",
+ "anchorTop": "أعلى",
"font": "الخط",
- "language": "اللغة",
- "background": "الخلفية",
- "translate": "ترجمة",
+ "translating": "جارٍ الترجمة…",
"position": "الموضع",
- "distanceFromLeft": "المسافة من اليسار",
- "lineLength": "طول السطر",
- "alignLeft": "يسار",
- "deleteTranslation": "حذف هذه الترجمة",
- "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
- "minWords": "أقل عدد كلمات في السطر",
- "showBackground": "إظهار الخلفية",
- "fontSize": "الحجم",
+ "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
+ "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
+ "backgroundColor": "لون الخلفية",
+ "text": "النص",
+ "textColor": "لون النص",
"hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
- "distanceFromBottom": "المسافة من الأسفل",
- "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
+ "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
"distanceFromTop": "المسافة من الأعلى",
+ "alignLeft": "يسار",
"anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
- "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
- "translating": "جارٍ الترجمة…",
- "backgroundOpacity": "العتامة",
+ "deleteTranslation": "حذف هذه الترجمة",
+ "distanceFromBottom": "المسافة من الأسفل",
+ "displayLanguage": "العرض",
+ "background": "الخلفية",
+ "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
+ "distanceFromLeft": "المسافة من اليسار",
+ "alignCenter": "توسيط",
+ "fontSize": "الحجم",
+ "bold": "عريض",
+ "translateFailed": "فشلت الترجمة.",
"distanceFromRight": "المسافة من اليمين",
- "bold": "عريض"
- },
- "transcript": {
- "revertWord": "استعادة \"{{original}}\"",
- "laneRecording": "التسجيل",
- "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
- "noTranscript": "لا يوجد نص بعد",
- "insertRecordingOnly": "لا يمكن إضافة الكلمات إلا على التسجيل — فالوقفة تُجمّد إطارًا من الفيلم.",
- "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
- "insertAria": "كلمة جديدة",
- "blankedWord": "مُفرَّغة",
- "restoreSilence": "استعادة الصمت ({{duration}} ث)",
- "laneLabel": "اقرأ النص من",
- "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
- "removeInserted": "حذف \"{{word}}\"",
- "transcribeNow": "فرّغ النص الآن",
- "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
- "editWord": "تحرير \"{{word}}\"",
- "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
- "clipLabel": "المقطع {{index}}",
- "title": "النص الحالي",
- "laneVoiceover": "التعليق الصوتي",
- "trimSilence": "قص الصمت ({{duration}} ث)",
- "helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
- "editorAria": "نص {{filename}}",
- "silence": "[صمت {{duration}} ث]",
- "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
- "transcribing": "جارٍ التفريغ…",
- "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
- "restoreWord": "استعادة \"{{word}}\"",
- "noClips": "لا توجد مقاطع بعد",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع."
+ "showBackground": "إظهار الخلفية",
+ "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "show": "إظهار الترجمة",
+ "language": "اللغة",
+ "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ."
},
"zoom": {
- "level": "مستوى التكبير",
- "position": {
- "x": "X (%)",
- "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
- "title": "موضع التركيز",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "يمين",
- "left": "يسار",
- "iso": "متساوي القياس"
+ "iso": "متساوي القياس",
+ "left": "يسار"
},
- "title": "دوران ثلاثي الأبعاد",
- "none": "بلا"
+ "none": "بلا",
+ "title": "دوران ثلاثي الأبعاد"
},
"focusMode": {
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
- "auto": "تلقائي",
"lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
+ "title": "وضع التركيز",
"manual": "يدوي",
- "title": "وضع التركيز"
+ "auto": "تلقائي"
+ },
+ "position": {
+ "title": "موضع التركيز",
+ "x": "X (%)",
+ "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
+ "y": "Y (%)"
},
+ "deleteZoom": "حذف التكبير",
+ "level": "مستوى التكبير",
"selectRegion": "حدد منطقة التكبير للتعديل",
- "customScale": "تكبير مخصص",
"previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
- "deleteZoom": "حذف التكبير"
+ "customScale": "تكبير مخصص"
},
- "project": {
- "load": "تحميل المشروع",
- "save": "حفظ المشروع",
- "new": "مشروع جديد"
+ "textAnimation": {
+ "pop": "ظهور",
+ "rise": "ارتفاع",
+ "selectAnimation": "حدد الحركة",
+ "fade": "تلاشي",
+ "pulse": "نبض",
+ "typewriter": "آلة كاتبة",
+ "title": "تحريك النص",
+ "none": "بدون",
+ "slideLeft": "انزلاق لليسار"
+ },
+ "crop": {
+ "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
+ "title": "اقتصاص",
+ "done": "تم",
+ "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
+ "ratio": "النسبة",
+ "free": "حر",
+ "cropVideo": "اقتصاص الفيديو",
+ "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
+ },
+ "background": {
+ "imageLabel": "الخلفية {{index}}",
+ "color": "لون",
+ "gradient": "تدرج لوني",
+ "colorLabel": "اللون {{color}}",
+ "colorWheel": "عجلة الألوان",
+ "customWallpaper": "خلفية مخصصة",
+ "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
+ "image": "صورة",
+ "gradientLabel": "تدرج لوني {{index}}",
+ "imageReadFailed": "تعذّر قراءة ملف الصورة.",
+ "presets": "إعدادات مسبقة",
+ "custom": "مخصص",
+ "colorPalette": "لوحة الألوان",
+ "uploadCustom": "رفع صورة مخصصة",
+ "title": "الخلفية",
+ "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG."
+ },
+ "audio": {
+ "title": "الصوت",
+ "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
+ "reset": "إعادة ضبط الصوت",
+ "outputGain": "ضبط مستوى الإخراج"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "دقة التصدير",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "تعذّر إضافة الصوت",
- "defaultLabel": "مسار صوتي",
- "add": "إضافة مسار صوتي",
- "fadeIn": "تلاشٍ للداخل",
- "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
- "fadeOut": "تلاشٍ للخارج",
- "loop": "تكرار",
- "remove": "حذف المسار",
- "mute": "كتم",
- "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل"
+ "high": "Source",
+ "title": "دقة التصدير"
},
"gifSettings": {
+ "loop": "تكرار GIF",
"frameRate": "معدل إطارات GIF",
- "size": "حجم GIF",
- "loop": "تكرار GIF"
- },
- "crop": {
- "ratio": "النسبة",
- "free": "حر",
- "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
- "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
- "cropVideo": "اقتصاص الفيديو",
- "title": "اقتصاص",
- "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
- "done": "تم"
- },
- "cursor": {
- "clickBounce": "ارتداد النقر",
- "clipToBounds": "القص ضمن اللوحة",
- "title": "المؤشر",
- "size": "الحجم",
- "show": "إظهار المؤشر",
- "motionBlur": "ضبابية الحركة",
- "themeDefault": "افتراضي",
- "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
- "smoothing": "التنعيم",
- "theme": "نمط المؤشر",
- "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر."
+ "size": "حجم GIF"
},
"export": {
- "videoButton": "تصدير الفيديو",
+ "chooseSaveLocation": "اختيار موقع الحفظ",
"gifButton": "تصدير GIF",
- "chooseSaveLocation": "اختيار موقع الحفظ"
+ "videoButton": "تصدير الفيديو"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "ملف فيديو عالي الجودة",
- "gifDescription": "صورة متحركة للمشاركة",
- "gifAnimation": "صورة GIF متحركة",
- "mp4Video": "فيديو MP4"
+ "project": {
+ "load": "تحميل المشروع",
+ "save": "حفظ المشروع",
+ "new": "مشروع جديد"
},
"speed": {
"previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير.",
- "deleteRegion": "حذف منطقة السرعة",
"customPlaybackSpeed": "سرعة تشغيل مخصصة",
+ "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
+ "deleteRegion": "حذف منطقة السرعة",
"selectRegion": "حدد منطقة السرعة للتعديل",
- "playbackSpeed": "سرعة التشغيل",
- "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×"
+ "playbackSpeed": "سرعة التشغيل"
},
- "textAnimation": {
- "rise": "ارتفاع",
- "pop": "ظهور",
- "selectAnimation": "حدد الحركة",
- "pulse": "نبض",
- "slideLeft": "انزلاق لليسار",
- "typewriter": "آلة كاتبة",
- "none": "بدون",
- "fade": "تلاشي",
- "title": "تحريك النص"
+ "language": {
+ "title": "اللغة"
},
- "imageUpload": {
- "invalidFileType": "نوع ملف غير صالح",
- "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
- "failedToUpload": "فشل رفع الصورة",
- "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
- "errorReading": "حدث خطأ أثناء قراءة الملف."
+ "support": {
+ "starOnGithub": "إعطاء نجمة على GitHub",
+ "reportBug": "الإبلاغ عن خطأ",
+ "saveDiagnostics": "حفظ التشخيصات"
},
- "panes": {
- "help": "مساعدة"
+ "trim": {
+ "deleteRegion": "حذف منطقة القص"
},
"facets": {
"transcript": "النص",
"captions": "الترجمة"
},
- "language": {
- "title": "اللغة"
- },
- "trim": {
- "deleteRegion": "حذف منطقة القص"
+ "panes": {
+ "help": "مساعدة"
}
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 352a23eb2..88fed708e 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF Animation",
+ "mp4Description": "High quality video file",
+ "mp4": "MP4",
+ "mp4Video": "MP4 Video",
+ "gif": "GIF",
+ "gifDescription": "Animated image for sharing"
+ },
"customFont": {
- "nameHelp": "This is how the font will appear in the font selector",
- "errorEmptyUrl": "Please enter a Google Fonts import URL",
- "urlLabel": "Google Fonts Import URL",
- "errorExtractFailed": "Could not extract font family from URL",
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Add Google Font",
+ "errorTimeout": "Font took too long to load. Please check the URL and try again.",
+ "nameLabel": "Display Name",
"failedToAdd": "Failed to add font",
- "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
+ "urlLabel": "Google Fonts Import URL",
+ "namePlaceholder": "My Custom Font",
+ "errorExtractFailed": "Could not extract font family from URL",
"addingButton": "Adding...",
- "dialogTitle": "Add Google Font",
+ "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
+ "errorEmptyUrl": "Please enter a Google Fonts import URL",
"successMessage": "Font \"{{fontName}}\" added successfully",
- "addButton": "Add Font",
- "namePlaceholder": "My Custom Font",
+ "nameHelp": "This is how the font will appear in the font selector",
"errorEmptyName": "Please enter a font name",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "Font took too long to load. Please check the URL and try again.",
- "errorInvalidUrl": "Please enter a valid Google Fonts URL",
- "nameLabel": "Display Name"
+ "addButton": "Add Font",
+ "errorInvalidUrl": "Please enter a valid Google Fonts URL"
},
"annotation": {
- "defaultText": "Hello",
- "size": "Size",
+ "colorWheel": "Color Wheel",
"imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
+ "typeArrow": "Arrow",
+ "selectStyle": "Select style",
+ "blurColorWhite": "White",
"blurType": "Blur Type",
- "mosaicBlockSize": "Mosaic Block Size",
- "colorWheel": "Color Wheel",
- "typeImage": "Image",
- "textContent": "Text Content",
- "clearBackground": "Clear Background",
- "invalidImageType": "Invalid file type",
- "shortcutsAndTips": "Shortcuts & Tips",
- "colorPalette": "Color Palette",
+ "blurShapeRectangle": "Rectangle",
+ "blurColor": "Blur Color",
"arrowColor": "Arrow Color",
- "strokeWidth": "Stroke Width: {{width}}px",
- "blurColorWhite": "White",
+ "textContent": "Text Content",
"background": "Background",
- "blurColor": "Blur Color",
- "typeArrow": "Arrow",
- "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Mosaic",
- "textPlaceholder": "Enter your text...",
+ "clearBackground": "Clear Background",
+ "blurShapeFreehand": "Freehand",
"blurIntensity": "Blur Intensity",
- "textColor": "Text Color",
- "blurShapeRectangle": "Rectangle",
- "deleteAnnotation": "Delete Annotation",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
- "blurShapeFreehand": "Freehand",
- "arrowDirection": "Arrow Direction",
- "selectStyle": "Select style",
- "fontStyle": "Font Style",
- "imageUploadSuccess": "Image uploaded successfully!",
"active": "Active",
- "tipTabCycle": "Use Tab to cycle through overlapping items.",
- "blurShapeOval": "Oval",
+ "size": "Size",
"blurColorBlack": "Black",
- "color": "Color",
+ "typeImage": "Image",
+ "mosaicBlockSize": "Mosaic Block Size",
+ "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Stroke Width: {{width}}px",
+ "textColor": "Text Color",
+ "defaultText": "Hello",
"blurShape": "Blur Shape",
- "customFonts": "Custom Fonts",
- "typeBlur": "Blur",
- "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
+ "tipTabCycle": "Use Tab to cycle through overlapping items.",
"type": "Type",
+ "typeText": "Text",
+ "textPlaceholder": "Enter your text...",
+ "fontStyle": "Font Style",
+ "imageUploadSuccess": "Image uploaded successfully!",
+ "colorPalette": "Color Palette",
+ "color": "Color",
+ "shortcutsAndTips": "Shortcuts & Tips",
+ "none": "None",
+ "invalidImageType": "Invalid file type",
+ "arrowDirection": "Arrow Direction",
+ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
"uploadImage": "Upload Image",
+ "customFonts": "Custom Fonts",
+ "blurTypeMosaic": "Mosaic",
"blurTypeBlur": "Gaussian",
- "none": "None",
+ "typeBlur": "Blur",
"title": "Annotation Settings",
- "typeText": "Text"
+ "deleteAnnotation": "Delete Annotation",
+ "blurShapeOval": "Oval"
+ },
+ "transcript": {
+ "restoreSilence": "Restore silence ({{duration}}s)",
+ "editWord": "Edit \"{{word}}\"",
+ "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
+ "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
+ "insertedWord": "Added by you — no audio behind it",
+ "laneFeedsCaptions": "Captions are burnt from this lane.",
+ "insertAria": "New word",
+ "transcribing": "Transcribing…",
+ "noAudio": "This media has no audio track",
+ "noTranscript": "No transcript yet",
+ "silence": "[silence {{duration}}s]",
+ "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
+ "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
+ "noClips": "No clips yet",
+ "laneVoiceover": "Voice-over",
+ "laneLabel": "Read the transcript from",
+ "revertWord": "Restore \"{{original}}\"",
+ "helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
+ "blankedWord": "blanked",
+ "clipLabel": "Clip {{index}}",
+ "title": "Current transcription",
+ "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
+ "transcribeNow": "Transcribe now",
+ "laneRecording": "Recording",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
+ "removeInserted": "Delete \"{{word}}\"",
+ "trimSilence": "Trim silence ({{duration}}s)",
+ "editorAria": "Transcript for {{filename}}",
+ "restoreWord": "Restore \"{{word}}\""
},
"effects": {
- "fitClipMany": "{{count}} clips",
+ "shadow": "Shadow",
+ "fitClipOne": "{{count}} clip",
+ "blurBg": "Blur BG",
+ "fitClip": "Fit",
+ "formatOriginal": "Original",
+ "title": "Composition",
"format": "Format",
"motionBlur": "Motion Blur",
+ "fitClipMany": "{{count}} clips",
+ "roundness": "Roundness",
"fitClipFew": "{{count}} clips",
- "fitClip": "Fit",
+ "motion": "Motion",
"on": "on",
- "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
- "formatOriginal": "Original",
- "blurBg": "Blur BG",
"frame": "Frame",
"padding": "Padding",
- "shadow": "Shadow",
- "off": "off",
- "title": "Composition",
- "motion": "Motion",
- "fitClipOne": "{{count}} clip",
- "roundness": "Roundness"
+ "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
+ "off": "off"
+ },
+ "audioTrack": {
+ "mute": "Mute",
+ "fadeIn": "Fade in",
+ "loop": "Loop",
+ "slipHint": "Alt-drag to slide the audio inside it",
+ "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "importFailed": "Could not add audio",
+ "fadeOut": "Fade out",
+ "add": "Add audio track",
+ "defaultLabel": "Audio track",
+ "remove": "Delete track"
},
"layout": {
- "noWebcam": "No Webcam",
- "bgModes": {
- "none": "Original",
- "transparent": "Cutout",
- "blur": "Blur",
- "custom": "Custom"
- },
- "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
- "webcamFraming": "Webcam crop",
- "webcamCropX": "Pan horizontally",
- "preset": "Preset",
- "webcamSize": "Webcam Size",
"shapes": {
"circle": "Circle",
"square": "Square",
"rectangle": "Rect",
"rounded": "Rounded"
},
- "dualFrame": "Dual Frame",
"verticalStack": "Vertical Stack",
- "webcamCropZoom": "Zoom",
+ "webcamSize": "Webcam Size",
+ "preset": "Preset",
+ "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
+ "dualFrame": "Dual Frame",
"title": "Camera layout",
- "webcamCropY": "Pan vertically",
"reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
+ "bgModes": {
+ "transparent": "Cutout",
+ "custom": "Custom",
+ "none": "Original",
+ "blur": "Blur"
+ },
+ "webcamCropZoom": "Zoom",
+ "selectPreset": "Select preset",
+ "webcamCropY": "Pan vertically",
+ "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
"pictureInPicture": "Picture in Picture",
- "mirrorWebcam": "Mirror Webcam",
+ "reactiveWebcam": "Shrink on Zoom",
+ "webcamBackground": "Camera Background",
"webcamBlurIntensity": "Blur Intensity",
+ "mirrorWebcam": "Mirror Webcam",
"webcamShape": "Camera Shape",
- "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
- "selectPreset": "Select preset",
- "reactiveWebcam": "Shrink on Zoom",
- "webcamBackground": "Camera Background"
- },
- "background": {
- "colorPalette": "Color Palette",
- "gradient": "Gradient",
- "imageLabel": "Background {{index}}",
- "custom": "Custom",
- "image": "Image",
- "title": "Background",
- "colorWheel": "Color Wheel",
- "customWallpaper": "Custom wallpaper",
- "uploadCustom": "Upload Custom",
- "colorLabel": "Color {{color}}",
- "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
- "gradientLabel": "Gradient {{index}}",
- "imageReadFailed": "Could not read that image file.",
- "presets": "Presets",
- "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
- "color": "Color"
+ "noWebcam": "No Webcam",
+ "webcamCropX": "Pan horizontally",
+ "webcamFraming": "Webcam crop"
},
- "support": {
- "saveDiagnostics": "Save Diagnostics",
- "starOnGithub": "Star on GitHub",
- "reportBug": "Report Bug"
+ "imageUpload": {
+ "uploadSuccess": "Custom image uploaded successfully!",
+ "failedToUpload": "Failed to upload image",
+ "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
+ "errorReading": "There was an error reading the file.",
+ "invalidFileType": "Invalid file type"
},
- "audio": {
- "outputGain": "Output level",
- "help": "Adjust the audio output level. It applies identically in the preview and the export.",
- "reset": "Reset audio",
- "title": "Audio"
+ "cursor": {
+ "themeDefault": "Default",
+ "motionBlur": "Motion Blur",
+ "smoothing": "Smoothing",
+ "title": "Cursor",
+ "theme": "Cursor Style",
+ "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
+ "show": "Show Cursor",
+ "clipToBounds": "Clip to Canvas",
+ "size": "Size",
+ "clickBounce": "Click Bounce",
+ "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis."
},
"captions": {
- "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
- "alignCenter": "Center",
- "backgroundColor": "Background color",
"original": "Original (transcript)",
- "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
- "removeLegacyAnnotations": "Remove old caption annotations",
- "displayLanguage": "Display",
"alignRight": "Right",
+ "backgroundOpacity": "Opacity",
"anchorBottom": "Bottom",
- "anchorTop": "Top",
- "text": "Text",
- "translateHint": "Translate the transcript with the configured AI provider",
- "textColor": "Text color",
+ "minWords": "Min words per line",
+ "anchorHintTop": "Long captions grow downward — the top edge stays put.",
+ "translate": "Translate",
"maxWords": "Max words per line",
- "show": "Show captions",
- "translateFailed": "Translation failed.",
+ "lineLength": "Line length",
+ "anchorTop": "Top",
"font": "Font",
- "language": "Language",
- "background": "Background",
- "translate": "Translate",
+ "translating": "Translating…",
"position": "Position",
- "distanceFromLeft": "Distance from left",
- "lineLength": "Line length",
- "alignLeft": "Left",
- "deleteTranslation": "Delete this translation",
- "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
- "minWords": "Min words per line",
- "showBackground": "Show background",
- "fontSize": "Size",
+ "removeLegacyAnnotations": "Remove old caption annotations",
+ "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
+ "backgroundColor": "Background color",
+ "text": "Text",
+ "textColor": "Text color",
"hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
- "distanceFromBottom": "Distance from bottom",
- "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
+ "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
"distanceFromTop": "Distance from top",
+ "alignLeft": "Left",
"anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
- "anchorHintTop": "Long captions grow downward — the top edge stays put.",
- "translating": "Translating…",
- "backgroundOpacity": "Opacity",
+ "deleteTranslation": "Delete this translation",
+ "distanceFromBottom": "Distance from bottom",
+ "displayLanguage": "Display",
+ "background": "Background",
+ "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
+ "distanceFromLeft": "Distance from left",
+ "alignCenter": "Center",
+ "fontSize": "Size",
+ "bold": "Bold",
+ "translateFailed": "Translation failed.",
"distanceFromRight": "Distance from right",
- "bold": "Bold"
- },
- "transcript": {
- "revertWord": "Restore \"{{original}}\"",
- "laneRecording": "Recording",
- "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
- "noAudio": "This media has no audio track",
- "noTranscript": "No transcript yet",
- "insertRecordingOnly": "Words can only be added on the recording — a pause holds a frame of film.",
- "insertedWord": "Added by you — no audio behind it",
- "insertAria": "New word",
- "blankedWord": "blanked",
- "restoreSilence": "Restore silence ({{duration}}s)",
- "laneLabel": "Read the transcript from",
- "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
- "removeInserted": "Delete \"{{word}}\"",
- "transcribeNow": "Transcribe now",
- "laneFeedsCaptions": "Captions are burnt from this lane.",
- "editWord": "Edit \"{{word}}\"",
- "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
- "clipLabel": "Clip {{index}}",
- "title": "Current transcription",
- "laneVoiceover": "Voice-over",
- "trimSilence": "Trim silence ({{duration}}s)",
- "helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
- "editorAria": "Transcript for {{filename}}",
- "silence": "[silence {{duration}}s]",
- "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
- "transcribing": "Transcribing…",
- "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
- "restoreWord": "Restore \"{{word}}\"",
- "noClips": "No clips yet",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it."
+ "showBackground": "Show background",
+ "translateHint": "Translate the transcript with the configured AI provider",
+ "show": "Show captions",
+ "language": "Language",
+ "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript."
},
"zoom": {
- "level": "Zoom Level",
- "position": {
- "x": "X (%)",
- "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
- "title": "Focus Position",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Right",
- "left": "Left",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "Left"
},
- "title": "3D Rotation",
- "none": "None"
+ "none": "None",
+ "title": "3D Rotation"
},
"focusMode": {
"autoDescription": "Camera follows the recorded cursor position",
- "auto": "Auto",
"lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
+ "title": "Focus Mode",
"manual": "Manual",
- "title": "Focus Mode"
+ "auto": "Auto"
+ },
+ "position": {
+ "title": "Focus Position",
+ "x": "X (%)",
+ "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
+ "y": "Y (%)"
},
+ "deleteZoom": "Delete Zoom",
+ "level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
- "customScale": "Custom Zoom",
"previewHold": "Hold to preview zoom effect",
- "deleteZoom": "Delete Zoom"
+ "customScale": "Custom Zoom"
},
- "project": {
- "load": "Load Project",
- "save": "Save Project",
- "new": "New Project"
+ "textAnimation": {
+ "pop": "Pop",
+ "rise": "Rise",
+ "selectAnimation": "Select animation",
+ "fade": "Fade",
+ "pulse": "Pulse",
+ "typewriter": "Typewriter",
+ "title": "Text Animation",
+ "none": "None",
+ "slideLeft": "Slide Left"
+ },
+ "crop": {
+ "lockAspectRatio": "Lock aspect ratio",
+ "title": "Crop",
+ "done": "Done",
+ "dragInstruction": "Drag on each side to adjust the crop area",
+ "ratio": "Ratio",
+ "free": "Free",
+ "cropVideo": "Crop Video",
+ "unlockAspectRatio": "Unlock aspect ratio"
+ },
+ "background": {
+ "imageLabel": "Background {{index}}",
+ "color": "Color",
+ "gradient": "Gradient",
+ "colorLabel": "Color {{color}}",
+ "colorWheel": "Color Wheel",
+ "customWallpaper": "Custom wallpaper",
+ "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
+ "image": "Image",
+ "gradientLabel": "Gradient {{index}}",
+ "imageReadFailed": "Could not read that image file.",
+ "presets": "Presets",
+ "custom": "Custom",
+ "colorPalette": "Color Palette",
+ "uploadCustom": "Upload Custom",
+ "title": "Background",
+ "unsupportedImage": "Unsupported image. Use a JPG or PNG file."
+ },
+ "audio": {
+ "title": "Audio",
+ "help": "Adjust the audio output level. It applies identically in the preview and the export.",
+ "reset": "Reset audio",
+ "outputGain": "Output level"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Export resolution",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "Could not add audio",
- "defaultLabel": "Audio track",
- "add": "Add audio track",
- "fadeIn": "Fade in",
- "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
- "fadeOut": "Fade out",
- "loop": "Loop",
- "remove": "Delete track",
- "mute": "Mute",
- "slipHint": "Alt-drag to slide the audio inside it"
+ "high": "Source",
+ "title": "Export resolution"
},
"gifSettings": {
+ "loop": "Loop GIF",
"frameRate": "GIF Frame Rate",
- "size": "GIF Size",
- "loop": "Loop GIF"
- },
- "crop": {
- "ratio": "Ratio",
- "free": "Free",
- "lockAspectRatio": "Lock aspect ratio",
- "unlockAspectRatio": "Unlock aspect ratio",
- "cropVideo": "Crop Video",
- "title": "Crop",
- "dragInstruction": "Drag on each side to adjust the crop area",
- "done": "Done"
- },
- "cursor": {
- "clickBounce": "Click Bounce",
- "clipToBounds": "Clip to Canvas",
- "title": "Cursor",
- "size": "Size",
- "show": "Show Cursor",
- "motionBlur": "Motion Blur",
- "themeDefault": "Default",
- "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
- "smoothing": "Smoothing",
- "theme": "Cursor Style",
- "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis."
+ "size": "GIF Size"
},
"export": {
- "videoButton": "Export Video",
+ "chooseSaveLocation": "Choose Save Location",
"gifButton": "Export GIF",
- "chooseSaveLocation": "Choose Save Location"
+ "videoButton": "Export Video"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "High quality video file",
- "gifDescription": "Animated image for sharing",
- "gifAnimation": "GIF Animation",
- "mp4Video": "MP4 Video"
+ "project": {
+ "load": "Load Project",
+ "save": "Save Project",
+ "new": "New Project"
},
"speed": {
"previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected.",
- "deleteRegion": "Delete Speed Region",
"customPlaybackSpeed": "Custom Playback Speed",
+ "maxSpeedError": "Speed can't go higher than {{max}}×",
+ "deleteRegion": "Delete Speed Region",
"selectRegion": "Select a speed region to adjust",
- "playbackSpeed": "Playback Speed",
- "maxSpeedError": "Speed can't go higher than {{max}}×"
+ "playbackSpeed": "Playback Speed"
},
- "textAnimation": {
- "rise": "Rise",
- "pop": "Pop",
- "selectAnimation": "Select animation",
- "pulse": "Pulse",
- "slideLeft": "Slide Left",
- "typewriter": "Typewriter",
- "none": "None",
- "fade": "Fade",
- "title": "Text Animation"
+ "language": {
+ "title": "Language"
},
- "imageUpload": {
- "invalidFileType": "Invalid file type",
- "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
- "failedToUpload": "Failed to upload image",
- "uploadSuccess": "Custom image uploaded successfully!",
- "errorReading": "There was an error reading the file."
+ "support": {
+ "starOnGithub": "Star on GitHub",
+ "reportBug": "Report Bug",
+ "saveDiagnostics": "Save Diagnostics"
},
- "panes": {
- "help": "Help"
+ "trim": {
+ "deleteRegion": "Delete Trim Region"
},
"facets": {
"transcript": "Transcript",
"captions": "Captions"
},
- "language": {
- "title": "Language"
- },
- "trim": {
- "deleteRegion": "Delete Trim Region"
+ "panes": {
+ "help": "Help"
}
}
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 360bb5a85..3681fc652 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "Animación GIF",
+ "mp4Description": "Archivo de video de alta calidad",
+ "mp4": "MP4",
+ "mp4Video": "Video MP4",
+ "gif": "GIF",
+ "gifDescription": "Imagen animada para compartir"
+ },
"customFont": {
- "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
- "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
- "urlLabel": "URL de importación de Google Fonts",
- "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Agregar fuente de Google",
+ "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
+ "nameLabel": "Nombre para mostrar",
"failedToAdd": "Error al agregar la fuente",
- "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
+ "urlLabel": "URL de importación de Google Fonts",
+ "namePlaceholder": "Mi fuente personalizada",
+ "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
"addingButton": "Agregando...",
- "dialogTitle": "Agregar fuente de Google",
+ "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
+ "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
"successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
- "addButton": "Agregar fuente",
- "namePlaceholder": "Mi fuente personalizada",
+ "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
"errorEmptyName": "Por favor ingresa un nombre de fuente",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
- "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
- "nameLabel": "Nombre para mostrar"
+ "addButton": "Agregar fuente",
+ "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts"
},
"annotation": {
- "defaultText": "Hola",
- "size": "Tamaño",
+ "colorWheel": "Rueda de colores",
"imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
+ "typeArrow": "Flecha",
+ "selectStyle": "Seleccionar estilo",
+ "blurColorWhite": "Blanco",
"blurType": "Tipo de desenfoque",
- "mosaicBlockSize": "Tamano del bloque mosaico",
- "colorWheel": "Rueda de colores",
- "typeImage": "Imagen",
- "textContent": "Contenido de texto",
- "clearBackground": "Quitar fondo",
- "invalidImageType": "Tipo de archivo no válido",
- "shortcutsAndTips": "Atajos y consejos",
- "colorPalette": "Paleta de colores",
+ "blurShapeRectangle": "Rectángulo",
+ "blurColor": "Color del desenfoque",
"arrowColor": "Color de la flecha",
- "strokeWidth": "Grosor del trazo: {{width}}px",
- "blurColorWhite": "Blanco",
+ "textContent": "Contenido de texto",
"background": "Fondo",
- "blurColor": "Color del desenfoque",
- "typeArrow": "Flecha",
- "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Mosaico",
- "textPlaceholder": "Escribe tu texto...",
+ "clearBackground": "Quitar fondo",
+ "blurShapeFreehand": "Mano alzada",
"blurIntensity": "Intensidad del desenfoque",
- "textColor": "Color de texto",
- "blurShapeRectangle": "Rectángulo",
- "deleteAnnotation": "Eliminar anotación",
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
- "blurShapeFreehand": "Mano alzada",
- "arrowDirection": "Dirección de la flecha",
- "selectStyle": "Seleccionar estilo",
- "fontStyle": "Estilo de fuente",
- "imageUploadSuccess": "¡Imagen subida exitosamente!",
"active": "Activo",
- "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
- "blurShapeOval": "Óvalo",
+ "size": "Tamaño",
"blurColorBlack": "Negro",
- "color": "Color",
+ "typeImage": "Imagen",
+ "mosaicBlockSize": "Tamano del bloque mosaico",
+ "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Grosor del trazo: {{width}}px",
+ "textColor": "Color de texto",
+ "defaultText": "Hola",
"blurShape": "Forma del desenfoque",
- "customFonts": "Fuentes personalizadas",
- "typeBlur": "Desenfoque",
- "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
+ "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
"type": "Tipo",
+ "typeText": "Texto",
+ "textPlaceholder": "Escribe tu texto...",
+ "fontStyle": "Estilo de fuente",
+ "imageUploadSuccess": "¡Imagen subida exitosamente!",
+ "colorPalette": "Paleta de colores",
+ "color": "Color",
+ "shortcutsAndTips": "Atajos y consejos",
+ "none": "Ninguno",
+ "invalidImageType": "Tipo de archivo no válido",
+ "arrowDirection": "Dirección de la flecha",
+ "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
"uploadImage": "Subir imagen",
+ "customFonts": "Fuentes personalizadas",
+ "blurTypeMosaic": "Mosaico",
"blurTypeBlur": "Gaussiano",
- "none": "Ninguno",
+ "typeBlur": "Desenfoque",
"title": "Configuración de anotaciones",
- "typeText": "Texto"
+ "deleteAnnotation": "Eliminar anotación",
+ "blurShapeOval": "Óvalo"
+ },
+ "transcript": {
+ "restoreSilence": "Restaurar silencio ({{duration}} s)",
+ "editWord": "Editar «{{word}}»",
+ "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
+ "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
+ "insertAria": "Palabra nueva",
+ "transcribing": "Transcribiendo…",
+ "noAudio": "Este medio no tiene pista de audio",
+ "noTranscript": "Aún no hay transcripción",
+ "silence": "[silencio {{duration}} s]",
+ "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
+ "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
+ "noClips": "Aún no hay clips",
+ "laneVoiceover": "Voz en off",
+ "laneLabel": "Leer la transcripción desde",
+ "revertWord": "Restaurar «{{original}}»",
+ "helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
+ "blankedWord": "vaciada",
+ "clipLabel": "Clip {{index}}",
+ "title": "Transcripción actual",
+ "correctedWord": "Corregida: la transcripción decía «{{original}}»",
+ "transcribeNow": "Transcribir ahora",
+ "laneRecording": "Grabación",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "removeInserted": "Eliminar «{{word}}»",
+ "trimSilence": "Recortar silencio ({{duration}} s)",
+ "editorAria": "Transcripción de {{filename}}",
+ "restoreWord": "Restaurar «{{word}}»"
},
"effects": {
- "fitClipMany": "{{count}} clips",
+ "shadow": "Sombra",
+ "fitClipOne": "{{count}} clip",
+ "blurBg": "Desenfocar fondo",
+ "fitClip": "Ajustar",
+ "formatOriginal": "Original",
+ "title": "Composición",
"format": "Formato",
"motionBlur": "Desenfoque de movimiento",
+ "fitClipMany": "{{count}} clips",
+ "roundness": "Redondez",
"fitClipFew": "{{count}} clips",
- "fitClip": "Ajustar",
+ "motion": "Movimiento",
"on": "activado",
- "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
- "formatOriginal": "Original",
- "blurBg": "Desenfocar fondo",
"frame": "Marco",
"padding": "Relleno",
- "shadow": "Sombra",
- "off": "desactivado",
- "title": "Composición",
- "motion": "Movimiento",
- "fitClipOne": "{{count}} clip",
- "roundness": "Redondez"
+ "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
+ "off": "desactivado"
+ },
+ "audioTrack": {
+ "mute": "Silenciar",
+ "fadeIn": "Aparición",
+ "loop": "Bucle",
+ "slipHint": "Alt + arrastrar para desplazar el audio dentro",
+ "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "importFailed": "No se pudo añadir el audio",
+ "fadeOut": "Desvanecido",
+ "add": "Añadir pista de audio",
+ "defaultLabel": "Pista de audio",
+ "remove": "Eliminar pista"
},
"layout": {
- "noWebcam": "Sin cámara",
- "bgModes": {
- "none": "Original",
- "transparent": "Recortado",
- "blur": "Desenfocado",
- "custom": "Personalizado"
- },
- "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
- "webcamFraming": "Encuadre de cámara",
- "webcamCropX": "Desplazamiento horizontal",
- "preset": "Predefinido",
- "webcamSize": "Tamaño de cámara",
"shapes": {
"circle": "Círculo",
"square": "Cuadrado",
"rectangle": "Rect.",
"rounded": "Redondeado"
},
- "dualFrame": "Marco dual",
"verticalStack": "Apilado vertical",
- "webcamCropZoom": "Zoom de recorte",
+ "webcamSize": "Tamaño de cámara",
+ "preset": "Predefinido",
+ "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
+ "dualFrame": "Marco dual",
"title": "Disposición de cámara",
- "webcamCropY": "Desplazamiento vertical",
"reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
+ "bgModes": {
+ "transparent": "Recortado",
+ "custom": "Personalizado",
+ "none": "Original",
+ "blur": "Desenfocado"
+ },
+ "webcamCropZoom": "Zoom de recorte",
+ "selectPreset": "Seleccionar predefinido",
+ "webcamCropY": "Desplazamiento vertical",
+ "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
"pictureInPicture": "Imagen en imagen",
- "mirrorWebcam": "Reflejar cámara",
+ "reactiveWebcam": "Reducir al ampliar",
+ "webcamBackground": "Fondo de la cámara",
"webcamBlurIntensity": "Intensidad del desenfoque",
+ "mirrorWebcam": "Reflejar cámara",
"webcamShape": "Forma de cámara",
- "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
- "selectPreset": "Seleccionar predefinido",
- "reactiveWebcam": "Reducir al ampliar",
- "webcamBackground": "Fondo de la cámara"
- },
- "background": {
- "colorPalette": "Paleta de colores",
- "gradient": "Degradado",
- "imageLabel": "Fondo {{index}}",
- "custom": "Personalizado",
- "image": "Imagen",
- "title": "Fondo",
- "colorWheel": "Rueda de colores",
- "customWallpaper": "Fondo personalizado",
- "uploadCustom": "Subir personalizado",
- "colorLabel": "Color {{color}}",
- "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
- "gradientLabel": "Degradado {{index}}",
- "imageReadFailed": "No se pudo leer ese archivo de imagen.",
- "presets": "Ajustes preestablecidos",
- "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
- "color": "Color"
+ "noWebcam": "Sin cámara",
+ "webcamCropX": "Desplazamiento horizontal",
+ "webcamFraming": "Encuadre de cámara"
},
- "support": {
- "saveDiagnostics": "Guardar diagnósticos",
- "starOnGithub": "Dar estrella en GitHub",
- "reportBug": "Reportar error"
+ "imageUpload": {
+ "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
+ "failedToUpload": "Error al subir la imagen",
+ "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
+ "errorReading": "Hubo un error al leer el archivo.",
+ "invalidFileType": "Tipo de archivo no válido"
},
- "audio": {
- "outputGain": "Ajuste de salida",
- "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
- "reset": "Restablecer audio",
- "title": "Audio"
+ "cursor": {
+ "themeDefault": "Predeterminado",
+ "motionBlur": "Desenfoque de movimiento",
+ "smoothing": "Suavizado",
+ "title": "Cursor",
+ "theme": "Estilo del cursor",
+ "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
+ "show": "Mostrar cursor",
+ "clipToBounds": "Recortar al lienzo",
+ "size": "Tamaño",
+ "clickBounce": "Rebote al clic",
+ "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic."
},
"captions": {
- "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
- "alignCenter": "Centro",
- "backgroundColor": "Color del fondo",
"original": "Original (transcripción)",
- "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
- "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
- "displayLanguage": "Visualización",
"alignRight": "Derecha",
+ "backgroundOpacity": "Opacidad",
"anchorBottom": "Abajo",
- "anchorTop": "Arriba",
- "text": "Texto",
- "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
- "textColor": "Color del texto",
+ "minWords": "Mín. palabras por línea",
+ "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
+ "translate": "Traducir",
"maxWords": "Máx. palabras por línea",
- "show": "Mostrar subtítulos",
- "translateFailed": "La traducción ha fallado.",
+ "lineLength": "Longitud de línea",
+ "anchorTop": "Arriba",
"font": "Fuente",
- "language": "Idioma",
- "background": "Fondo",
- "translate": "Traducir",
+ "translating": "Traduciendo…",
"position": "Posición",
- "distanceFromLeft": "Distancia desde la izquierda",
- "lineLength": "Longitud de línea",
- "alignLeft": "Izquierda",
- "deleteTranslation": "Eliminar esta traducción",
- "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
- "minWords": "Mín. palabras por línea",
- "showBackground": "Mostrar fondo",
- "fontSize": "Tamaño",
+ "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
+ "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
+ "backgroundColor": "Color del fondo",
+ "text": "Texto",
+ "textColor": "Color del texto",
"hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
- "distanceFromBottom": "Distancia desde abajo",
- "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
+ "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
"distanceFromTop": "Distancia desde arriba",
+ "alignLeft": "Izquierda",
"anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
- "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
- "translating": "Traduciendo…",
- "backgroundOpacity": "Opacidad",
+ "deleteTranslation": "Eliminar esta traducción",
+ "distanceFromBottom": "Distancia desde abajo",
+ "displayLanguage": "Visualización",
+ "background": "Fondo",
+ "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
+ "distanceFromLeft": "Distancia desde la izquierda",
+ "alignCenter": "Centro",
+ "fontSize": "Tamaño",
+ "bold": "Negrita",
+ "translateFailed": "La traducción ha fallado.",
"distanceFromRight": "Distancia desde la derecha",
- "bold": "Negrita"
- },
- "transcript": {
- "revertWord": "Restaurar «{{original}}»",
- "laneRecording": "Grabación",
- "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
- "noAudio": "Este medio no tiene pista de audio",
- "noTranscript": "Aún no hay transcripción",
- "insertRecordingOnly": "Solo se pueden añadir palabras en la grabación: una pausa congela un fotograma.",
- "insertedWord": "Añadida por ti: no hay audio detrás",
- "insertAria": "Palabra nueva",
- "blankedWord": "vaciada",
- "restoreSilence": "Restaurar silencio ({{duration}} s)",
- "laneLabel": "Leer la transcripción desde",
- "correctedWord": "Corregida: la transcripción decía «{{original}}»",
- "removeInserted": "Eliminar «{{word}}»",
- "transcribeNow": "Transcribir ahora",
- "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
- "editWord": "Editar «{{word}}»",
- "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
- "clipLabel": "Clip {{index}}",
- "title": "Transcripción actual",
- "laneVoiceover": "Voz en off",
- "trimSilence": "Recortar silencio ({{duration}} s)",
- "helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
- "editorAria": "Transcripción de {{filename}}",
- "silence": "[silencio {{duration}} s]",
- "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
- "transcribing": "Transcribiendo…",
- "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
- "restoreWord": "Restaurar «{{word}}»",
- "noClips": "Aún no hay clips",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer."
+ "showBackground": "Mostrar fondo",
+ "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
+ "show": "Mostrar subtítulos",
+ "language": "Idioma",
+ "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción."
},
"zoom": {
- "level": "Nivel de zoom",
- "position": {
- "x": "X (%)",
- "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
- "title": "Posición de enfoque",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Derecha",
- "left": "Izquierda",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "Izquierda"
},
- "title": "Rotación 3D",
- "none": "Ninguna"
+ "none": "Ninguna",
+ "title": "Rotación 3D"
},
"focusMode": {
"autoDescription": "La cámara sigue la posición del cursor grabado",
- "auto": "Auto",
"lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
+ "title": "Modo de enfoque",
"manual": "Manual",
- "title": "Modo de enfoque"
+ "auto": "Auto"
+ },
+ "position": {
+ "title": "Posición de enfoque",
+ "x": "X (%)",
+ "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
+ "y": "Y (%)"
},
+ "deleteZoom": "Eliminar zoom",
+ "level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
- "customScale": "Zoom personalizado",
"previewHold": "Mantener para previsualizar el efecto de zoom",
- "deleteZoom": "Eliminar zoom"
+ "customScale": "Zoom personalizado"
},
- "project": {
- "load": "Cargar proyecto",
- "save": "Guardar proyecto",
- "new": "Nuevo proyecto"
+ "textAnimation": {
+ "pop": "Aparecer",
+ "rise": "Ascender",
+ "selectAnimation": "Seleccionar animación",
+ "fade": "Desvanecimiento",
+ "pulse": "Pulso",
+ "typewriter": "Máquina de escribir",
+ "title": "Animación de texto",
+ "none": "Ninguna",
+ "slideLeft": "Deslizar izquierda"
+ },
+ "crop": {
+ "lockAspectRatio": "Bloquear relación de aspecto",
+ "title": "Recortar",
+ "done": "Listo",
+ "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
+ "ratio": "Proporción",
+ "free": "Libre",
+ "cropVideo": "Recortar video",
+ "unlockAspectRatio": "Desbloquear relación de aspecto"
+ },
+ "background": {
+ "imageLabel": "Fondo {{index}}",
+ "color": "Color",
+ "gradient": "Degradado",
+ "colorLabel": "Color {{color}}",
+ "colorWheel": "Rueda de colores",
+ "customWallpaper": "Fondo personalizado",
+ "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
+ "image": "Imagen",
+ "gradientLabel": "Degradado {{index}}",
+ "imageReadFailed": "No se pudo leer ese archivo de imagen.",
+ "presets": "Ajustes preestablecidos",
+ "custom": "Personalizado",
+ "colorPalette": "Paleta de colores",
+ "uploadCustom": "Subir personalizado",
+ "title": "Fondo",
+ "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG."
+ },
+ "audio": {
+ "title": "Audio",
+ "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
+ "reset": "Restablecer audio",
+ "outputGain": "Ajuste de salida"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Resolución de exportación",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "No se pudo añadir el audio",
- "defaultLabel": "Pista de audio",
- "add": "Añadir pista de audio",
- "fadeIn": "Aparición",
- "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
- "fadeOut": "Desvanecido",
- "loop": "Bucle",
- "remove": "Eliminar pista",
- "mute": "Silenciar",
- "slipHint": "Alt + arrastrar para desplazar el audio dentro"
+ "high": "Source",
+ "title": "Resolución de exportación"
},
"gifSettings": {
+ "loop": "Repetir GIF",
"frameRate": "Velocidad de cuadros del GIF",
- "size": "Tamaño del GIF",
- "loop": "Repetir GIF"
- },
- "crop": {
- "ratio": "Proporción",
- "free": "Libre",
- "lockAspectRatio": "Bloquear relación de aspecto",
- "unlockAspectRatio": "Desbloquear relación de aspecto",
- "cropVideo": "Recortar video",
- "title": "Recortar",
- "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
- "done": "Listo"
- },
- "cursor": {
- "clickBounce": "Rebote al clic",
- "clipToBounds": "Recortar al lienzo",
- "title": "Cursor",
- "size": "Tamaño",
- "show": "Mostrar cursor",
- "motionBlur": "Desenfoque de movimiento",
- "themeDefault": "Predeterminado",
- "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
- "smoothing": "Suavizado",
- "theme": "Estilo del cursor",
- "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic."
+ "size": "Tamaño del GIF"
},
"export": {
- "videoButton": "Exportar video",
+ "chooseSaveLocation": "Elegir ubicación de guardado",
"gifButton": "Exportar GIF",
- "chooseSaveLocation": "Elegir ubicación de guardado"
+ "videoButton": "Exportar video"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "Archivo de video de alta calidad",
- "gifDescription": "Imagen animada para compartir",
- "gifAnimation": "Animación GIF",
- "mp4Video": "Video MP4"
+ "project": {
+ "load": "Cargar proyecto",
+ "save": "Guardar proyecto",
+ "new": "Nuevo proyecto"
},
"speed": {
"previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada.",
- "deleteRegion": "Eliminar región de velocidad",
"customPlaybackSpeed": "Velocidad personalizada",
+ "maxSpeedError": "La velocidad no puede superar {{max}}×",
+ "deleteRegion": "Eliminar región de velocidad",
"selectRegion": "Selecciona una región de velocidad para ajustar",
- "playbackSpeed": "Velocidad de reproducción",
- "maxSpeedError": "La velocidad no puede superar {{max}}×"
+ "playbackSpeed": "Velocidad de reproducción"
},
- "textAnimation": {
- "rise": "Ascender",
- "pop": "Aparecer",
- "selectAnimation": "Seleccionar animación",
- "pulse": "Pulso",
- "slideLeft": "Deslizar izquierda",
- "typewriter": "Máquina de escribir",
- "none": "Ninguna",
- "fade": "Desvanecimiento",
- "title": "Animación de texto"
+ "language": {
+ "title": "Idioma"
},
- "imageUpload": {
- "invalidFileType": "Tipo de archivo no válido",
- "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
- "failedToUpload": "Error al subir la imagen",
- "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
- "errorReading": "Hubo un error al leer el archivo."
+ "support": {
+ "starOnGithub": "Dar estrella en GitHub",
+ "reportBug": "Reportar error",
+ "saveDiagnostics": "Guardar diagnósticos"
},
- "panes": {
- "help": "Ayuda"
+ "trim": {
+ "deleteRegion": "Eliminar región de recorte"
},
"facets": {
"transcript": "Transcripción",
"captions": "Subtítulos"
},
- "language": {
- "title": "Idioma"
- },
- "trim": {
- "deleteRegion": "Eliminar región de recorte"
+ "panes": {
+ "help": "Ayuda"
}
}
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 0ad628610..2bdb79684 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "Animation GIF",
+ "mp4Description": "Fichier vidéo haute qualité",
+ "mp4": "MP4",
+ "mp4Video": "Vidéo MP4",
+ "gif": "GIF",
+ "gifDescription": "Image animée pour le partage"
+ },
"customFont": {
- "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
- "urlLabel": "URL d'import Google Fonts",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
"errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Ajouter une police Google",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
+ "nameLabel": "Nom d'affichage",
"failedToAdd": "Échec de l'ajout de la police",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
+ "urlLabel": "URL d'import Google Fonts",
+ "namePlaceholder": "Ma police personnalisée",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
"addingButton": "Ajout en cours...",
- "dialogTitle": "Ajouter une police Google",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
"successMessage": "Police « {{fontName}} » ajoutée avec succès",
- "addButton": "Ajouter la police",
- "namePlaceholder": "Ma police personnalisée",
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
"errorEmptyName": "Veuillez saisir un nom de police",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
- "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
- "nameLabel": "Nom d'affichage"
+ "addButton": "Ajouter la police",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide"
},
"annotation": {
- "defaultText": "Bonjour",
- "size": "Taille",
+ "colorWheel": "Roue chromatique",
"imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "typeArrow": "Flèche",
+ "selectStyle": "Choisir un style",
+ "blurColorWhite": "Blanc",
"blurType": "Type de flou",
- "mosaicBlockSize": "Taille des blocs de mosaique",
- "colorWheel": "Roue chromatique",
- "typeImage": "Image",
- "textContent": "Contenu du texte",
- "clearBackground": "Supprimer l'arrière-plan",
- "invalidImageType": "Type de fichier invalide",
- "shortcutsAndTips": "Raccourcis & Astuces",
- "colorPalette": "Palette de couleurs",
+ "blurShapeRectangle": "Rectangle",
+ "blurColor": "Couleur du flou",
"arrowColor": "Couleur de la flèche",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
- "blurColorWhite": "Blanc",
+ "textContent": "Contenu du texte",
"background": "Arrière-plan",
- "blurColor": "Couleur du flou",
- "typeArrow": "Flèche",
- "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Mosaïque",
- "textPlaceholder": "Saisissez votre texte...",
+ "clearBackground": "Supprimer l'arrière-plan",
+ "blurShapeFreehand": "Main levée",
"blurIntensity": "Intensité du flou",
- "textColor": "Couleur du texte",
- "blurShapeRectangle": "Rectangle",
- "deleteAnnotation": "Supprimer l'annotation",
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "blurShapeFreehand": "Main levée",
- "arrowDirection": "Direction de la flèche",
- "selectStyle": "Choisir un style",
- "fontStyle": "Style de police",
- "imageUploadSuccess": "Image téléversée avec succès !",
"active": "Actif",
- "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
- "blurShapeOval": "Ovale",
+ "size": "Taille",
"blurColorBlack": "Noir",
- "color": "Couleur",
+ "typeImage": "Image",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
+ "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "textColor": "Couleur du texte",
+ "defaultText": "Bonjour",
"blurShape": "Forme du flou",
- "customFonts": "Polices personnalisées",
- "typeBlur": "Flou",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
"type": "Type",
+ "typeText": "Texte",
+ "textPlaceholder": "Saisissez votre texte...",
+ "fontStyle": "Style de police",
+ "imageUploadSuccess": "Image téléversée avec succès !",
+ "colorPalette": "Palette de couleurs",
+ "color": "Couleur",
+ "shortcutsAndTips": "Raccourcis & Astuces",
+ "none": "Aucun",
+ "invalidImageType": "Type de fichier invalide",
+ "arrowDirection": "Direction de la flèche",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
"uploadImage": "Téléverser une image",
+ "customFonts": "Polices personnalisées",
+ "blurTypeMosaic": "Mosaïque",
"blurTypeBlur": "Gaussien",
- "none": "Aucun",
+ "typeBlur": "Flou",
"title": "Paramètres d'annotation",
- "typeText": "Texte"
+ "deleteAnnotation": "Supprimer l'annotation",
+ "blurShapeOval": "Ovale"
+ },
+ "transcript": {
+ "restoreSilence": "Restaurer le silence ({{duration}} s)",
+ "editWord": "Modifier « {{word}} »",
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
+ "insertAria": "Nouveau mot",
+ "transcribing": "Transcription…",
+ "noAudio": "Ce média n'a pas de piste audio",
+ "noTranscript": "Aucune transcription pour l'instant",
+ "silence": "[silence {{duration}} s]",
+ "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
+ "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
+ "noClips": "Aucun clip pour l'instant",
+ "laneVoiceover": "Voix off",
+ "laneLabel": "Lire la transcription depuis",
+ "revertWord": "Rétablir « {{original}} »",
+ "helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
+ "blankedWord": "vidé",
+ "clipLabel": "Clip {{index}}",
+ "title": "Transcription actuelle",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
+ "transcribeNow": "Transcrire maintenant",
+ "laneRecording": "Enregistrement",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
+ "removeInserted": "Supprimer « {{word}} »",
+ "trimSilence": "Couper le silence ({{duration}} s)",
+ "editorAria": "Transcription de {{filename}}",
+ "restoreWord": "Restaurer « {{word}} »"
},
"effects": {
- "fitClipMany": "{{count}} clips",
+ "shadow": "Ombre",
+ "fitClipOne": "{{count}} clip",
+ "blurBg": "Flou arrière-plan",
+ "fitClip": "Ajuster",
+ "formatOriginal": "Original",
+ "title": "Composition",
"format": "Format",
"motionBlur": "Flou de mouvement",
+ "fitClipMany": "{{count}} clips",
+ "roundness": "Arrondi",
"fitClipFew": "{{count}} clips",
- "fitClip": "Ajuster",
+ "motion": "Mouvement",
"on": "activé",
- "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
- "formatOriginal": "Original",
- "blurBg": "Flou arrière-plan",
"frame": "Cadre",
"padding": "Marge",
- "shadow": "Ombre",
- "off": "désactivé",
- "title": "Composition",
- "motion": "Mouvement",
- "fitClipOne": "{{count}} clip",
- "roundness": "Arrondi"
+ "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
+ "off": "désactivé"
+ },
+ "audioTrack": {
+ "mute": "Muet",
+ "fadeIn": "Fondu d'entrée",
+ "loop": "Boucle",
+ "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
+ "importFailed": "Impossible d’ajouter l’audio",
+ "fadeOut": "Fondu de sortie",
+ "add": "Ajouter une piste audio",
+ "defaultLabel": "Piste audio",
+ "remove": "Supprimer la piste"
},
"layout": {
- "noWebcam": "Sans webcam",
- "bgModes": {
- "none": "Original",
- "transparent": "Détouré",
- "blur": "Flouté",
- "custom": "Personnalisé"
- },
- "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
- "webcamFraming": "Cadrage de la webcam",
- "webcamCropX": "Déplacement horizontal",
- "preset": "Préréglage",
- "webcamSize": "Taille de la caméra",
"shapes": {
"circle": "Cercle",
"square": "Carré",
"rectangle": "Rect.",
"rounded": "Arrondi"
},
- "dualFrame": "Double cadre",
"verticalStack": "Empilement vertical",
- "webcamCropZoom": "Zoom du recadrage",
+ "webcamSize": "Taille de la caméra",
+ "preset": "Préréglage",
+ "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
+ "dualFrame": "Double cadre",
"title": "Disposition caméra",
- "webcamCropY": "Déplacement vertical",
"reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
+ "bgModes": {
+ "transparent": "Détouré",
+ "custom": "Personnalisé",
+ "none": "Original",
+ "blur": "Flouté"
+ },
+ "webcamCropZoom": "Zoom du recadrage",
+ "selectPreset": "Choisir un préréglage",
+ "webcamCropY": "Déplacement vertical",
+ "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
"pictureInPicture": "Incrustation d'image",
- "mirrorWebcam": "Inverser la webcam",
+ "reactiveWebcam": "Réduire au zoom",
+ "webcamBackground": "Arrière-plan de la caméra",
"webcamBlurIntensity": "Intensité du flou",
+ "mirrorWebcam": "Inverser la webcam",
"webcamShape": "Forme de la caméra",
- "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
- "selectPreset": "Choisir un préréglage",
- "reactiveWebcam": "Réduire au zoom",
- "webcamBackground": "Arrière-plan de la caméra"
- },
- "background": {
- "colorPalette": "Palette de couleurs",
- "gradient": "Dégradé",
- "imageLabel": "Fond {{index}}",
- "custom": "Personnalisé",
- "image": "Image",
- "title": "Arrière-plan",
- "colorWheel": "Roue chromatique",
- "customWallpaper": "Fond personnalisé",
- "uploadCustom": "Téléverser une image",
- "colorLabel": "Couleur {{color}}",
- "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
- "gradientLabel": "Dégradé {{index}}",
- "imageReadFailed": "Impossible de lire ce fichier image.",
- "presets": "Préréglages",
- "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
- "color": "Couleur"
+ "noWebcam": "Sans webcam",
+ "webcamCropX": "Déplacement horizontal",
+ "webcamFraming": "Cadrage de la webcam"
},
- "support": {
- "saveDiagnostics": "Enregistrer les diagnostics",
- "starOnGithub": "Étoile sur GitHub",
- "reportBug": "Signaler un bug"
+ "imageUpload": {
+ "uploadSuccess": "Image personnalisée téléversée avec succès !",
+ "failedToUpload": "Échec du téléversement de l'image",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
+ "errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
+ "invalidFileType": "Type de fichier invalide"
},
- "audio": {
- "outputGain": "Niveau de sortie",
- "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
- "reset": "Réinitialiser l’audio",
- "title": "Audio"
+ "cursor": {
+ "themeDefault": "Par défaut",
+ "motionBlur": "Flou de mouvement",
+ "smoothing": "Lissage",
+ "title": "Curseur",
+ "theme": "Style du curseur",
+ "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
+ "show": "Afficher le curseur",
+ "clipToBounds": "Rogner au canevas",
+ "size": "Taille",
+ "clickBounce": "Rebond au clic",
+ "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic."
},
"captions": {
- "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
- "alignCenter": "Centre",
- "backgroundColor": "Couleur du fond",
"original": "Original (transcription)",
- "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
- "removeLegacyAnnotations": "Supprimer les anciennes annotations",
- "displayLanguage": "Affichage",
"alignRight": "Droite",
+ "backgroundOpacity": "Opacité",
"anchorBottom": "Bas",
- "anchorTop": "Haut",
- "text": "Texte",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
- "textColor": "Couleur du texte",
+ "minWords": "Mots min. par ligne",
+ "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
+ "translate": "Traduire",
"maxWords": "Mots max. par ligne",
- "show": "Afficher les sous-titres",
- "translateFailed": "La traduction a échoué.",
+ "lineLength": "Longueur des lignes",
+ "anchorTop": "Haut",
"font": "Police",
- "language": "Langue",
- "background": "Fond",
- "translate": "Traduire",
+ "translating": "Traduction…",
"position": "Position",
- "distanceFromLeft": "Distance depuis la gauche",
- "lineLength": "Longueur des lignes",
- "alignLeft": "Gauche",
- "deleteTranslation": "Supprimer cette traduction",
- "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
- "minWords": "Mots min. par ligne",
- "showBackground": "Afficher le fond",
- "fontSize": "Taille",
+ "removeLegacyAnnotations": "Supprimer les anciennes annotations",
+ "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
+ "backgroundColor": "Couleur du fond",
+ "text": "Texte",
+ "textColor": "Couleur du texte",
"hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "distanceFromBottom": "Distance depuis le bas",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
+ "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
"distanceFromTop": "Distance depuis le haut",
+ "alignLeft": "Gauche",
"anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
- "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
- "translating": "Traduction…",
- "backgroundOpacity": "Opacité",
+ "deleteTranslation": "Supprimer cette traduction",
+ "distanceFromBottom": "Distance depuis le bas",
+ "displayLanguage": "Affichage",
+ "background": "Fond",
+ "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
+ "distanceFromLeft": "Distance depuis la gauche",
+ "alignCenter": "Centre",
+ "fontSize": "Taille",
+ "bold": "Gras",
+ "translateFailed": "La traduction a échoué.",
"distanceFromRight": "Distance depuis la droite",
- "bold": "Gras"
- },
- "transcript": {
- "revertWord": "Rétablir « {{original}} »",
- "laneRecording": "Enregistrement",
- "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
- "noAudio": "Ce média n'a pas de piste audio",
- "noTranscript": "Aucune transcription pour l'instant",
- "insertRecordingOnly": "On ne peut ajouter un mot que sur l’enregistrement : une pause fige une image du film.",
- "insertedWord": "Ajouté par vous — aucun son derrière",
- "insertAria": "Nouveau mot",
- "blankedWord": "vidé",
- "restoreSilence": "Restaurer le silence ({{duration}} s)",
- "laneLabel": "Lire la transcription depuis",
- "correctedWord": "Corrigé — la transcription disait « {{original}} »",
- "removeInserted": "Supprimer « {{word}} »",
- "transcribeNow": "Transcrire maintenant",
- "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
- "editWord": "Modifier « {{word}} »",
- "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
- "clipLabel": "Clip {{index}}",
- "title": "Transcription actuelle",
- "laneVoiceover": "Voix off",
- "trimSilence": "Couper le silence ({{duration}} s)",
- "helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
- "editorAria": "Transcription de {{filename}}",
- "silence": "[silence {{duration}} s]",
- "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
- "transcribing": "Transcription…",
- "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
- "restoreWord": "Restaurer « {{word}} »",
- "noClips": "Aucun clip pour l'instant",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler."
+ "showBackground": "Afficher le fond",
+ "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "show": "Afficher les sous-titres",
+ "language": "Langue",
+ "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct."
},
"zoom": {
- "level": "Niveau de zoom",
- "position": {
- "x": "X (%)",
- "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
- "title": "Position du focus",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Droite",
- "left": "Gauche",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "Gauche"
},
- "title": "Rotation 3D",
- "none": "Aucune"
+ "none": "Aucune",
+ "title": "Rotation 3D"
},
"focusMode": {
"autoDescription": "La caméra suit la position du curseur enregistré",
- "auto": "Auto",
"lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
+ "title": "Mode focus",
"manual": "Manuel",
- "title": "Mode focus"
+ "auto": "Auto"
+ },
+ "position": {
+ "title": "Position du focus",
+ "x": "X (%)",
+ "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
+ "y": "Y (%)"
},
+ "deleteZoom": "Supprimer le zoom",
+ "level": "Niveau de zoom",
"selectRegion": "Sélectionnez une région de zoom à ajuster",
- "customScale": "Zoom personnalisé",
"previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "deleteZoom": "Supprimer le zoom"
+ "customScale": "Zoom personnalisé"
},
- "project": {
- "load": "Charger un projet",
- "save": "Enregistrer le projet",
- "new": "Nouveau projet"
+ "textAnimation": {
+ "pop": "Apparition",
+ "rise": "Monter",
+ "selectAnimation": "Sélectionner une animation",
+ "fade": "Fondu",
+ "pulse": "Pulsation",
+ "typewriter": "Machine à écrire",
+ "title": "Animation de texte",
+ "none": "Aucune",
+ "slideLeft": "Glisser à gauche"
+ },
+ "crop": {
+ "lockAspectRatio": "Verrouiller le ratio",
+ "title": "Recadrage",
+ "done": "Terminer",
+ "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
+ "ratio": "Ratio",
+ "free": "Libre",
+ "cropVideo": "Recadrer la vidéo",
+ "unlockAspectRatio": "Déverrouiller le ratio"
+ },
+ "background": {
+ "imageLabel": "Fond {{index}}",
+ "color": "Couleur",
+ "gradient": "Dégradé",
+ "colorLabel": "Couleur {{color}}",
+ "colorWheel": "Roue chromatique",
+ "customWallpaper": "Fond personnalisé",
+ "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
+ "image": "Image",
+ "gradientLabel": "Dégradé {{index}}",
+ "imageReadFailed": "Impossible de lire ce fichier image.",
+ "presets": "Préréglages",
+ "custom": "Personnalisé",
+ "colorPalette": "Palette de couleurs",
+ "uploadCustom": "Téléverser une image",
+ "title": "Arrière-plan",
+ "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG."
+ },
+ "audio": {
+ "title": "Audio",
+ "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
+ "reset": "Réinitialiser l’audio",
+ "outputGain": "Niveau de sortie"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Résolution d'export",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "Impossible d’ajouter l’audio",
- "defaultLabel": "Piste audio",
- "add": "Ajouter une piste audio",
- "fadeIn": "Fondu d'entrée",
- "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
- "fadeOut": "Fondu de sortie",
- "loop": "Boucle",
- "remove": "Supprimer la piste",
- "mute": "Muet",
- "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur"
+ "high": "Source",
+ "title": "Résolution d'export"
},
"gifSettings": {
+ "loop": "GIF en boucle",
"frameRate": "Fréquence d'images GIF",
- "size": "Taille du GIF",
- "loop": "GIF en boucle"
- },
- "crop": {
- "ratio": "Ratio",
- "free": "Libre",
- "lockAspectRatio": "Verrouiller le ratio",
- "unlockAspectRatio": "Déverrouiller le ratio",
- "cropVideo": "Recadrer la vidéo",
- "title": "Recadrage",
- "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
- "done": "Terminer"
- },
- "cursor": {
- "clickBounce": "Rebond au clic",
- "clipToBounds": "Rogner au canevas",
- "title": "Curseur",
- "size": "Taille",
- "show": "Afficher le curseur",
- "motionBlur": "Flou de mouvement",
- "themeDefault": "Par défaut",
- "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
- "smoothing": "Lissage",
- "theme": "Style du curseur",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic."
+ "size": "Taille du GIF"
},
"export": {
- "videoButton": "Exporter la vidéo",
+ "chooseSaveLocation": "Choisir l'emplacement d'enregistrement",
"gifButton": "Exporter le GIF",
- "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
+ "videoButton": "Exporter la vidéo"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "Fichier vidéo haute qualité",
- "gifDescription": "Image animée pour le partage",
- "gifAnimation": "Animation GIF",
- "mp4Video": "Vidéo MP4"
+ "project": {
+ "load": "Charger un projet",
+ "save": "Enregistrer le projet",
+ "new": "Nouveau projet"
},
"speed": {
"previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
- "deleteRegion": "Supprimer la région de vitesse",
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
+ "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
+ "deleteRegion": "Supprimer la région de vitesse",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
- "playbackSpeed": "Vitesse de lecture",
- "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×"
+ "playbackSpeed": "Vitesse de lecture"
},
- "textAnimation": {
- "rise": "Monter",
- "pop": "Apparition",
- "selectAnimation": "Sélectionner une animation",
- "pulse": "Pulsation",
- "slideLeft": "Glisser à gauche",
- "typewriter": "Machine à écrire",
- "none": "Aucune",
- "fade": "Fondu",
- "title": "Animation de texte"
+ "language": {
+ "title": "Langue"
},
- "imageUpload": {
- "invalidFileType": "Type de fichier invalide",
- "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
- "failedToUpload": "Échec du téléversement de l'image",
- "uploadSuccess": "Image personnalisée téléversée avec succès !",
- "errorReading": "Une erreur s'est produite lors de la lecture du fichier."
+ "support": {
+ "starOnGithub": "Étoile sur GitHub",
+ "reportBug": "Signaler un bug",
+ "saveDiagnostics": "Enregistrer les diagnostics"
},
- "panes": {
- "help": "Aide"
+ "trim": {
+ "deleteRegion": "Supprimer la région de coupe"
},
"facets": {
"transcript": "Transcription",
"captions": "Sous-titres"
},
- "language": {
- "title": "Langue"
- },
- "trim": {
- "deleteRegion": "Supprimer la région de coupe"
+ "panes": {
+ "help": "Aide"
}
}
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 95067ade9..03415b750 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "Animazione GIF",
+ "mp4Description": "File video di alta qualità",
+ "mp4": "MP4",
+ "mp4Video": "Video MP4",
+ "gif": "GIF",
+ "gifDescription": "Immagine animata per la condivisione"
+ },
"customFont": {
- "nameHelp": "Così apparirà il font nel selettore",
- "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
- "urlLabel": "URL importazione Google Fonts",
- "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
"errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Aggiungi font Google",
+ "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
+ "nameLabel": "Nome visualizzato",
"failedToAdd": "Impossibile aggiungere il font",
- "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
+ "urlLabel": "URL importazione Google Fonts",
+ "namePlaceholder": "Il mio font personalizzato",
+ "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
"addingButton": "Aggiunta in corso...",
- "dialogTitle": "Aggiungi font Google",
+ "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
+ "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
"successMessage": "Font \"{{fontName}}\" aggiunto con successo",
- "addButton": "Aggiungi font",
- "namePlaceholder": "Il mio font personalizzato",
+ "nameHelp": "Così apparirà il font nel selettore",
"errorEmptyName": "Inserisci un nome per il font",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
- "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
- "nameLabel": "Nome visualizzato"
+ "addButton": "Aggiungi font",
+ "errorInvalidUrl": "Inserisci un URL Google Fonts valido"
},
"annotation": {
- "defaultText": "Ciao",
- "size": "Dimensione",
+ "colorWheel": "Ruota dei colori",
"imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
+ "typeArrow": "Freccia",
+ "selectStyle": "Seleziona stile",
+ "blurColorWhite": "Bianco",
"blurType": "Tipo sfocatura",
- "mosaicBlockSize": "Dimensione blocco mosaico",
- "colorWheel": "Ruota dei colori",
- "typeImage": "Immagine",
- "textContent": "Contenuto testo",
- "clearBackground": "Rimuovi sfondo",
- "invalidImageType": "Tipo di file non valido",
- "shortcutsAndTips": "Scorciatoie e suggerimenti",
- "colorPalette": "Tavolozza dei colori",
+ "blurShapeRectangle": "Rettangolo",
+ "blurColor": "Colore sfocatura",
"arrowColor": "Colore freccia",
- "strokeWidth": "Larghezza tratto: {{width}}px",
- "blurColorWhite": "Bianco",
+ "textContent": "Contenuto testo",
"background": "Sfondo",
- "blurColor": "Colore sfocatura",
- "typeArrow": "Freccia",
- "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Mosaico",
- "textPlaceholder": "Inserisci il tuo testo...",
+ "clearBackground": "Rimuovi sfondo",
+ "blurShapeFreehand": "A mano libera",
"blurIntensity": "Intensità sfocatura",
- "textColor": "Colore testo",
- "blurShapeRectangle": "Rettangolo",
- "deleteAnnotation": "Elimina annotazione",
"tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
- "blurShapeFreehand": "A mano libera",
- "arrowDirection": "Direzione freccia",
- "selectStyle": "Seleziona stile",
- "fontStyle": "Stile carattere",
- "imageUploadSuccess": "Immagine caricata con successo!",
"active": "Attivo",
- "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
- "blurShapeOval": "Ovale",
+ "size": "Dimensione",
"blurColorBlack": "Nero",
- "color": "Colore",
+ "typeImage": "Immagine",
+ "mosaicBlockSize": "Dimensione blocco mosaico",
+ "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Larghezza tratto: {{width}}px",
+ "textColor": "Colore testo",
+ "defaultText": "Ciao",
"blurShape": "Forma sfocatura",
- "customFonts": "Caratteri personalizzati",
- "typeBlur": "Sfocatura",
- "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
+ "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
"type": "Tipo",
+ "typeText": "Testo",
+ "textPlaceholder": "Inserisci il tuo testo...",
+ "fontStyle": "Stile carattere",
+ "imageUploadSuccess": "Immagine caricata con successo!",
+ "colorPalette": "Tavolozza dei colori",
+ "color": "Colore",
+ "shortcutsAndTips": "Scorciatoie e suggerimenti",
+ "none": "Nessuno",
+ "invalidImageType": "Tipo di file non valido",
+ "arrowDirection": "Direzione freccia",
+ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
"uploadImage": "Carica immagine",
+ "customFonts": "Caratteri personalizzati",
+ "blurTypeMosaic": "Mosaico",
"blurTypeBlur": "Gaussiano",
- "none": "Nessuno",
+ "typeBlur": "Sfocatura",
"title": "Impostazioni annotazione",
- "typeText": "Testo"
+ "deleteAnnotation": "Elimina annotazione",
+ "blurShapeOval": "Ovale"
+ },
+ "transcript": {
+ "restoreSilence": "Ripristina silenzio ({{duration}} s)",
+ "editWord": "Modifica «{{word}}»",
+ "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
+ "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
+ "insertAria": "Nuova parola",
+ "transcribing": "Trascrizione…",
+ "noAudio": "Questo contenuto non ha una traccia audio",
+ "noTranscript": "Ancora nessuna trascrizione",
+ "silence": "[silenzio {{duration}} s]",
+ "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
+ "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
+ "noClips": "Ancora nessun clip",
+ "laneVoiceover": "Voce fuori campo",
+ "laneLabel": "Leggi la trascrizione da",
+ "revertWord": "Ripristina «{{original}}»",
+ "helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
+ "blankedWord": "svuotata",
+ "clipLabel": "Clip {{index}}",
+ "title": "Trascrizione corrente",
+ "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
+ "transcribeNow": "Trascrivi ora",
+ "laneRecording": "Registrazione",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
+ "removeInserted": "Elimina «{{word}}»",
+ "trimSilence": "Taglia silenzio ({{duration}} s)",
+ "editorAria": "Trascrizione di {{filename}}",
+ "restoreWord": "Ripristina «{{word}}»"
},
"effects": {
- "fitClipMany": "{{count}} clip",
+ "shadow": "Ombra",
+ "fitClipOne": "{{count}} clip",
+ "blurBg": "Sfuma sfondo",
+ "fitClip": "Adatta",
+ "formatOriginal": "Originale",
+ "title": "Composizione",
"format": "Formato",
"motionBlur": "Sfocatura movimento",
+ "fitClipMany": "{{count}} clip",
+ "roundness": "Arrotondamento",
"fitClipFew": "{{count}} clip",
- "fitClip": "Adatta",
+ "motion": "Movimento",
"on": "acceso",
- "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
- "formatOriginal": "Originale",
- "blurBg": "Sfuma sfondo",
"frame": "Cornice",
"padding": "Spaziatura",
- "shadow": "Ombra",
- "off": "spento",
- "title": "Composizione",
- "motion": "Movimento",
- "fitClipOne": "{{count}} clip",
- "roundness": "Arrotondamento"
+ "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
+ "off": "spento"
+ },
+ "audioTrack": {
+ "mute": "Muto",
+ "fadeIn": "Dissolvenza in entrata",
+ "loop": "Ripeti",
+ "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
+ "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "importFailed": "Impossibile aggiungere l’audio",
+ "fadeOut": "Dissolvenza in uscita",
+ "add": "Aggiungi traccia audio",
+ "defaultLabel": "Traccia audio",
+ "remove": "Elimina traccia"
},
"layout": {
- "noWebcam": "Nessuna webcam",
- "bgModes": {
- "none": "Originale",
- "transparent": "Scontornato",
- "blur": "Sfocato",
- "custom": "Personalizzato"
- },
- "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
- "webcamFraming": "Inquadratura webcam",
- "webcamCropX": "Spostamento orizzontale",
- "preset": "Predefinito",
- "webcamSize": "Dimensione webcam",
"shapes": {
"circle": "Cerchio",
"square": "Quadrato",
"rectangle": "Rett.",
"rounded": "Arrotondato"
},
- "dualFrame": "Doppio frame",
"verticalStack": "Pila verticale",
- "webcamCropZoom": "Zoom ritaglio",
+ "webcamSize": "Dimensione webcam",
+ "preset": "Predefinito",
+ "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
+ "dualFrame": "Doppio frame",
"title": "Disposizione camera",
- "webcamCropY": "Spostamento verticale",
"reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
+ "bgModes": {
+ "transparent": "Scontornato",
+ "custom": "Personalizzato",
+ "none": "Originale",
+ "blur": "Sfocato"
+ },
+ "webcamCropZoom": "Zoom ritaglio",
+ "selectPreset": "Seleziona predefinito",
+ "webcamCropY": "Spostamento verticale",
+ "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
"pictureInPicture": "Immagine nell'immagine",
- "mirrorWebcam": "Specchia webcam",
+ "reactiveWebcam": "Riduci con lo zoom",
+ "webcamBackground": "Sfondo della fotocamera",
"webcamBlurIntensity": "Intensità sfocatura",
+ "mirrorWebcam": "Specchia webcam",
"webcamShape": "Forma fotocamera",
- "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
- "selectPreset": "Seleziona predefinito",
- "reactiveWebcam": "Riduci con lo zoom",
- "webcamBackground": "Sfondo della fotocamera"
- },
- "background": {
- "colorPalette": "Tavolozza dei colori",
- "gradient": "Sfumatura",
- "imageLabel": "Sfondo {{index}}",
- "custom": "Personalizzato",
- "image": "Immagine",
- "title": "Sfondo",
- "colorWheel": "Ruota dei colori",
- "customWallpaper": "Sfondo personalizzato",
- "uploadCustom": "Carica personalizzato",
- "colorLabel": "Colore {{color}}",
- "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
- "gradientLabel": "Sfumatura {{index}}",
- "imageReadFailed": "Impossibile leggere quel file immagine.",
- "presets": "Predefiniti",
- "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
- "color": "Colore"
+ "noWebcam": "Nessuna webcam",
+ "webcamCropX": "Spostamento orizzontale",
+ "webcamFraming": "Inquadratura webcam"
},
- "support": {
- "saveDiagnostics": "Salva dati diagnostici",
- "starOnGithub": "Metti stella su GitHub",
- "reportBug": "Segnala bug"
+ "imageUpload": {
+ "uploadSuccess": "Immagine personalizzata caricata con successo!",
+ "failedToUpload": "Impossibile caricare l'immagine",
+ "jpgOnly": "Carica un file immagine JPG o JPEG.",
+ "errorReading": "Si è verificato un errore durante la lettura del file.",
+ "invalidFileType": "Tipo di file non valido"
},
- "audio": {
- "outputGain": "Livello di uscita",
- "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
- "reset": "Reimposta audio",
- "title": "Audio"
+ "cursor": {
+ "themeDefault": "Predefinito",
+ "motionBlur": "Sfocatura movimento",
+ "smoothing": "Smussatura",
+ "title": "Cursore",
+ "theme": "Stile del cursore",
+ "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
+ "show": "Mostra cursore",
+ "clipToBounds": "Ritaglia al canvas",
+ "size": "Dimensione",
+ "clickBounce": "Rimbalzo clic",
+ "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic."
},
"captions": {
- "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
- "alignCenter": "Centro",
- "backgroundColor": "Colore dello sfondo",
"original": "Originale (trascrizione)",
- "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
- "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
- "displayLanguage": "Visualizzazione",
"alignRight": "Destra",
+ "backgroundOpacity": "Opacità",
"anchorBottom": "Basso",
- "anchorTop": "Alto",
- "text": "Testo",
- "translateHint": "Traduci la trascrizione con il provider IA configurato",
- "textColor": "Colore del testo",
+ "minWords": "Parole min. per riga",
+ "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
+ "translate": "Traduci",
"maxWords": "Parole max. per riga",
- "show": "Mostra sottotitoli",
- "translateFailed": "Traduzione non riuscita.",
+ "lineLength": "Lunghezza riga",
+ "anchorTop": "Alto",
"font": "Carattere",
- "language": "Lingua",
- "background": "Sfondo",
- "translate": "Traduci",
+ "translating": "Traduzione…",
"position": "Posizione",
- "distanceFromLeft": "Distanza da sinistra",
- "lineLength": "Lunghezza riga",
- "alignLeft": "Sinistra",
- "deleteTranslation": "Elimina questa traduzione",
- "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
- "minWords": "Parole min. per riga",
- "showBackground": "Mostra sfondo",
- "fontSize": "Dimensione",
+ "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
+ "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
+ "backgroundColor": "Colore dello sfondo",
+ "text": "Testo",
+ "textColor": "Colore del testo",
"hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
- "distanceFromBottom": "Distanza dal basso",
- "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
+ "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
"distanceFromTop": "Distanza dall'alto",
+ "alignLeft": "Sinistra",
"anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
- "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
- "translating": "Traduzione…",
- "backgroundOpacity": "Opacità",
+ "deleteTranslation": "Elimina questa traduzione",
+ "distanceFromBottom": "Distanza dal basso",
+ "displayLanguage": "Visualizzazione",
+ "background": "Sfondo",
+ "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
+ "distanceFromLeft": "Distanza da sinistra",
+ "alignCenter": "Centro",
+ "fontSize": "Dimensione",
+ "bold": "Grassetto",
+ "translateFailed": "Traduzione non riuscita.",
"distanceFromRight": "Distanza da destra",
- "bold": "Grassetto"
- },
- "transcript": {
- "revertWord": "Ripristina «{{original}}»",
- "laneRecording": "Registrazione",
- "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
- "noAudio": "Questo contenuto non ha una traccia audio",
- "noTranscript": "Ancora nessuna trascrizione",
- "insertRecordingOnly": "Le parole si possono aggiungere solo sulla registrazione: una pausa congela un fotogramma.",
- "insertedWord": "Aggiunta da te — nessun audio dietro",
- "insertAria": "Nuova parola",
- "blankedWord": "svuotata",
- "restoreSilence": "Ripristina silenzio ({{duration}} s)",
- "laneLabel": "Leggi la trascrizione da",
- "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
- "removeInserted": "Elimina «{{word}}»",
- "transcribeNow": "Trascrivi ora",
- "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
- "editWord": "Modifica «{{word}}»",
- "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
- "clipLabel": "Clip {{index}}",
- "title": "Trascrizione corrente",
- "laneVoiceover": "Voce fuori campo",
- "trimSilence": "Taglia silenzio ({{duration}} s)",
- "helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
- "editorAria": "Trascrizione di {{filename}}",
- "silence": "[silenzio {{duration}} s]",
- "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
- "transcribing": "Trascrizione…",
- "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
- "restoreWord": "Ripristina «{{word}}»",
- "noClips": "Ancora nessun clip",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare."
+ "showBackground": "Mostra sfondo",
+ "translateHint": "Traduci la trascrizione con il provider IA configurato",
+ "show": "Mostra sottotitoli",
+ "language": "Lingua",
+ "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione."
},
"zoom": {
- "level": "Livello zoom",
- "position": {
- "x": "X (%)",
- "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
- "title": "Posizione messa a fuoco",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Destra",
- "left": "Sinistra",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "Sinistra"
},
- "title": "Rotazione 3D",
- "none": "Nessuna"
+ "none": "Nessuna",
+ "title": "Rotazione 3D"
},
"focusMode": {
"autoDescription": "La fotocamera segue la posizione del cursore registrato",
- "auto": "Automatico",
"lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
+ "title": "Modalità messa a fuoco",
"manual": "Manuale",
- "title": "Modalità messa a fuoco"
+ "auto": "Automatico"
+ },
+ "position": {
+ "title": "Posizione messa a fuoco",
+ "x": "X (%)",
+ "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
+ "y": "Y (%)"
},
+ "deleteZoom": "Elimina zoom",
+ "level": "Livello zoom",
"selectRegion": "Seleziona una regione zoom da regolare",
- "customScale": "Zoom personalizzato",
"previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
- "deleteZoom": "Elimina zoom"
+ "customScale": "Zoom personalizzato"
},
- "project": {
- "load": "Carica progetto",
- "save": "Salva progetto",
- "new": "Nuovo progetto"
+ "textAnimation": {
+ "pop": "Apparizione",
+ "rise": "Ascesa",
+ "selectAnimation": "Seleziona animazione",
+ "fade": "Dissolvenza",
+ "pulse": "Pulsazione",
+ "typewriter": "Macchina da scrivere",
+ "title": "Animazione testo",
+ "none": "Nessuna",
+ "slideLeft": "Scivola a sinistra"
+ },
+ "crop": {
+ "lockAspectRatio": "Blocca proporzioni",
+ "title": "Ritaglia",
+ "done": "Fatto",
+ "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
+ "ratio": "Proporzioni",
+ "free": "Libero",
+ "cropVideo": "Ritaglia video",
+ "unlockAspectRatio": "Sblocca proporzioni"
+ },
+ "background": {
+ "imageLabel": "Sfondo {{index}}",
+ "color": "Colore",
+ "gradient": "Sfumatura",
+ "colorLabel": "Colore {{color}}",
+ "colorWheel": "Ruota dei colori",
+ "customWallpaper": "Sfondo personalizzato",
+ "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
+ "image": "Immagine",
+ "gradientLabel": "Sfumatura {{index}}",
+ "imageReadFailed": "Impossibile leggere quel file immagine.",
+ "presets": "Predefiniti",
+ "custom": "Personalizzato",
+ "colorPalette": "Tavolozza dei colori",
+ "uploadCustom": "Carica personalizzato",
+ "title": "Sfondo",
+ "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG."
+ },
+ "audio": {
+ "title": "Audio",
+ "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
+ "reset": "Reimposta audio",
+ "outputGain": "Livello di uscita"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Risoluzione esportazione",
- "high": "Originale"
- },
- "audioTrack": {
- "importFailed": "Impossibile aggiungere l’audio",
- "defaultLabel": "Traccia audio",
- "add": "Aggiungi traccia audio",
- "fadeIn": "Dissolvenza in entrata",
- "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
- "fadeOut": "Dissolvenza in uscita",
- "loop": "Ripeti",
- "remove": "Elimina traccia",
- "mute": "Muto",
- "slipHint": "Alt + trascina per far scorrere l’audio all’interno"
+ "high": "Originale",
+ "title": "Risoluzione esportazione"
},
"gifSettings": {
+ "loop": "GIF in loop",
"frameRate": "Frequenza fotogrammi GIF",
- "size": "Dimensione GIF",
- "loop": "GIF in loop"
- },
- "crop": {
- "ratio": "Proporzioni",
- "free": "Libero",
- "lockAspectRatio": "Blocca proporzioni",
- "unlockAspectRatio": "Sblocca proporzioni",
- "cropVideo": "Ritaglia video",
- "title": "Ritaglia",
- "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
- "done": "Fatto"
- },
- "cursor": {
- "clickBounce": "Rimbalzo clic",
- "clipToBounds": "Ritaglia al canvas",
- "title": "Cursore",
- "size": "Dimensione",
- "show": "Mostra cursore",
- "motionBlur": "Sfocatura movimento",
- "themeDefault": "Predefinito",
- "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
- "smoothing": "Smussatura",
- "theme": "Stile del cursore",
- "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic."
+ "size": "Dimensione GIF"
},
"export": {
- "videoButton": "Esporta video",
+ "chooseSaveLocation": "Scegli posizione di salvataggio",
"gifButton": "Esporta GIF",
- "chooseSaveLocation": "Scegli posizione di salvataggio"
+ "videoButton": "Esporta video"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "File video di alta qualità",
- "gifDescription": "Immagine animata per la condivisione",
- "gifAnimation": "Animazione GIF",
- "mp4Video": "Video MP4"
+ "project": {
+ "load": "Carica progetto",
+ "save": "Salva progetto",
+ "new": "Nuovo progetto"
},
"speed": {
"previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata.",
- "deleteRegion": "Elimina regione velocità",
"customPlaybackSpeed": "Velocità di riproduzione personalizzata",
+ "maxSpeedError": "La velocità non può superare {{max}}×",
+ "deleteRegion": "Elimina regione velocità",
"selectRegion": "Seleziona una regione velocità da regolare",
- "playbackSpeed": "Velocità di riproduzione",
- "maxSpeedError": "La velocità non può superare {{max}}×"
+ "playbackSpeed": "Velocità di riproduzione"
},
- "textAnimation": {
- "rise": "Ascesa",
- "pop": "Apparizione",
- "selectAnimation": "Seleziona animazione",
- "pulse": "Pulsazione",
- "slideLeft": "Scivola a sinistra",
- "typewriter": "Macchina da scrivere",
- "none": "Nessuna",
- "fade": "Dissolvenza",
- "title": "Animazione testo"
+ "language": {
+ "title": "Lingua"
},
- "imageUpload": {
- "invalidFileType": "Tipo di file non valido",
- "jpgOnly": "Carica un file immagine JPG o JPEG.",
- "failedToUpload": "Impossibile caricare l'immagine",
- "uploadSuccess": "Immagine personalizzata caricata con successo!",
- "errorReading": "Si è verificato un errore durante la lettura del file."
+ "support": {
+ "starOnGithub": "Metti stella su GitHub",
+ "reportBug": "Segnala bug",
+ "saveDiagnostics": "Salva dati diagnostici"
},
- "panes": {
- "help": "Aiuto"
+ "trim": {
+ "deleteRegion": "Elimina regione taglio"
},
"facets": {
"transcript": "Trascrizione",
"captions": "Sottotitoli"
},
- "language": {
- "title": "Lingua"
- },
- "trim": {
- "deleteRegion": "Elimina regione taglio"
+ "panes": {
+ "help": "Aiuto"
}
}
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 7707e9292..92be6ec28 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF アニメーション",
+ "mp4Description": "高品質の動画ファイル",
+ "mp4": "MP4",
+ "mp4Video": "MP4 動画",
+ "gif": "GIF",
+ "gifDescription": "共有用のアニメーション画像"
+ },
"customFont": {
- "nameHelp": "フォントセレクターに表示される名前です",
- "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
- "urlLabel": "GoogleフォントのインポートURL",
- "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
"errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Googleフォントを追加",
+ "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
+ "nameLabel": "表示名",
"failedToAdd": "フォントの追加に失敗しました",
- "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
+ "urlLabel": "GoogleフォントのインポートURL",
+ "namePlaceholder": "マイカスタムフォント",
+ "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
"addingButton": "追加中...",
- "dialogTitle": "Googleフォントを追加",
+ "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
+ "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
"successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
- "addButton": "フォントを追加",
- "namePlaceholder": "マイカスタムフォント",
+ "nameHelp": "フォントセレクターに表示される名前です",
"errorEmptyName": "フォント名を入力してください",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
- "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
- "nameLabel": "表示名"
+ "addButton": "フォントを追加",
+ "errorInvalidUrl": "有効なGoogleフォントURLを入力してください"
},
"annotation": {
- "defaultText": "こんにちは",
- "size": "サイズ",
+ "colorWheel": "カラーホイール",
"imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "typeArrow": "矢印",
+ "selectStyle": "スタイルを選択",
+ "blurColorWhite": "白",
"blurType": "ぼかしの種類",
- "mosaicBlockSize": "モザイクブロックのサイズ",
- "colorWheel": "カラーホイール",
- "typeImage": "画像",
- "textContent": "テキスト内容",
- "clearBackground": "背景をクリア",
- "invalidImageType": "無効なファイル形式",
- "shortcutsAndTips": "ショートカットとヒント",
- "colorPalette": "カラーパレット",
+ "blurShapeRectangle": "長方形",
+ "blurColor": "ぼかしの色",
"arrowColor": "矢印の色",
- "strokeWidth": "線の太さ: {{width}}px",
- "blurColorWhite": "白",
+ "textContent": "テキスト内容",
"background": "背景",
- "blurColor": "ぼかしの色",
- "typeArrow": "矢印",
- "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "モザイク",
- "textPlaceholder": "テキストを入力してください...",
+ "clearBackground": "背景をクリア",
+ "blurShapeFreehand": "自由形状",
"blurIntensity": "ぼかしの強さ",
- "textColor": "文字色",
- "blurShapeRectangle": "長方形",
- "deleteAnnotation": "注釈を削除",
"tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
- "blurShapeFreehand": "自由形状",
- "arrowDirection": "矢印の方向",
- "selectStyle": "スタイルを選択",
- "fontStyle": "フォントスタイル",
- "imageUploadSuccess": "画像を読み込みました。",
"active": "アクティブ",
- "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
- "blurShapeOval": "楕円",
+ "size": "サイズ",
"blurColorBlack": "黒",
- "color": "色",
+ "typeImage": "画像",
+ "mosaicBlockSize": "モザイクブロックのサイズ",
+ "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
+ "strokeWidth": "線の太さ: {{width}}px",
+ "textColor": "文字色",
+ "defaultText": "こんにちは",
"blurShape": "ぼかしの形状",
- "customFonts": "カスタムフォント",
- "typeBlur": "ぼかし",
- "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
+ "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
"type": "種類",
+ "typeText": "テキスト",
+ "textPlaceholder": "テキストを入力してください...",
+ "fontStyle": "フォントスタイル",
+ "imageUploadSuccess": "画像を読み込みました。",
+ "colorPalette": "カラーパレット",
+ "color": "色",
+ "shortcutsAndTips": "ショートカットとヒント",
+ "none": "なし",
+ "invalidImageType": "無効なファイル形式",
+ "arrowDirection": "矢印の方向",
+ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
"uploadImage": "画像を読み込む",
+ "customFonts": "カスタムフォント",
+ "blurTypeMosaic": "モザイク",
"blurTypeBlur": "ガウス",
- "none": "なし",
+ "typeBlur": "ぼかし",
"title": "注釈設定",
- "typeText": "テキスト"
+ "deleteAnnotation": "注釈を削除",
+ "blurShapeOval": "楕円"
+ },
+ "transcript": {
+ "restoreSilence": "無音を元に戻す({{duration}} 秒)",
+ "editWord": "「{{word}}」を編集",
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
+ "insertAria": "新しい単語",
+ "transcribing": "文字起こし中…",
+ "noAudio": "このメディアには音声トラックがありません",
+ "noTranscript": "文字起こしがまだありません",
+ "silence": "[無音 {{duration}} 秒]",
+ "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
+ "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
+ "noClips": "クリップがまだありません",
+ "laneVoiceover": "ナレーション",
+ "laneLabel": "文字起こしの読み込み元",
+ "revertWord": "「{{original}}」に戻す",
+ "helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
+ "blankedWord": "空欄",
+ "clipLabel": "クリップ {{index}}",
+ "title": "現在の文字起こし",
+ "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
+ "transcribeNow": "今すぐ文字起こし",
+ "laneRecording": "録画",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "removeInserted": "「{{word}}」を削除",
+ "trimSilence": "無音をトリム({{duration}} 秒)",
+ "editorAria": "{{filename}} の文字起こし",
+ "restoreWord": "「{{word}}」を元に戻す"
},
"effects": {
- "fitClipMany": "{{count}} クリップ",
+ "shadow": "影",
+ "fitClipOne": "{{count}} クリップ",
+ "blurBg": "背景をぼかす",
+ "fitClip": "合わせる",
+ "formatOriginal": "元のサイズ",
+ "title": "コンポジション",
"format": "フォーマット",
"motionBlur": "モーションブラー",
+ "fitClipMany": "{{count}} クリップ",
+ "roundness": "丸み",
"fitClipFew": "{{count}} クリップ",
- "fitClip": "合わせる",
+ "motion": "モーション",
"on": "オン",
- "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
- "formatOriginal": "元のサイズ",
- "blurBg": "背景をぼかす",
"frame": "フレーム",
"padding": "余白",
- "shadow": "影",
- "off": "オフ",
- "title": "コンポジション",
- "motion": "モーション",
- "fitClipOne": "{{count}} クリップ",
- "roundness": "丸み"
+ "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
+ "off": "オフ"
+ },
+ "audioTrack": {
+ "mute": "ミュート",
+ "fadeIn": "フェードイン",
+ "loop": "ループ",
+ "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
+ "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "importFailed": "オーディオを追加できませんでした",
+ "fadeOut": "フェードアウト",
+ "add": "オーディオトラックを追加",
+ "defaultLabel": "オーディオトラック",
+ "remove": "トラックを削除"
},
"layout": {
- "noWebcam": "Webカメラなし",
- "bgModes": {
- "none": "オリジナル",
- "transparent": "切り抜き",
- "blur": "ぼかし",
- "custom": "カスタム"
- },
- "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
- "webcamFraming": "ウェブカメラの構図",
- "webcamCropX": "水平方向に移動",
- "preset": "プリセット",
- "webcamSize": "カメラのサイズ",
"shapes": {
"circle": "円",
"square": "正方形",
"rectangle": "長方形",
"rounded": "角丸"
},
- "dualFrame": "デュアルフレーム",
"verticalStack": "縦並び",
- "webcamCropZoom": "クロップのズーム",
+ "webcamSize": "カメラのサイズ",
+ "preset": "プリセット",
+ "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
+ "dualFrame": "デュアルフレーム",
"title": "カメラレイアウト",
- "webcamCropY": "垂直方向に移動",
"reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
+ "bgModes": {
+ "transparent": "切り抜き",
+ "custom": "カスタム",
+ "none": "オリジナル",
+ "blur": "ぼかし"
+ },
+ "webcamCropZoom": "クロップのズーム",
+ "selectPreset": "プリセットを選択",
+ "webcamCropY": "垂直方向に移動",
+ "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
"pictureInPicture": "ピクチャーインピクチャ",
- "mirrorWebcam": "Webカメラを反転",
+ "reactiveWebcam": "ズーム時に縮小",
+ "webcamBackground": "カメラ背景",
"webcamBlurIntensity": "ぼかしの強さ",
+ "mirrorWebcam": "Webカメラを反転",
"webcamShape": "カメラの形状",
- "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
- "selectPreset": "プリセットを選択",
- "reactiveWebcam": "ズーム時に縮小",
- "webcamBackground": "カメラ背景"
- },
- "background": {
- "colorPalette": "カラーパレット",
- "gradient": "グラデーション",
- "imageLabel": "背景 {{index}}",
- "custom": "カスタム",
- "image": "画像",
- "title": "背景",
- "colorWheel": "カラーホイール",
- "customWallpaper": "カスタム壁紙",
- "uploadCustom": "カスタム画像を読み込む",
- "colorLabel": "色 {{color}}",
- "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
- "gradientLabel": "グラデーション {{index}}",
- "imageReadFailed": "この画像ファイルを読み込めませんでした。",
- "presets": "プリセット",
- "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
- "color": "色"
+ "noWebcam": "Webカメラなし",
+ "webcamCropX": "水平方向に移動",
+ "webcamFraming": "ウェブカメラの構図"
},
- "support": {
- "saveDiagnostics": "診断情報を保存",
- "starOnGithub": "GitHub でスターを付ける",
- "reportBug": "バグを報告"
+ "imageUpload": {
+ "uploadSuccess": "カスタム画像を読み込みました。",
+ "failedToUpload": "画像の読み込みに失敗しました",
+ "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "errorReading": "ファイルの読み取り中にエラーが発生しました。",
+ "invalidFileType": "無効なファイル形式"
},
- "audio": {
- "outputGain": "出力レベル",
- "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
- "reset": "オーディオをリセット",
- "title": "オーディオ"
+ "cursor": {
+ "themeDefault": "デフォルト",
+ "motionBlur": "モーションブラー",
+ "smoothing": "スムージング",
+ "title": "カーソル",
+ "theme": "カーソルのスタイル",
+ "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
+ "show": "カーソルを表示",
+ "clipToBounds": "キャンバスにクリップ",
+ "size": "サイズ",
+ "clickBounce": "クリックバウンス",
+ "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。"
},
"captions": {
- "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
- "alignCenter": "中央",
- "backgroundColor": "背景色",
"original": "オリジナル(文字起こし)",
- "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
- "removeLegacyAnnotations": "古い字幕の注釈を削除",
- "displayLanguage": "表示",
"alignRight": "右",
+ "backgroundOpacity": "不透明度",
"anchorBottom": "下",
- "anchorTop": "上",
- "text": "テキスト",
- "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
- "textColor": "文字色",
+ "minWords": "1 行の最小単語数",
+ "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
+ "translate": "翻訳",
"maxWords": "1 行の最大単語数",
- "show": "字幕を表示",
- "translateFailed": "翻訳に失敗しました。",
+ "lineLength": "行の長さ",
+ "anchorTop": "上",
"font": "フォント",
- "language": "言語",
- "background": "背景",
- "translate": "翻訳",
+ "translating": "翻訳中…",
"position": "位置",
- "distanceFromLeft": "左端からの距離",
- "lineLength": "行の長さ",
- "alignLeft": "左",
- "deleteTranslation": "この翻訳を削除",
- "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
- "minWords": "1 行の最小単語数",
- "showBackground": "背景を表示",
- "fontSize": "サイズ",
+ "removeLegacyAnnotations": "古い字幕の注釈を削除",
+ "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
+ "backgroundColor": "背景色",
+ "text": "テキスト",
+ "textColor": "文字色",
"hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
- "distanceFromBottom": "下端からの距離",
- "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
+ "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
"distanceFromTop": "上端からの距離",
+ "alignLeft": "左",
"anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
- "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
- "translating": "翻訳中…",
- "backgroundOpacity": "不透明度",
+ "deleteTranslation": "この翻訳を削除",
+ "distanceFromBottom": "下端からの距離",
+ "displayLanguage": "表示",
+ "background": "背景",
+ "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
+ "distanceFromLeft": "左端からの距離",
+ "alignCenter": "中央",
+ "fontSize": "サイズ",
+ "bold": "太字",
+ "translateFailed": "翻訳に失敗しました。",
"distanceFromRight": "右端からの距離",
- "bold": "太字"
- },
- "transcript": {
- "revertWord": "「{{original}}」に戻す",
- "laneRecording": "録画",
- "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
- "noAudio": "このメディアには音声トラックがありません",
- "noTranscript": "文字起こしがまだありません",
- "insertRecordingOnly": "単語を追加できるのは録画だけです。ポーズは映像の 1 コマを保持します。",
- "insertedWord": "あなたが追加した単語 — 音声はありません",
- "insertAria": "新しい単語",
- "blankedWord": "空欄",
- "restoreSilence": "無音を元に戻す({{duration}} 秒)",
- "laneLabel": "文字起こしの読み込み元",
- "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
- "removeInserted": "「{{word}}」を削除",
- "transcribeNow": "今すぐ文字起こし",
- "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
- "editWord": "「{{word}}」を編集",
- "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
- "clipLabel": "クリップ {{index}}",
- "title": "現在の文字起こし",
- "laneVoiceover": "ナレーション",
- "trimSilence": "無音をトリム({{duration}} 秒)",
- "helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
- "editorAria": "{{filename}} の文字起こし",
- "silence": "[無音 {{duration}} 秒]",
- "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
- "transcribing": "文字起こし中…",
- "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
- "restoreWord": "「{{word}}」を元に戻す",
- "noClips": "クリップがまだありません",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。"
+ "showBackground": "背景を表示",
+ "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "show": "字幕を表示",
+ "language": "言語",
+ "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。"
},
"zoom": {
- "level": "ズーム倍率",
- "position": {
- "x": "X (%)",
- "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
- "title": "フォーカス位置",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "右",
- "left": "左",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "左"
},
- "title": "3D回転",
- "none": "なし"
+ "none": "なし",
+ "title": "3D回転"
},
"focusMode": {
"autoDescription": "表示範囲が録画中のカーソル位置に追従します",
- "auto": "自動",
"lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
+ "title": "フォーカスモード",
"manual": "手動",
- "title": "フォーカスモード"
+ "auto": "自動"
+ },
+ "position": {
+ "title": "フォーカス位置",
+ "x": "X (%)",
+ "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
+ "y": "Y (%)"
},
+ "deleteZoom": "ズームを削除",
+ "level": "ズーム倍率",
"selectRegion": "ズーム範囲を選択して調整",
- "customScale": "カスタムズーム",
"previewHold": "押している間ズーム効果をプレビュー",
- "deleteZoom": "ズームを削除"
+ "customScale": "カスタムズーム"
},
- "project": {
- "load": "プロジェクトを読み込む",
- "save": "プロジェクトを保存",
- "new": "新規プロジェクト"
+ "textAnimation": {
+ "pop": "ポップ",
+ "rise": "上昇",
+ "selectAnimation": "アニメーションを選択",
+ "fade": "フェード",
+ "pulse": "パルス",
+ "typewriter": "タイプライター",
+ "title": "テキストアニメーション",
+ "none": "なし",
+ "slideLeft": "左へスライド"
+ },
+ "crop": {
+ "lockAspectRatio": "アスペクト比を固定",
+ "title": "クロップ",
+ "done": "完了",
+ "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
+ "ratio": "比率",
+ "free": "自由",
+ "cropVideo": "動画をクロップ",
+ "unlockAspectRatio": "アスペクト比の固定を解除"
+ },
+ "background": {
+ "imageLabel": "背景 {{index}}",
+ "color": "色",
+ "gradient": "グラデーション",
+ "colorLabel": "色 {{color}}",
+ "colorWheel": "カラーホイール",
+ "customWallpaper": "カスタム壁紙",
+ "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
+ "image": "画像",
+ "gradientLabel": "グラデーション {{index}}",
+ "imageReadFailed": "この画像ファイルを読み込めませんでした。",
+ "presets": "プリセット",
+ "custom": "カスタム",
+ "colorPalette": "カラーパレット",
+ "uploadCustom": "カスタム画像を読み込む",
+ "title": "背景",
+ "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。"
+ },
+ "audio": {
+ "title": "オーディオ",
+ "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
+ "reset": "オーディオをリセット",
+ "outputGain": "出力レベル"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "書き出し解像度",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "オーディオを追加できませんでした",
- "defaultLabel": "オーディオトラック",
- "add": "オーディオトラックを追加",
- "fadeIn": "フェードイン",
- "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
- "fadeOut": "フェードアウト",
- "loop": "ループ",
- "remove": "トラックを削除",
- "mute": "ミュート",
- "slipHint": "Alt を押しながらドラッグで中の音声をずらす"
+ "high": "Source",
+ "title": "書き出し解像度"
},
"gifSettings": {
+ "loop": "GIF をループする",
"frameRate": "GIF フレームレート",
- "size": "GIF サイズ",
- "loop": "GIF をループする"
- },
- "crop": {
- "ratio": "比率",
- "free": "自由",
- "lockAspectRatio": "アスペクト比を固定",
- "unlockAspectRatio": "アスペクト比の固定を解除",
- "cropVideo": "動画をクロップ",
- "title": "クロップ",
- "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
- "done": "完了"
- },
- "cursor": {
- "clickBounce": "クリックバウンス",
- "clipToBounds": "キャンバスにクリップ",
- "title": "カーソル",
- "size": "サイズ",
- "show": "カーソルを表示",
- "motionBlur": "モーションブラー",
- "themeDefault": "デフォルト",
- "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
- "smoothing": "スムージング",
- "theme": "カーソルのスタイル",
- "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。"
+ "size": "GIF サイズ"
},
"export": {
- "videoButton": "動画をエクスポート",
+ "chooseSaveLocation": "保存場所を選択",
"gifButton": "GIF をエクスポート",
- "chooseSaveLocation": "保存場所を選択"
+ "videoButton": "動画をエクスポート"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "高品質の動画ファイル",
- "gifDescription": "共有用のアニメーション画像",
- "gifAnimation": "GIF アニメーション",
- "mp4Video": "MP4 動画"
+ "project": {
+ "load": "プロジェクトを読み込む",
+ "save": "プロジェクトを保存",
+ "new": "新規プロジェクト"
},
"speed": {
"previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。",
- "deleteRegion": "再生速度の範囲を削除",
"customPlaybackSpeed": "カスタム再生速度",
+ "maxSpeedError": "速度は{{max}}×を超えることはできません",
+ "deleteRegion": "再生速度の範囲を削除",
"selectRegion": "再生速度の範囲を選択して調整",
- "playbackSpeed": "再生速度",
- "maxSpeedError": "速度は{{max}}×を超えることはできません"
+ "playbackSpeed": "再生速度"
},
- "textAnimation": {
- "rise": "上昇",
- "pop": "ポップ",
- "selectAnimation": "アニメーションを選択",
- "pulse": "パルス",
- "slideLeft": "左へスライド",
- "typewriter": "タイプライター",
- "none": "なし",
- "fade": "フェード",
- "title": "テキストアニメーション"
+ "language": {
+ "title": "言語"
},
- "imageUpload": {
- "invalidFileType": "無効なファイル形式",
- "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "failedToUpload": "画像の読み込みに失敗しました",
- "uploadSuccess": "カスタム画像を読み込みました。",
- "errorReading": "ファイルの読み取り中にエラーが発生しました。"
+ "support": {
+ "starOnGithub": "GitHub でスターを付ける",
+ "reportBug": "バグを報告",
+ "saveDiagnostics": "診断情報を保存"
},
- "panes": {
- "help": "ヘルプ"
+ "trim": {
+ "deleteRegion": "トリム範囲を削除"
},
"facets": {
"transcript": "文字起こし",
"captions": "字幕"
},
- "language": {
- "title": "言語"
- },
- "trim": {
- "deleteRegion": "トリム範囲を削除"
+ "panes": {
+ "help": "ヘルプ"
}
}
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 53ee828b5..94cf3e251 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF 애니메이션",
+ "mp4Description": "고화질 비디오 파일",
+ "mp4": "MP4",
+ "mp4Video": "MP4 비디오",
+ "gif": "GIF",
+ "gifDescription": "공유용 애니메이션 이미지"
+ },
"customFont": {
- "nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
- "urlLabel": "Google Fonts 가져오기 URL",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
"errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Google 폰트 추가",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
+ "nameLabel": "표시 이름",
"failedToAdd": "폰트 추가에 실패했습니다",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "namePlaceholder": "내 커스텀 폰트",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
"addingButton": "추가 중...",
- "dialogTitle": "Google 폰트 추가",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
"successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
- "addButton": "폰트 추가",
- "namePlaceholder": "내 커스텀 폰트",
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
"errorEmptyName": "폰트 이름을 입력해 주세요",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
- "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
- "nameLabel": "표시 이름"
+ "addButton": "폰트 추가",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요"
},
"annotation": {
- "defaultText": "안녕하세요",
- "size": "크기",
+ "colorWheel": "색상 휠",
"imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "typeArrow": "화살표",
+ "selectStyle": "스타일 선택",
+ "blurColorWhite": "흰색",
"blurType": "블러 종류",
- "mosaicBlockSize": "모자이크 블록 크기",
- "colorWheel": "색상 휠",
- "typeImage": "이미지",
- "textContent": "텍스트 내용",
- "clearBackground": "배경 지우기",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "shortcutsAndTips": "단축키 및 팁",
- "colorPalette": "색상 팔레트",
+ "blurShapeRectangle": "사각형",
+ "blurColor": "블러 색상",
"arrowColor": "화살표 색상",
- "strokeWidth": "선 두께: {{width}}px",
- "blurColorWhite": "흰색",
+ "textContent": "텍스트 내용",
"background": "배경",
- "blurColor": "블러 색상",
- "typeArrow": "화살표",
- "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "모자이크",
- "textPlaceholder": "텍스트를 입력하세요...",
+ "clearBackground": "배경 지우기",
+ "blurShapeFreehand": "자유 곡선",
"blurIntensity": "블러 강도",
- "textColor": "텍스트 색상",
- "blurShapeRectangle": "사각형",
- "deleteAnnotation": "주석 삭제",
"tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "blurShapeFreehand": "자유 곡선",
- "arrowDirection": "화살표 방향",
- "selectStyle": "스타일 선택",
- "fontStyle": "폰트 스타일",
- "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
"active": "활성",
- "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
- "blurShapeOval": "타원",
+ "size": "크기",
"blurColorBlack": "검정",
- "color": "색상",
+ "typeImage": "이미지",
+ "mosaicBlockSize": "모자이크 블록 크기",
+ "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "strokeWidth": "선 두께: {{width}}px",
+ "textColor": "텍스트 색상",
+ "defaultText": "안녕하세요",
"blurShape": "블러 모양",
- "customFonts": "커스텀 폰트",
- "typeBlur": "블러",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
"type": "유형",
+ "typeText": "텍스트",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "fontStyle": "폰트 스타일",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "colorPalette": "색상 팔레트",
+ "color": "색상",
+ "shortcutsAndTips": "단축키 및 팁",
+ "none": "없음",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "arrowDirection": "화살표 방향",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
"uploadImage": "이미지 업로드",
+ "customFonts": "커스텀 폰트",
+ "blurTypeMosaic": "모자이크",
"blurTypeBlur": "가우시안",
- "none": "없음",
+ "typeBlur": "블러",
"title": "주석 설정",
- "typeText": "텍스트"
+ "deleteAnnotation": "주석 삭제",
+ "blurShapeOval": "타원"
+ },
+ "transcript": {
+ "restoreSilence": "무음 복원 ({{duration}}초)",
+ "editWord": "\"{{word}}\" 편집",
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
+ "insertAria": "새 단어",
+ "transcribing": "전사 중…",
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "noTranscript": "아직 전사가 없습니다",
+ "silence": "[무음 {{duration}}초]",
+ "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
+ "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
+ "noClips": "아직 클립이 없습니다",
+ "laneVoiceover": "내레이션",
+ "laneLabel": "전사본을 읽어올 소스",
+ "revertWord": "\"{{original}}\"(으)로 되돌리기",
+ "helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
+ "blankedWord": "비움",
+ "clipLabel": "클립 {{index}}",
+ "title": "현재 전사",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
+ "transcribeNow": "지금 전사하기",
+ "laneRecording": "녹화",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "removeInserted": "\"{{word}}\" 삭제",
+ "trimSilence": "무음 자르기 ({{duration}}초)",
+ "editorAria": "{{filename}}의 전사",
+ "restoreWord": "\"{{word}}\" 복원"
},
"effects": {
- "fitClipMany": "{{count}}개 클립",
+ "shadow": "그림자",
+ "fitClipOne": "{{count}}개 클립",
+ "blurBg": "배경 흐림",
+ "fitClip": "맞추기",
+ "formatOriginal": "원본",
+ "title": "컴포지션",
"format": "형식",
"motionBlur": "모션 블러",
+ "fitClipMany": "{{count}}개 클립",
+ "roundness": "모서리 둥글기",
"fitClipFew": "{{count}}개 클립",
- "fitClip": "맞추기",
+ "motion": "모션",
"on": "켜기",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
- "formatOriginal": "원본",
- "blurBg": "배경 흐림",
"frame": "프레임",
"padding": "여백",
- "shadow": "그림자",
- "off": "끄기",
- "title": "컴포지션",
- "motion": "모션",
- "fitClipOne": "{{count}}개 클립",
- "roundness": "모서리 둥글기"
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "off": "끄기"
+ },
+ "audioTrack": {
+ "mute": "음소거",
+ "fadeIn": "페이드 인",
+ "loop": "반복",
+ "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "importFailed": "오디오를 추가할 수 없습니다",
+ "fadeOut": "페이드 아웃",
+ "add": "오디오 트랙 추가",
+ "defaultLabel": "오디오 트랙",
+ "remove": "트랙 삭제"
},
"layout": {
- "noWebcam": "웹캠 없음",
- "bgModes": {
- "none": "원본",
- "transparent": "누끼",
- "blur": "블러",
- "custom": "사용자 지정"
- },
- "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
- "webcamFraming": "웹캠 구도",
- "webcamCropX": "가로 이동",
- "preset": "프리셋",
- "webcamSize": "웹캠 크기",
"shapes": {
"circle": "원형",
"square": "정사각형",
"rectangle": "직사각형",
"rounded": "둥근 모서리"
},
- "dualFrame": "듀얼 프레임",
"verticalStack": "세로 배치",
- "webcamCropZoom": "자르기 확대",
+ "webcamSize": "웹캠 크기",
+ "preset": "프리셋",
+ "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
+ "dualFrame": "듀얼 프레임",
"title": "카메라 레이아웃",
- "webcamCropY": "세로 이동",
"reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
+ "bgModes": {
+ "transparent": "누끼",
+ "custom": "사용자 지정",
+ "none": "원본",
+ "blur": "블러"
+ },
+ "webcamCropZoom": "자르기 확대",
+ "selectPreset": "프리셋 선택",
+ "webcamCropY": "세로 이동",
+ "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
"pictureInPicture": "화면 속 화면",
- "mirrorWebcam": "웹캠 미러링",
+ "reactiveWebcam": "확대 시 축소",
+ "webcamBackground": "카메라 배경",
"webcamBlurIntensity": "블러 강도",
+ "mirrorWebcam": "웹캠 미러링",
"webcamShape": "카메라 모양",
- "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
- "selectPreset": "프리셋 선택",
- "reactiveWebcam": "확대 시 축소",
- "webcamBackground": "카메라 배경"
- },
- "background": {
- "colorPalette": "색상 팔레트",
- "gradient": "그라디언트",
- "imageLabel": "배경 {{index}}",
- "custom": "사용자 지정",
- "image": "이미지",
- "title": "배경",
- "colorWheel": "색상 휠",
- "customWallpaper": "사용자 배경",
- "uploadCustom": "직접 업로드",
- "colorLabel": "색상 {{color}}",
- "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
- "gradientLabel": "그라디언트 {{index}}",
- "imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "presets": "프리셋",
- "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
- "color": "색상"
+ "noWebcam": "웹캠 없음",
+ "webcamCropX": "가로 이동",
+ "webcamFraming": "웹캠 구도"
},
- "support": {
- "saveDiagnostics": "Save Diagnostics",
- "starOnGithub": "GitHub에 Star 남기기",
- "reportBug": "버그 신고"
+ "imageUpload": {
+ "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
+ "failedToUpload": "이미지 업로드에 실패했습니다",
+ "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
+ "errorReading": "파일을 읽는 중 오류가 발생했습니다.",
+ "invalidFileType": "지원하지 않는 파일 형식입니다"
},
- "audio": {
- "outputGain": "출력 레벨",
- "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
- "reset": "오디오 재설정",
- "title": "오디오"
+ "cursor": {
+ "themeDefault": "기본",
+ "motionBlur": "모션 블러",
+ "smoothing": "부드러움",
+ "title": "커서",
+ "theme": "커서 스타일",
+ "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
+ "show": "커서 표시",
+ "clipToBounds": "캔버스에 맞춰 자르기",
+ "size": "크기",
+ "clickBounce": "클릭 바운스",
+ "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스."
},
"captions": {
- "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
- "alignCenter": "가운데",
- "backgroundColor": "배경 색",
"original": "원본 (전사)",
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "displayLanguage": "표시",
"alignRight": "오른쪽",
+ "backgroundOpacity": "불투명도",
"anchorBottom": "아래",
- "anchorTop": "위",
- "text": "텍스트",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
- "textColor": "글자 색",
+ "minWords": "줄당 최소 단어 수",
+ "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
+ "translate": "번역",
"maxWords": "줄당 최대 단어 수",
- "show": "자막 표시",
- "translateFailed": "번역에 실패했습니다.",
+ "lineLength": "줄 길이",
+ "anchorTop": "위",
"font": "글꼴",
- "language": "언어",
- "background": "배경",
- "translate": "번역",
+ "translating": "번역 중…",
"position": "위치",
- "distanceFromLeft": "왼쪽에서의 거리",
- "lineLength": "줄 길이",
- "alignLeft": "왼쪽",
- "deleteTranslation": "이 번역 삭제",
- "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
- "minWords": "줄당 최소 단어 수",
- "showBackground": "배경 표시",
- "fontSize": "크기",
+ "removeLegacyAnnotations": "이전 자막 주석 제거",
+ "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "backgroundColor": "배경 색",
+ "text": "텍스트",
+ "textColor": "글자 색",
"hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "distanceFromBottom": "아래에서의 거리",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
+ "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
"distanceFromTop": "위에서의 거리",
+ "alignLeft": "왼쪽",
"anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
- "translating": "번역 중…",
- "backgroundOpacity": "불투명도",
+ "deleteTranslation": "이 번역 삭제",
+ "distanceFromBottom": "아래에서의 거리",
+ "displayLanguage": "표시",
+ "background": "배경",
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "alignCenter": "가운데",
+ "fontSize": "크기",
+ "bold": "굵게",
+ "translateFailed": "번역에 실패했습니다.",
"distanceFromRight": "오른쪽에서의 거리",
- "bold": "굵게"
- },
- "transcript": {
- "revertWord": "\"{{original}}\"(으)로 되돌리기",
- "laneRecording": "녹화",
- "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
- "noTranscript": "아직 전사가 없습니다",
- "insertRecordingOnly": "단어는 녹화에만 추가할 수 있습니다 — 일시 정지는 영상의 한 프레임을 붙듭니다.",
- "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
- "insertAria": "새 단어",
- "blankedWord": "비움",
- "restoreSilence": "무음 복원 ({{duration}}초)",
- "laneLabel": "전사본을 읽어올 소스",
- "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
- "removeInserted": "\"{{word}}\" 삭제",
- "transcribeNow": "지금 전사하기",
- "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
- "editWord": "\"{{word}}\" 편집",
- "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "clipLabel": "클립 {{index}}",
- "title": "현재 전사",
- "laneVoiceover": "내레이션",
- "trimSilence": "무음 자르기 ({{duration}}초)",
- "helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
- "editorAria": "{{filename}}의 전사",
- "silence": "[무음 {{duration}}초]",
- "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
- "transcribing": "전사 중…",
- "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
- "restoreWord": "\"{{word}}\" 복원",
- "noClips": "아직 클립이 없습니다",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다."
+ "showBackground": "배경 표시",
+ "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "show": "자막 표시",
+ "language": "언어",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄."
},
"zoom": {
- "level": "줌 레벨",
- "position": {
- "x": "X (%)",
- "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
- "title": "포커스 위치",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "오른쪽",
- "left": "왼쪽",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "왼쪽"
},
- "title": "3D 회전",
- "none": "없음"
+ "none": "없음",
+ "title": "3D 회전"
},
"focusMode": {
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
- "auto": "자동",
"lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
+ "title": "포커스 모드",
"manual": "수동",
- "title": "포커스 모드"
+ "auto": "자동"
+ },
+ "position": {
+ "title": "포커스 위치",
+ "x": "X (%)",
+ "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
+ "y": "Y (%)"
},
+ "deleteZoom": "줌 삭제",
+ "level": "줌 레벨",
"selectRegion": "조정할 줌 구간을 선택하세요",
- "customScale": "커스텀 줌",
"previewHold": "누르고 있으면 줌 효과 미리보기",
- "deleteZoom": "줌 삭제"
+ "customScale": "커스텀 줌"
},
- "project": {
- "load": "프로젝트 불러오기",
- "save": "프로젝트 저장",
- "new": "새 프로젝트"
+ "textAnimation": {
+ "pop": "팝",
+ "rise": "상승",
+ "selectAnimation": "애니메이션 선택",
+ "fade": "페이드",
+ "pulse": "펄스",
+ "typewriter": "타자기",
+ "title": "텍스트 애니메이션",
+ "none": "없음",
+ "slideLeft": "왼쪽 슬라이드"
+ },
+ "crop": {
+ "lockAspectRatio": "화면 비율 고정",
+ "title": "자르기",
+ "done": "완료",
+ "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
+ "ratio": "비율",
+ "free": "자유",
+ "cropVideo": "비디오 자르기",
+ "unlockAspectRatio": "화면 비율 해제"
+ },
+ "background": {
+ "imageLabel": "배경 {{index}}",
+ "color": "색상",
+ "gradient": "그라디언트",
+ "colorLabel": "색상 {{color}}",
+ "colorWheel": "색상 휠",
+ "customWallpaper": "사용자 배경",
+ "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
+ "image": "이미지",
+ "gradientLabel": "그라디언트 {{index}}",
+ "imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
+ "presets": "프리셋",
+ "custom": "사용자 지정",
+ "colorPalette": "색상 팔레트",
+ "uploadCustom": "직접 업로드",
+ "title": "배경",
+ "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요."
+ },
+ "audio": {
+ "title": "오디오",
+ "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
+ "reset": "오디오 재설정",
+ "outputGain": "출력 레벨"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "내보내기 해상도",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "오디오를 추가할 수 없습니다",
- "defaultLabel": "오디오 트랙",
- "add": "오디오 트랙 추가",
- "fadeIn": "페이드 인",
- "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
- "fadeOut": "페이드 아웃",
- "loop": "반복",
- "remove": "트랙 삭제",
- "mute": "음소거",
- "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다"
+ "high": "Source",
+ "title": "내보내기 해상도"
},
"gifSettings": {
+ "loop": "GIF 반복",
"frameRate": "GIF 프레임 속도",
- "size": "GIF 크기",
- "loop": "GIF 반복"
- },
- "crop": {
- "ratio": "비율",
- "free": "자유",
- "lockAspectRatio": "화면 비율 고정",
- "unlockAspectRatio": "화면 비율 해제",
- "cropVideo": "비디오 자르기",
- "title": "자르기",
- "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
- "done": "완료"
- },
- "cursor": {
- "clickBounce": "클릭 바운스",
- "clipToBounds": "캔버스에 맞춰 자르기",
- "title": "커서",
- "size": "크기",
- "show": "커서 표시",
- "motionBlur": "모션 블러",
- "themeDefault": "기본",
- "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
- "smoothing": "부드러움",
- "theme": "커서 스타일",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스."
+ "size": "GIF 크기"
},
"export": {
- "videoButton": "비디오 내보내기",
+ "chooseSaveLocation": "저장 위치 선택",
"gifButton": "GIF 내보내기",
- "chooseSaveLocation": "저장 위치 선택"
+ "videoButton": "비디오 내보내기"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "고화질 비디오 파일",
- "gifDescription": "공유용 애니메이션 이미지",
- "gifAnimation": "GIF 애니메이션",
- "mp4Video": "MP4 비디오"
+ "project": {
+ "load": "프로젝트 불러오기",
+ "save": "프로젝트 저장",
+ "new": "새 프로젝트"
},
"speed": {
"previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
- "deleteRegion": "속도 구간 삭제",
"customPlaybackSpeed": "재생 속도 직접 입력",
+ "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
+ "deleteRegion": "속도 구간 삭제",
"selectRegion": "조정할 속도 구간을 선택하세요",
- "playbackSpeed": "재생 속도",
- "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다"
+ "playbackSpeed": "재생 속도"
},
- "textAnimation": {
- "rise": "상승",
- "pop": "팝",
- "selectAnimation": "애니메이션 선택",
- "pulse": "펄스",
- "slideLeft": "왼쪽 슬라이드",
- "typewriter": "타자기",
- "none": "없음",
- "fade": "페이드",
- "title": "텍스트 애니메이션"
+ "language": {
+ "title": "언어"
},
- "imageUpload": {
- "invalidFileType": "지원하지 않는 파일 형식입니다",
- "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
- "failedToUpload": "이미지 업로드에 실패했습니다",
- "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
- "errorReading": "파일을 읽는 중 오류가 발생했습니다."
+ "support": {
+ "starOnGithub": "GitHub에 Star 남기기",
+ "reportBug": "버그 신고",
+ "saveDiagnostics": "Save Diagnostics"
},
- "panes": {
- "help": "도움말"
+ "trim": {
+ "deleteRegion": "트림 구간 삭제"
},
"facets": {
"transcript": "대본",
"captions": "자막"
},
- "language": {
- "title": "언어"
- },
- "trim": {
- "deleteRegion": "트림 구간 삭제"
+ "panes": {
+ "help": "도움말"
}
}
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index c893f670d..499bd1df6 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "Animação GIF",
+ "mp4Description": "Arquivo de vídeo de alta qualidade",
+ "mp4": "MP4",
+ "mp4Video": "Vídeo MP4",
+ "gif": "GIF",
+ "gifDescription": "Imagem animada para compartilhamento"
+ },
"customFont": {
- "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
- "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
- "urlLabel": "URL de Importação do Google Fonts",
- "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
"errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Adicionar Google Font",
+ "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
+ "nameLabel": "Nome de Exibição",
"failedToAdd": "Falha ao adicionar fonte",
- "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
+ "urlLabel": "URL de Importação do Google Fonts",
+ "namePlaceholder": "Minha Fonte Personalizada",
+ "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
"addingButton": "Adicionando...",
- "dialogTitle": "Adicionar Google Font",
+ "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
+ "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
"successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
- "addButton": "Adicionar Fonte",
- "namePlaceholder": "Minha Fonte Personalizada",
+ "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
"errorEmptyName": "Por favor, insira um nome para a fonte",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
- "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
- "nameLabel": "Nome de Exibição"
+ "addButton": "Adicionar Fonte",
+ "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts"
},
"annotation": {
- "defaultText": "Olá",
- "size": "Tamanho",
+ "colorWheel": "Roda de Cores",
"imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
+ "typeArrow": "Seta",
+ "selectStyle": "Selecionar estilo",
+ "blurColorWhite": "Branco",
"blurType": "Tipo de Desfoque",
- "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
- "colorWheel": "Roda de Cores",
- "typeImage": "Imagem",
- "textContent": "Conteúdo do Texto",
- "clearBackground": "Limpar Fundo",
- "invalidImageType": "Tipo de imagem inválido",
- "shortcutsAndTips": "Atalhos e Dicas",
- "colorPalette": "Paleta de Cores",
+ "blurShapeRectangle": "Retângulo",
+ "blurColor": "Cor do Desfoque",
"arrowColor": "Cor da Seta",
- "strokeWidth": "Largura do Traço: {{width}}px",
- "blurColorWhite": "Branco",
+ "textContent": "Conteúdo do Texto",
"background": "Fundo",
- "blurColor": "Cor do Desfoque",
- "typeArrow": "Seta",
- "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Mosaico",
- "textPlaceholder": "Digite seu texto...",
+ "clearBackground": "Limpar Fundo",
+ "blurShapeFreehand": "Mão Livre",
"blurIntensity": "Intensidade do Desfoque",
- "textColor": "Cor do Texto",
- "blurShapeRectangle": "Retângulo",
- "deleteAnnotation": "Excluir Anotação",
"tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
- "blurShapeFreehand": "Mão Livre",
- "arrowDirection": "Direção da Seta",
- "selectStyle": "Selecionar estilo",
- "fontStyle": "Estilo da Fonte",
- "imageUploadSuccess": "Imagem enviada com sucesso!",
"active": "Ativo",
- "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
- "blurShapeOval": "Oval",
+ "size": "Tamanho",
"blurColorBlack": "Preto",
- "color": "Cor",
+ "typeImage": "Imagem",
+ "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
+ "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Largura do Traço: {{width}}px",
+ "textColor": "Cor do Texto",
+ "defaultText": "Olá",
"blurShape": "Formato do Desfoque",
- "customFonts": "Fontes Personalizadas",
- "typeBlur": "Desfoque",
- "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
+ "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
"type": "Tipo",
+ "typeText": "Texto",
+ "textPlaceholder": "Digite seu texto...",
+ "fontStyle": "Estilo da Fonte",
+ "imageUploadSuccess": "Imagem enviada com sucesso!",
+ "colorPalette": "Paleta de Cores",
+ "color": "Cor",
+ "shortcutsAndTips": "Atalhos e Dicas",
+ "none": "Nenhum",
+ "invalidImageType": "Tipo de imagem inválido",
+ "arrowDirection": "Direção da Seta",
+ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
"uploadImage": "Enviar Imagem",
+ "customFonts": "Fontes Personalizadas",
+ "blurTypeMosaic": "Mosaico",
"blurTypeBlur": "Gaussiano",
- "none": "Nenhum",
+ "typeBlur": "Desfoque",
"title": "Configurações de Anotação",
- "typeText": "Texto"
+ "deleteAnnotation": "Excluir Anotação",
+ "blurShapeOval": "Oval"
+ },
+ "transcript": {
+ "restoreSilence": "Restaurar silêncio ({{duration}} s)",
+ "editWord": "Editar \"{{word}}\"",
+ "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
+ "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
+ "insertAria": "Nova palavra",
+ "transcribing": "Transcrevendo…",
+ "noAudio": "Esta mídia não tem faixa de áudio",
+ "noTranscript": "Nenhuma transcrição ainda",
+ "silence": "[silêncio {{duration}} s]",
+ "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
+ "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
+ "noClips": "Nenhum clipe ainda",
+ "laneVoiceover": "Narração",
+ "laneLabel": "Ler a transcrição de",
+ "revertWord": "Restaurar \"{{original}}\"",
+ "helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
+ "blankedWord": "apagada",
+ "clipLabel": "Clipe {{index}}",
+ "title": "Transcrição atual",
+ "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
+ "transcribeNow": "Transcrever agora",
+ "laneRecording": "Gravação",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "removeInserted": "Excluir \"{{word}}\"",
+ "trimSilence": "Cortar silêncio ({{duration}} s)",
+ "editorAria": "Transcrição de {{filename}}",
+ "restoreWord": "Restaurar \"{{word}}\""
},
"effects": {
- "fitClipMany": "{{count}} clipes",
+ "shadow": "Sombra",
+ "fitClipOne": "{{count}} clipe",
+ "blurBg": "Desfocar Fundo",
+ "fitClip": "Ajustar",
+ "formatOriginal": "Original",
+ "title": "Composição",
"format": "Formato",
"motionBlur": "Desfoque de Movimento",
+ "fitClipMany": "{{count}} clipes",
+ "roundness": "Arredondamento",
"fitClipFew": "{{count}} clipes",
- "fitClip": "Ajustar",
+ "motion": "Movimento",
"on": "ativado",
- "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
- "formatOriginal": "Original",
- "blurBg": "Desfocar Fundo",
"frame": "Moldura",
"padding": "Espaçamento",
- "shadow": "Sombra",
- "off": "desativado",
- "title": "Composição",
- "motion": "Movimento",
- "fitClipOne": "{{count}} clipe",
- "roundness": "Arredondamento"
+ "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
+ "off": "desativado"
+ },
+ "audioTrack": {
+ "mute": "Silenciar",
+ "fadeIn": "Fade in",
+ "loop": "Repetir",
+ "slipHint": "Alt + arrastar para deslizar o áudio dentro",
+ "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "importFailed": "Não foi possível adicionar o áudio",
+ "fadeOut": "Fade out",
+ "add": "Adicionar faixa de áudio",
+ "defaultLabel": "Faixa de áudio",
+ "remove": "Excluir faixa"
},
"layout": {
- "noWebcam": "Sem Webcam",
- "bgModes": {
- "none": "Original",
- "transparent": "Recorte",
- "blur": "Desfocado",
- "custom": "Personalizado"
- },
- "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
- "webcamFraming": "Enquadramento da webcam",
- "webcamCropX": "Deslocamento horizontal",
- "preset": "Predefinição",
- "webcamSize": "Tamanho da Webcam",
"shapes": {
"circle": "Círculo",
"square": "Quadrado",
"rectangle": "Ret.",
"rounded": "Arredondado"
},
- "dualFrame": "Quadro Duplo",
"verticalStack": "Empilhamento Vertical",
- "webcamCropZoom": "Zoom do recorte",
+ "webcamSize": "Tamanho da Webcam",
+ "preset": "Predefinição",
+ "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
+ "dualFrame": "Quadro Duplo",
"title": "Layout da câmera",
- "webcamCropY": "Deslocamento vertical",
"reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
+ "bgModes": {
+ "transparent": "Recorte",
+ "custom": "Personalizado",
+ "none": "Original",
+ "blur": "Desfocado"
+ },
+ "webcamCropZoom": "Zoom do recorte",
+ "selectPreset": "Selecionar predefinição",
+ "webcamCropY": "Deslocamento vertical",
+ "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
"pictureInPicture": "Picture in Picture",
- "mirrorWebcam": "Espelhar Webcam",
+ "reactiveWebcam": "Encolher ao ampliar",
+ "webcamBackground": "Plano de fundo da câmera",
"webcamBlurIntensity": "Intensidade do desfoque",
+ "mirrorWebcam": "Espelhar Webcam",
"webcamShape": "Formato da Câmera",
- "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
- "selectPreset": "Selecionar predefinição",
- "reactiveWebcam": "Encolher ao ampliar",
- "webcamBackground": "Plano de fundo da câmera"
- },
- "background": {
- "colorPalette": "Paleta de Cores",
- "gradient": "Gradiente",
- "imageLabel": "Fundo {{index}}",
- "custom": "Personalizado",
- "image": "Imagem",
- "title": "Fundo",
- "colorWheel": "Roda de Cores",
- "customWallpaper": "Papel de parede personalizado",
- "uploadCustom": "Enviar Personalizada",
- "colorLabel": "Cor {{color}}",
- "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
- "gradientLabel": "Gradiente {{index}}",
- "imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
- "presets": "Predefinições",
- "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
- "color": "Cor"
+ "noWebcam": "Sem Webcam",
+ "webcamCropX": "Deslocamento horizontal",
+ "webcamFraming": "Enquadramento da webcam"
},
- "support": {
- "saveDiagnostics": "Salvar Diagnósticos",
- "starOnGithub": "Dar Estrela no GitHub",
- "reportBug": "Relatar Bug"
+ "imageUpload": {
+ "uploadSuccess": "Imagem personalizada enviada com sucesso!",
+ "failedToUpload": "Falha ao enviar imagem",
+ "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
+ "errorReading": "Ocorreu um erro ao ler o arquivo.",
+ "invalidFileType": "Tipo de arquivo inválido"
},
- "audio": {
- "outputGain": "Nível de saída",
- "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
- "reset": "Redefinir áudio",
- "title": "Áudio"
+ "cursor": {
+ "themeDefault": "Padrão",
+ "motionBlur": "Desfoque de movimento",
+ "smoothing": "Suavização",
+ "title": "Cursor",
+ "theme": "Estilo do cursor",
+ "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
+ "show": "Mostrar cursor",
+ "clipToBounds": "Recortar à tela",
+ "size": "Tamanho",
+ "clickBounce": "Rebote ao clicar",
+ "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar."
},
"captions": {
- "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
- "alignCenter": "Centro",
- "backgroundColor": "Cor do fundo",
"original": "Original (transcrição)",
- "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
- "removeLegacyAnnotations": "Remover anotações de legenda antigas",
- "displayLanguage": "Exibição",
"alignRight": "Direita",
+ "backgroundOpacity": "Opacidade",
"anchorBottom": "Base",
- "anchorTop": "Topo",
- "text": "Texto",
- "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
- "textColor": "Cor do texto",
+ "minWords": "Mín. de palavras por linha",
+ "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
+ "translate": "Traduzir",
"maxWords": "Máx. de palavras por linha",
- "show": "Mostrar legendas",
- "translateFailed": "A tradução falhou.",
+ "lineLength": "Comprimento da linha",
+ "anchorTop": "Topo",
"font": "Fonte",
- "language": "Idioma",
- "background": "Fundo",
- "translate": "Traduzir",
+ "translating": "Traduzindo…",
"position": "Posição",
- "distanceFromLeft": "Distância da esquerda",
- "lineLength": "Comprimento da linha",
- "alignLeft": "Esquerda",
- "deleteTranslation": "Excluir esta tradução",
- "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
- "minWords": "Mín. de palavras por linha",
- "showBackground": "Mostrar fundo",
- "fontSize": "Tamanho",
+ "removeLegacyAnnotations": "Remover anotações de legenda antigas",
+ "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
+ "backgroundColor": "Cor do fundo",
+ "text": "Texto",
+ "textColor": "Cor do texto",
"hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
- "distanceFromBottom": "Distância da base",
- "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
+ "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
"distanceFromTop": "Distância do topo",
+ "alignLeft": "Esquerda",
"anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
- "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
- "translating": "Traduzindo…",
- "backgroundOpacity": "Opacidade",
+ "deleteTranslation": "Excluir esta tradução",
+ "distanceFromBottom": "Distância da base",
+ "displayLanguage": "Exibição",
+ "background": "Fundo",
+ "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
+ "distanceFromLeft": "Distância da esquerda",
+ "alignCenter": "Centro",
+ "fontSize": "Tamanho",
+ "bold": "Negrito",
+ "translateFailed": "A tradução falhou.",
"distanceFromRight": "Distância da direita",
- "bold": "Negrito"
- },
- "transcript": {
- "revertWord": "Restaurar \"{{original}}\"",
- "laneRecording": "Gravação",
- "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
- "noAudio": "Esta mídia não tem faixa de áudio",
- "noTranscript": "Nenhuma transcrição ainda",
- "insertRecordingOnly": "Só é possível adicionar palavras na gravação — uma pausa congela um quadro do filme.",
- "insertedWord": "Adicionada por você — sem áudio por trás",
- "insertAria": "Nova palavra",
- "blankedWord": "apagada",
- "restoreSilence": "Restaurar silêncio ({{duration}} s)",
- "laneLabel": "Ler a transcrição de",
- "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
- "removeInserted": "Excluir \"{{word}}\"",
- "transcribeNow": "Transcrever agora",
- "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
- "editWord": "Editar \"{{word}}\"",
- "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
- "clipLabel": "Clipe {{index}}",
- "title": "Transcrição atual",
- "laneVoiceover": "Narração",
- "trimSilence": "Cortar silêncio ({{duration}} s)",
- "helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
- "editorAria": "Transcrição de {{filename}}",
- "silence": "[silêncio {{duration}} s]",
- "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
- "transcribing": "Transcrevendo…",
- "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
- "restoreWord": "Restaurar \"{{word}}\"",
- "noClips": "Nenhum clipe ainda",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer."
+ "showBackground": "Mostrar fundo",
+ "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
+ "show": "Mostrar legendas",
+ "language": "Idioma",
+ "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição."
},
"zoom": {
- "level": "Nível de Zoom",
- "position": {
- "x": "X (%)",
- "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
- "title": "Posição do Foco",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Direita",
- "left": "Esquerda",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "Esquerda"
},
- "title": "Rotação 3D",
- "none": "Nenhuma"
+ "none": "Nenhuma",
+ "title": "Rotação 3D"
},
"focusMode": {
"autoDescription": "A câmera segue a posição do cursor gravado",
- "auto": "Automático",
"lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
+ "title": "Modo de Foco",
"manual": "Manual",
- "title": "Modo de Foco"
+ "auto": "Automático"
+ },
+ "position": {
+ "title": "Posição do Foco",
+ "x": "X (%)",
+ "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
+ "y": "Y (%)"
},
+ "deleteZoom": "Excluir Zoom",
+ "level": "Nível de Zoom",
"selectRegion": "Selecione uma região de zoom para ajustar",
- "customScale": "Zoom Personalizado",
"previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
- "deleteZoom": "Excluir Zoom"
+ "customScale": "Zoom Personalizado"
},
- "project": {
- "load": "Carregar Projeto",
- "save": "Salvar Projeto",
- "new": "Novo Projeto"
+ "textAnimation": {
+ "pop": "Aparecer",
+ "rise": "Subir",
+ "selectAnimation": "Selecionar animação",
+ "fade": "Esmaecer",
+ "pulse": "Pulsar",
+ "typewriter": "Máquina de Escrever",
+ "title": "Animação de Texto",
+ "none": "Nenhuma",
+ "slideLeft": "Deslizar à Esquerda"
+ },
+ "crop": {
+ "lockAspectRatio": "Bloquear proporção",
+ "title": "Cortar",
+ "done": "Concluir",
+ "dragInstruction": "Arraste cada lado para ajustar a área de corte",
+ "ratio": "Proporção",
+ "free": "Livre",
+ "cropVideo": "Cortar Vídeo",
+ "unlockAspectRatio": "Desbloquear proporção"
+ },
+ "background": {
+ "imageLabel": "Fundo {{index}}",
+ "color": "Cor",
+ "gradient": "Gradiente",
+ "colorLabel": "Cor {{color}}",
+ "colorWheel": "Roda de Cores",
+ "customWallpaper": "Papel de parede personalizado",
+ "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
+ "image": "Imagem",
+ "gradientLabel": "Gradiente {{index}}",
+ "imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
+ "presets": "Predefinições",
+ "custom": "Personalizado",
+ "colorPalette": "Paleta de Cores",
+ "uploadCustom": "Enviar Personalizada",
+ "title": "Fundo",
+ "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG."
+ },
+ "audio": {
+ "title": "Áudio",
+ "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
+ "reset": "Redefinir áudio",
+ "outputGain": "Nível de saída"
},
"exportQuality": {
"low": "Baixa",
"medium": "Média",
- "title": "Qualidade de Exportação",
- "high": "Alta"
- },
- "audioTrack": {
- "importFailed": "Não foi possível adicionar o áudio",
- "defaultLabel": "Faixa de áudio",
- "add": "Adicionar faixa de áudio",
- "fadeIn": "Fade in",
- "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
- "fadeOut": "Fade out",
- "loop": "Repetir",
- "remove": "Excluir faixa",
- "mute": "Silenciar",
- "slipHint": "Alt + arrastar para deslizar o áudio dentro"
+ "high": "Alta",
+ "title": "Qualidade de Exportação"
},
"gifSettings": {
+ "loop": "Loop no GIF",
"frameRate": "Taxa de Quadros do GIF",
- "size": "Tamanho do GIF",
- "loop": "Loop no GIF"
- },
- "crop": {
- "ratio": "Proporção",
- "free": "Livre",
- "lockAspectRatio": "Bloquear proporção",
- "unlockAspectRatio": "Desbloquear proporção",
- "cropVideo": "Cortar Vídeo",
- "title": "Cortar",
- "dragInstruction": "Arraste cada lado para ajustar a área de corte",
- "done": "Concluir"
- },
- "cursor": {
- "clickBounce": "Rebote ao clicar",
- "clipToBounds": "Recortar à tela",
- "title": "Cursor",
- "size": "Tamanho",
- "show": "Mostrar cursor",
- "motionBlur": "Desfoque de movimento",
- "themeDefault": "Padrão",
- "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
- "smoothing": "Suavização",
- "theme": "Estilo do cursor",
- "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar."
+ "size": "Tamanho do GIF"
},
"export": {
- "videoButton": "Exportar Vídeo",
+ "chooseSaveLocation": "Escolher Local para Salvar",
"gifButton": "Exportar GIF",
- "chooseSaveLocation": "Escolher Local para Salvar"
+ "videoButton": "Exportar Vídeo"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "Arquivo de vídeo de alta qualidade",
- "gifDescription": "Imagem animada para compartilhamento",
- "gifAnimation": "Animação GIF",
- "mp4Video": "Vídeo MP4"
+ "project": {
+ "load": "Carregar Projeto",
+ "save": "Salvar Projeto",
+ "new": "Novo Projeto"
},
"speed": {
"previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada.",
- "deleteRegion": "Excluir Região de Velocidade",
"customPlaybackSpeed": "Velocidade Personalizada",
+ "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
+ "deleteRegion": "Excluir Região de Velocidade",
"selectRegion": "Selecione uma região de velocidade para ajustar",
- "playbackSpeed": "Velocidade de Reprodução",
- "maxSpeedError": "A velocidade não pode ser superior a {{max}}×"
+ "playbackSpeed": "Velocidade de Reprodução"
},
- "textAnimation": {
- "rise": "Subir",
- "pop": "Aparecer",
- "selectAnimation": "Selecionar animação",
- "pulse": "Pulsar",
- "slideLeft": "Deslizar à Esquerda",
- "typewriter": "Máquina de Escrever",
- "none": "Nenhuma",
- "fade": "Esmaecer",
- "title": "Animação de Texto"
+ "language": {
+ "title": "Idioma"
},
- "imageUpload": {
- "invalidFileType": "Tipo de arquivo inválido",
- "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
- "failedToUpload": "Falha ao enviar imagem",
- "uploadSuccess": "Imagem personalizada enviada com sucesso!",
- "errorReading": "Ocorreu um erro ao ler o arquivo."
+ "support": {
+ "starOnGithub": "Dar Estrela no GitHub",
+ "reportBug": "Relatar Bug",
+ "saveDiagnostics": "Salvar Diagnósticos"
},
- "panes": {
- "help": "Ajuda"
+ "trim": {
+ "deleteRegion": "Excluir Região de Recorte"
},
"facets": {
"transcript": "Transcrição",
"captions": "Legendas"
},
- "language": {
- "title": "Idioma"
- },
- "trim": {
- "deleteRegion": "Excluir Região de Recorte"
+ "panes": {
+ "help": "Ajuda"
}
}
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index fac31f88c..dce1fd195 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF анимация",
+ "mp4Description": "Видеофайл высокого качества",
+ "mp4": "MP4",
+ "mp4Video": "MP4 видео",
+ "gif": "GIF",
+ "gifDescription": "Анимированное изображение для обмена"
+ },
"customFont": {
- "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
- "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
- "urlLabel": "URL импорта Google Fonts",
- "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
"errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Добавить шрифт Google",
+ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
+ "nameLabel": "Отображаемое имя",
"failedToAdd": "Не удалось добавить шрифт",
- "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "urlLabel": "URL импорта Google Fonts",
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
"addingButton": "Добавление...",
- "dialogTitle": "Добавить шрифт Google",
+ "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
"successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
- "addButton": "Добавить шрифт",
- "namePlaceholder": "Мой пользовательский шрифт",
+ "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
"errorEmptyName": "Пожалуйста, введите имя шрифта",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
- "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
- "nameLabel": "Отображаемое имя"
+ "addButton": "Добавить шрифт",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts"
},
"annotation": {
- "defaultText": "Привет",
- "size": "Размер",
+ "colorWheel": "Цветовой круг",
"imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
+ "typeArrow": "Стрелка",
+ "selectStyle": "Выбрать стиль",
+ "blurColorWhite": "Белый",
"blurType": "Тип размытия",
- "mosaicBlockSize": "Размер блока мозаики",
- "colorWheel": "Цветовой круг",
- "typeImage": "Изображение",
- "textContent": "Содержание текста",
- "clearBackground": "Очистить фон",
- "invalidImageType": "Неверный тип файла",
- "shortcutsAndTips": "Горячие клавиши и советы",
- "colorPalette": "Палитра цветов",
+ "blurShapeRectangle": "Прямоугольник",
+ "blurColor": "Цвет размытия",
"arrowColor": "Цвет стрелки",
- "strokeWidth": "Толщина линии: {{width}}px",
- "blurColorWhite": "Белый",
+ "textContent": "Содержание текста",
"background": "Фон",
- "blurColor": "Цвет размытия",
- "typeArrow": "Стрелка",
- "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Мозаика",
- "textPlaceholder": "Введите ваш текст...",
+ "clearBackground": "Очистить фон",
+ "blurShapeFreehand": "От руки",
"blurIntensity": "Интенсивность размытия",
- "textColor": "Цвет текста",
- "blurShapeRectangle": "Прямоугольник",
- "deleteAnnotation": "Удалить аннотацию",
"tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
- "blurShapeFreehand": "От руки",
- "arrowDirection": "Направление стрелки",
- "selectStyle": "Выбрать стиль",
- "fontStyle": "Стиль шрифта",
- "imageUploadSuccess": "Изображение успешно загружено!",
"active": "Активно",
- "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
- "blurShapeOval": "Овал",
+ "size": "Размер",
"blurColorBlack": "Чёрный",
- "color": "Цвет",
+ "typeImage": "Изображение",
+ "mosaicBlockSize": "Размер блока мозаики",
+ "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Толщина линии: {{width}}px",
+ "textColor": "Цвет текста",
+ "defaultText": "Привет",
"blurShape": "Форма размытия",
- "customFonts": "Пользовательские шрифты",
- "typeBlur": "Размытие",
- "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
+ "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
"type": "Тип",
+ "typeText": "Текст",
+ "textPlaceholder": "Введите ваш текст...",
+ "fontStyle": "Стиль шрифта",
+ "imageUploadSuccess": "Изображение успешно загружено!",
+ "colorPalette": "Палитра цветов",
+ "color": "Цвет",
+ "shortcutsAndTips": "Горячие клавиши и советы",
+ "none": "Нет",
+ "invalidImageType": "Неверный тип файла",
+ "arrowDirection": "Направление стрелки",
+ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
"uploadImage": "Загрузить изображение",
+ "customFonts": "Пользовательские шрифты",
+ "blurTypeMosaic": "Мозаика",
"blurTypeBlur": "Гауссово",
- "none": "Нет",
+ "typeBlur": "Размытие",
"title": "Настройки аннотаций",
- "typeText": "Текст"
+ "deleteAnnotation": "Удалить аннотацию",
+ "blurShapeOval": "Овал"
+ },
+ "transcript": {
+ "restoreSilence": "Вернуть тишину ({{duration}} с)",
+ "editWord": "Изменить «{{word}}»",
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
+ "insertAria": "Новое слово",
+ "transcribing": "Расшифровка…",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "noTranscript": "Расшифровки пока нет",
+ "silence": "[тишина {{duration}} с]",
+ "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
+ "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
+ "noClips": "Клипов пока нет",
+ "laneVoiceover": "Закадровый голос",
+ "laneLabel": "Читать расшифровку из",
+ "revertWord": "Вернуть «{{original}}»",
+ "helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
+ "blankedWord": "очищено",
+ "clipLabel": "Клип {{index}}",
+ "title": "Текущая расшифровка",
+ "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
+ "transcribeNow": "Расшифровать сейчас",
+ "laneRecording": "Запись",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "removeInserted": "Удалить «{{word}}»",
+ "trimSilence": "Вырезать тишину ({{duration}} с)",
+ "editorAria": "Расшифровка «{{filename}}»",
+ "restoreWord": "Вернуть «{{word}}»"
},
"effects": {
- "fitClipMany": "{{count}} клипов",
+ "shadow": "Тень",
+ "fitClipOne": "{{count}} клип",
+ "blurBg": "Размытие фона",
+ "fitClip": "Подогнать",
+ "formatOriginal": "Исходный",
+ "title": "Композиция",
"format": "Формат",
"motionBlur": "Размытие движения",
+ "fitClipMany": "{{count}} клипов",
+ "roundness": "Скругление",
"fitClipFew": "{{count}} клипа",
- "fitClip": "Подогнать",
+ "motion": "Движение",
"on": "вкл",
- "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
- "formatOriginal": "Исходный",
- "blurBg": "Размытие фона",
"frame": "Рамка",
"padding": "Отступ",
- "shadow": "Тень",
- "off": "выкл",
- "title": "Композиция",
- "motion": "Движение",
- "fitClipOne": "{{count}} клип",
- "roundness": "Скругление"
+ "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
+ "off": "выкл"
+ },
+ "audioTrack": {
+ "mute": "Без звука",
+ "fadeIn": "Нарастание",
+ "loop": "Повтор",
+ "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
+ "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "importFailed": "Не удалось добавить аудио",
+ "fadeOut": "Затухание",
+ "add": "Добавить аудиодорожку",
+ "defaultLabel": "Аудиодорожка",
+ "remove": "Удалить дорожку"
},
"layout": {
- "noWebcam": "Без веб-камеры",
- "bgModes": {
- "none": "Оригинал",
- "transparent": "Вырезка",
- "blur": "Размытие",
- "custom": "Пользовательский"
- },
- "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
- "webcamFraming": "Кадрирование веб-камеры",
- "webcamCropX": "Смещение по горизонтали",
- "preset": "Пресет",
- "webcamSize": "Размер веб-камеры",
"shapes": {
"circle": "Круг",
"square": "Квадрат",
"rectangle": "Прямоуг.",
"rounded": "Скруглённый"
},
- "dualFrame": "Двойной кадр",
"verticalStack": "Вертикальный стек",
- "webcamCropZoom": "Масштаб обрезки",
+ "webcamSize": "Размер веб-камеры",
+ "preset": "Пресет",
+ "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
+ "dualFrame": "Двойной кадр",
"title": "Расположение камеры",
- "webcamCropY": "Смещение по вертикали",
"reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
+ "bgModes": {
+ "transparent": "Вырезка",
+ "custom": "Пользовательский",
+ "none": "Оригинал",
+ "blur": "Размытие"
+ },
+ "webcamCropZoom": "Масштаб обрезки",
+ "selectPreset": "Выбрать пресет",
+ "webcamCropY": "Смещение по вертикали",
+ "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
"pictureInPicture": "Картинка в картинке",
- "mirrorWebcam": "Зеркалить веб-камеру",
+ "reactiveWebcam": "Уменьшать при зуме",
+ "webcamBackground": "Фон камеры",
"webcamBlurIntensity": "Интенсивность размытия",
+ "mirrorWebcam": "Зеркалить веб-камеру",
"webcamShape": "Форма камеры",
- "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
- "selectPreset": "Выбрать пресет",
- "reactiveWebcam": "Уменьшать при зуме",
- "webcamBackground": "Фон камеры"
- },
- "background": {
- "colorPalette": "Палитра цветов",
- "gradient": "Градиент",
- "imageLabel": "Фон {{index}}",
- "custom": "Свой",
- "image": "Изображение",
- "title": "Фон",
- "colorWheel": "Цветовой круг",
- "customWallpaper": "Свои обои",
- "uploadCustom": "Загрузить свой",
- "colorLabel": "Цвет {{color}}",
- "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
- "gradientLabel": "Градиент {{index}}",
- "imageReadFailed": "Не удалось прочитать этот файл изображения.",
- "presets": "Пресеты",
- "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
- "color": "Цвет"
+ "noWebcam": "Без веб-камеры",
+ "webcamCropX": "Смещение по горизонтали",
+ "webcamFraming": "Кадрирование веб-камеры"
},
- "support": {
- "saveDiagnostics": "Сохранить диагностику",
- "starOnGithub": "Звезда на GitHub",
- "reportBug": "Сообщить об ошибке"
+ "imageUpload": {
+ "uploadSuccess": "Пользовательское изображение успешно загружено!",
+ "failedToUpload": "Не удалось загрузить изображение",
+ "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
+ "errorReading": "Произошла ошибка при чтении файла.",
+ "invalidFileType": "Неверный тип файла"
},
- "audio": {
- "outputGain": "Уровень выхода",
- "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
- "reset": "Сбросить аудио",
- "title": "Аудио"
+ "cursor": {
+ "themeDefault": "По умолчанию",
+ "motionBlur": "Размытие движения",
+ "smoothing": "Сглаживание",
+ "title": "Курсор",
+ "theme": "Стиль курсора",
+ "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
+ "show": "Показывать курсор",
+ "clipToBounds": "Обрезать по холсту",
+ "size": "Размер",
+ "clickBounce": "Отскок при клике",
+ "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике."
},
"captions": {
- "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
- "alignCenter": "По центру",
- "backgroundColor": "Цвет фона",
"original": "Оригинал (расшифровка)",
- "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
- "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
- "displayLanguage": "Отображение",
"alignRight": "Справа",
+ "backgroundOpacity": "Непрозрачность",
"anchorBottom": "Снизу",
- "anchorTop": "Сверху",
- "text": "Текст",
- "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
- "textColor": "Цвет текста",
+ "minWords": "Мин. слов в строке",
+ "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
+ "translate": "Перевести",
"maxWords": "Макс. слов в строке",
- "show": "Показывать субтитры",
- "translateFailed": "Не удалось перевести.",
+ "lineLength": "Длина строки",
+ "anchorTop": "Сверху",
"font": "Шрифт",
- "language": "Язык",
- "background": "Фон",
- "translate": "Перевести",
+ "translating": "Перевод…",
"position": "Положение",
- "distanceFromLeft": "Отступ слева",
- "lineLength": "Длина строки",
- "alignLeft": "Слева",
- "deleteTranslation": "Удалить этот перевод",
- "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
- "minWords": "Мин. слов в строке",
- "showBackground": "Показывать фон",
- "fontSize": "Размер",
+ "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
+ "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
+ "backgroundColor": "Цвет фона",
+ "text": "Текст",
+ "textColor": "Цвет текста",
"hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
- "distanceFromBottom": "Отступ снизу",
- "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
+ "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
"distanceFromTop": "Отступ сверху",
+ "alignLeft": "Слева",
"anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
- "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
- "translating": "Перевод…",
- "backgroundOpacity": "Непрозрачность",
+ "deleteTranslation": "Удалить этот перевод",
+ "distanceFromBottom": "Отступ снизу",
+ "displayLanguage": "Отображение",
+ "background": "Фон",
+ "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
+ "distanceFromLeft": "Отступ слева",
+ "alignCenter": "По центру",
+ "fontSize": "Размер",
+ "bold": "Полужирный",
+ "translateFailed": "Не удалось перевести.",
"distanceFromRight": "Отступ справа",
- "bold": "Полужирный"
- },
- "transcript": {
- "revertWord": "Вернуть «{{original}}»",
- "laneRecording": "Запись",
- "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
- "noAudio": "В этом медиафайле нет аудиодорожки",
- "noTranscript": "Расшифровки пока нет",
- "insertRecordingOnly": "Слова можно добавлять только к записи: пауза удерживает кадр фильма.",
- "insertedWord": "Добавлено вами — за ним нет звука",
- "insertAria": "Новое слово",
- "blankedWord": "очищено",
- "restoreSilence": "Вернуть тишину ({{duration}} с)",
- "laneLabel": "Читать расшифровку из",
- "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
- "removeInserted": "Удалить «{{word}}»",
- "transcribeNow": "Расшифровать сейчас",
- "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
- "editWord": "Изменить «{{word}}»",
- "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
- "clipLabel": "Клип {{index}}",
- "title": "Текущая расшифровка",
- "laneVoiceover": "Закадровый голос",
- "trimSilence": "Вырезать тишину ({{duration}} с)",
- "helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
- "editorAria": "Расшифровка «{{filename}}»",
- "silence": "[тишина {{duration}} с]",
- "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
- "transcribing": "Расшифровка…",
- "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
- "restoreWord": "Вернуть «{{word}}»",
- "noClips": "Клипов пока нет",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить."
+ "showBackground": "Показывать фон",
+ "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "show": "Показывать субтитры",
+ "language": "Язык",
+ "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени."
},
"zoom": {
- "level": "Уровень масштабирования",
- "position": {
- "x": "X (%)",
- "hint": "0 = край слева / сверху, 100 = край справа / снизу",
- "title": "Положение фокуса",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Справа",
- "left": "Слева",
- "iso": "Изометрия"
+ "iso": "Изометрия",
+ "left": "Слева"
},
- "title": "3D вращение",
- "none": "Нет"
+ "none": "Нет",
+ "title": "3D вращение"
},
"focusMode": {
"autoDescription": "Камера следует за записанной позицией курсора",
- "auto": "Авто",
"lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
+ "title": "Режим фокуса",
"manual": "Ручной",
- "title": "Режим фокуса"
+ "auto": "Авто"
+ },
+ "position": {
+ "title": "Положение фокуса",
+ "x": "X (%)",
+ "hint": "0 = край слева / сверху, 100 = край справа / снизу",
+ "y": "Y (%)"
},
+ "deleteZoom": "Удалить масштабирование",
+ "level": "Уровень масштабирования",
"selectRegion": "Выберите область масштабирования для настройки",
- "customScale": "Пользовательский масштаб",
"previewHold": "Удерживайте для предпросмотра эффекта зума",
- "deleteZoom": "Удалить масштабирование"
+ "customScale": "Пользовательский масштаб"
},
- "project": {
- "load": "Загрузить проект",
- "save": "Сохранить проект",
- "new": "Новый проект"
+ "textAnimation": {
+ "pop": "Всплытие",
+ "rise": "Подъем",
+ "selectAnimation": "Выбрать анимацию",
+ "fade": "Затухание",
+ "pulse": "Импульс",
+ "typewriter": "Пишущая машинка",
+ "title": "Анимация текста",
+ "none": "Нет",
+ "slideLeft": "Скольжение влево"
+ },
+ "crop": {
+ "lockAspectRatio": "Заблокировать соотношение сторон",
+ "title": "Обрезка",
+ "done": "Готово",
+ "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
+ "ratio": "Соотношение сторон",
+ "free": "Свободно",
+ "cropVideo": "Обрезать видео",
+ "unlockAspectRatio": "Разблокировать соотношение сторон"
+ },
+ "background": {
+ "imageLabel": "Фон {{index}}",
+ "color": "Цвет",
+ "gradient": "Градиент",
+ "colorLabel": "Цвет {{color}}",
+ "colorWheel": "Цветовой круг",
+ "customWallpaper": "Свои обои",
+ "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
+ "image": "Изображение",
+ "gradientLabel": "Градиент {{index}}",
+ "imageReadFailed": "Не удалось прочитать этот файл изображения.",
+ "presets": "Пресеты",
+ "custom": "Свой",
+ "colorPalette": "Палитра цветов",
+ "uploadCustom": "Загрузить свой",
+ "title": "Фон",
+ "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG."
+ },
+ "audio": {
+ "title": "Аудио",
+ "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
+ "reset": "Сбросить аудио",
+ "outputGain": "Уровень выхода"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Разрешение экспорта",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "Не удалось добавить аудио",
- "defaultLabel": "Аудиодорожка",
- "add": "Добавить аудиодорожку",
- "fadeIn": "Нарастание",
- "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
- "fadeOut": "Затухание",
- "loop": "Повтор",
- "remove": "Удалить дорожку",
- "mute": "Без звука",
- "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри"
+ "high": "Source",
+ "title": "Разрешение экспорта"
},
"gifSettings": {
+ "loop": "Зациклить GIF",
"frameRate": "Частота кадров GIF",
- "size": "Размер GIF",
- "loop": "Зациклить GIF"
- },
- "crop": {
- "ratio": "Соотношение сторон",
- "free": "Свободно",
- "lockAspectRatio": "Заблокировать соотношение сторон",
- "unlockAspectRatio": "Разблокировать соотношение сторон",
- "cropVideo": "Обрезать видео",
- "title": "Обрезка",
- "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
- "done": "Готово"
- },
- "cursor": {
- "clickBounce": "Отскок при клике",
- "clipToBounds": "Обрезать по холсту",
- "title": "Курсор",
- "size": "Размер",
- "show": "Показывать курсор",
- "motionBlur": "Размытие движения",
- "themeDefault": "По умолчанию",
- "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
- "smoothing": "Сглаживание",
- "theme": "Стиль курсора",
- "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике."
+ "size": "Размер GIF"
},
"export": {
- "videoButton": "Экспорт видео",
+ "chooseSaveLocation": "Выбрать место сохранения",
"gifButton": "Экспорт GIF",
- "chooseSaveLocation": "Выбрать место сохранения"
+ "videoButton": "Экспорт видео"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "Видеофайл высокого качества",
- "gifDescription": "Анимированное изображение для обмена",
- "gifAnimation": "GIF анимация",
- "mp4Video": "MP4 видео"
+ "project": {
+ "load": "Загрузить проект",
+ "save": "Сохранить проект",
+ "new": "Новый проект"
},
"speed": {
"previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет.",
- "deleteRegion": "Удалить область скорости",
"customPlaybackSpeed": "Пользовательская скорость воспроизведения",
+ "maxSpeedError": "Скорость не может быть выше {{max}}×",
+ "deleteRegion": "Удалить область скорости",
"selectRegion": "Выберите область скорости для настройки",
- "playbackSpeed": "Скорость воспроизведения",
- "maxSpeedError": "Скорость не может быть выше {{max}}×"
+ "playbackSpeed": "Скорость воспроизведения"
},
- "textAnimation": {
- "rise": "Подъем",
- "pop": "Всплытие",
- "selectAnimation": "Выбрать анимацию",
- "pulse": "Импульс",
- "slideLeft": "Скольжение влево",
- "typewriter": "Пишущая машинка",
- "none": "Нет",
- "fade": "Затухание",
- "title": "Анимация текста"
+ "language": {
+ "title": "Язык"
},
- "imageUpload": {
- "invalidFileType": "Неверный тип файла",
- "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
- "failedToUpload": "Не удалось загрузить изображение",
- "uploadSuccess": "Пользовательское изображение успешно загружено!",
- "errorReading": "Произошла ошибка при чтении файла."
+ "support": {
+ "starOnGithub": "Звезда на GitHub",
+ "reportBug": "Сообщить об ошибке",
+ "saveDiagnostics": "Сохранить диагностику"
},
- "panes": {
- "help": "Справка"
+ "trim": {
+ "deleteRegion": "Удалить область обрезки"
},
"facets": {
"transcript": "Транскрипт",
"captions": "Субтитры"
},
- "language": {
- "title": "Язык"
- },
- "trim": {
- "deleteRegion": "Удалить область обрезки"
+ "panes": {
+ "help": "Справка"
}
}
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 9b7d988da..1156c4b31 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF Animasyon",
+ "mp4Description": "Yüksek kaliteli video dosyası",
+ "mp4": "MP4",
+ "mp4Video": "MP4 Video",
+ "gif": "GIF",
+ "gifDescription": "Paylaşım için hareketli görüntü"
+ },
"customFont": {
- "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
- "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
- "urlLabel": "Google Fonts İçe Aktarım URL'si",
- "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
"errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Google Yazı Tipi Ekle",
+ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
+ "nameLabel": "Görünen Ad",
"failedToAdd": "Yazı tipi eklenemedi",
- "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
+ "urlLabel": "Google Fonts İçe Aktarım URL'si",
+ "namePlaceholder": "Özel Yazı Tipim",
+ "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
"addingButton": "Ekleniyor...",
- "dialogTitle": "Google Yazı Tipi Ekle",
+ "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
+ "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
"successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
- "addButton": "Yazı Tipi Ekle",
- "namePlaceholder": "Özel Yazı Tipim",
+ "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
"errorEmptyName": "Lütfen bir yazı tipi adı girin",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
- "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
- "nameLabel": "Görünen Ad"
+ "addButton": "Yazı Tipi Ekle",
+ "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin"
},
"annotation": {
- "defaultText": "Merhaba",
- "size": "Boyut",
+ "colorWheel": "Renk çarkı",
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
+ "typeArrow": "Ok",
+ "selectStyle": "Stil seçin",
+ "blurColorWhite": "Beyaz",
"blurType": "Bulanıklık Türü",
- "mosaicBlockSize": "Mozaik Blok Boyutu",
- "colorWheel": "Renk çarkı",
- "typeImage": "Görüntü",
- "textContent": "Metin İçeriği",
- "clearBackground": "Arka Planı Temizle",
- "invalidImageType": "Geçersiz dosya türü",
- "shortcutsAndTips": "Kısayollar ve İpuçları",
- "colorPalette": "Renk paleti",
+ "blurShapeRectangle": "Dikdörtgen",
+ "blurColor": "Bulanıklık Rengi",
"arrowColor": "Ok Rengi",
- "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
- "blurColorWhite": "Beyaz",
+ "textContent": "Metin İçeriği",
"background": "Arka Plan",
- "blurColor": "Bulanıklık Rengi",
- "typeArrow": "Ok",
- "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Mozaik",
- "textPlaceholder": "Metninizi girin...",
+ "clearBackground": "Arka Planı Temizle",
+ "blurShapeFreehand": "Serbest",
"blurIntensity": "Bulanıklık Yoğunluğu",
- "textColor": "Metin Rengi",
- "blurShapeRectangle": "Dikdörtgen",
- "deleteAnnotation": "Açıklamayı Sil",
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
- "blurShapeFreehand": "Serbest",
- "arrowDirection": "Ok Yönü",
- "selectStyle": "Stil seçin",
- "fontStyle": "Yazı Tipi Stili",
- "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
"active": "Aktif",
- "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
- "blurShapeOval": "Oval",
+ "size": "Boyut",
"blurColorBlack": "Siyah",
- "color": "Renk",
+ "typeImage": "Görüntü",
+ "mosaicBlockSize": "Mozaik Blok Boyutu",
+ "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "textColor": "Metin Rengi",
+ "defaultText": "Merhaba",
"blurShape": "Bulanık Şekli",
- "customFonts": "Özel Yazı Tipleri",
- "typeBlur": "Bulanık",
- "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
+ "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
"type": "Tür",
+ "typeText": "Metin",
+ "textPlaceholder": "Metninizi girin...",
+ "fontStyle": "Yazı Tipi Stili",
+ "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
+ "colorPalette": "Renk paleti",
+ "color": "Renk",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
+ "none": "Yok",
+ "invalidImageType": "Geçersiz dosya türü",
+ "arrowDirection": "Ok Yönü",
+ "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
"uploadImage": "Görüntü Yükle",
+ "customFonts": "Özel Yazı Tipleri",
+ "blurTypeMosaic": "Mozaik",
"blurTypeBlur": "Gauss",
- "none": "Yok",
+ "typeBlur": "Bulanık",
"title": "Açıklama Ayarları",
- "typeText": "Metin"
+ "deleteAnnotation": "Açıklamayı Sil",
+ "blurShapeOval": "Oval"
+ },
+ "transcript": {
+ "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
+ "editWord": "\"{{word}}\" kelimesini düzenle",
+ "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
+ "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
+ "insertAria": "Yeni kelime",
+ "transcribing": "Döküm çıkarılıyor…",
+ "noAudio": "Bu medyada ses parçası yok",
+ "noTranscript": "Henüz döküm yok",
+ "silence": "[sessizlik {{duration}} sn]",
+ "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
+ "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
+ "noClips": "Henüz klip yok",
+ "laneVoiceover": "Dış ses",
+ "laneLabel": "Deşifreyi şuradan oku",
+ "revertWord": "\"{{original}}\" haline getir",
+ "helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
+ "blankedWord": "boşaltıldı",
+ "clipLabel": "Klip {{index}}",
+ "title": "Geçerli döküm",
+ "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
+ "transcribeNow": "Şimdi dökümünü çıkar",
+ "laneRecording": "Kayıt",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
+ "trimSilence": "Sessizliği kırp ({{duration}} sn)",
+ "editorAria": "{{filename}} dökümü",
+ "restoreWord": "\"{{word}}\" kelimesini geri al"
},
"effects": {
- "fitClipMany": "{{count}} klip",
+ "shadow": "Gölge",
+ "fitClipOne": "{{count}} klip",
+ "blurBg": "Arka Planı Bulanıklaştır",
+ "fitClip": "Sığdır",
+ "formatOriginal": "Orijinal",
+ "title": "Kompozisyon",
"format": "Biçim",
"motionBlur": "Hareket Bulanıklığı",
+ "fitClipMany": "{{count}} klip",
+ "roundness": "Yuvarlaklık",
"fitClipFew": "{{count}} klip",
- "fitClip": "Sığdır",
+ "motion": "Hareket",
"on": "açık",
- "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
- "formatOriginal": "Orijinal",
- "blurBg": "Arka Planı Bulanıklaştır",
"frame": "Çerçeve",
"padding": "Dolgu",
- "shadow": "Gölge",
- "off": "kapalı",
- "title": "Kompozisyon",
- "motion": "Hareket",
- "fitClipOne": "{{count}} klip",
- "roundness": "Yuvarlaklık"
+ "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
+ "off": "kapalı"
+ },
+ "audioTrack": {
+ "mute": "Sessiz",
+ "fadeIn": "Açılma",
+ "loop": "Döngü",
+ "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
+ "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "importFailed": "Ses eklenemedi",
+ "fadeOut": "Kararma",
+ "add": "Ses parçası ekle",
+ "defaultLabel": "Ses parçası",
+ "remove": "Parçayı sil"
},
"layout": {
- "noWebcam": "Web kamerası yok",
- "bgModes": {
- "none": "Orijinal",
- "transparent": "Kesme",
- "blur": "Bulanık",
- "custom": "Özel"
- },
- "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
- "webcamFraming": "Webcam kadrajı",
- "webcamCropX": "Yatay kaydırma",
- "preset": "Ön Ayar",
- "webcamSize": "Webcam Boyutu",
"shapes": {
"circle": "Daire",
"square": "Kare",
"rectangle": "Dikdörtgen",
"rounded": "Yuvarlatılmış"
},
- "dualFrame": "Çift Kare",
"verticalStack": "Dikey Yığın",
- "webcamCropZoom": "Kırpma yakınlaştırması",
+ "webcamSize": "Webcam Boyutu",
+ "preset": "Ön Ayar",
+ "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
+ "dualFrame": "Çift Kare",
"title": "Kamera düzeni",
- "webcamCropY": "Dikey kaydırma",
"reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
+ "bgModes": {
+ "transparent": "Kesme",
+ "custom": "Özel",
+ "none": "Orijinal",
+ "blur": "Bulanık"
+ },
+ "webcamCropZoom": "Kırpma yakınlaştırması",
+ "selectPreset": "Ön ayar seçin",
+ "webcamCropY": "Dikey kaydırma",
+ "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
"pictureInPicture": "Resim İçinde Resim",
- "mirrorWebcam": "Web kamerasını aynala",
+ "reactiveWebcam": "Yakınlaştırınca küçült",
+ "webcamBackground": "Kamera Arka Planı",
"webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "mirrorWebcam": "Web kamerasını aynala",
"webcamShape": "Kamera Şekli",
- "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
- "selectPreset": "Ön ayar seçin",
- "reactiveWebcam": "Yakınlaştırınca küçült",
- "webcamBackground": "Kamera Arka Planı"
- },
- "background": {
- "colorPalette": "Renk paleti",
- "gradient": "Gradyan",
- "imageLabel": "Arka plan {{index}}",
- "custom": "Özel",
- "image": "Görüntü",
- "title": "Arka Plan",
- "colorWheel": "Renk çarkı",
- "customWallpaper": "Özel duvar kâğıdı",
- "uploadCustom": "Özel Yükle",
- "colorLabel": "Renk {{color}}",
- "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
- "gradientLabel": "Gradyan {{index}}",
- "imageReadFailed": "Bu görsel dosyası okunamadı.",
- "presets": "Ön ayarlar",
- "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
- "color": "Renk"
+ "noWebcam": "Web kamerası yok",
+ "webcamCropX": "Yatay kaydırma",
+ "webcamFraming": "Webcam kadrajı"
},
- "support": {
- "saveDiagnostics": "Teşhis Verilerini Kaydet",
- "starOnGithub": "GitHub'da Yıldızla",
- "reportBug": "Hata Bildir"
+ "imageUpload": {
+ "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
+ "failedToUpload": "Görüntü yüklenemedi",
+ "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.",
+ "errorReading": "Dosya okunurken bir hata oluştu.",
+ "invalidFileType": "Geçersiz dosya türü"
},
- "audio": {
- "outputGain": "Çıkış seviyesi",
- "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
- "reset": "Sesi sıfırla",
- "title": "Ses"
+ "cursor": {
+ "themeDefault": "Varsayılan",
+ "motionBlur": "Hareket Bulanıklığı",
+ "smoothing": "Yumuşatma",
+ "title": "İmleç",
+ "theme": "İmleç Stili",
+ "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
+ "show": "İmleci Göster",
+ "clipToBounds": "Tuvale Kırp",
+ "size": "Boyut",
+ "clickBounce": "Tıklama Sıçraması",
+ "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması."
},
"captions": {
- "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
- "alignCenter": "Orta",
- "backgroundColor": "Arka plan rengi",
"original": "Özgün (döküm)",
- "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
- "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
- "displayLanguage": "Görüntüleme",
"alignRight": "Sağ",
+ "backgroundOpacity": "Saydamlık",
"anchorBottom": "Alt",
- "anchorTop": "Üst",
- "text": "Metin",
- "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
- "textColor": "Metin rengi",
+ "minWords": "Satır başına en az kelime",
+ "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
+ "translate": "Çevir",
"maxWords": "Satır başına en çok kelime",
- "show": "Altyazıları göster",
- "translateFailed": "Çeviri başarısız oldu.",
+ "lineLength": "Satır uzunluğu",
+ "anchorTop": "Üst",
"font": "Yazı tipi",
- "language": "Dil",
- "background": "Arka plan",
- "translate": "Çevir",
+ "translating": "Çevriliyor…",
"position": "Konum",
- "distanceFromLeft": "Soldan uzaklık",
- "lineLength": "Satır uzunluğu",
- "alignLeft": "Sol",
- "deleteTranslation": "Bu çeviriyi sil",
- "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
- "minWords": "Satır başına en az kelime",
- "showBackground": "Arka planı göster",
- "fontSize": "Boyut",
+ "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
+ "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
+ "backgroundColor": "Arka plan rengi",
+ "text": "Metin",
+ "textColor": "Metin rengi",
"hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
- "distanceFromBottom": "Alttan uzaklık",
- "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
+ "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
"distanceFromTop": "Üstten uzaklık",
+ "alignLeft": "Sol",
"anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
- "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
- "translating": "Çevriliyor…",
- "backgroundOpacity": "Saydamlık",
+ "deleteTranslation": "Bu çeviriyi sil",
+ "distanceFromBottom": "Alttan uzaklık",
+ "displayLanguage": "Görüntüleme",
+ "background": "Arka plan",
+ "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
+ "distanceFromLeft": "Soldan uzaklık",
+ "alignCenter": "Orta",
+ "fontSize": "Boyut",
+ "bold": "Kalın",
+ "translateFailed": "Çeviri başarısız oldu.",
"distanceFromRight": "Sağdan uzaklık",
- "bold": "Kalın"
- },
- "transcript": {
- "revertWord": "\"{{original}}\" haline getir",
- "laneRecording": "Kayıt",
- "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
- "noAudio": "Bu medyada ses parçası yok",
- "noTranscript": "Henüz döküm yok",
- "insertRecordingOnly": "Kelimeler yalnızca kayda eklenebilir — bir duraklama filmden bir kareyi dondurur.",
- "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
- "insertAria": "Yeni kelime",
- "blankedWord": "boşaltıldı",
- "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
- "laneLabel": "Deşifreyi şuradan oku",
- "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
- "removeInserted": "\"{{word}}\" kelimesini sil",
- "transcribeNow": "Şimdi dökümünü çıkar",
- "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
- "editWord": "\"{{word}}\" kelimesini düzenle",
- "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
- "clipLabel": "Klip {{index}}",
- "title": "Geçerli döküm",
- "laneVoiceover": "Dış ses",
- "trimSilence": "Sessizliği kırp ({{duration}} sn)",
- "helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
- "editorAria": "{{filename}} dökümü",
- "silence": "[sessizlik {{duration}} sn]",
- "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
- "transcribing": "Döküm çıkarılıyor…",
- "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
- "restoreWord": "\"{{word}}\" kelimesini geri al",
- "noClips": "Henüz klip yok",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz."
+ "showBackground": "Arka planı göster",
+ "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
+ "show": "Altyazıları göster",
+ "language": "Dil",
+ "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi."
},
"zoom": {
- "level": "Yakınlaştırma Seviyesi",
- "position": {
- "x": "X (%)",
- "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
- "title": "Odak Konumu",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Sağ",
- "left": "Sol",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "Sol"
},
- "title": "3D Döndürme",
- "none": "Yok"
+ "none": "Yok",
+ "title": "3D Döndürme"
},
"focusMode": {
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
- "auto": "Otomatik",
"lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
+ "title": "Odak Modu",
"manual": "Manuel",
- "title": "Odak Modu"
+ "auto": "Otomatik"
+ },
+ "position": {
+ "title": "Odak Konumu",
+ "x": "X (%)",
+ "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
+ "y": "Y (%)"
},
+ "deleteZoom": "Yakınlaştırmayı Sil",
+ "level": "Yakınlaştırma Seviyesi",
"selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
- "customScale": "Özel Yakınlaştırma",
"previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
- "deleteZoom": "Yakınlaştırmayı Sil"
+ "customScale": "Özel Yakınlaştırma"
},
- "project": {
- "load": "Proje Yükle",
- "save": "Projeyi Kaydet",
- "new": "Yeni Proje"
+ "textAnimation": {
+ "pop": "Fırlama",
+ "rise": "Yükselme",
+ "selectAnimation": "Animasyon seçin",
+ "fade": "Belirme",
+ "pulse": "Nabız",
+ "typewriter": "Daktilo",
+ "title": "Metin Animasyonu",
+ "none": "Yok",
+ "slideLeft": "Sola Kaydırma"
+ },
+ "crop": {
+ "lockAspectRatio": "En boy oranını kilitle",
+ "title": "Kırpma",
+ "done": "Tamam",
+ "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
+ "ratio": "Oran",
+ "free": "Serbest",
+ "cropVideo": "Videoyu Kırp",
+ "unlockAspectRatio": "En boy oranının kilidini aç"
+ },
+ "background": {
+ "imageLabel": "Arka plan {{index}}",
+ "color": "Renk",
+ "gradient": "Gradyan",
+ "colorLabel": "Renk {{color}}",
+ "colorWheel": "Renk çarkı",
+ "customWallpaper": "Özel duvar kâğıdı",
+ "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
+ "image": "Görüntü",
+ "gradientLabel": "Gradyan {{index}}",
+ "imageReadFailed": "Bu görsel dosyası okunamadı.",
+ "presets": "Ön ayarlar",
+ "custom": "Özel",
+ "colorPalette": "Renk paleti",
+ "uploadCustom": "Özel Yükle",
+ "title": "Arka Plan",
+ "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın."
+ },
+ "audio": {
+ "title": "Ses",
+ "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
+ "reset": "Sesi sıfırla",
+ "outputGain": "Çıkış seviyesi"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Dışa aktarma çözünürlüğü",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "Ses eklenemedi",
- "defaultLabel": "Ses parçası",
- "add": "Ses parçası ekle",
- "fadeIn": "Açılma",
- "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
- "fadeOut": "Kararma",
- "loop": "Döngü",
- "remove": "Parçayı sil",
- "mute": "Sessiz",
- "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin"
+ "high": "Source",
+ "title": "Dışa aktarma çözünürlüğü"
},
"gifSettings": {
+ "loop": "GIF Döngüsü",
"frameRate": "GIF Kare Hızı",
- "size": "GIF Boyutu",
- "loop": "GIF Döngüsü"
- },
- "crop": {
- "ratio": "Oran",
- "free": "Serbest",
- "lockAspectRatio": "En boy oranını kilitle",
- "unlockAspectRatio": "En boy oranının kilidini aç",
- "cropVideo": "Videoyu Kırp",
- "title": "Kırpma",
- "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
- "done": "Tamam"
- },
- "cursor": {
- "clickBounce": "Tıklama Sıçraması",
- "clipToBounds": "Tuvale Kırp",
- "title": "İmleç",
- "size": "Boyut",
- "show": "İmleci Göster",
- "motionBlur": "Hareket Bulanıklığı",
- "themeDefault": "Varsayılan",
- "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
- "smoothing": "Yumuşatma",
- "theme": "İmleç Stili",
- "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması."
+ "size": "GIF Boyutu"
},
"export": {
- "videoButton": "Videoyu Dışa Aktar",
+ "chooseSaveLocation": "Kayıt Konumu Seç",
"gifButton": "GIF Olarak Dışa Aktar",
- "chooseSaveLocation": "Kayıt Konumu Seç"
+ "videoButton": "Videoyu Dışa Aktar"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "Yüksek kaliteli video dosyası",
- "gifDescription": "Paylaşım için hareketli görüntü",
- "gifAnimation": "GIF Animasyon",
- "mp4Video": "MP4 Video"
+ "project": {
+ "load": "Proje Yükle",
+ "save": "Projeyi Kaydet",
+ "new": "Yeni Proje"
},
"speed": {
"previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez.",
- "deleteRegion": "Hız Bölgesini Sil",
"customPlaybackSpeed": "Özel Oynatma Hızı",
+ "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
+ "deleteRegion": "Hız Bölgesini Sil",
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
- "playbackSpeed": "Oynatma Hızı",
- "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz"
+ "playbackSpeed": "Oynatma Hızı"
},
- "textAnimation": {
- "rise": "Yükselme",
- "pop": "Fırlama",
- "selectAnimation": "Animasyon seçin",
- "pulse": "Nabız",
- "slideLeft": "Sola Kaydırma",
- "typewriter": "Daktilo",
- "none": "Yok",
- "fade": "Belirme",
- "title": "Metin Animasyonu"
+ "language": {
+ "title": "Dil"
},
- "imageUpload": {
- "invalidFileType": "Geçersiz dosya türü",
- "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.",
- "failedToUpload": "Görüntü yüklenemedi",
- "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
- "errorReading": "Dosya okunurken bir hata oluştu."
+ "support": {
+ "starOnGithub": "GitHub'da Yıldızla",
+ "reportBug": "Hata Bildir",
+ "saveDiagnostics": "Teşhis Verilerini Kaydet"
},
- "panes": {
- "help": "Yardım"
+ "trim": {
+ "deleteRegion": "Kırpma Bölgesini Sil"
},
"facets": {
"transcript": "Metin Dökümü",
"captions": "Altyazılar"
},
- "language": {
- "title": "Dil"
- },
- "trim": {
- "deleteRegion": "Kırpma Bölgesini Sil"
+ "panes": {
+ "help": "Yardım"
}
}
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 2cb2be1bc..d59b3ec4d 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "Ảnh động GIF",
+ "mp4Description": "Tệp video chất lượng cao",
+ "mp4": "MP4",
+ "mp4Video": "Video MP4",
+ "gif": "GIF",
+ "gifDescription": "Hình ảnh động để chia sẻ"
+ },
"customFont": {
- "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
- "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
- "urlLabel": "URL nhập Google Fonts",
- "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
"errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Thêm Google Font",
+ "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
+ "nameLabel": "Tên hiển thị",
"failedToAdd": "Thêm phông chữ thất bại",
- "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
+ "urlLabel": "URL nhập Google Fonts",
+ "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
+ "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
"addingButton": "Đang thêm...",
- "dialogTitle": "Thêm Google Font",
+ "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
+ "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
"successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
- "addButton": "Thêm phông chữ",
- "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
+ "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
"errorEmptyName": "Vui lòng nhập tên phông chữ",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
- "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
- "nameLabel": "Tên hiển thị"
+ "addButton": "Thêm phông chữ",
+ "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ"
},
"annotation": {
- "defaultText": "Xin chào",
- "size": "Kích thước",
+ "colorWheel": "Vòng màu",
"imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
+ "typeArrow": "Mũi tên",
+ "selectStyle": "Chọn kiểu",
+ "blurColorWhite": "Trắng",
"blurType": "Loại làm mờ",
- "mosaicBlockSize": "Kích thước khối khảm",
- "colorWheel": "Vòng màu",
- "typeImage": "Hình ảnh",
- "textContent": "Nội dung văn bản",
- "clearBackground": "Xóa nền",
- "invalidImageType": "Loại tệp không hợp lệ",
- "shortcutsAndTips": "Phím tắt & Mẹo",
- "colorPalette": "Bảng màu",
+ "blurShapeRectangle": "Chữ nhật",
+ "blurColor": "Màu làm mờ",
"arrowColor": "Màu mũi tên",
- "strokeWidth": "Độ dày nét: {{width}}px",
- "blurColorWhite": "Trắng",
+ "textContent": "Nội dung văn bản",
"background": "Nền",
- "blurColor": "Màu làm mờ",
- "typeArrow": "Mũi tên",
- "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
- "blurTypeMosaic": "Khảm",
- "textPlaceholder": "Nhập văn bản của bạn...",
+ "clearBackground": "Xóa nền",
+ "blurShapeFreehand": "Vẽ tự do",
"blurIntensity": "Cường độ làm mờ",
- "textColor": "Màu văn bản",
- "blurShapeRectangle": "Chữ nhật",
- "deleteAnnotation": "Xóa chú thích",
"tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
- "blurShapeFreehand": "Vẽ tự do",
- "arrowDirection": "Hướng mũi tên",
- "selectStyle": "Chọn kiểu",
- "fontStyle": "Kiểu phông chữ",
- "imageUploadSuccess": "Tải lên hình ảnh thành công!",
"active": "Hoạt động",
- "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
- "blurShapeOval": "Bầu dục",
+ "size": "Kích thước",
"blurColorBlack": "Đen",
- "color": "Màu sắc",
+ "typeImage": "Hình ảnh",
+ "mosaicBlockSize": "Kích thước khối khảm",
+ "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
+ "strokeWidth": "Độ dày nét: {{width}}px",
+ "textColor": "Màu văn bản",
+ "defaultText": "Xin chào",
"blurShape": "Hình dạng làm mờ",
- "customFonts": "Phông chữ tùy chỉnh",
- "typeBlur": "Làm mờ",
- "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
+ "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
"type": "Loại",
+ "typeText": "Văn bản",
+ "textPlaceholder": "Nhập văn bản của bạn...",
+ "fontStyle": "Kiểu phông chữ",
+ "imageUploadSuccess": "Tải lên hình ảnh thành công!",
+ "colorPalette": "Bảng màu",
+ "color": "Màu sắc",
+ "shortcutsAndTips": "Phím tắt & Mẹo",
+ "none": "Không có",
+ "invalidImageType": "Loại tệp không hợp lệ",
+ "arrowDirection": "Hướng mũi tên",
+ "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
"uploadImage": "Tải lên hình ảnh",
+ "customFonts": "Phông chữ tùy chỉnh",
+ "blurTypeMosaic": "Khảm",
"blurTypeBlur": "Gaussian",
- "none": "Không có",
+ "typeBlur": "Làm mờ",
"title": "Cài đặt chú thích",
- "typeText": "Văn bản"
+ "deleteAnnotation": "Xóa chú thích",
+ "blurShapeOval": "Bầu dục"
+ },
+ "transcript": {
+ "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
+ "editWord": "Sửa \"{{word}}\"",
+ "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
+ "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
+ "insertAria": "Từ mới",
+ "transcribing": "Đang chép lời…",
+ "noAudio": "Media này không có bản âm thanh",
+ "noTranscript": "Chưa có bản chép lời",
+ "silence": "[khoảng lặng {{duration}} giây]",
+ "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
+ "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
+ "noClips": "Chưa có clip nào",
+ "laneVoiceover": "Lời thuyết minh",
+ "laneLabel": "Đọc bản chép lời từ",
+ "revertWord": "Khôi phục \"{{original}}\"",
+ "helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
+ "blankedWord": "đã xoá",
+ "clipLabel": "Clip {{index}}",
+ "title": "Bản chép lời hiện tại",
+ "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
+ "transcribeNow": "Chép lời ngay",
+ "laneRecording": "Bản ghi",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "removeInserted": "Xoá \"{{word}}\"",
+ "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
+ "editorAria": "Bản chép lời của {{filename}}",
+ "restoreWord": "Khôi phục \"{{word}}\""
},
"effects": {
- "fitClipMany": "{{count}} clip",
+ "shadow": "Bóng đổ",
+ "fitClipOne": "{{count}} clip",
+ "blurBg": "Làm mờ nền",
+ "fitClip": "Vừa khít",
+ "formatOriginal": "Gốc",
+ "title": "Bố cục hình ảnh",
"format": "Định dạng",
"motionBlur": "Làm mờ chuyển động",
+ "fitClipMany": "{{count}} clip",
+ "roundness": "Độ bo tròn",
"fitClipFew": "{{count}} clip",
- "fitClip": "Vừa khít",
+ "motion": "Chuyển động",
"on": "bật",
- "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
- "formatOriginal": "Gốc",
- "blurBg": "Làm mờ nền",
"frame": "Khung",
"padding": "Phần đệm",
- "shadow": "Bóng đổ",
- "off": "tắt",
- "title": "Bố cục hình ảnh",
- "motion": "Chuyển động",
- "fitClipOne": "{{count}} clip",
- "roundness": "Độ bo tròn"
+ "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
+ "off": "tắt"
+ },
+ "audioTrack": {
+ "mute": "Tắt tiếng",
+ "fadeIn": "Mờ vào",
+ "loop": "Lặp",
+ "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
+ "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "importFailed": "Không thể thêm âm thanh",
+ "fadeOut": "Mờ ra",
+ "add": "Thêm bản âm thanh",
+ "defaultLabel": "Bản âm thanh",
+ "remove": "Xóa bản nhạc"
},
"layout": {
- "noWebcam": "Không có webcam",
- "bgModes": {
- "none": "Gốc",
- "transparent": "Tách nền",
- "blur": "Làm mờ",
- "custom": "Tùy chỉnh"
- },
- "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
- "webcamFraming": "Khung hình webcam",
- "webcamCropX": "Dịch chuyển ngang",
- "preset": "Cài đặt sẵn",
- "webcamSize": "Kích thước Webcam",
"shapes": {
"circle": "Tròn",
"square": "Vuông",
"rectangle": "Chữ nhật",
"rounded": "Bo góc"
},
- "dualFrame": "Khung kép",
"verticalStack": "Xếp chồng dọc",
- "webcamCropZoom": "Thu phóng vùng cắt",
+ "webcamSize": "Kích thước Webcam",
+ "preset": "Cài đặt sẵn",
+ "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
+ "dualFrame": "Khung kép",
"title": "Bố cục camera",
- "webcamCropY": "Dịch chuyển dọc",
"reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
+ "bgModes": {
+ "transparent": "Tách nền",
+ "custom": "Tùy chỉnh",
+ "none": "Gốc",
+ "blur": "Làm mờ"
+ },
+ "webcamCropZoom": "Thu phóng vùng cắt",
+ "selectPreset": "Chọn cài đặt sẵn",
+ "webcamCropY": "Dịch chuyển dọc",
+ "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
"pictureInPicture": "Hình trong hình",
- "mirrorWebcam": "Lật webcam",
+ "reactiveWebcam": "Thu nhỏ khi phóng to",
+ "webcamBackground": "Nền máy ảnh",
"webcamBlurIntensity": "Độ mờ",
+ "mirrorWebcam": "Lật webcam",
"webcamShape": "Hình dạng máy ảnh",
- "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
- "selectPreset": "Chọn cài đặt sẵn",
- "reactiveWebcam": "Thu nhỏ khi phóng to",
- "webcamBackground": "Nền máy ảnh"
- },
- "background": {
- "colorPalette": "Bảng màu",
- "gradient": "Dải màu",
- "imageLabel": "Nền {{index}}",
- "custom": "Tùy chỉnh",
- "image": "Hình ảnh",
- "title": "Nền",
- "colorWheel": "Vòng màu",
- "customWallpaper": "Ảnh nền tùy chỉnh",
- "uploadCustom": "Tải lên tùy chỉnh",
- "colorLabel": "Màu {{color}}",
- "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
- "gradientLabel": "Dải màu {{index}}",
- "imageReadFailed": "Không thể đọc tệp ảnh này.",
- "presets": "Có sẵn",
- "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
- "color": "Màu sắc"
+ "noWebcam": "Không có webcam",
+ "webcamCropX": "Dịch chuyển ngang",
+ "webcamFraming": "Khung hình webcam"
},
- "support": {
- "saveDiagnostics": "Lưu thông tin chẩn đoán",
- "starOnGithub": "Đánh giá sao trên GitHub",
- "reportBug": "Báo cáo lỗi"
+ "imageUpload": {
+ "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
+ "failedToUpload": "Tải lên hình ảnh thất bại",
+ "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
+ "errorReading": "Đã xảy ra lỗi khi đọc tệp.",
+ "invalidFileType": "Loại tệp không hợp lệ"
},
- "audio": {
- "outputGain": "Mức đầu ra",
- "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
- "reset": "Đặt lại âm thanh",
- "title": "Âm thanh"
+ "cursor": {
+ "themeDefault": "Mặc định",
+ "motionBlur": "Làm mờ chuyển động",
+ "smoothing": "Làm mượt",
+ "title": "Con trỏ",
+ "theme": "Kiểu con trỏ",
+ "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
+ "show": "Hiện con trỏ",
+ "clipToBounds": "Cắt theo khung",
+ "size": "Kích thước",
+ "clickBounce": "Nảy khi nhấp",
+ "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp."
},
"captions": {
- "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
- "alignCenter": "Giữa",
- "backgroundColor": "Màu nền",
"original": "Gốc (bản chép lời)",
- "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
- "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
- "displayLanguage": "Hiển thị",
"alignRight": "Phải",
+ "backgroundOpacity": "Độ mờ",
"anchorBottom": "Dưới",
- "anchorTop": "Trên",
- "text": "Văn bản",
- "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
- "textColor": "Màu chữ",
+ "minWords": "Số từ tối thiểu mỗi dòng",
+ "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
+ "translate": "Dịch",
"maxWords": "Số từ tối đa mỗi dòng",
- "show": "Hiện phụ đề",
- "translateFailed": "Dịch thất bại.",
+ "lineLength": "Độ dài dòng",
+ "anchorTop": "Trên",
"font": "Phông chữ",
- "language": "Ngôn ngữ",
- "background": "Nền",
- "translate": "Dịch",
+ "translating": "Đang dịch…",
"position": "Vị trí",
- "distanceFromLeft": "Khoảng cách từ trái",
- "lineLength": "Độ dài dòng",
- "alignLeft": "Trái",
- "deleteTranslation": "Xóa bản dịch này",
- "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
- "minWords": "Số từ tối thiểu mỗi dòng",
- "showBackground": "Hiện nền",
- "fontSize": "Cỡ chữ",
+ "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
+ "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
+ "backgroundColor": "Màu nền",
+ "text": "Văn bản",
+ "textColor": "Màu chữ",
"hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
- "distanceFromBottom": "Khoảng cách từ dưới",
- "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
+ "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
"distanceFromTop": "Khoảng cách từ trên",
+ "alignLeft": "Trái",
"anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
- "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
- "translating": "Đang dịch…",
- "backgroundOpacity": "Độ mờ",
+ "deleteTranslation": "Xóa bản dịch này",
+ "distanceFromBottom": "Khoảng cách từ dưới",
+ "displayLanguage": "Hiển thị",
+ "background": "Nền",
+ "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
+ "distanceFromLeft": "Khoảng cách từ trái",
+ "alignCenter": "Giữa",
+ "fontSize": "Cỡ chữ",
+ "bold": "Đậm",
+ "translateFailed": "Dịch thất bại.",
"distanceFromRight": "Khoảng cách từ phải",
- "bold": "Đậm"
- },
- "transcript": {
- "revertWord": "Khôi phục \"{{original}}\"",
- "laneRecording": "Bản ghi",
- "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
- "noAudio": "Media này không có bản âm thanh",
- "noTranscript": "Chưa có bản chép lời",
- "insertRecordingOnly": "Chỉ có thể thêm từ trên bản ghi — một khoảng dừng giữ lại một khung hình.",
- "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
- "insertAria": "Từ mới",
- "blankedWord": "đã xoá",
- "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
- "laneLabel": "Đọc bản chép lời từ",
- "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
- "removeInserted": "Xoá \"{{word}}\"",
- "transcribeNow": "Chép lời ngay",
- "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
- "editWord": "Sửa \"{{word}}\"",
- "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
- "clipLabel": "Clip {{index}}",
- "title": "Bản chép lời hiện tại",
- "laneVoiceover": "Lời thuyết minh",
- "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
- "helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
- "editorAria": "Bản chép lời của {{filename}}",
- "silence": "[khoảng lặng {{duration}} giây]",
- "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
- "transcribing": "Đang chép lời…",
- "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
- "restoreWord": "Khôi phục \"{{word}}\"",
- "noClips": "Chưa có clip nào",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác."
+ "showBackground": "Hiện nền",
+ "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
+ "show": "Hiện phụ đề",
+ "language": "Ngôn ngữ",
+ "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời."
},
"zoom": {
- "level": "Mức độ thu phóng",
- "position": {
- "x": "X (%)",
- "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
- "title": "Vị trí tiêu điểm",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "Phải",
- "left": "Trái",
- "iso": "Đẳng phối"
+ "iso": "Đẳng phối",
+ "left": "Trái"
},
- "title": "Xoay 3D",
- "none": "Không"
+ "none": "Không",
+ "title": "Xoay 3D"
},
"focusMode": {
"autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
- "auto": "Tự động",
"lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
+ "title": "Chế độ lấy nét",
"manual": "Thủ công",
- "title": "Chế độ lấy nét"
+ "auto": "Tự động"
+ },
+ "position": {
+ "title": "Vị trí tiêu điểm",
+ "x": "X (%)",
+ "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
+ "y": "Y (%)"
},
+ "deleteZoom": "Xóa thu phóng",
+ "level": "Mức độ thu phóng",
"selectRegion": "Chọn vùng thu phóng để điều chỉnh",
- "customScale": "Thu phóng tùy chỉnh",
"previewHold": "Giữ để xem trước hiệu ứng phóng to",
- "deleteZoom": "Xóa thu phóng"
+ "customScale": "Thu phóng tùy chỉnh"
},
- "project": {
- "load": "Tải dự án",
- "save": "Lưu dự án",
- "new": "Dự án mới"
+ "textAnimation": {
+ "pop": "Bật lên",
+ "rise": "Trồi lên",
+ "selectAnimation": "Chọn hoạt ảnh",
+ "fade": "Mờ dần",
+ "pulse": "Nhấp nháy",
+ "typewriter": "Máy đánh chữ",
+ "title": "Hoạt ảnh văn bản",
+ "none": "Không có",
+ "slideLeft": "Trượt sang trái"
+ },
+ "crop": {
+ "lockAspectRatio": "Khóa tỷ lệ khung hình",
+ "title": "Cắt xén",
+ "done": "Hoàn tất",
+ "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
+ "ratio": "Tỷ lệ",
+ "free": "Tự do",
+ "cropVideo": "Cắt xén video",
+ "unlockAspectRatio": "Mở khóa tỷ lệ khung hình"
+ },
+ "background": {
+ "imageLabel": "Nền {{index}}",
+ "color": "Màu sắc",
+ "gradient": "Dải màu",
+ "colorLabel": "Màu {{color}}",
+ "colorWheel": "Vòng màu",
+ "customWallpaper": "Ảnh nền tùy chỉnh",
+ "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
+ "image": "Hình ảnh",
+ "gradientLabel": "Dải màu {{index}}",
+ "imageReadFailed": "Không thể đọc tệp ảnh này.",
+ "presets": "Có sẵn",
+ "custom": "Tùy chỉnh",
+ "colorPalette": "Bảng màu",
+ "uploadCustom": "Tải lên tùy chỉnh",
+ "title": "Nền",
+ "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG."
+ },
+ "audio": {
+ "title": "Âm thanh",
+ "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
+ "reset": "Đặt lại âm thanh",
+ "outputGain": "Mức đầu ra"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "Độ phân giải xuất",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "Không thể thêm âm thanh",
- "defaultLabel": "Bản âm thanh",
- "add": "Thêm bản âm thanh",
- "fadeIn": "Mờ vào",
- "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
- "fadeOut": "Mờ ra",
- "loop": "Lặp",
- "remove": "Xóa bản nhạc",
- "mute": "Tắt tiếng",
- "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong"
+ "high": "Source",
+ "title": "Độ phân giải xuất"
},
"gifSettings": {
+ "loop": "Lặp lại GIF",
"frameRate": "Tốc độ khung hình GIF",
- "size": "Kích thước GIF",
- "loop": "Lặp lại GIF"
- },
- "crop": {
- "ratio": "Tỷ lệ",
- "free": "Tự do",
- "lockAspectRatio": "Khóa tỷ lệ khung hình",
- "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
- "cropVideo": "Cắt xén video",
- "title": "Cắt xén",
- "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
- "done": "Hoàn tất"
- },
- "cursor": {
- "clickBounce": "Nảy khi nhấp",
- "clipToBounds": "Cắt theo khung",
- "title": "Con trỏ",
- "size": "Kích thước",
- "show": "Hiện con trỏ",
- "motionBlur": "Làm mờ chuyển động",
- "themeDefault": "Mặc định",
- "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
- "smoothing": "Làm mượt",
- "theme": "Kiểu con trỏ",
- "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp."
+ "size": "Kích thước GIF"
},
"export": {
- "videoButton": "Xuất Video",
+ "chooseSaveLocation": "Chọn vị trí lưu",
"gifButton": "Xuất GIF",
- "chooseSaveLocation": "Chọn vị trí lưu"
+ "videoButton": "Xuất Video"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "Tệp video chất lượng cao",
- "gifDescription": "Hình ảnh động để chia sẻ",
- "gifAnimation": "Ảnh động GIF",
- "mp4Video": "Video MP4"
+ "project": {
+ "load": "Tải dự án",
+ "save": "Lưu dự án",
+ "new": "Dự án mới"
},
"speed": {
"previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng.",
- "deleteRegion": "Xóa vùng tốc độ",
"customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
+ "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
+ "deleteRegion": "Xóa vùng tốc độ",
"selectRegion": "Chọn vùng tốc độ để điều chỉnh",
- "playbackSpeed": "Tốc độ phát",
- "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×"
+ "playbackSpeed": "Tốc độ phát"
},
- "textAnimation": {
- "rise": "Trồi lên",
- "pop": "Bật lên",
- "selectAnimation": "Chọn hoạt ảnh",
- "pulse": "Nhấp nháy",
- "slideLeft": "Trượt sang trái",
- "typewriter": "Máy đánh chữ",
- "none": "Không có",
- "fade": "Mờ dần",
- "title": "Hoạt ảnh văn bản"
+ "language": {
+ "title": "Ngôn ngữ"
},
- "imageUpload": {
- "invalidFileType": "Loại tệp không hợp lệ",
- "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
- "failedToUpload": "Tải lên hình ảnh thất bại",
- "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
- "errorReading": "Đã xảy ra lỗi khi đọc tệp."
+ "support": {
+ "starOnGithub": "Đánh giá sao trên GitHub",
+ "reportBug": "Báo cáo lỗi",
+ "saveDiagnostics": "Lưu thông tin chẩn đoán"
},
- "panes": {
- "help": "Trợ giúp"
+ "trim": {
+ "deleteRegion": "Xóa vùng cắt"
},
"facets": {
"transcript": "Bản ghi lời thoại",
"captions": "Phụ đề"
},
- "language": {
- "title": "Ngôn ngữ"
- },
- "trim": {
- "deleteRegion": "Xóa vùng cắt"
+ "panes": {
+ "help": "Trợ giúp"
}
}
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index bd69163cd..48112744e 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF 动画",
+ "mp4Description": "高质量视频文件",
+ "mp4": "MP4",
+ "mp4Video": "MP4 视频",
+ "gif": "GIF",
+ "gifDescription": "可分享的动态图片"
+ },
"customFont": {
- "nameHelp": "这是字体在字体选择器中显示的名称",
- "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
- "urlLabel": "Google Fonts 导入 URL",
- "errorExtractFailed": "无法从 URL 中提取字体系列",
"errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "添加 Google 字体",
+ "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
+ "nameLabel": "显示名称",
"failedToAdd": "添加字体失败",
- "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
+ "urlLabel": "Google Fonts 导入 URL",
+ "namePlaceholder": "我的自定义字体",
+ "errorExtractFailed": "无法从 URL 中提取字体系列",
"addingButton": "添加中...",
- "dialogTitle": "添加 Google 字体",
+ "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
+ "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
"successMessage": "字体 \"{{fontName}}\" 添加成功",
- "addButton": "添加字体",
- "namePlaceholder": "我的自定义字体",
+ "nameHelp": "这是字体在字体选择器中显示的名称",
"errorEmptyName": "请输入字体名称",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
- "errorInvalidUrl": "请输入有效的 Google Fonts URL",
- "nameLabel": "显示名称"
+ "addButton": "添加字体",
+ "errorInvalidUrl": "请输入有效的 Google Fonts URL"
},
"annotation": {
- "defaultText": "你好",
- "size": "大小",
+ "colorWheel": "颜色轮",
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
+ "typeArrow": "箭头",
+ "selectStyle": "选择样式",
+ "blurColorWhite": "白色",
"blurType": "模糊类型",
- "mosaicBlockSize": "马赛克块大小",
- "colorWheel": "颜色轮",
- "typeImage": "图片",
- "textContent": "文本内容",
- "clearBackground": "清除背景",
- "invalidImageType": "无效的文件类型",
- "shortcutsAndTips": "快捷键与提示",
- "colorPalette": "颜色调色板",
+ "blurShapeRectangle": "矩形",
+ "blurColor": "模糊颜色",
"arrowColor": "箭头颜色",
- "strokeWidth": "描边宽度:{{width}}px",
- "blurColorWhite": "白色",
+ "textContent": "文本内容",
"background": "背景",
- "blurColor": "模糊颜色",
- "typeArrow": "箭头",
- "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
- "blurTypeMosaic": "马赛克",
- "textPlaceholder": "输入您的文本...",
+ "clearBackground": "清除背景",
+ "blurShapeFreehand": "自由手绘",
"blurIntensity": "模糊强度",
- "textColor": "文本颜色",
- "blurShapeRectangle": "矩形",
- "deleteAnnotation": "删除标注",
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
- "blurShapeFreehand": "自由手绘",
- "arrowDirection": "箭头方向",
- "selectStyle": "选择样式",
- "fontStyle": "字体样式",
- "imageUploadSuccess": "图片上传成功!",
"active": "活动",
- "tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
- "blurShapeOval": "椭圆",
+ "size": "大小",
"blurColorBlack": "黑色",
- "color": "颜色",
+ "typeImage": "图片",
+ "mosaicBlockSize": "马赛克块大小",
+ "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
+ "strokeWidth": "描边宽度:{{width}}px",
+ "textColor": "文本颜色",
+ "defaultText": "你好",
"blurShape": "模糊形状",
- "customFonts": "自定义字体",
- "typeBlur": "模糊",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
+ "tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
"type": "类型",
+ "typeText": "文本",
+ "textPlaceholder": "输入您的文本...",
+ "fontStyle": "字体样式",
+ "imageUploadSuccess": "图片上传成功!",
+ "colorPalette": "颜色调色板",
+ "color": "颜色",
+ "shortcutsAndTips": "快捷键与提示",
+ "none": "无",
+ "invalidImageType": "无效的文件类型",
+ "arrowDirection": "箭头方向",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
"uploadImage": "上传图片",
+ "customFonts": "自定义字体",
+ "blurTypeMosaic": "马赛克",
"blurTypeBlur": "高斯",
- "none": "无",
+ "typeBlur": "模糊",
"title": "标注设置",
- "typeText": "文本"
+ "deleteAnnotation": "删除标注",
+ "blurShapeOval": "椭圆"
+ },
+ "transcript": {
+ "restoreSilence": "恢复静音({{duration}} 秒)",
+ "editWord": "编辑“{{word}}”",
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "laneFeedsCaptions": "字幕从这条轨道烧录。",
+ "insertAria": "新词",
+ "transcribing": "转录中…",
+ "noAudio": "此媒体没有音频轨道",
+ "noTranscript": "暂无转录",
+ "silence": "[静音 {{duration}} 秒]",
+ "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
+ "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
+ "noClips": "暂无片段",
+ "laneVoiceover": "配音",
+ "laneLabel": "转写文本读取自",
+ "revertWord": "还原为“{{original}}”",
+ "helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
+ "blankedWord": "已清空",
+ "clipLabel": "片段 {{index}}",
+ "title": "当前转录",
+ "correctedWord": "已更正 — 转录原文为“{{original}}”",
+ "transcribeNow": "立即转录",
+ "laneRecording": "录制",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
+ "removeInserted": "删除“{{word}}”",
+ "trimSilence": "修剪静音({{duration}} 秒)",
+ "editorAria": "{{filename}} 的转录",
+ "restoreWord": "恢复“{{word}}”"
},
"effects": {
- "fitClipMany": "{{count}} 个片段",
+ "shadow": "阴影",
+ "fitClipOne": "{{count}} 个片段",
+ "blurBg": "模糊背景",
+ "fitClip": "适配",
+ "formatOriginal": "原始",
+ "title": "画面合成",
"format": "格式",
"motionBlur": "运动模糊",
+ "fitClipMany": "{{count}} 个片段",
+ "roundness": "圆角",
"fitClipFew": "{{count}} 个片段",
- "fitClip": "适配",
+ "motion": "运动",
"on": "开",
- "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
- "formatOriginal": "原始",
- "blurBg": "模糊背景",
"frame": "画框",
"padding": "内边距",
- "shadow": "阴影",
- "off": "关",
- "title": "画面合成",
- "motion": "运动",
- "fitClipOne": "{{count}} 个片段",
- "roundness": "圆角"
+ "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
+ "off": "关"
+ },
+ "audioTrack": {
+ "mute": "静音",
+ "fadeIn": "淡入",
+ "loop": "循环",
+ "slipHint": "按住 Alt 拖动可在其中滑动音频",
+ "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "importFailed": "无法添加音频",
+ "fadeOut": "淡出",
+ "add": "添加音频轨道",
+ "defaultLabel": "音频轨道",
+ "remove": "删除轨道"
},
"layout": {
- "noWebcam": "无摄像头",
- "bgModes": {
- "none": "原画",
- "transparent": "抠图",
- "blur": "模糊",
- "custom": "自定义"
- },
- "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
- "webcamFraming": "摄像头构图",
- "webcamCropX": "水平移动",
- "preset": "预设",
- "webcamSize": "摄像头大小",
"shapes": {
"circle": "圆形",
"square": "正方形",
"rectangle": "矩形",
"rounded": "圆角"
},
- "dualFrame": "双画框",
"verticalStack": "垂直堆叠",
- "webcamCropZoom": "裁剪缩放",
+ "webcamSize": "摄像头大小",
+ "preset": "预设",
+ "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
+ "dualFrame": "双画框",
"title": "摄像头布局",
- "webcamCropY": "垂直移动",
"reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
+ "bgModes": {
+ "transparent": "抠图",
+ "custom": "自定义",
+ "none": "原画",
+ "blur": "模糊"
+ },
+ "webcamCropZoom": "裁剪缩放",
+ "selectPreset": "选择预设",
+ "webcamCropY": "垂直移动",
+ "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
"pictureInPicture": "画中画",
- "mirrorWebcam": "镜像摄像头",
+ "reactiveWebcam": "缩放时缩小",
+ "webcamBackground": "摄像头背景",
"webcamBlurIntensity": "模糊强度",
+ "mirrorWebcam": "镜像摄像头",
"webcamShape": "摄像头形状",
- "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
- "selectPreset": "选择预设",
- "reactiveWebcam": "缩放时缩小",
- "webcamBackground": "摄像头背景"
- },
- "background": {
- "colorPalette": "颜色调色板",
- "gradient": "渐变",
- "imageLabel": "背景 {{index}}",
- "custom": "自定义",
- "image": "图片",
- "title": "背景",
- "colorWheel": "颜色轮",
- "customWallpaper": "自定义壁纸",
- "uploadCustom": "上传自定义",
- "colorLabel": "颜色 {{color}}",
- "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
- "gradientLabel": "渐变 {{index}}",
- "imageReadFailed": "无法读取该图片文件。",
- "presets": "预设",
- "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
- "color": "颜色"
+ "noWebcam": "无摄像头",
+ "webcamCropX": "水平移动",
+ "webcamFraming": "摄像头构图"
},
- "support": {
- "saveDiagnostics": "保存诊断信息",
- "starOnGithub": "在 GitHub 上加星",
- "reportBug": "报告错误"
+ "imageUpload": {
+ "uploadSuccess": "自定义图片上传成功!",
+ "failedToUpload": "上传图片失败",
+ "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。",
+ "errorReading": "读取文件时出错。",
+ "invalidFileType": "无效的文件类型"
},
- "audio": {
- "outputGain": "输出电平",
- "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
- "reset": "重置音频",
- "title": "音频"
+ "cursor": {
+ "themeDefault": "默认",
+ "motionBlur": "运动模糊",
+ "smoothing": "平滑",
+ "title": "光标",
+ "theme": "光标样式",
+ "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
+ "show": "显示光标",
+ "clipToBounds": "裁剪到画布",
+ "size": "大小",
+ "clickBounce": "点击弹跳",
+ "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。"
},
"captions": {
- "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
- "alignCenter": "居中",
- "backgroundColor": "背景颜色",
"original": "原文(转录)",
- "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
- "removeLegacyAnnotations": "移除旧的字幕批注",
- "displayLanguage": "显示",
"alignRight": "右对齐",
+ "backgroundOpacity": "不透明度",
"anchorBottom": "底部",
- "anchorTop": "顶部",
- "text": "文本",
- "translateHint": "使用已配置的 AI 提供方翻译转录",
- "textColor": "文字颜色",
+ "minWords": "每行最少词数",
+ "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
+ "translate": "翻译",
"maxWords": "每行最多词数",
- "show": "显示字幕",
- "translateFailed": "翻译失败。",
+ "lineLength": "行长",
+ "anchorTop": "顶部",
"font": "字体",
- "language": "语言",
- "background": "背景",
- "translate": "翻译",
+ "translating": "翻译中…",
"position": "位置",
- "distanceFromLeft": "距左侧",
- "lineLength": "行长",
- "alignLeft": "左对齐",
- "deleteTranslation": "删除此翻译",
- "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
- "minWords": "每行最少词数",
- "showBackground": "显示背景",
- "fontSize": "字号",
+ "removeLegacyAnnotations": "移除旧的字幕批注",
+ "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
+ "backgroundColor": "背景颜色",
+ "text": "文本",
+ "textColor": "文字颜色",
"hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
- "distanceFromBottom": "距底部",
- "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
+ "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
"distanceFromTop": "距顶部",
+ "alignLeft": "左对齐",
"anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
- "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
- "translating": "翻译中…",
- "backgroundOpacity": "不透明度",
+ "deleteTranslation": "删除此翻译",
+ "distanceFromBottom": "距底部",
+ "displayLanguage": "显示",
+ "background": "背景",
+ "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
+ "distanceFromLeft": "距左侧",
+ "alignCenter": "居中",
+ "fontSize": "字号",
+ "bold": "粗体",
+ "translateFailed": "翻译失败。",
"distanceFromRight": "距右侧",
- "bold": "粗体"
- },
- "transcript": {
- "revertWord": "还原为“{{original}}”",
- "laneRecording": "录制",
- "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
- "noAudio": "此媒体没有音频轨道",
- "noTranscript": "暂无转录",
- "insertRecordingOnly": "只能在录制上添加词语——停顿会定格一帧画面。",
- "insertedWord": "你添加的词 — 背后没有声音",
- "insertAria": "新词",
- "blankedWord": "已清空",
- "restoreSilence": "恢复静音({{duration}} 秒)",
- "laneLabel": "转写文本读取自",
- "correctedWord": "已更正 — 转录原文为“{{original}}”",
- "removeInserted": "删除“{{word}}”",
- "transcribeNow": "立即转录",
- "laneFeedsCaptions": "字幕从这条轨道烧录。",
- "editWord": "编辑“{{word}}”",
- "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
- "clipLabel": "片段 {{index}}",
- "title": "当前转录",
- "laneVoiceover": "配音",
- "trimSilence": "修剪静音({{duration}} 秒)",
- "helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
- "editorAria": "{{filename}} 的转录",
- "silence": "[静音 {{duration}} 秒]",
- "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
- "transcribing": "转录中…",
- "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
- "restoreWord": "恢复“{{word}}”",
- "noClips": "暂无片段",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。"
+ "showBackground": "显示背景",
+ "translateHint": "使用已配置的 AI 提供方翻译转录",
+ "show": "显示字幕",
+ "language": "语言",
+ "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。"
},
"zoom": {
- "level": "缩放级别",
- "position": {
- "x": "X (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "title": "焦点位置",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "右",
- "left": "左",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "左"
},
- "title": "3D 旋转",
- "none": "无"
+ "none": "无",
+ "title": "3D 旋转"
},
"focusMode": {
"autoDescription": "摄像头跟随录制时的光标位置",
- "auto": "自动",
"lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
+ "title": "对焦模式",
"manual": "手动",
- "title": "对焦模式"
+ "auto": "自动"
+ },
+ "position": {
+ "title": "焦点位置",
+ "x": "X (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "y": "Y (%)"
},
+ "deleteZoom": "删除缩放",
+ "level": "缩放级别",
"selectRegion": "选择要调整的缩放区域",
- "customScale": "自定义缩放",
"previewHold": "按住预览放大效果",
- "deleteZoom": "删除缩放"
+ "customScale": "自定义缩放"
},
- "project": {
- "load": "加载项目",
- "save": "保存项目",
- "new": "新建项目"
+ "textAnimation": {
+ "pop": "弹出",
+ "rise": "上升",
+ "selectAnimation": "选择动画",
+ "fade": "淡入淡出",
+ "pulse": "脉动",
+ "typewriter": "打字机",
+ "title": "文本动画",
+ "none": "无",
+ "slideLeft": "向左滑动"
+ },
+ "crop": {
+ "lockAspectRatio": "锁定宽高比",
+ "title": "裁剪",
+ "done": "完成",
+ "dragInstruction": "拖动每一侧来调整裁剪区域",
+ "ratio": "比例",
+ "free": "自由",
+ "cropVideo": "裁剪视频",
+ "unlockAspectRatio": "解锁宽高比"
+ },
+ "background": {
+ "imageLabel": "背景 {{index}}",
+ "color": "颜色",
+ "gradient": "渐变",
+ "colorLabel": "颜色 {{color}}",
+ "colorWheel": "颜色轮",
+ "customWallpaper": "自定义壁纸",
+ "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
+ "image": "图片",
+ "gradientLabel": "渐变 {{index}}",
+ "imageReadFailed": "无法读取该图片文件。",
+ "presets": "预设",
+ "custom": "自定义",
+ "colorPalette": "颜色调色板",
+ "uploadCustom": "上传自定义",
+ "title": "背景",
+ "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。"
+ },
+ "audio": {
+ "title": "音频",
+ "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
+ "reset": "重置音频",
+ "outputGain": "输出电平"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "导出分辨率",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "无法添加音频",
- "defaultLabel": "音频轨道",
- "add": "添加音频轨道",
- "fadeIn": "淡入",
- "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
- "fadeOut": "淡出",
- "loop": "循环",
- "remove": "删除轨道",
- "mute": "静音",
- "slipHint": "按住 Alt 拖动可在其中滑动音频"
+ "high": "Source",
+ "title": "导出分辨率"
},
"gifSettings": {
+ "loop": "循环 GIF",
"frameRate": "GIF 帧率",
- "size": "GIF 尺寸",
- "loop": "循环 GIF"
- },
- "crop": {
- "ratio": "比例",
- "free": "自由",
- "lockAspectRatio": "锁定宽高比",
- "unlockAspectRatio": "解锁宽高比",
- "cropVideo": "裁剪视频",
- "title": "裁剪",
- "dragInstruction": "拖动每一侧来调整裁剪区域",
- "done": "完成"
- },
- "cursor": {
- "clickBounce": "点击弹跳",
- "clipToBounds": "裁剪到画布",
- "title": "光标",
- "size": "大小",
- "show": "显示光标",
- "motionBlur": "运动模糊",
- "themeDefault": "默认",
- "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
- "smoothing": "平滑",
- "theme": "光标样式",
- "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。"
+ "size": "GIF 尺寸"
},
"export": {
- "videoButton": "导出视频",
+ "chooseSaveLocation": "选择保存位置",
"gifButton": "导出 GIF",
- "chooseSaveLocation": "选择保存位置"
+ "videoButton": "导出视频"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "高质量视频文件",
- "gifDescription": "可分享的动态图片",
- "gifAnimation": "GIF 动画",
- "mp4Video": "MP4 视频"
+ "project": {
+ "load": "加载项目",
+ "save": "保存项目",
+ "new": "新建项目"
},
"speed": {
"previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。",
- "deleteRegion": "删除速度区域",
"customPlaybackSpeed": "自定义播放速度",
+ "maxSpeedError": "速度不能超过 {{max}}×",
+ "deleteRegion": "删除速度区域",
"selectRegion": "选择要调整的速度区域",
- "playbackSpeed": "播放速度",
- "maxSpeedError": "速度不能超过 {{max}}×"
+ "playbackSpeed": "播放速度"
},
- "textAnimation": {
- "rise": "上升",
- "pop": "弹出",
- "selectAnimation": "选择动画",
- "pulse": "脉动",
- "slideLeft": "向左滑动",
- "typewriter": "打字机",
- "none": "无",
- "fade": "淡入淡出",
- "title": "文本动画"
+ "language": {
+ "title": "语言"
},
- "imageUpload": {
- "invalidFileType": "无效的文件类型",
- "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。",
- "failedToUpload": "上传图片失败",
- "uploadSuccess": "自定义图片上传成功!",
- "errorReading": "读取文件时出错。"
+ "support": {
+ "starOnGithub": "在 GitHub 上加星",
+ "reportBug": "报告错误",
+ "saveDiagnostics": "保存诊断信息"
},
- "panes": {
- "help": "帮助"
+ "trim": {
+ "deleteRegion": "删除剪辑区域"
},
"facets": {
"transcript": "转录文本",
"captions": "字幕"
},
- "language": {
- "title": "语言"
- },
- "trim": {
- "deleteRegion": "删除剪辑区域"
+ "panes": {
+ "help": "帮助"
}
}
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 3e9d6efdd..93365f38c 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -1,356 +1,355 @@
{
+ "exportFormat": {
+ "gifAnimation": "GIF 動畫",
+ "mp4Description": "高品質影片檔案",
+ "mp4": "MP4",
+ "mp4Video": "MP4 影片",
+ "gif": "GIF",
+ "gifDescription": "可分享的動態圖片"
+ },
"customFont": {
- "nameHelp": "這是字體在字體選擇器中顯示的名稱",
- "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
- "urlLabel": "Google Fonts 匯入 URL",
- "errorExtractFailed": "無法從 URL 中提取字體系列",
"errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "新增 Google 字體",
+ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
+ "nameLabel": "顯示名稱",
"failedToAdd": "新增字體失敗",
- "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "urlLabel": "Google Fonts 匯入 URL",
+ "namePlaceholder": "我的自訂字體",
+ "errorExtractFailed": "無法從 URL 中提取字體系列",
"addingButton": "新增中...",
- "dialogTitle": "新增 Google 字體",
+ "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
"successMessage": "字體 \"{{fontName}}\" 新增成功",
- "addButton": "新增字體",
- "namePlaceholder": "我的自訂字體",
+ "nameHelp": "這是字體在字體選擇器中顯示的名稱",
"errorEmptyName": "請輸入字體名稱",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
- "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
- "nameLabel": "顯示名稱"
+ "addButton": "新增字體",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL"
},
"annotation": {
- "defaultText": "你好",
- "size": "大小",
+ "colorWheel": "色輪",
"imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
+ "typeArrow": "箭頭",
+ "selectStyle": "選擇樣式",
+ "blurColorWhite": "白色",
"blurType": "模糊類型",
- "mosaicBlockSize": "馬賽克區塊大小",
- "colorWheel": "色輪",
- "typeImage": "圖片",
- "textContent": "文字內容",
- "clearBackground": "清除背景",
- "invalidImageType": "無效的檔案類型",
- "shortcutsAndTips": "快捷鍵與提示",
- "colorPalette": "調色盤",
+ "blurShapeRectangle": "矩形",
+ "blurColor": "模糊顏色",
"arrowColor": "箭頭顏色",
- "strokeWidth": "描邊寬度:{{width}}px",
- "blurColorWhite": "白色",
+ "textContent": "文字內容",
"background": "背景",
- "blurColor": "模糊顏色",
- "typeArrow": "箭頭",
- "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
- "blurTypeMosaic": "馬賽克",
- "textPlaceholder": "輸入您的文字...",
+ "clearBackground": "清除背景",
+ "blurShapeFreehand": "自由手繪",
"blurIntensity": "模糊強度",
- "textColor": "文字顏色",
- "blurShapeRectangle": "矩形",
- "deleteAnnotation": "刪除標註",
"tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
- "blurShapeFreehand": "自由手繪",
- "arrowDirection": "箭頭方向",
- "selectStyle": "選擇樣式",
- "fontStyle": "字體樣式",
- "imageUploadSuccess": "圖片上傳成功!",
"active": "啟用",
- "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
- "blurShapeOval": "橢圓",
+ "size": "大小",
"blurColorBlack": "黑色",
- "color": "顏色",
+ "typeImage": "圖片",
+ "mosaicBlockSize": "馬賽克區塊大小",
+ "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
+ "strokeWidth": "描邊寬度:{{width}}px",
+ "textColor": "文字顏色",
+ "defaultText": "你好",
"blurShape": "模糊形狀",
- "customFonts": "自訂字體",
- "typeBlur": "模糊",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
"type": "類型",
+ "typeText": "文字",
+ "textPlaceholder": "輸入您的文字...",
+ "fontStyle": "字體樣式",
+ "imageUploadSuccess": "圖片上傳成功!",
+ "colorPalette": "調色盤",
+ "color": "顏色",
+ "shortcutsAndTips": "快捷鍵與提示",
+ "none": "無",
+ "invalidImageType": "無效的檔案類型",
+ "arrowDirection": "箭頭方向",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
"uploadImage": "上傳圖片",
+ "customFonts": "自訂字體",
+ "blurTypeMosaic": "馬賽克",
"blurTypeBlur": "高斯",
- "none": "無",
+ "typeBlur": "模糊",
"title": "標註設定",
- "typeText": "文字"
+ "deleteAnnotation": "刪除標註",
+ "blurShapeOval": "橢圓"
+ },
+ "transcript": {
+ "restoreSilence": "還原靜音({{duration}} 秒)",
+ "editWord": "編輯「{{word}}」",
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "laneFeedsCaptions": "字幕從這條軌道燒錄。",
+ "insertAria": "新字詞",
+ "transcribing": "轉錄中…",
+ "noAudio": "此媒體沒有音訊軌道",
+ "noTranscript": "尚無逐字稿",
+ "silence": "[靜音 {{duration}} 秒]",
+ "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
+ "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
+ "noClips": "尚無片段",
+ "laneVoiceover": "旁白",
+ "laneLabel": "轉錄文字讀取自",
+ "revertWord": "還原為「{{original}}」",
+ "helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
+ "blankedWord": "已清空",
+ "clipLabel": "片段 {{index}}",
+ "title": "目前的逐字稿",
+ "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
+ "transcribeNow": "立即產生逐字稿",
+ "laneRecording": "錄影",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
+ "removeInserted": "刪除「{{word}}」",
+ "trimSilence": "修剪靜音({{duration}} 秒)",
+ "editorAria": "{{filename}} 的逐字稿",
+ "restoreWord": "還原「{{word}}」"
},
"effects": {
- "fitClipMany": "{{count}} 個片段",
+ "shadow": "陰影",
+ "fitClipOne": "{{count}} 個片段",
+ "blurBg": "模糊背景",
+ "fitClip": "符合",
+ "formatOriginal": "原始",
+ "title": "畫面合成",
"format": "格式",
"motionBlur": "動態模糊",
+ "fitClipMany": "{{count}} 個片段",
+ "roundness": "圓角",
"fitClipFew": "{{count}} 個片段",
- "fitClip": "符合",
+ "motion": "動態",
"on": "開",
- "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
- "formatOriginal": "原始",
- "blurBg": "模糊背景",
"frame": "外框",
"padding": "內邊距",
- "shadow": "陰影",
- "off": "關",
- "title": "畫面合成",
- "motion": "動態",
- "fitClipOne": "{{count}} 個片段",
- "roundness": "圓角"
+ "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
+ "off": "關"
+ },
+ "audioTrack": {
+ "mute": "靜音",
+ "fadeIn": "淡入",
+ "loop": "循環",
+ "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
+ "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "importFailed": "無法新增音訊",
+ "fadeOut": "淡出",
+ "add": "新增音訊軌道",
+ "defaultLabel": "音訊軌道",
+ "remove": "刪除軌道"
},
"layout": {
- "noWebcam": "無網路攝影機",
- "bgModes": {
- "none": "原畫",
- "transparent": "去背",
- "blur": "模糊",
- "custom": "自訂"
- },
- "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
- "webcamFraming": "攝影機構圖",
- "webcamCropX": "水平移動",
- "preset": "預設",
- "webcamSize": "攝影機大小",
"shapes": {
"circle": "圓形",
"square": "正方形",
"rectangle": "矩形",
"rounded": "圓角"
},
- "dualFrame": "雙畫框",
"verticalStack": "垂直堆疊",
- "webcamCropZoom": "裁切縮放",
+ "webcamSize": "攝影機大小",
+ "preset": "預設",
+ "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
+ "dualFrame": "雙畫框",
"title": "攝影機版面",
- "webcamCropY": "垂直移動",
"reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
+ "bgModes": {
+ "transparent": "去背",
+ "custom": "自訂",
+ "none": "原畫",
+ "blur": "模糊"
+ },
+ "webcamCropZoom": "裁切縮放",
+ "selectPreset": "選擇預設",
+ "webcamCropY": "垂直移動",
+ "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
"pictureInPicture": "子母畫面",
- "mirrorWebcam": "鏡像攝影機",
+ "reactiveWebcam": "縮放時縮小",
+ "webcamBackground": "攝影機背景",
"webcamBlurIntensity": "模糊強度",
+ "mirrorWebcam": "鏡像攝影機",
"webcamShape": "攝影機形狀",
- "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
- "selectPreset": "選擇預設",
- "reactiveWebcam": "縮放時縮小",
- "webcamBackground": "攝影機背景"
- },
- "background": {
- "colorPalette": "調色盤",
- "gradient": "漸層",
- "imageLabel": "背景 {{index}}",
- "custom": "自訂",
- "image": "圖片",
- "title": "背景",
- "colorWheel": "色輪",
- "customWallpaper": "自訂桌布",
- "uploadCustom": "上傳自訂",
- "colorLabel": "顏色 {{color}}",
- "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
- "gradientLabel": "漸層 {{index}}",
- "imageReadFailed": "無法讀取該圖片檔案。",
- "presets": "預設",
- "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
- "color": "顏色"
+ "noWebcam": "無網路攝影機",
+ "webcamCropX": "水平移動",
+ "webcamFraming": "攝影機構圖"
},
- "support": {
- "saveDiagnostics": "儲存診斷資料",
- "starOnGithub": "在 GitHub 上加星",
- "reportBug": "回報錯誤"
+ "imageUpload": {
+ "uploadSuccess": "自訂圖片上傳成功!",
+ "failedToUpload": "上傳圖片失敗",
+ "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。",
+ "errorReading": "讀取檔案時出錯。",
+ "invalidFileType": "無效的檔案類型"
},
- "audio": {
- "outputGain": "輸出音量",
- "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
- "reset": "重設音訊",
- "title": "音訊"
+ "cursor": {
+ "themeDefault": "預設",
+ "motionBlur": "動態模糊",
+ "smoothing": "平滑",
+ "title": "游標",
+ "theme": "游標樣式",
+ "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
+ "show": "顯示游標",
+ "clipToBounds": "裁切至畫布",
+ "size": "大小",
+ "clickBounce": "點擊彈跳",
+ "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。"
},
"captions": {
- "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
- "alignCenter": "置中",
- "backgroundColor": "背景顏色",
"original": "原文(逐字稿)",
- "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
- "removeLegacyAnnotations": "移除舊的字幕註解",
- "displayLanguage": "顯示",
"alignRight": "靠右",
+ "backgroundOpacity": "不透明度",
"anchorBottom": "下",
- "anchorTop": "上",
- "text": "文字",
- "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
- "textColor": "文字顏色",
+ "minWords": "每行最少字數",
+ "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
+ "translate": "翻譯",
"maxWords": "每行最多字數",
- "show": "顯示字幕",
- "translateFailed": "翻譯失敗。",
+ "lineLength": "行長",
+ "anchorTop": "上",
"font": "字型",
- "language": "語言",
- "background": "背景",
- "translate": "翻譯",
+ "translating": "翻譯中…",
"position": "位置",
- "distanceFromLeft": "距左緣",
- "lineLength": "行長",
- "alignLeft": "靠左",
- "deleteTranslation": "刪除這個翻譯",
- "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
- "minWords": "每行最少字數",
- "showBackground": "顯示背景",
- "fontSize": "大小",
+ "removeLegacyAnnotations": "移除舊的字幕註解",
+ "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
+ "backgroundColor": "背景顏色",
+ "text": "文字",
+ "textColor": "文字顏色",
"hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
- "distanceFromBottom": "距下緣",
- "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
+ "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
"distanceFromTop": "距上緣",
+ "alignLeft": "靠左",
"anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
- "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
- "translating": "翻譯中…",
- "backgroundOpacity": "不透明度",
+ "deleteTranslation": "刪除這個翻譯",
+ "distanceFromBottom": "距下緣",
+ "displayLanguage": "顯示",
+ "background": "背景",
+ "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
+ "distanceFromLeft": "距左緣",
+ "alignCenter": "置中",
+ "fontSize": "大小",
+ "bold": "粗體",
+ "translateFailed": "翻譯失敗。",
"distanceFromRight": "距右緣",
- "bold": "粗體"
- },
- "transcript": {
- "revertWord": "還原為「{{original}}」",
- "laneRecording": "錄影",
- "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
- "noAudio": "此媒體沒有音訊軌道",
- "noTranscript": "尚無逐字稿",
- "insertRecordingOnly": "只能在錄影上新增字詞——停頓會定格一格畫面。",
- "insertedWord": "你加入的字詞 — 背後沒有聲音",
- "insertAria": "新字詞",
- "blankedWord": "已清空",
- "restoreSilence": "還原靜音({{duration}} 秒)",
- "laneLabel": "轉錄文字讀取自",
- "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
- "removeInserted": "刪除「{{word}}」",
- "transcribeNow": "立即產生逐字稿",
- "laneFeedsCaptions": "字幕從這條軌道燒錄。",
- "editWord": "編輯「{{word}}」",
- "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
- "clipLabel": "片段 {{index}}",
- "title": "目前的逐字稿",
- "laneVoiceover": "旁白",
- "trimSilence": "修剪靜音({{duration}} 秒)",
- "helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
- "editorAria": "{{filename}} 的逐字稿",
- "silence": "[靜音 {{duration}} 秒]",
- "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
- "transcribing": "轉錄中…",
- "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
- "restoreWord": "還原「{{word}}」",
- "noClips": "尚無片段",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。"
+ "showBackground": "顯示背景",
+ "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
+ "show": "顯示字幕",
+ "language": "語言",
+ "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。"
},
"zoom": {
- "level": "縮放級別",
- "position": {
- "x": "X (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "title": "焦點位置",
- "y": "Y (%)"
- },
"threeD": {
"preset": {
"right": "右",
- "left": "左",
- "iso": "Iso"
+ "iso": "Iso",
+ "left": "左"
},
- "title": "3D 旋轉",
- "none": "無"
+ "none": "無",
+ "title": "3D 旋轉"
},
"focusMode": {
"autoDescription": "攝影機跟隨錄製時的游標位置",
- "auto": "自動",
"lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
+ "title": "對焦模式",
"manual": "手動",
- "title": "對焦模式"
+ "auto": "自動"
+ },
+ "position": {
+ "title": "焦點位置",
+ "x": "X (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "y": "Y (%)"
},
+ "deleteZoom": "刪除縮放",
+ "level": "縮放級別",
"selectRegion": "選擇要調整的縮放區域",
- "customScale": "自訂縮放",
"previewHold": "按住預覽放大效果",
- "deleteZoom": "刪除縮放"
+ "customScale": "自訂縮放"
},
- "project": {
- "load": "載入專案",
- "save": "儲存專案",
- "new": "新增專案"
+ "textAnimation": {
+ "pop": "彈出",
+ "rise": "上升",
+ "selectAnimation": "選擇動畫",
+ "fade": "淡入淡出",
+ "pulse": "脈動",
+ "typewriter": "打字機",
+ "title": "文字動畫",
+ "none": "無",
+ "slideLeft": "向左滑動"
+ },
+ "crop": {
+ "lockAspectRatio": "鎖定長寬比",
+ "title": "裁剪",
+ "done": "完成",
+ "dragInstruction": "拖動每一側來調整裁剪區域",
+ "ratio": "比例",
+ "free": "自由",
+ "cropVideo": "裁剪影片",
+ "unlockAspectRatio": "解鎖長寬比"
+ },
+ "background": {
+ "imageLabel": "背景 {{index}}",
+ "color": "顏色",
+ "gradient": "漸層",
+ "colorLabel": "顏色 {{color}}",
+ "colorWheel": "色輪",
+ "customWallpaper": "自訂桌布",
+ "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
+ "image": "圖片",
+ "gradientLabel": "漸層 {{index}}",
+ "imageReadFailed": "無法讀取該圖片檔案。",
+ "presets": "預設",
+ "custom": "自訂",
+ "colorPalette": "調色盤",
+ "uploadCustom": "上傳自訂",
+ "title": "背景",
+ "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。"
+ },
+ "audio": {
+ "title": "音訊",
+ "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
+ "reset": "重設音訊",
+ "outputGain": "輸出音量"
},
"exportQuality": {
"low": "720p",
"medium": "1080p",
- "title": "匯出解析度",
- "high": "Source"
- },
- "audioTrack": {
- "importFailed": "無法新增音訊",
- "defaultLabel": "音訊軌道",
- "add": "新增音訊軌道",
- "fadeIn": "淡入",
- "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
- "fadeOut": "淡出",
- "loop": "循環",
- "remove": "刪除軌道",
- "mute": "靜音",
- "slipHint": "按住 Alt 拖曳可在其中滑動音訊"
+ "high": "Source",
+ "title": "匯出解析度"
},
"gifSettings": {
+ "loop": "循環 GIF",
"frameRate": "GIF 影格率",
- "size": "GIF 尺寸",
- "loop": "循環 GIF"
- },
- "crop": {
- "ratio": "比例",
- "free": "自由",
- "lockAspectRatio": "鎖定長寬比",
- "unlockAspectRatio": "解鎖長寬比",
- "cropVideo": "裁剪影片",
- "title": "裁剪",
- "dragInstruction": "拖動每一側來調整裁剪區域",
- "done": "完成"
- },
- "cursor": {
- "clickBounce": "點擊彈跳",
- "clipToBounds": "裁切至畫布",
- "title": "游標",
- "size": "大小",
- "show": "顯示游標",
- "motionBlur": "動態模糊",
- "themeDefault": "預設",
- "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
- "smoothing": "平滑",
- "theme": "游標樣式",
- "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。"
+ "size": "GIF 尺寸"
},
"export": {
- "videoButton": "匯出影片",
+ "chooseSaveLocation": "選擇儲存位置",
"gifButton": "匯出 GIF",
- "chooseSaveLocation": "選擇儲存位置"
+ "videoButton": "匯出影片"
},
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Description": "高品質影片檔案",
- "gifDescription": "可分享的動態圖片",
- "gifAnimation": "GIF 動畫",
- "mp4Video": "MP4 影片"
+ "project": {
+ "load": "載入專案",
+ "save": "儲存專案",
+ "new": "新增專案"
},
"speed": {
"previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。",
- "deleteRegion": "刪除速度區域",
"customPlaybackSpeed": "自訂播放速度",
+ "maxSpeedError": "速度不能超過 {{max}}×",
+ "deleteRegion": "刪除速度區域",
"selectRegion": "選擇要調整的速度區域",
- "playbackSpeed": "播放速度",
- "maxSpeedError": "速度不能超過 {{max}}×"
+ "playbackSpeed": "播放速度"
},
- "textAnimation": {
- "rise": "上升",
- "pop": "彈出",
- "selectAnimation": "選擇動畫",
- "pulse": "脈動",
- "slideLeft": "向左滑動",
- "typewriter": "打字機",
- "none": "無",
- "fade": "淡入淡出",
- "title": "文字動畫"
+ "language": {
+ "title": "語言"
},
- "imageUpload": {
- "invalidFileType": "無效的檔案類型",
- "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。",
- "failedToUpload": "上傳圖片失敗",
- "uploadSuccess": "自訂圖片上傳成功!",
- "errorReading": "讀取檔案時出錯。"
+ "support": {
+ "starOnGithub": "在 GitHub 上加星",
+ "reportBug": "回報錯誤",
+ "saveDiagnostics": "儲存診斷資料"
},
- "panes": {
- "help": "說明"
+ "trim": {
+ "deleteRegion": "刪除剪輯區域"
},
"facets": {
"transcript": "逐字稿",
"captions": "字幕"
},
- "language": {
- "title": "語言"
- },
- "trim": {
- "deleteRegion": "刪除剪輯區域"
+ "panes": {
+ "help": "說明"
}
}
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 4dd217209..8cf37988d 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -6,10 +6,11 @@
// everywhere except inside a pause — which is not a gap in the model, it is the pause.
import { describe, expect, it } from "vitest";
-import type { AxcutClip, AxcutInsertRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
collapseRawSec,
expandRawSec,
+ insertedWordMarks,
type RulerInsert,
rulerInserts,
totalInsertedSec,
@@ -155,3 +156,77 @@ describe("the expanded ruler", () => {
expect(collapseRawSec(4, [])).toEqual({ sec: 4, heldBy: null });
});
});
+
+// ─── Where an added word's mark goes ─────────────────────────────────────────
+// Issue #560. Two defects lived in one ternary in V4Timeline: a word WITH a pause was
+// placed on the expanded ruler and one WITHOUT at a fraction of the clip's SOURCE span —
+// two clocks, and the clip box is drawn in neither of them consistently. And both edges
+// were inclusive, so a word whose pause sits on a split boundary painted twice.
+
+function markClip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutClip;
+}
+
+const synth = (id: string, startSec: number): AxcutWord =>
+ ({ id, segmentId: "s", text: id, startSec, endSec: startSec, source: "synth" }) as AxcutWord;
+
+describe("insertedWordMarks", () => {
+ const split = [
+ markClip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ }),
+ markClip({
+ id: "c2",
+ sourceStartSec: 5,
+ sourceEndSec: 10,
+ timelineStartSec: 5,
+ timelineEndSec: 10,
+ }),
+ ];
+
+ it("paints a word on a split boundary exactly once", () => {
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_edge", 5)] }], split);
+ expect(marks).toHaveLength(1);
+ expect(marks[0]).toMatchObject({ clipId: "c2", atRawSec: 5 });
+ });
+
+ it("places every mark in RAW seconds through its own clip", () => {
+ const marks = insertedWordMarks(
+ [{ assetId: "a1", words: [synth("early", 2), synth("late", 7)] }],
+ split,
+ );
+ expect(marks.map((m) => [m.clipId, m.atRawSec])).toEqual([
+ ["c1", 2],
+ ["c2", 7],
+ ]);
+ });
+
+ it("keeps a word at the very end of the last clip", () => {
+ // Half-open everywhere but the tail, or the final word of a project vanishes.
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_end", 10)] }], split);
+ expect(marks.map((m) => m.wordId)).toEqual(["w_end"]);
+ });
+
+ it("ignores words nobody added", () => {
+ const spoken = { id: "w1", segmentId: "s", text: "w1", startSec: 2, endSec: 3 } as AxcutWord;
+ expect(insertedWordMarks([{ assetId: "a1", words: [spoken] }], split)).toEqual([]);
+ });
+
+ it("ignores a transcript no clip draws on", () => {
+ expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split)).toEqual([]);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index a7afcf27b..f514a6ba9 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -20,7 +20,7 @@
// of ruler maps to the single source moment being held. `collapseRawSec` returns that
// moment, which is exactly what a decoder parked on a held frame should be told.
-import type { AxcutClip, AxcutInsertRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
/** A pause placed on the raw ruler, ready to be counted. */
export interface RulerInsert {
@@ -106,3 +106,56 @@ export function collapseRawSec(
}
return { sec: sec - offset, heldBy: null };
}
+
+/** An added word, placed on the raw ruler through the clip that carries it. */
+export interface InsertedWordMark {
+ clipId: string;
+ wordId: string;
+ text: string;
+ atRawSec: number;
+}
+
+/**
+ * Where each added word's mark belongs, one per word.
+ *
+ * Claimed once, and half-open at a clip's far edge except for the last: a pause sits at the
+ * END of the word it follows, which is routinely a split boundary, and testing both edges
+ * inclusively painted the same word in BOTH halves (issue #560).
+ *
+ * Returns RAW seconds. The caller expands them; it used to mix a raw-then-expanded position
+ * for a word with a pause and a fraction of the clip's SOURCE span for one without, in the
+ * same ternary — two clocks, and the clip box is not drawn in the second.
+ */
+export function insertedWordMarks(
+ transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>,
+ clips: readonly AxcutClip[],
+): InsertedWordMark[] {
+ const byAsset = new Map>();
+ for (const transcript of transcripts) {
+ const added = transcript.words.filter((word) => word.source === "synth");
+ if (added.length > 0) byAsset.set(transcript.assetId, added);
+ }
+ if (byAsset.size === 0) return [];
+
+ const marks: InsertedWordMark[] = [];
+ const claimed = new Set();
+ clips.forEach((clip, index) => {
+ const words = byAsset.get(clip.assetId);
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (!words || sourceEnd <= clip.sourceStartSec) return;
+ const isLast = index === clips.length - 1;
+ for (const word of words) {
+ if (claimed.has(word.id)) continue;
+ if (word.startSec < clip.sourceStartSec) continue;
+ if (word.startSec > sourceEnd || (!isLast && word.startSec === sourceEnd)) continue;
+ claimed.add(word.id);
+ marks.push({
+ clipId: clip.id,
+ wordId: word.id,
+ text: word.text,
+ atRawSec: clip.timelineStartSec + (word.startSec - clip.sourceStartSec),
+ });
+ }
+ });
+ return marks;
+}
From f74ac544db512c653b2912e55669d5cd431305ac Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 19:25:21 +0200
Subject: [PATCH 077/113] feat(editor): cut the notch into the take, inside one
outline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A take that holds somewhere is drawn in pieces: one waveform per stretch it
plays, and a hatched amber column where the voice stops. Inside ONE outline, on
purpose — the take is still one take, one draggable and slippable object, and the
eye should read "the voice stops here", not "two takes".
Opposite polarity to the clip lane's band. That one means the picture freezes
here; this one means the voice stops here and the film runs on underneath, which
is the whole difference between the two insertion lanes.
Positioned from the same walk the preview and the export read, so a notch cannot
appear where the voice does not actually stop. A take with no insertion — and
every music bed, and every looping take — keeps the single unbroken waveform it
has always had, so the common pill is untouched.
The notch takes no hit target of its own: clicking still selects the take, and
the word is deleted from the transcript, which is where it was created.
Refs #560.
---
.../ai-edition/v4/EditorShellV4.module.css | 26 ++++++
src/components/ai-edition/v4/V4Timeline.tsx | 91 +++++++++++++++++--
.../timeline/take-programme.test.ts | 36 ++++++++
3 files changed, 144 insertions(+), 9 deletions(-)
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 7ed68d78c..15af76b3e 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1562,6 +1562,32 @@
/* Alt is held: the next drag on this pill slides the file under it rather than
moving the pill. The cursor is the confirmation, not the lesson — the tooltip
carries the words. */
+/* A take that holds somewhere is drawn in pieces inside ONE outline: the notch is cut out
+ of the fill, not laid over it, so the pill still reads as one take — one draggable,
+ slippable object. Opposite polarity to the clip lane's band, which means "the picture
+ freezes here"; this one means "the voice stops here, and the film runs on underneath"
+ (issue #560). */
+.laneAudioPiece {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ overflow: hidden;
+ pointer-events: none;
+}
+.laneAudioNotch {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ min-width: 2px;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ -45deg,
+ color-mix(in srgb, var(--warn) 42%, transparent) 0 3px,
+ transparent 3px 6px
+ );
+ border-left: 1px solid color-mix(in srgb, var(--warn) 70%, transparent);
+ border-right: 1px solid color-mix(in srgb, var(--warn) 70%, transparent);
+}
.laneAudioSlip {
cursor: ew-resize;
}
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 7e9c9e796..9f6ce3930 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -62,7 +62,9 @@ import {
newRegionDurationSec,
setTimelineScale,
} from "@/lib/ai-edition/timeline/newRegionDuration";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation";
+import { type TakePiece, takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap";
import {
coalescedTrimGroups,
@@ -397,6 +399,7 @@ const AudioLanePill = memo(function AudioLanePill({
slipArmed,
outputGain,
ghost,
+ pieces,
}: {
track: AxcutAudioTrack;
url: string | undefined;
@@ -437,8 +440,18 @@ const AudioLanePill = memo(function AudioLanePill({
sourceStartSec: number;
sourceEndSec: number;
} | null;
+ /** The take's own walk, when it has one. Absent for music, for a looping take, and for
+ * a take with no insertion — all of which draw one unbroken waveform, exactly as
+ * before. */
+ pieces?: readonly TakePiece[] | null;
}) {
const duration = assetDurationSec ?? track.durationSec;
+ // Only a take that actually holds somewhere is drawn in pieces. Everything else keeps
+ // the single waveform it has always had, so the common pill is untouched.
+ const notched = pieces?.some((piece) => piece.kind === "hold") ? pieces : null;
+ const pillRawStart = track.startMs / 1000;
+ const pillRawSpan = Math.max(1e-6, track.endMs / 1000 - pillRawStart);
+ const atPctOfPill = (rawSec: number) => ((rawSec - pillRawStart) / pillRawSpan) * 100;
return (
<>
{/* The rest of the tape, dimmed and unclickable, behind the pill — so the pill
@@ -493,15 +506,50 @@ const AudioLanePill = memo(function AudioLanePill({
style={{ left: 0 }}
onPointerDown={(e) => onStartDrag(e, track, "l")}
/>
-
+ {notched ? (
+ // A notch cut out of the fill, inside ONE outline. The take is still one
+ // take — one draggable, slippable object — and the eye should read "the
+ // voice stops here", not "two takes". The opposite polarity of the clip
+ // lane's band, which means "the picture freezes here" (issue #560).
+ notched.map((piece) => {
+ const left = atPctOfPill(piece.rawStartSec);
+ const width = atPctOfPill(piece.rawEndSec) - left;
+ return piece.kind === "hold" ? (
+
+ ) : (
+
+
+
+ );
+ })
+ ) : (
+
+ )}
{/* Where the file starts over, so a looping bed reads as one deliberate
repeat rather than a mystery. Only drawn when the pill actually
outruns its source — otherwise there is nothing to repeat. */}
@@ -1046,6 +1094,30 @@ export function V4Timeline({
// it keeps a document written before that rule legible instead of stacking its pills on
// top of each other. A kind with no tracks takes no row, so the common single-bed
// project stays exactly as tall as it was.
+ // One walk per take, for the lane to draw. Same inputs the preview and the export use,
+ // so a notch cannot appear where the voice does not actually stop.
+ const takePieces = useMemo(() => {
+ const clipAssetIds = new Set(clips.map((c) => c.assetId));
+ const removed = removedRawSpans(clips, tl.trimRanges);
+ const out = new Map();
+ for (const pill of audioPills) {
+ if (pill.kind !== "voiceover" || pill.loop) continue;
+ // A range naming an asset that is no clip's is a take's — the same test
+ // `resolveInsertPlacement` makes, available here without a document.
+ const inserts = (tl.insertRanges ?? [])
+ .filter((range) => range.assetId === pill.assetId && !clipAssetIds.has(range.assetId))
+ .map((range) => ({
+ id: range.id,
+ wordId: range.wordId,
+ atSourceSec: range.atSec,
+ durationSec: range.durationSec,
+ }));
+ if (inserts.length === 0) continue;
+ out.set(pill.id, takeProgramme(pill, removed, inserts));
+ }
+ return out;
+ }, [audioPills, clips, tl.trimRanges, tl.insertRanges]);
+
const audioRows = useMemo(() => {
const voice = audioPills.filter((p) => p.kind === "voiceover");
const music = audioPills.filter((p) => p.kind !== "voiceover");
@@ -2146,6 +2218,7 @@ export function V4Timeline({
slipHint={ts("audioTrack.slipHint")}
slipArmed={slipArmed}
outputGain={audioGainScalar(settings.audioGainDb)}
+ pieces={takePieces.get(track.id) ?? null}
ghost={((g) =>
g
? {
diff --git a/src/lib/ai-edition/timeline/take-programme.test.ts b/src/lib/ai-edition/timeline/take-programme.test.ts
index 4e157d18b..37c7d595a 100644
--- a/src/lib/ai-edition/timeline/take-programme.test.ts
+++ b/src/lib/ai-edition/timeline/take-programme.test.ts
@@ -236,3 +236,39 @@ describe("preview and export agree over a take with a cut and a pause", () => {
expect(resumed).toBeCloseTo(parked ?? -1, 1);
});
});
+
+// ─── What the lane has to draw ──────────────────────────────────────────────
+// The notch is positioned from the walk, as a fraction of the PILL's own raw span. These
+// pin the arithmetic the drawing does, so a notch cannot appear where the voice does not
+// actually stop.
+
+describe("the pieces a pill draws", () => {
+ const pctOfPill = (pieces: ReturnType, rawSec: number) =>
+ ((rawSec - TAKE.startMs / 1000) / (TAKE.endMs / 1000 - TAKE.startMs / 1000)) * 100;
+
+ it("cuts one notch, in the middle, at the width of the time it took", () => {
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1)]);
+ const hold = pieces.find((p) => p.kind === "hold");
+ expect(hold).toBeDefined();
+ if (!hold) return;
+ expect(pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(40, 6);
+ expect(pctOfPill(pieces, hold.rawEndSec) - pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(
+ 10,
+ 6,
+ );
+ });
+
+ it("leaves a take with no insertion in one piece, so the pill draws as it always did", () => {
+ expect(takeProgramme(TAKE, [], []).some((p) => p.kind === "hold")).toBe(false);
+ });
+
+ it("covers the pill end to end, with no overlap and no hole", () => {
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)]), [ins(4, 1)]);
+ let cursor = TAKE.startMs / 1000;
+ for (const piece of pieces) {
+ expect(piece.rawStartSec).toBeCloseTo(cursor, 6);
+ cursor = piece.rawEndSec;
+ }
+ expect(cursor).toBeCloseTo(TAKE.endMs / 1000, 6);
+ });
+});
From 47b3fd59a86b1d3020223d495f6591b9c2fb03bd Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 19:40:49 +0200
Subject: [PATCH 078/113] fix(timeline): a scrub names a moment on the ruler,
not on the tape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pointer was measured against the EXPANDED ruler and the result written straight
into a store every consumer reads as a RAW second: the preview seek, the caption
lookup, the transcript cue, the audio mix. Past the first pause the playhead sat one
accumulated hold AHEAD of everything it pointed at — the right playhead over the wrong
subtitle.
`collapseRawSec` on the way in fixes the desync, but on its own it makes the scrub
unusable over a pause: a pause is zero raw seconds wide, so every ruler second inside
one collapses to the same raw moment and the playhead snapped back to the pause's left
edge the instant the pointer entered it. The drag therefore carries its ruler position
alongside, and the playhead prefers it — the only coordinate that can name a moment
INSIDE a pause.
The round trip is pinned in `inserted-time.test.ts`: expand→collapse returns the raw
second it started from on both sides of every pause, a pointer inside one lands on the
held moment and says which pause holds it, and a pointer past two counts both.
---
src/components/ai-edition/v4/V4Timeline.tsx | 37 +++++++++++++++++--
.../ai-edition/timeline/inserted-time.test.ts | 34 +++++++++++++++++
2 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 9f6ce3930..1b1d094df 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -51,6 +51,7 @@ import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
import {
+ collapseRawSec,
expandRawSec,
type InsertedWordMark,
insertedWordMarks,
@@ -233,8 +234,11 @@ interface PlayheadOverlayProps {
* drawn on counts the pauses, so it has to be placed through them or it drifts from
* the clips by the whole added time. */
inserts: readonly RulerInsert[];
- /** Live scrub position, when a drag is in flight. Takes precedence over the store. */
+ /** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
+ /** The same drag's RULER position. Preferred when present: it is the only coordinate
+ * that can name a moment INSIDE a pause, which is zero raw seconds wide. */
+ overrideRulerSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
playheadRef?: React.MutableRefObject;
@@ -260,12 +264,17 @@ const PlayheadOverlay = memo(function PlayheadOverlay({
totalSec,
inserts,
overrideTimeSec,
+ overrideRulerSec,
canvasStyle,
onPointerDown,
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const pct = (expandRawSec(overrideTimeSec ?? storeTimeSec, inserts) / totalSec) * 100;
+ // The scrub hands its RULER position straight through. Expanding the raw one instead
+ // would snap the playhead to a pause's left edge the moment the pointer entered it,
+ // because every ruler second inside a pause collapses to the same raw moment.
+ const pct =
+ ((overrideRulerSec ?? expandRawSec(overrideTimeSec ?? storeTimeSec, inserts)) / totalSec) * 100;
return (
@@ -830,6 +839,11 @@ export function V4Timeline({
// pointer for the frame the store hasn't caught up on yet. Handed down as an
// override to the two components that read the playhead from the store.
const [scrubbingTimeSec, setScrubbingTimeSec] = useState(null);
+ // The pointer's RULER position while scrubbing, kept apart from the raw one above.
+ // Two numbers because they mean different things: the timecode reads the raw clock,
+ // like the store, and the playhead has to be able to sit INSIDE a pause — which is
+ // zero raw seconds wide, so no raw value can address a moment within it.
+ const [scrubRulerSec, setScrubRulerSec] = useState(null);
const rafSeekRef = useRef(0);
const pendingSeekTimeRef = useRef(null);
@@ -846,7 +860,19 @@ export function V4Timeline({
if (!el) return;
const r = el.getBoundingClientRect();
const pct = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
- const targetTime = pct * total;
+ // `total` is the EXPANDED ruler, so `pct * total` is a ruler second — and
+ // `setCurrentTime` is read as a RAW one by every consumer: the preview seek, the
+ // caption lookup, the transcript cue, the audio mix. Writing the ruler value
+ // straight in put the playhead one accumulated pause AHEAD of everything it was
+ // supposed to be pointing at, which is what showed as the wrong subtitle under a
+ // correctly-placed playhead (issue #560).
+ //
+ // Collapsing lands on the held moment when the pointer is inside a pause, which
+ // is the honest answer: a pause is zero raw seconds, so there is no raw value
+ // inside it to seek to. The ruler position is kept separately below so the
+ // playhead still follows the pointer across it.
+ const rulerTime = pct * total;
+ const { sec: targetTime } = collapseRawSec(rulerTime, inserts);
// Direct DOM playhead update (0ms latency, zero React re-render overhead)
if (playheadElRef.current) {
@@ -855,6 +881,7 @@ export function V4Timeline({
// Optimistic local UI state update
setScrubbingTimeSec(targetTime);
+ setScrubRulerSec(rulerTime);
pendingSeekTimeRef.current = targetTime;
if (isImmediate) {
@@ -876,7 +903,7 @@ export function V4Timeline({
});
}
},
- [setCurrentTime, total],
+ [setCurrentTime, total, inserts],
);
// Mousedown anywhere on the empty timeline (ruler, lanes background, or
@@ -916,6 +943,7 @@ export function V4Timeline({
pendingSeekTimeRef.current = null;
}
setScrubbingTimeSec(null);
+ setScrubRulerSec(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -2418,6 +2446,7 @@ export function V4Timeline({
totalSec={total}
inserts={inserts}
overrideTimeSec={scrubbingTimeSec}
+ overrideRulerSec={scrubRulerSec}
canvasStyle={canvasStyle}
onPointerDown={startScrub}
playheadRef={playheadElRef}
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 8cf37988d..509050812 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -230,3 +230,37 @@ describe("insertedWordMarks", () => {
expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split)).toEqual([]);
});
});
+
+// ─── The scrub round-trip ───────────────────────────────────────────────────
+// The timeline measures the pointer against the EXPANDED ruler and writes the result into
+// a store every consumer reads as a RAW second — the preview seek, the caption lookup, the
+// transcript cue, the audio mix. Straight through, the playhead sat one accumulated pause
+// AHEAD of everything it pointed at: the right playhead, the wrong subtitle (issue #560).
+
+describe("a scrub survives the round trip", () => {
+ const marks: RulerInsert[] = [
+ { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
+ { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+ ];
+
+ it("comes back to the raw second it started from, before and after each pause", () => {
+ for (const raw of [0, 1.5, 3.9, 6, 8.5, 12, 30]) {
+ expect(collapseRawSec(expandRawSec(raw, marks), marks).sec).toBeCloseTo(raw, 6);
+ }
+ });
+
+ it("lands on the held moment for a pointer inside a pause, and says so", () => {
+ // Every ruler second inside a pause is the same raw moment: the film is frozen
+ // there, so there is nothing else it could mean.
+ const inside = collapseRawSec(5, marks);
+ expect(inside.sec).toBeCloseTo(4, 6);
+ expect(inside.heldBy?.id).toBe("i1");
+ expect(collapseRawSec(5.9, marks).sec).toBeCloseTo(4, 6);
+ });
+
+ it("counts every pause before the pointer, not just the first", () => {
+ // Ruler 13 is past both: 13 − 2 − 1 = raw 10.
+ expect(collapseRawSec(13, marks).sec).toBeCloseTo(10, 6);
+ expect(collapseRawSec(13, marks).heldBy).toBeNull();
+ });
+});
From 8adca43745083c56d72294db9d7829392c2c9f05 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 19:49:43 +0200
Subject: [PATCH 079/113] fix(preview): the picture spends the pause a word
bought
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A word added to a lane buys OUTPUT seconds, and the film has to spend them somewhere.
Three of the four clocks already did: the export holds a frame (`hold_sec`), the ruler
grows (`expandRawSec`), the programme clock steps over the pause
(`projectRawTimelineSecToPlayback`). The DOM preview spent nothing — the `` ran
straight through, because a pause has no frames of its own to decode. Past the first
pause the picture was D seconds BEHIND every imported track, which is positioned on the
programme clock, and the preview stopped being the same film as the export.
The element is paused and a wall clock runs the hold out. That is the honest model: the
frame is frozen, so MEDIA time is exactly what stops advancing while real time does not.
`heldElapsedSec` is added to `outputTimeSec` and nowhere else — the raw playhead stays
pinned to the held moment, which is what the caption lookup, the transcript cue and the
take walks should all read, because for the length of the pause the film really is at
that one instant.
`holdEnteredBetween` lifts the entry rule into `inserted-time` with the other ruler
arithmetic, because its half-open left edge is the whole reason the hold terminates: the
playhead is pinned to exactly `atRawSec`, so `>` is what refuses that same moment on the
way out. With `>=` the pause is re-entered the frame it ends and the film never gets
past it — pinned in the tests. A seek clears the hold outright, so scrubbing during a
pause cannot hand the film back to `play()` half a second later.
Known limit: the ruler playhead stands still for the length of the pause rather than
crawling across it. The store holds raw seconds, and a pause is zero raw seconds wide,
so naming a moment inside one needs a ruler channel the timeline does not have yet — the
same coordinate the scrub now carries in `overrideRulerSec`.
---
src/components/ai-edition/VirtualPreview.tsx | 96 +++++++++++++++++--
.../ai-edition/timeline/inserted-time.test.ts | 33 +++++++
src/lib/ai-edition/timeline/inserted-time.ts | 20 ++++
3 files changed, 140 insertions(+), 9 deletions(-)
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 60918a86c..e5b80f3dd 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -22,7 +22,7 @@ import type {
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
-import { rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
+import { holdEnteredBetween, rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
@@ -564,6 +564,28 @@ export function VirtualPreview({
// it once per voiceover per frame, and walking every trim there would be wasteful.
// The film's pauses, placed on the raw ruler once. The projection needs them or every
// track after a pause lands D seconds early — the bug this argument exists to close.
+ /** The pause the picture is currently sitting on, if any.
+ *
+ * A word added to a lane buys OUTPUT seconds, and the film has to spend them somewhere.
+ * The export spends them holding a frame (`hold_sec`, `walk_composited_timeline`); the
+ * ruler spends them by growing (`expandRawSec`); the programme clock spends them by
+ * stepping over them (`projectRawTimelineSecToPlayback`). The DOM preview did not spend
+ * them at all — the `` ran straight through, because a pause has no frames of its
+ * own to decode. So past the first pause the picture was D seconds BEHIND every track
+ * positioned on the programme clock, and the preview stopped being the same film as the
+ * export (issue #560).
+ *
+ * The element is paused and a WALL clock runs the hold out, which is the honest model:
+ * the frame is frozen, so media time is exactly what must stop advancing while real time
+ * does not. */
+ const holdRef = useRef<{
+ insertId: string;
+ rawSec: number;
+ durationSec: number;
+ startedAtMs: number;
+ } | null>(null);
+ /** Set when the hold interrupted actual playback, so the film resumes on its own. */
+ const resumeAfterHoldRef = useRef(false);
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
@@ -667,6 +689,27 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
+ // Run the pause out FIRST: everything below that is positioned on the programme
+ // clock — the imported tracks especially — is still advancing during a hold even
+ // though the picture is not, exactly as the render's output stream is.
+ let heldElapsedSec = 0;
+ const hold = holdRef.current;
+ if (hold) {
+ heldElapsedSec = Math.min(hold.durationSec, (performance.now() - hold.startedAtMs) / 1000);
+ if (heldElapsedSec >= hold.durationSec) {
+ holdRef.current = null;
+ heldElapsedSec = hold.durationSec;
+ if (resumeAfterHoldRef.current) {
+ resumeAfterHoldRef.current = false;
+ const resumed = v.play();
+ if (resumed) void resumed.catch(() => undefined);
+ }
+ } else if (!v.paused) {
+ // A `play()` from elsewhere (autoplay, a resume racing the hold) would
+ // otherwise let the picture walk out from under the pause.
+ v.pause();
+ }
+ }
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -703,13 +746,19 @@ export function VirtualPreview({
// was never given a faster `playbackRate`, but seeking it twice as fast
// amounts to the same thing. Dividing raw time by the rate turns that
// back into 1x wall-clock, which is what the render does too.
- const outputTimeSec = projectRawTimelineSecToPlayback(
- clipsRef.current,
- trimRangesRef.current,
- virtualTimeSecRef.current,
- filmInsertsRef.current,
- speedRegionsRef.current,
- );
+ // `+ heldElapsedSec`, and only here: the raw playhead is pinned to the held moment
+ // for the whole pause, and the projection of that moment is the pause's OPENING
+ // (`expandRawSec` and this walk both give the frame about to be held its own
+ // instant). Adding the wall-clock elapsed walks the programme through the pause,
+ // which is what the mixer downstream is doing over the same seconds.
+ const outputTimeSec =
+ projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ virtualTimeSecRef.current,
+ filmInsertsRef.current,
+ speedRegionsRef.current,
+ ) + heldElapsedSec;
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -980,7 +1029,28 @@ export function VirtualPreview({
seekToVirtualTimeRef.current?.(nextClip.timelineStartSec, true);
return;
}
- updateVirtualTime(clampVirtualTime(clipsRef.current, position.virtualTimeSec));
+ const nextRawTime = clampVirtualTime(clipsRef.current, position.virtualTimeSec);
+ // The first pause this frame stepped over — the rule, and why it is half-open,
+ // live with the other ruler arithmetic.
+ const entering = holdRef.current
+ ? undefined
+ : holdEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
+ if (entering) {
+ holdRef.current = {
+ insertId: entering.id,
+ rawSec: entering.atRawSec,
+ durationSec: entering.durationSec,
+ startedAtMs: performance.now(),
+ };
+ resumeAfterHoldRef.current = true;
+ v.pause();
+ // The held moment, not the frame we happened to land on: the transcript cue,
+ // the caption lookup and the audio mix all read this, and for the length of
+ // the pause the film really is at that one instant.
+ updateVirtualTime(entering.atRawSec);
+ return;
+ }
+ updateVirtualTime(nextRawTime);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
@@ -1079,6 +1149,14 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
+ // A seek ends any pause the picture was holding: the playhead is somewhere else
+ // now, so the frame we were frozen on is not the frame any more. Without this the
+ // hold's wall clock would run out under the new position and hand the film back to
+ // `play()` — the film starting itself again because the user scrubbed during a
+ // pause. The rAF's own seeks (clip advance, trim skip) are gated on `!paused` and
+ // so never reach here while a hold is in flight.
+ holdRef.current = null;
+ resumeAfterHoldRef.current = false;
const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 509050812..6d4a32ada 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -10,6 +10,7 @@ import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
collapseRawSec,
expandRawSec,
+ holdEnteredBetween,
insertedWordMarks,
type RulerInsert,
rulerInserts,
@@ -264,3 +265,35 @@ describe("a scrub survives the round trip", () => {
expect(collapseRawSec(13, marks).heldBy).toBeNull();
});
});
+
+// ─── Entering a pause ───────────────────────────────────────────────────────
+// The preview holds the picture for a pause the way the export does (`hold_sec`). The
+// half-open rule below is what keeps that from becoming an infinite hold.
+
+describe("the pause a frame steps over", () => {
+ const marks: RulerInsert[] = [
+ { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
+ { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+ ];
+
+ it("is found when the frame crosses it", () => {
+ expect(holdEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
+ expect(holdEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
+ });
+
+ it("is not found again from the moment it holds", () => {
+ // The hold pins the playhead to exactly 4. Coming out, the next frames must not
+ // re-enter — otherwise the film never gets past the pause.
+ expect(holdEnteredBetween(4, 4.02, marks)).toBeUndefined();
+ expect(holdEnteredBetween(4, 4.5, marks)).toBeUndefined();
+ });
+
+ it("takes the earliest of several in one frame, and none outside", () => {
+ expect(holdEnteredBetween(0, 20, marks)?.id).toBe("i1");
+ expect(holdEnteredBetween(5, 8, marks)).toBeUndefined();
+ });
+
+ it("holds a pause landing exactly on the frame boundary", () => {
+ expect(holdEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index f514a6ba9..6b6de359d 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -107,6 +107,26 @@ export function collapseRawSec(
return { sec: sec - offset, heldBy: null };
}
+/**
+ * The pause a frame of playback stepped over, if it stepped over one.
+ *
+ * Half-open on the LEFT, and that is the whole point: a player that holds pins its raw
+ * playhead to exactly `atRawSec` for the length of the pause, so `>` is what refuses that
+ * same moment on the way OUT. With `>=` the pause is re-entered the instant it ends and the
+ * film never gets past it. Closed on the right (with the frame epsilon) so a pause landing
+ * precisely on a frame boundary is held rather than skipped.
+ */
+export function holdEnteredBetween(
+ prevRawSec: number,
+ nextRawSec: number,
+ inserts: readonly RulerInsert[],
+ epsilonSec = 1e-6,
+): RulerInsert | undefined {
+ return inserts.find(
+ (insert) => insert.atRawSec > prevRawSec && insert.atRawSec <= nextRawSec + epsilonSec,
+ );
+}
+
/** An added word, placed on the raw ruler through the clip that carries it. */
export interface InsertedWordMark {
clipId: string;
From d2c4740da42fe7c2802a4657a81bff809d3508fc Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 20:08:36 +0200
Subject: [PATCH 080/113] fix(preview): an insertion is media that plays, not a
pause
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two bugs, one wrong model. I had written the added word's media as a PAUSE — the film
stopping for a beat — and both symptoms follow directly from that.
**Playback stopped at an insertion.** Pausing the `` is how I "held" it, and the
store's `playing` flag mirrors the element's own `pause` event: the transport went to
stopped the moment an insertion began, and never came back. An insertion is a piece of
media on the timeline like any other, so playback runs THROUGH it. The element is now
PINNED (`currentTime` re-set to the insertion's source moment each frame, which is the
fixed frame standing in until there is a generator) and MUTED (the inserted audio is
silence today), never paused. The film keeps playing, because it is playing.
**Releasing a scrub over an insertion snapped back to its start.** The playhead had one
coordinate, `currentTimeSec`, and an insertion takes up ruler seconds while taking up
none of the recording — so every ruler second inside one collapses to the same raw
moment and no raw value can name a position within it. The store now carries
`currentRulerSec` beside it. Consumers that resolve MEDIA keep reading the raw second,
which is right: through an insertion the recording really is at that one instant. Only
what draws or measures the ruler reads the new one, and it is trusted only while it
still collapses back to the raw second, so a caller that predates insertions and writes
the same number for both is caught rather than believed.
That second coordinate is also what lets the playhead cross an insertion during
playback: the preview publishes raw and ruler together, the ruler one carrying how far
into the insertion its wall clock has run. Previously noted as a known limit; it is the
same fix.
Vocabulary swept through the ruler arithmetic, the projection, the timeline and the
preview: insertion, inserted media, fixed frame. "Pause" described the stand-in as
though it were the model, and the model is what the next reader builds on.
---
src/components/ai-edition/NewEditorShell.tsx | 4 +-
src/components/ai-edition/VirtualPreview.tsx | 153 ++++++++++--------
src/components/ai-edition/v4/V4Timeline.tsx | 77 +++++----
src/lib/ai-edition/document/timeline.ts | 30 ++--
src/lib/ai-edition/store/projectStore.ts | 24 ++-
.../ai-edition/timeline/inserted-time.test.ts | 37 ++---
src/lib/ai-edition/timeline/inserted-time.ts | 41 ++---
7 files changed, 217 insertions(+), 149 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 1240d5d8e..fdfcd1110 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -429,8 +429,8 @@ export function NewEditorShell() {
);
const handleTimeChange = useCallback(
- (timeSec: number) => {
- setCurrentTime(timeSec);
+ (timeSec: number, rulerSec?: number) => {
+ setCurrentTime(timeSec, rulerSec);
},
[setCurrentTime],
);
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index e5b80f3dd..7e3ba49b4 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -22,7 +22,11 @@ import type {
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
-import { holdEnteredBetween, rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
+import {
+ expandRawSec,
+ insertionEnteredBetween,
+ rulerInserts,
+} from "@/lib/ai-edition/timeline/inserted-time";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
@@ -215,10 +219,12 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
- /** The pauses added words created — they lengthen playback, they do not cut it. */
+ /** The media added words inserted — it lengthens playback, it does not cut it. */
insertRanges?: AxcutInsertRange[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
- onTimeChange?: (timeSec: number) => void;
+ /** `rulerSec` is the same moment measured on the ruler the user sees, which differs
+ * from `timeSec` as soon as an insertion sits before it, or under it. */
+ onTimeChange?: (timeSec: number, rulerSec: number) => void;
onLoadedMetadata?: (
durationSec: number,
assetId: string,
@@ -562,30 +568,33 @@ export function VirtualPreview({
trimRangesRef.current = trimRanges;
// What the film no longer contains, recomputed only when the cuts move — the rAF asks
// it once per voiceover per frame, and walking every trim there would be wasteful.
- // The film's pauses, placed on the raw ruler once. The projection needs them or every
- // track after a pause lands D seconds early — the bug this argument exists to close.
- /** The pause the picture is currently sitting on, if any.
+ // The film's insertions, placed on the raw ruler once. The projection needs them or every
+ // track after one lands D seconds early — the bug this argument exists to close.
+ /** The insertion currently playing, if any.
*
- * A word added to a lane buys OUTPUT seconds, and the film has to spend them somewhere.
- * The export spends them holding a frame (`hold_sec`, `walk_composited_timeline`); the
- * ruler spends them by growing (`expandRawSec`); the programme clock spends them by
- * stepping over them (`projectRawTimelineSecToPlayback`). The DOM preview did not spend
- * them at all — the `` ran straight through, because a pause has no frames of its
- * own to decode. So past the first pause the picture was D seconds BEHIND every track
- * positioned on the programme clock, and the preview stopped being the same film as the
- * export (issue #560).
+ * An added word inserts MEDIA inside the clip (issue #560). There is no generator for it
+ * yet, so the stand-in is a fixed frame and silence — but it is a piece of media on the
+ * timeline like any other, and playback runs THROUGH it rather than around it.
*
- * The element is paused and a WALL clock runs the hold out, which is the honest model:
- * the frame is frozen, so media time is exactly what must stop advancing while real time
- * does not. */
- const holdRef = useRef<{
+ * The `` cannot supply those seconds: they are not in the file. So for the
+ * insertion's duration the element is PINNED (`currentTime` held at its source moment,
+ * which is the fixed frame) and MUTED (the inserted audio is silence today). It is
+ * deliberately NOT `pause()`d — the film is playing, and pausing the element told the
+ * whole app otherwise: the store's `playing` flag mirrors the element's `pause` event,
+ * so the transport flipped to stopped the moment an insertion began. */
+ const insertionRef = useRef<{
insertId: string;
rawSec: number;
+ sourceSec: number;
durationSec: number;
startedAtMs: number;
} | null>(null);
- /** Set when the hold interrupted actual playback, so the film resumes on its own. */
- const resumeAfterHoldRef = useRef(false);
+ /** The element's own muted flag, to restore when the insertion ends. */
+ const mutedBeforeInsertionRef = useRef(false);
+ /** How far into the insertion currently playing we are, in ruler seconds. Read by
+ * `updateVirtualTime` so the position it publishes advances ACROSS the insertion while
+ * the raw second it also publishes stands still at the insertion's moment. */
+ const insertionElapsedRef = useRef(0);
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
@@ -689,27 +698,34 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
- // Run the pause out FIRST: everything below that is positioned on the programme
- // clock — the imported tracks especially — is still advancing during a hold even
- // though the picture is not, exactly as the render's output stream is.
- let heldElapsedSec = 0;
- const hold = holdRef.current;
- if (hold) {
- heldElapsedSec = Math.min(hold.durationSec, (performance.now() - hold.startedAtMs) / 1000);
- if (heldElapsedSec >= hold.durationSec) {
- holdRef.current = null;
- heldElapsedSec = hold.durationSec;
- if (resumeAfterHoldRef.current) {
- resumeAfterHoldRef.current = false;
- const resumed = v.play();
- if (resumed) void resumed.catch(() => undefined);
+ // Play the insertion FIRST: everything below is positioned against a clock that is
+ // running through it — the imported tracks especially, which sit on the programme
+ // clock exactly as they do in the render's output stream.
+ let insertionElapsedSec = 0;
+ const insertion = insertionRef.current;
+ if (insertion) {
+ insertionElapsedSec = Math.min(
+ insertion.durationSec,
+ (performance.now() - insertion.startedAtMs) / 1000,
+ );
+ if (insertionElapsedSec >= insertion.durationSec) {
+ insertionRef.current = null;
+ insertionElapsedSec = 0;
+ v.muted = mutedBeforeInsertionRef.current;
+ } else {
+ // Re-pinned every frame: the element is still playing, so left alone it
+ // would decode straight past the fixed frame the insertion stands for.
+ if (Math.abs(v.currentTime - insertion.sourceSec) > 0.01) {
+ try {
+ v.currentTime = insertion.sourceSec;
+ } catch {
+ // not seekable this instant; the next frame retries
+ }
}
- } else if (!v.paused) {
- // A `play()` from elsewhere (autoplay, a resume racing the hold) would
- // otherwise let the picture walk out from under the pause.
- v.pause();
+ v.muted = true;
}
}
+ insertionElapsedRef.current = insertionElapsedSec;
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -746,11 +762,12 @@ export function VirtualPreview({
// was never given a faster `playbackRate`, but seeking it twice as fast
// amounts to the same thing. Dividing raw time by the rate turns that
// back into 1x wall-clock, which is what the render does too.
- // `+ heldElapsedSec`, and only here: the raw playhead is pinned to the held moment
- // for the whole pause, and the projection of that moment is the pause's OPENING
- // (`expandRawSec` and this walk both give the frame about to be held its own
- // instant). Adding the wall-clock elapsed walks the programme through the pause,
- // which is what the mixer downstream is doing over the same seconds.
+ // `+ insertionElapsedSec`, and only here: the RAW playhead stands still at the
+ // insertion's moment for its whole duration — none of those seconds come from the
+ // recording — and the projection of that moment is where the insertion OPENS
+ // (`expandRawSec` and this walk both give the last recorded frame its own instant).
+ // Adding the elapsed walks the programme through the inserted media, which is what
+ // the mixer downstream is doing over the same seconds.
const outputTimeSec =
projectRawTimelineSecToPlayback(
clipsRef.current,
@@ -758,7 +775,7 @@ export function VirtualPreview({
virtualTimeSecRef.current,
filmInsertsRef.current,
speedRegionsRef.current,
- ) + heldElapsedSec;
+ ) + insertionElapsedSec;
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -1030,23 +1047,23 @@ export function VirtualPreview({
return;
}
const nextRawTime = clampVirtualTime(clipsRef.current, position.virtualTimeSec);
- // The first pause this frame stepped over — the rule, and why it is half-open,
- // live with the other ruler arithmetic.
- const entering = holdRef.current
+ // The first insertion this frame ran into — the rule, and why it is half-open,
+ // lives with the other ruler arithmetic.
+ const entering = insertionRef.current
? undefined
- : holdEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
+ : insertionEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
if (entering) {
- holdRef.current = {
+ mutedBeforeInsertionRef.current = v.muted;
+ insertionRef.current = {
insertId: entering.id,
rawSec: entering.atRawSec,
+ sourceSec: position.sourceTimeSec,
durationSec: entering.durationSec,
startedAtMs: performance.now(),
};
- resumeAfterHoldRef.current = true;
- v.pause();
- // The held moment, not the frame we happened to land on: the transcript cue,
- // the caption lookup and the audio mix all read this, and for the length of
- // the pause the film really is at that one instant.
+ // The insertion's own moment, not the frame we happened to land on: the
+ // transcript cue, the caption lookup and the audio mix all read this, and
+ // through the insertion the RECORDING really is at that one instant.
updateVirtualTime(entering.atRawSec);
return;
}
@@ -1071,7 +1088,14 @@ export function VirtualPreview({
const updateVirtualTime = useCallback(
(nextTimeSec: number) => {
setVirtualTimeSec(nextTimeSec);
- onTimeChange?.(nextTimeSec);
+ // Two coordinates, one publish. The raw second says where the RECORDING is; the
+ // ruler second says where on the timeline the playhead is, which is the only one
+ // that can move while an insertion plays — the recording is standing still at the
+ // insertion's moment for its whole duration.
+ onTimeChange?.(
+ nextTimeSec,
+ expandRawSec(nextTimeSec, filmInsertsRef.current) + insertionElapsedRef.current,
+ );
// ponytail: mirrors main's per-frame `video.playbackRate = ...`
// (videoEventHandlers.ts) — the browser does the actual time
// warping, so this is the only thing speed regions need. No
@@ -1149,14 +1173,17 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
- // A seek ends any pause the picture was holding: the playhead is somewhere else
- // now, so the frame we were frozen on is not the frame any more. Without this the
- // hold's wall clock would run out under the new position and hand the film back to
- // `play()` — the film starting itself again because the user scrubbed during a
- // pause. The rAF's own seeks (clip advance, trim skip) are gated on `!paused` and
- // so never reach here while a hold is in flight.
- holdRef.current = null;
- resumeAfterHoldRef.current = false;
+ // A seek AWAY ends the insertion that was playing: the playhead is somewhere else
+ // now, so the fixed frame it was pinning is not the frame any more. A seek TO the
+ // insertion's own moment is not "away" — it is the echo of the position this very
+ // tick published, and clearing on it would cut every insertion short the frame it
+ // began.
+ const playingInsertion = insertionRef.current;
+ if (playingInsertion && Math.abs(playingInsertion.rawSec - nextVirtualTimeSec) > 1e-3) {
+ insertionRef.current = null;
+ const el = videoRef.current;
+ if (el) el.muted = mutedBeforeInsertionRef.current;
+ }
const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 1b1d094df..8b202fca8 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -230,14 +230,14 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
- /** The pauses added words created. `currentTimeSec` is a STORED second; the ruler it is
- * drawn on counts the pauses, so it has to be placed through them or it drifts from
+ /** The media added words inserted. `currentTimeSec` is a STORED second; the ruler it is
+ * drawn on counts the insertions, so it has to be placed through them or it drifts from
* the clips by the whole added time. */
inserts: readonly RulerInsert[];
/** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
/** The same drag's RULER position. Preferred when present: it is the only coordinate
- * that can name a moment INSIDE a pause, which is zero raw seconds wide. */
+ * that can name a moment INSIDE an insertion, which is zero raw seconds wide. */
overrideRulerSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
@@ -270,11 +270,27 @@ const PlayheadOverlay = memo(function PlayheadOverlay({
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- // The scrub hands its RULER position straight through. Expanding the raw one instead
- // would snap the playhead to a pause's left edge the moment the pointer entered it,
- // because every ruler second inside a pause collapses to the same raw moment.
+ const storeRulerSec = useProjectStore((s) => s.currentRulerSec);
+ // The ruler position, from the most trustworthy source that has one.
+ //
+ // The raw second alone cannot draw this playhead: an insertion occupies ruler seconds
+ // and none of the recording, so every ruler second inside one collapses to the same raw
+ // moment and expanding it back puts the playhead on the insertion's near edge — where it
+ // visibly stalls through playback, and where it snaps back to after a scrub released over
+ // one (issue #560).
+ //
+ // The store's ruler second is trusted only when it still AGREES with the raw one: a
+ // caller that predates insertions writes the raw value for both, which is right until an
+ // insertion sits before it. Collapsing is the test, and expanding is the fallback.
+ const storeSec =
+ Math.abs(collapseRawSec(storeRulerSec, inserts).sec - storeTimeSec) < 1e-3
+ ? storeRulerSec
+ : expandRawSec(storeTimeSec, inserts);
const pct =
- ((overrideRulerSec ?? expandRawSec(overrideTimeSec ?? storeTimeSec, inserts)) / totalSec) * 100;
+ ((overrideRulerSec ??
+ (overrideTimeSec !== null ? expandRawSec(overrideTimeSec, inserts) : storeSec)) /
+ totalSec) *
+ 100;
return (
@@ -613,7 +629,9 @@ export function V4Timeline({
onAddVoiceover,
}: {
tl: TimelineApi;
- setCurrentTime: (sec: number) => void;
+ /** `rulerSec` is the same moment on the ruler the user sees; it differs from `sec` as
+ * soon as an insertion sits before it, or under it. */
+ setCurrentTime: (sec: number, rulerSec?: number) => void;
variant?: "edit" | "media";
onDropAsset?: (assetId: string) => Promise;
videoSources?: VideoSource[];
@@ -701,11 +719,11 @@ export function V4Timeline({
// clicked instead of looking like it worked. Same question, same helper as the Layout
// pane: is a camera attached anywhere on this timeline?
const hasAnyCamera = useMemo(() => hasAnyClipWithCamera(tl.assets, clips), [tl.assets, clips]);
- // The pauses added words created, placed on the ruler. Everything below measures the
- // EXPANDED ruler — stored clip geometry plus the time those pauses add — because that
+ // The media added words inserted, placed on the ruler. Everything below measures the
+ // EXPANDED ruler — stored clip geometry plus the time those insertions add — because that
// is the film's real length and the one the playhead runs along. Stored geometry is
// never rewritten for this: only what is drawn moves.
- // `?? []` because the key is additive: a document written before it has no pauses.
+ // `?? []` because the key is additive: a document written before it has no insertions.
const inserts = useMemo(
() => rulerInserts(tl.insertRanges ?? [], clips),
[tl.insertRanges, clips],
@@ -841,11 +859,12 @@ export function V4Timeline({
const [scrubbingTimeSec, setScrubbingTimeSec] = useState(null);
// The pointer's RULER position while scrubbing, kept apart from the raw one above.
// Two numbers because they mean different things: the timecode reads the raw clock,
- // like the store, and the playhead has to be able to sit INSIDE a pause — which is
- // zero raw seconds wide, so no raw value can address a moment within it.
+ // like the store, and the playhead has to be able to sit INSIDE an insertion — which
+ // takes up none of the recording, so no raw value can address a moment within it.
const [scrubRulerSec, setScrubRulerSec] = useState(null);
const rafSeekRef = useRef(0);
const pendingSeekTimeRef = useRef(null);
+ const pendingSeekRulerRef = useRef(null);
// ── interactions ────────────────────────────────────────────────
const playheadElRef = useRef(null);
@@ -863,14 +882,15 @@ export function V4Timeline({
// `total` is the EXPANDED ruler, so `pct * total` is a ruler second — and
// `setCurrentTime` is read as a RAW one by every consumer: the preview seek, the
// caption lookup, the transcript cue, the audio mix. Writing the ruler value
- // straight in put the playhead one accumulated pause AHEAD of everything it was
+ // straight in put the playhead one accumulated insertion AHEAD of everything it was
// supposed to be pointing at, which is what showed as the wrong subtitle under a
// correctly-placed playhead (issue #560).
//
- // Collapsing lands on the held moment when the pointer is inside a pause, which
- // is the honest answer: a pause is zero raw seconds, so there is no raw value
- // inside it to seek to. The ruler position is kept separately below so the
- // playhead still follows the pointer across it.
+ // Collapsing lands on the insertion's own moment when the pointer is inside one,
+ // which is the honest answer: an insertion takes up none of the recording, so
+ // there is no raw value inside it to seek the media to. The ruler position goes
+ // to the store ALONGSIDE it, which is what lets the playhead stay where it was
+ // released instead of snapping to the insertion's near edge.
const rulerTime = pct * total;
const { sec: targetTime } = collapseRawSec(rulerTime, inserts);
@@ -883,13 +903,14 @@ export function V4Timeline({
setScrubbingTimeSec(targetTime);
setScrubRulerSec(rulerTime);
pendingSeekTimeRef.current = targetTime;
+ pendingSeekRulerRef.current = rulerTime;
if (isImmediate) {
if (rafSeekRef.current !== 0) {
cancelAnimationFrame(rafSeekRef.current);
rafSeekRef.current = 0;
}
- setCurrentTime(targetTime);
+ setCurrentTime(targetTime, rulerTime);
return;
}
@@ -898,7 +919,7 @@ export function V4Timeline({
rafSeekRef.current = requestAnimationFrame(() => {
rafSeekRef.current = 0;
if (pendingSeekTimeRef.current !== null) {
- setCurrentTime(pendingSeekTimeRef.current);
+ setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
}
});
}
@@ -1726,8 +1747,8 @@ export function V4Timeline({
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
left: `${pctAt(seg.segStart)}%`,
- // Measured on the expanded ruler at BOTH ends: a region straddling a pause
- // covers it, so its box has to grow by that pause and not merely slide.
+ // Measured on the expanded ruler at BOTH ends: a region straddling an insertion
+ // covers it, so its box has to grow by that insertion and not merely slide.
width: `${pctOf(expandRawSec(seg.segEnd, inserts) - expandRawSec(seg.segStart, inserts))}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
@@ -2227,7 +2248,7 @@ export function V4Timeline({
assetDurationSec={duration}
// `pctAt`, not `pctOf`: the clip boxes are drawn on the EXPANDED
// ruler and the audio pills were drawn on the stored one, so any
- // pause in the film slid the two lanes apart. The take keeps its
+ // insertion in the film slid the two lanes apart. The take keeps its
// own length — only its head follows the ruler.
leftPct={pctAt(start)}
widthPct={pctOf(widthSec)}
@@ -2291,7 +2312,7 @@ export function V4Timeline({
>
{clips.map((c, i) => {
const dur = c.timelineEndSec - c.timelineStartSec;
- // On the expanded ruler the box also carries whatever pauses fall
+ // On the expanded ruler the box also carries whatever insertions fall
// inside it — the film really does stay on this clip's frame for
// them, so they belong to its box rather than between boxes.
const boxStart = expandRawSec(c.timelineStartSec, inserts);
@@ -2373,7 +2394,7 @@ export function V4Timeline({
{(insertedWordsByClip.get(c.id) ?? []).map(({ wordId, text, atRawSec }) => {
- // A word whose pause the film holds gets a BAND as wide as the time
+ // A word whose insertion the film plays gets a BAND as wide as the time
// it adds — that width IS the added time, drawn. One that fitted in
// silence already there adds nothing and stays a hairline.
//
@@ -2381,16 +2402,16 @@ export function V4Timeline({
// the expanded ruler and an unpaused one at a fraction of the clip's
// SOURCE span, in the same ternary — two clocks, one of which the
// box is not drawn in.
- const pause = inserts.find((ins) => ins.wordId === wordId);
+ const inserted = inserts.find((ins) => ins.wordId === wordId);
const left = ((expandRawSec(atRawSec, inserts) - boxStart) / boxLen) * 100;
- const width = pause ? (pause.durationSec / boxLen) * 100 : 0;
+ const width = inserted ? (inserted.durationSec / boxLen) * 100 : 0;
return (
0
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index c437a3638..d61ce3475 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -13,7 +13,7 @@ import type {
/**
* What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
- * one thing a stored clip can never carry — `heldSec`, the pause an added word created.
+ * one thing a stored clip can never carry — `heldSec`, the media an added word inserted.
*
* A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
* The field lives only on this derived shape, never on `clipSchema`, so nothing can write
@@ -172,8 +172,8 @@ export function resolvePlaybackSegments(
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
const result: PlaybackSegment[] = [];
let timelineCursor = 0;
- // The pauses added words need, in the order they will be met. Consumed as the walk
- // passes each one's moment, so a pause inside a span a trim removed is never reached —
+ // The media added words insert, in the order they will be met. Consumed as the walk
+ // passes each one's moment, so an insertion inside a span a trim removed is never reached —
// which is right: the moment it holds is not in the film any more.
const pending = [...insertRanges].sort((a, b) => a.atSec - b.atSec);
const holdAt = (clip: AxcutClip, atSec: number): PlaybackSegment | null => {
@@ -211,10 +211,10 @@ export function resolvePlaybackSegments(
if (!trimAppliesToClip(trim, clip)) continue;
kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
}
- // A pause sits at the END of the word it follows, which is almost never a boundary a
+ // An insertion sits at the END of the word it follows, which is almost never a boundary a
// trim happened to leave. So each kept span is cut at the moments it holds, and the
// held frame goes between the halves: the stream plays up to that frame, stays on it
- // for the pause, then carries on — which is what makes the film longer.
+ // for the insertion, then carries on — which is what makes the film longer.
const pieces: Array<{ startSec: number; endSec: number; holdAtEnd: boolean }> = [];
for (const iv of kept) {
const moments = pending
@@ -331,9 +331,9 @@ function outputDurationOfRawSpan(
* The raw span that plays for `outSec` OUTPUT seconds starting at `fromRawSec` — the
* inverse of {@link outputDurationOfRawSpan}, and the identity when nothing is sped up.
*
- * A voice-over plays at 1x in the mix, so a pause for a spoken word is D seconds of the
+ * A voice-over plays at 1x in the mix, so an insertion for a spoken word is D seconds of the
* take's own clock. Under a 2x region that is 2 raw seconds, not 1, and getting it wrong
- * puts the resumed narration half a pause out of step with the picture.
+ * puts the resumed narration half an insertion out of step with the picture.
*/
export function rawSpanForOutDuration(
fromRawSec: number,
@@ -372,7 +372,7 @@ export function projectRawTimelineSecToPlayback(
trimRanges: AxcutTrimRange[],
rawSec: number,
/**
- * The recording lane's pauses, already placed on the raw ruler by `rulerInserts`.
+ * The recording lane's insertions, already placed on the raw ruler by `rulerInserts`.
*
* REQUIRED, not optional, and every call site was migrated with it. An optional
* parameter would silently keep the early-audio bug alive at every site that had not
@@ -393,10 +393,10 @@ export function projectRawTimelineSecToPlayback(
let lastRawEnd = 0; // raw end of the last kept segment, for the trailing overhang
let landed: number | null = null; // output(rawSec), once it falls in/before a kept segment
- // A pause the film holds occupies ZERO raw seconds and D OUTPUT seconds — it is the one
+ // An insertion occupies ZERO raw seconds and D OUTPUT seconds — it is the one
// thing a flat kept-interval list cannot express, which is why it was left out and why
- // every audio track after a pause has been landing D seconds early in both the preview
- // and the export. Interleaved here rather than added afterwards, because where the pause
+ // every audio track after an insertion has been landing D seconds early in both the preview
+ // and the export. Interleaved here rather than added afterwards, because where the insertion
// sits inside the kept span decides which side of it `rawSec` falls on.
const holds = [...filmInserts].sort((a, b) => a.atRawSec - b.atRawSec);
let nextHold = 0;
@@ -408,9 +408,9 @@ export function projectRawTimelineSecToPlayback(
// answers, because a speed region scales it.
for (const seg of keptRawSpans(ordered, trimRanges)) {
let from = seg.startSec;
- // Every pause this segment carries, in order. A pause whose moment a trim removed is
+ // Every insertion this segment carries, in order. One whose moment a trim removed is
// in no kept span at all and is never reached — the moment it holds is not in the
- // film any more, so neither is the pause.
+ // film any more, so neither is the insertion.
while (nextHold < holds.length && holds[nextHold].atRawSec < seg.startSec) nextHold++;
while (nextHold < holds.length && holds[nextHold].atRawSec < seg.endSec) {
const hold = holds[nextHold++];
@@ -421,8 +421,8 @@ export function projectRawTimelineSecToPlayback(
}
outCursor += outputDurationOfRawSpan(from, at, speedRegions);
from = at;
- // Strictly after the pause's own moment, matching `expandRawSec`: a track whose
- // head sits exactly there starts WITH the pause, not after it.
+ // Strictly after the insertion's own moment, matching `expandRawSec`: a track whose
+ // head sits exactly there starts WITH the insertion, not after it.
if (landed === null && rawSec <= at) landed = outCursor;
outCursor += hold.durationSec;
}
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index fd47da9cc..69d0cd340 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -60,6 +60,20 @@ export interface ProjectState {
error: string | null;
sourceDurationSec: number;
currentTimeSec: number;
+ /** The playhead's position on the RULER — the timeline the user sees and scrubs.
+ *
+ * `currentTimeSec` is the stored, RAW second: where the recording is. The two are the
+ * same number until an insertion exists, and then they permanently are not. An insertion
+ * is media added INSIDE a clip (issue #560), so it takes up ruler seconds while taking up
+ * none of the recording — every ruler second inside one names the same raw moment.
+ *
+ * Which means the raw second cannot say WHERE inside an insertion the playhead is, and a
+ * playhead that only had that number fell to the insertion's near edge the instant it
+ * entered: playback stalled visually there, and releasing a scrub over one snapped back
+ * to its start. Consumers that resolve MEDIA (seek, captions, transcript cue, mix) keep
+ * reading `currentTimeSec` — through an insertion the recording really is at that one
+ * instant. Only what draws or measures the ruler reads this. */
+ currentRulerSec: number;
/** The selected imported audio track (issue #350), or null. In the store — not
* `useTimeline`'s local selection — because the media panel (which imports the
* file) and the inspector (which edits it) sit in different component subtrees
@@ -146,7 +160,9 @@ export interface ProjectState {
opts: DocumentWriteOptions,
) => Promise;
setSourceDuration: (sec: number) => void;
- setCurrentTime: (sec: number) => void;
+ /** `rulerSec` defaults to `sec`, which is right everywhere no insertion is involved
+ * and is what every existing caller means. */
+ setCurrentTime: (sec: number, rulerSec?: number) => void;
setPlaying: (playing: boolean) => void;
markClean: () => void;
/**
@@ -197,6 +213,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
@@ -549,8 +566,8 @@ export const useProjectStore = create((set, get) => ({
set({ sourceDurationSec: sec });
},
- setCurrentTime(sec) {
- set({ currentTimeSec: sec });
+ setCurrentTime(sec, rulerSec) {
+ set({ currentTimeSec: sec, currentRulerSec: rulerSec ?? sec });
},
setPlaying(playing) {
@@ -581,6 +598,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 6d4a32ada..bd16a698c 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -10,8 +10,8 @@ import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
collapseRawSec,
expandRawSec,
- holdEnteredBetween,
insertedWordMarks,
+ insertionEnteredBetween,
type RulerInsert,
rulerInserts,
totalInsertedSec,
@@ -251,8 +251,8 @@ describe("a scrub survives the round trip", () => {
});
it("lands on the held moment for a pointer inside a pause, and says so", () => {
- // Every ruler second inside a pause is the same raw moment: the film is frozen
- // there, so there is nothing else it could mean.
+ // Every ruler second inside an insertion is the same raw moment: none of those
+ // seconds come from the recording, so there is nothing else it could mean.
const inside = collapseRawSec(5, marks);
expect(inside.sec).toBeCloseTo(4, 6);
expect(inside.heldBy?.id).toBe("i1");
@@ -266,34 +266,35 @@ describe("a scrub survives the round trip", () => {
});
});
-// ─── Entering a pause ───────────────────────────────────────────────────────
-// The preview holds the picture for a pause the way the export does (`hold_sec`). The
-// half-open rule below is what keeps that from becoming an infinite hold.
+// ─── Running into an insertion ──────────────────────────────────────────────
+// An added word inserts MEDIA inside the clip — a fixed frame and silence, until there is
+// a generator for it. Playback runs THROUGH that media, and the half-open rule below is
+// what keeps it from running through the same insertion forever.
-describe("the pause a frame steps over", () => {
+describe("the insertion a frame runs into", () => {
const marks: RulerInsert[] = [
{ id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
{ id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
];
it("is found when the frame crosses it", () => {
- expect(holdEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
- expect(holdEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
+ expect(insertionEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
+ expect(insertionEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
});
- it("is not found again from the moment it holds", () => {
- // The hold pins the playhead to exactly 4. Coming out, the next frames must not
- // re-enter — otherwise the film never gets past the pause.
- expect(holdEnteredBetween(4, 4.02, marks)).toBeUndefined();
- expect(holdEnteredBetween(4, 4.5, marks)).toBeUndefined();
+ it("is not found again from the moment it occupies", () => {
+ // While the insertion plays, the raw playhead stands still at exactly 4. Coming out,
+ // the next frames must not re-enter — otherwise the film never gets past it.
+ expect(insertionEnteredBetween(4, 4.02, marks)).toBeUndefined();
+ expect(insertionEnteredBetween(4, 4.5, marks)).toBeUndefined();
});
it("takes the earliest of several in one frame, and none outside", () => {
- expect(holdEnteredBetween(0, 20, marks)?.id).toBe("i1");
- expect(holdEnteredBetween(5, 8, marks)).toBeUndefined();
+ expect(insertionEnteredBetween(0, 20, marks)?.id).toBe("i1");
+ expect(insertionEnteredBetween(5, 8, marks)).toBeUndefined();
});
- it("holds a pause landing exactly on the frame boundary", () => {
- expect(holdEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
+ it("plays an insertion landing exactly on the frame boundary", () => {
+ expect(insertionEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
});
});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index 6b6de359d..c6786d25c 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -10,23 +10,23 @@
// This module is the arithmetic, and nothing else: pure, no document, no React. It answers
// two questions.
//
-// • Where does a pause land on the RULER? A range is anchored in SOURCE time, so it has
+// • Where does an insertion land on the RULER? A range is anchored in SOURCE time, so it has
// to be projected through whichever clip plays that moment — `rulerInserts`.
-// • What does the ruler look like once the pauses are counted? Stored raw seconds and
+// • What does the ruler look like once the insertions are counted? Stored raw seconds and
// the seconds the user actually scrubs are no longer the same number, and
// `expandRawSec` / `collapseRawSec` are the one place that difference is resolved.
//
-// The two are inverses everywhere except INSIDE a pause, where they cannot be: a stretch
+// The two are inverses everywhere except INSIDE an insertion, where they cannot be: a stretch
// of ruler maps to the single source moment being held. `collapseRawSec` returns that
// moment, which is exactly what a decoder parked on a held frame should be told.
import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
-/** A pause placed on the raw ruler, ready to be counted. */
+/** An insertion placed on the raw ruler, ready to be counted. */
export interface RulerInsert {
id: string;
wordId: string;
- /** Where the pause begins, in STORED raw seconds — before any pause is counted. */
+ /** Where the insertion begins, in STORED raw seconds — before any insertion is counted. */
atRawSec: number;
durationSec: number;
}
@@ -34,7 +34,7 @@ export interface RulerInsert {
/**
* Project each insert onto the raw ruler through the clip that plays its source moment.
*
- * A range whose moment no clip plays yields nothing: the pause exists for a word that is
+ * A range whose moment no clip plays yields nothing: the insertion exists for a word that is
* not on the timeline, so there is no ruler position for it and nothing to add. Same rule
* the captions follow for a line no clip covers.
*
@@ -49,7 +49,7 @@ export function rulerInserts(
for (const clip of clips) {
if (clip.assetId !== insert.assetId) continue;
const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
- // Inclusive at both edges: a pause sits at the END of the word it follows, which
+ // Inclusive at both edges: an insertion sits at the END of the word it follows, which
// is routinely a clip's own boundary.
if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
placed.push({
@@ -64,7 +64,7 @@ export function rulerInserts(
return placed.sort((a, b) => a.atRawSec - b.atRawSec);
}
-/** How much time the pauses add in total — what the ruler grows by. */
+/** How much time the insertions add in total — what the ruler grows by. */
export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
return inserts.reduce((sum, insert) => sum + insert.durationSec, 0);
}
@@ -73,8 +73,8 @@ export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
* Stored raw seconds → the ruler the user sees.
*
* Monotone and total: every stored moment has exactly one place on the expanded ruler.
- * A moment sitting exactly ON a pause maps to where the pause BEGINS, so the frame that
- * is about to be held keeps its own instant and the pause opens after it.
+ * A moment sitting exactly ON an insertion maps to where the insertion BEGINS, so the last recorded
+ * frame keeps its own instant and the inserted media opens after it.
*/
export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): number {
let out = sec;
@@ -87,7 +87,7 @@ export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): numb
/**
* The ruler the user sees → stored raw seconds.
*
- * The inverse of {@link expandRawSec} outside a pause. Inside one it cannot be an inverse
+ * The inverse of {@link expandRawSec} outside an insertion. Inside one it cannot be an inverse
* — a whole stretch of ruler stands for a single held moment — and it returns that moment,
* flagged, so a caller driving a decoder knows to hold rather than to seek.
*/
@@ -108,15 +108,16 @@ export function collapseRawSec(
}
/**
- * The pause a frame of playback stepped over, if it stepped over one.
+ * The insertion a frame of playback ran into, if it ran into one.
*
- * Half-open on the LEFT, and that is the whole point: a player that holds pins its raw
- * playhead to exactly `atRawSec` for the length of the pause, so `>` is what refuses that
- * same moment on the way OUT. With `>=` the pause is re-entered the instant it ends and the
- * film never gets past it. Closed on the right (with the frame epsilon) so a pause landing
- * precisely on a frame boundary is held rather than skipped.
+ * Half-open on the LEFT, and that is the whole point: while inserted media is playing, the
+ * RAW playhead stands still at exactly `atRawSec` — the recording really is at that one
+ * instant, because none of the inserted seconds come from it. `>` is therefore what refuses
+ * that same moment on the way out; with `>=` the insertion is re-entered the frame it ends
+ * and the film never gets past it. Closed on the right (with the frame epsilon) so an
+ * insertion landing precisely on a frame boundary is played rather than skipped.
*/
-export function holdEnteredBetween(
+export function insertionEnteredBetween(
prevRawSec: number,
nextRawSec: number,
inserts: readonly RulerInsert[],
@@ -138,12 +139,12 @@ export interface InsertedWordMark {
/**
* Where each added word's mark belongs, one per word.
*
- * Claimed once, and half-open at a clip's far edge except for the last: a pause sits at the
+ * Claimed once, and half-open at a clip's far edge except for the last: an insertion sits at the
* END of the word it follows, which is routinely a split boundary, and testing both edges
* inclusively painted the same word in BOTH halves (issue #560).
*
* Returns RAW seconds. The caller expands them; it used to mix a raw-then-expanded position
- * for a word with a pause and a fraction of the clip's SOURCE span for one without, in the
+ * for a word with an insertion and a fraction of the clip's SOURCE span for one without, in the
* same ternary — two clocks, and the clip box is not drawn in the second.
*/
export function insertedWordMarks(
From f58a80226ede47d004608ae15c5ef6c6a82fd1b3 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 20:43:53 +0200
Subject: [PATCH 081/113] fix(preview): park the picture on the insertion,
don't re-seek it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Re-pinning `currentTime` every frame to a still-playing element is a seek storm the
decoder never settles out of — that is what "playback stops at the insertion" was. One
`pause()` parks it on the frame the insertion stands for and silences the recording
under it; one `play()` lets go. Both are what the insertion IS, so the mute bookkeeping
goes away with them.
The cost of parking is that `.paused` stops answering "is the film stopped?" — it
says the element is parked, and the programme is still running over it. That question is
now `filmPlaying`, asked once per frame and used by the four gates that always meant it:
the shared clock, the two imported-track gates, and the tick's own early return.
Net 18 lines lighter than the version it replaces.
---
src/components/ai-edition/VirtualPreview.tsx | 100 ++++++++-----------
1 file changed, 41 insertions(+), 59 deletions(-)
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 7e3ba49b4..5661f0e64 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -576,24 +576,20 @@ export function VirtualPreview({
* yet, so the stand-in is a fixed frame and silence — but it is a piece of media on the
* timeline like any other, and playback runs THROUGH it rather than around it.
*
- * The `` cannot supply those seconds: they are not in the file. So for the
- * insertion's duration the element is PINNED (`currentTime` held at its source moment,
- * which is the fixed frame) and MUTED (the inserted audio is silence today). It is
- * deliberately NOT `pause()`d — the film is playing, and pausing the element told the
- * whole app otherwise: the store's `playing` flag mirrors the element's `pause` event,
- * so the transport flipped to stopped the moment an insertion began. */
- const insertionRef = useRef<{
- insertId: string;
- rawSec: number;
- sourceSec: number;
- durationSec: number;
- startedAtMs: number;
- } | null>(null);
- /** The element's own muted flag, to restore when the insertion ends. */
- const mutedBeforeInsertionRef = useRef(false);
- /** How far into the insertion currently playing we are, in ruler seconds. Read by
- * `updateVirtualTime` so the position it publishes advances ACROSS the insertion while
- * the raw second it also publishes stands still at the insertion's moment. */
+ * The `` cannot supply those seconds: they are not in the file. So it is PARKED
+ * for the insertion's duration — paused, which holds the frame the insertion stands for
+ * and silences the recording under it — and a wall clock runs the insertion out.
+ *
+ * Parked, not re-seeked: writing `currentTime` every frame to a still-playing element
+ * is a seek storm the decoder never settles out of, and that is what "playback stops at
+ * the insertion" actually was. The cost is that `.paused` stops answering "is the
+ * film stopped?" — see `filmPlaying` in the tick. */
+ const insertionRef = useRef<{ rawSec: number; durationSec: number; startedAtMs: number } | null>(
+ null,
+ );
+ /** How far into the insertion the wall clock has run, in seconds. Read by
+ * `updateVirtualTime` so the RULER position it publishes crosses the insertion while the
+ * RAW second it publishes alongside stands still at the insertion's own moment. */
const insertionElapsedRef = useRef(0);
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
@@ -698,34 +694,25 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
- // Play the insertion FIRST: everything below is positioned against a clock that is
- // running through it — the imported tracks especially, which sit on the programme
- // clock exactly as they do in the render's output stream.
- let insertionElapsedSec = 0;
+ // Run the insertion out FIRST: everything below is positioned against a clock that
+ // crosses it — the imported tracks especially, which sit on the programme clock
+ // exactly as they do in the render's output stream.
const insertion = insertionRef.current;
if (insertion) {
- insertionElapsedSec = Math.min(
- insertion.durationSec,
- (performance.now() - insertion.startedAtMs) / 1000,
- );
- if (insertionElapsedSec >= insertion.durationSec) {
+ const elapsedSec = (performance.now() - insertion.startedAtMs) / 1000;
+ // `!v.paused` means something un-parked the element under us — the transport.
+ if (elapsedSec >= insertion.durationSec || !v.paused) {
insertionRef.current = null;
- insertionElapsedSec = 0;
- v.muted = mutedBeforeInsertionRef.current;
+ insertionElapsedRef.current = 0;
+ if (v.paused) void v.play().catch(() => undefined);
} else {
- // Re-pinned every frame: the element is still playing, so left alone it
- // would decode straight past the fixed frame the insertion stands for.
- if (Math.abs(v.currentTime - insertion.sourceSec) > 0.01) {
- try {
- v.currentTime = insertion.sourceSec;
- } catch {
- // not seekable this instant; the next frame retries
- }
- }
- v.muted = true;
+ insertionElapsedRef.current = elapsedSec;
}
}
- insertionElapsedRef.current = insertionElapsedSec;
+ // A PARKED element is not a stopped film, and this is the question every gate
+ // below actually means: the picture is held on the insertion's frame on purpose
+ // while the programme keeps running over it.
+ const filmPlaying = !v.paused || insertionRef.current !== null;
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -775,7 +762,7 @@ export function VirtualPreview({
virtualTimeSecRef.current,
filmInsertsRef.current,
speedRegionsRef.current,
- ) + insertionElapsedSec;
+ ) + insertionElapsedRef.current;
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -867,7 +854,7 @@ export function VirtualPreview({
// media metadata not ready yet
}
}
- if (!v.paused && trackTarget.shouldPlay && el.paused) {
+ if (filmPlaying && trackTarget.shouldPlay && el.paused) {
// Resume a context suspended by autoplay policy, exactly as the primary
// loop does above — otherwise a track that starts while the primary
// element is silent (its span is over, or a recording with no separate
@@ -877,7 +864,7 @@ export function VirtualPreview({
}
const playback = el.play();
if (playback) void playback.catch(() => undefined);
- } else if ((v.paused || !trackTarget.shouldPlay) && !el.paused) {
+ } else if ((!filmPlaying || !trackTarget.shouldPlay) && !el.paused) {
el.pause();
}
}
@@ -886,7 +873,7 @@ export function VirtualPreview({
// bypasses React state entirely.
if (clockRef) {
clockRef.current.sourceTimeSec = v.currentTime;
- clockRef.current.isPlaying = !v.paused;
+ clockRef.current.isPlaying = filmPlaying;
clockRef.current.playbackRate = v.playbackRate;
clockRef.current.virtualTimeSec = virtualTimeSecRef.current;
}
@@ -977,7 +964,7 @@ export function VirtualPreview({
// `clockRef` et `setSourceTimeSec` ci-dessus continuent d'être publiés : la webcam
// et le calque curseur ont besoin du temps source même à l'arrêt. Seule la
// position de la TIMELINE cesse d'être dictée par le média.
- if (v.paused) {
+ if (!filmPlaying) {
return;
}
if (clipsRef.current.length === 0) {
@@ -1053,14 +1040,15 @@ export function VirtualPreview({
? undefined
: insertionEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
if (entering) {
- mutedBeforeInsertionRef.current = v.muted;
insertionRef.current = {
- insertId: entering.id,
rawSec: entering.atRawSec,
- sourceSec: position.sourceTimeSec,
durationSec: entering.durationSec,
startedAtMs: performance.now(),
};
+ insertionElapsedRef.current = 0;
+ // Parks the picture on the frame the insertion stands for, and silences the
+ // recording under it. Both are what the insertion IS.
+ v.pause();
// The insertion's own moment, not the frame we happened to land on: the
// transcript cue, the caption lookup and the audio mix all read this, and
// through the insertion the RECORDING really is at that one instant.
@@ -1173,17 +1161,11 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
- // A seek AWAY ends the insertion that was playing: the playhead is somewhere else
- // now, so the fixed frame it was pinning is not the frame any more. A seek TO the
- // insertion's own moment is not "away" — it is the echo of the position this very
- // tick published, and clearing on it would cut every insertion short the frame it
- // began.
- const playingInsertion = insertionRef.current;
- if (playingInsertion && Math.abs(playingInsertion.rawSec - nextVirtualTimeSec) > 1e-3) {
- insertionRef.current = null;
- const el = videoRef.current;
- if (el) el.muted = mutedBeforeInsertionRef.current;
- }
+ // A seek ends the insertion that was playing: the playhead is somewhere else now,
+ // so the frame it parked on is not the frame any more. The rAF's own seeks (clip
+ // advance, trim skip) are gated on `!v.paused` and so never land here mid-insertion.
+ insertionRef.current = null;
+ insertionElapsedRef.current = 0;
const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
From 8866d056efa41c38dd59a53b274f546115df3240 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 20:53:23 +0200
Subject: [PATCH 082/113] fix(timeline): the scrub release carries its ruler
second too
The drag published both coordinates on every move and then dropped one on pointerup,
which is the whole snap-back: the raw second alone lands on the insertion's near edge,
because it is the one position inside an insertion no raw value can name.
---
src/components/ai-edition/v4/V4Timeline.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 8b202fca8..b97f24ab7 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -960,8 +960,12 @@ export function V4Timeline({
rafSeekRef.current = 0;
}
if (pendingSeekTimeRef.current !== null) {
- setCurrentTime(pendingSeekTimeRef.current);
+ // The RULER second goes with it, or releasing over an insertion drops the
+ // only coordinate that could name where the pointer was: the raw second
+ // alone lands on the insertion's near edge, which is the snap-back.
+ setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
pendingSeekTimeRef.current = null;
+ pendingSeekRulerRef.current = null;
}
setScrubbingTimeSec(null);
setScrubRulerSec(null);
From 156b87b6718ccb79ca0298810be5b4b38cb3d2d6 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 21:44:21 +0200
Subject: [PATCH 083/113] =?UTF-8?q?refactor(timeline):=20one=20clock=20?=
=?UTF-8?q?=E2=80=94=20an=20insertion=20is=20media,=20so=20the=20clip=20is?=
=?UTF-8?q?=20longer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two contradictory models were living in the tree and fighting. Under the one I wrote, an
insertion took ZERO seconds of stored timeline and a second "ruler" was computed on top;
under the one the native compositor already assumed, it took its real duration. The
native path could therefore never see a held segment at all — its raw span was zero wide
— which is why playback stuttered exactly where the user was looking.
An insertion is MEDIA inside the clip, so the clip carrying it is that much longer. That
is now said once, in `reflowClipsForInserts`, called from the single writer that keeps
the ranges true. It is absolute rather than incremental, so it is idempotent and doubles
as the migration for documents written before insertions existed.
Everything downstream then reads ONE coordinate, and the second one goes away with all
its conversions: `expandRawSec`, `collapseRawSec`, `totalInsertedSec`, the store's
`currentRulerSec` and its plumbing, and thirty lines of interleaving inside
`projectRawTimelineSecToPlayback`, which is now the identity when nothing is cut.
What replaces them is one fact stated in one place: inside a clip carrying insertions,
source ↔ timeline is no longer a plain shift. `sourceToTimelineSec` / `timelineToSourceSec`
say so, and the six mappers that used to shift by hand call them — the kept spans, the
removed spans, the caption placement, the preview's two position lookups, and the native
decoder's segment spans. `timelineToSourceSec` also answers the question a shift cannot:
inside an insertion there is no source moment, and it names the insertion instead.
Two tests changed because they pinned the old model, and say so now: the clip lengthens
with the word, and a take's cues do not move when the film below it gains an insertion.
---
.../ai-edition/NativeCompositorOverlay.tsx | 8 +-
src/components/ai-edition/NewEditorShell.tsx | 21 +-
src/components/ai-edition/VirtualPreview.tsx | 45 ++--
src/components/ai-edition/v4/V4Timeline.tsx | 86 ++------
.../ai-edition/captions/captionLane.test.ts | 16 +-
src/lib/ai-edition/captions/cues.ts | 26 +--
src/lib/ai-edition/document/timeline.ts | 100 +++++----
.../ai-edition/document/transcript.test.ts | 24 ++-
src/lib/ai-edition/document/transcript.ts | 13 +-
src/lib/ai-edition/store/projectStore.ts | 24 +--
.../ai-edition/timeline/inserted-time.test.ts | 194 ++++++++----------
src/lib/ai-edition/timeline/inserted-time.ts | 127 +++++++-----
.../timeline/programme-time.test.ts | 98 +++++----
src/lib/ai-edition/timeline/programme-time.ts | 33 ++-
src/lib/ai-edition/timeline/timelineMap.ts | 8 +-
.../ai-edition/timeline/virtual-preview.ts | 33 ++-
src/native/sceneDescription.ts | 3 +-
src/native/useNativePlaybackSync.ts | 9 +-
18 files changed, 461 insertions(+), 407 deletions(-)
diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx
index e4af64102..18b63f72e 100644
--- a/src/components/ai-edition/NativeCompositorOverlay.tsx
+++ b/src/components/ai-edition/NativeCompositorOverlay.tsx
@@ -74,7 +74,13 @@ export function NativeCompositorOverlay() {
return resolveVisibleClips(document);
}, [document]);
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
+ () =>
+ resolveNativePosition(
+ currentTimeSec,
+ nativeClips,
+ document?.timeline.clips ?? [],
+ document?.timeline.insertRanges ?? [],
+ ),
[nativeClips, currentTimeSec, document],
);
const activeClip = activePosition?.clip ?? null;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index fdfcd1110..93c5c7eb4 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -21,7 +21,12 @@ import {
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
-import { type AxcutAudioTrack, type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
+import {
+ type AxcutAudioTrack,
+ type AxcutClip,
+ type AxcutInsertRange,
+ documentSchema,
+} from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
@@ -92,15 +97,17 @@ const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
function NativePlaybackSync({
visibleClips,
clips,
+ insertRanges,
}: {
visibleClips: AxcutClip[];
clips: AxcutClip[];
+ insertRanges: readonly AxcutInsertRange[];
}) {
const playing = useProjectStore((s) => s.playing);
const currentTimeSec = useProjectStore((s) => s.currentTimeSec);
// visibleClips = trim-compressed native stream; `clips` = RAW layout currentTimeSec
// is measured against. resolveNativePosition needs both (see timelineMap).
- useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips);
+ useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips, insertRanges);
return null;
}
@@ -429,8 +436,8 @@ export function NewEditorShell() {
);
const handleTimeChange = useCallback(
- (timeSec: number, rulerSec?: number) => {
- setCurrentTime(timeSec, rulerSec);
+ (timeSec: number) => {
+ setCurrentTime(timeSec);
},
[setCurrentTime],
);
@@ -1428,7 +1435,11 @@ export function NewEditorShell() {
className={v4.app}
style={{ gridTemplateRows: `58px 1fr ${showTimeline ? timelineRow : "0px"}` }}
>
-
+ void;
+ onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
durationSec: number,
assetId: string,
@@ -591,6 +585,10 @@ export function VirtualPreview({
* `updateVirtualTime` so the RULER position it publishes crosses the insertion while the
* RAW second it publishes alongside stands still at the insertion's own moment. */
const insertionElapsedRef = useRef(0);
+ // The ranges themselves for anything that maps through a clip; their timeline positions
+ // for the one thing that asks "did this frame run into one".
+ const insertRangesRef = useRef(insertRanges);
+ insertRangesRef.current = insertRanges;
const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
@@ -760,7 +758,7 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
virtualTimeSecRef.current,
- filmInsertsRef.current,
+ insertRangesRef.current,
speedRegionsRef.current,
) + insertionElapsedRef.current;
for (const track of audioTracksRef.current) {
@@ -770,7 +768,7 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
track.startMs / 1000,
- filmInsertsRef.current,
+ insertRangesRef.current,
speedRegionsRef.current,
);
// Length is measured WITHOUT speed, position WITH it. A trim REMOVES
@@ -785,13 +783,13 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
track.endMs / 1000,
- filmInsertsRef.current,
+ insertRangesRef.current,
) -
projectRawTimelineSecToPlayback(
clipsRef.current,
trimRangesRef.current,
track.startMs / 1000,
- filmInsertsRef.current,
+ insertRangesRef.current,
),
);
// A voiceover follows the cuts AND its own insertions, through one walk over
@@ -985,6 +983,7 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (pos) {
activeClipIdRef.current = pos.clip.id;
@@ -1055,7 +1054,10 @@ export function VirtualPreview({
updateVirtualTime(entering.atRawSec);
return;
}
- updateVirtualTime(nextRawTime);
+ // While an insertion plays the element is parked, so the position it reports stands
+ // still at where the insertion opens. Its own wall clock is what carries the
+ // playhead across it — nothing else is moving. Zero the rest of the time.
+ updateVirtualTime(nextRawTime + insertionElapsedRef.current);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
@@ -1076,14 +1078,7 @@ export function VirtualPreview({
const updateVirtualTime = useCallback(
(nextTimeSec: number) => {
setVirtualTimeSec(nextTimeSec);
- // Two coordinates, one publish. The raw second says where the RECORDING is; the
- // ruler second says where on the timeline the playhead is, which is the only one
- // that can move while an insertion plays — the recording is standing still at the
- // insertion's moment for its whole duration.
- onTimeChange?.(
- nextTimeSec,
- expandRawSec(nextTimeSec, filmInsertsRef.current) + insertionElapsedRef.current,
- );
+ onTimeChange?.(nextTimeSec);
// ponytail: mirrors main's per-frame `video.playbackRate = ...`
// (videoEventHandlers.ts) — the browser does the actual time
// warping, so this is the only thing speed regions need. No
@@ -1166,7 +1161,7 @@ export function VirtualPreview({
// advance, trim skip) are gated on `!v.paused` and so never land here mid-insertion.
insertionRef.current = null;
insertionElapsedRef.current = 0;
- const position = locateVirtualPosition(clips, nextVirtualTimeSec);
+ const position = locateVirtualPosition(clips, nextVirtualTimeSec, insertRanges);
if (!position) {
videoRef.current?.pause();
updateVirtualTime(0);
@@ -1273,7 +1268,11 @@ export function VirtualPreview({
// the one that has to come back. An asset switch queued in the
// meantime is newer intent still, so it wins outright.
if (!pendingSeekRef.current) {
- const position = locateVirtualPosition(clipsRef.current, virtualTimeSecRef.current);
+ const position = locateVirtualPosition(
+ clipsRef.current,
+ virtualTimeSecRef.current,
+ insertRangesRef.current,
+ );
// `locateVirtualPosition` answers for whatever clip the playhead
// is on, which after a boundary advance can belong to a DIFFERENT
// asset — its source time would be a meaningless offset into the
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index b97f24ab7..ef487f931 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -51,13 +51,9 @@ import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
import {
- collapseRawSec,
- expandRawSec,
type InsertedWordMark,
insertedWordMarks,
- type RulerInsert,
rulerInserts,
- totalInsertedSec,
} from "@/lib/ai-edition/timeline/inserted-time";
import {
newRegionDurationSec,
@@ -230,15 +226,8 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
- /** The media added words inserted. `currentTimeSec` is a STORED second; the ruler it is
- * drawn on counts the insertions, so it has to be placed through them or it drifts from
- * the clips by the whole added time. */
- inserts: readonly RulerInsert[];
/** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
- /** The same drag's RULER position. Preferred when present: it is the only coordinate
- * that can name a moment INSIDE an insertion, which is zero raw seconds wide. */
- overrideRulerSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
playheadRef?: React.MutableRefObject;
@@ -262,35 +251,13 @@ interface PlayheadOverlayProps {
*/
const PlayheadOverlay = memo(function PlayheadOverlay({
totalSec,
- inserts,
overrideTimeSec,
- overrideRulerSec,
canvasStyle,
onPointerDown,
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const storeRulerSec = useProjectStore((s) => s.currentRulerSec);
- // The ruler position, from the most trustworthy source that has one.
- //
- // The raw second alone cannot draw this playhead: an insertion occupies ruler seconds
- // and none of the recording, so every ruler second inside one collapses to the same raw
- // moment and expanding it back puts the playhead on the insertion's near edge — where it
- // visibly stalls through playback, and where it snaps back to after a scrub released over
- // one (issue #560).
- //
- // The store's ruler second is trusted only when it still AGREES with the raw one: a
- // caller that predates insertions writes the raw value for both, which is right until an
- // insertion sits before it. Collapsing is the test, and expanding is the fallback.
- const storeSec =
- Math.abs(collapseRawSec(storeRulerSec, inserts).sec - storeTimeSec) < 1e-3
- ? storeRulerSec
- : expandRawSec(storeTimeSec, inserts);
- const pct =
- ((overrideRulerSec ??
- (overrideTimeSec !== null ? expandRawSec(overrideTimeSec, inserts) : storeSec)) /
- totalSec) *
- 100;
+ const pct = (((overrideTimeSec ?? storeTimeSec) / totalSec) * 100) as number;
return (
@@ -629,9 +596,7 @@ export function V4Timeline({
onAddVoiceover,
}: {
tl: TimelineApi;
- /** `rulerSec` is the same moment on the ruler the user sees; it differs from `sec` as
- * soon as an insertion sits before it, or under it. */
- setCurrentTime: (sec: number, rulerSec?: number) => void;
+ setCurrentTime: (sec: number) => void;
variant?: "edit" | "media";
onDropAsset?: (assetId: string) => Promise;
videoSources?: VideoSource[];
@@ -732,13 +697,11 @@ export function V4Timeline({
() =>
Math.max(
1,
- clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0) + totalInsertedSec(inserts),
+ clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0),
),
[clips, inserts],
);
const pctOf = useCallback((sec: number) => (sec / total) * 100, [total]);
- /** Stored raw seconds → a percentage of the expanded ruler. */
- const pctAt = useCallback((sec: number) => pctOf(expandRawSec(sec, inserts)), [pctOf, inserts]);
const showLanes = variant === "edit";
// The visible fraction of the timeline, and what one second is worth on screen
@@ -857,14 +820,8 @@ export function V4Timeline({
// pointer for the frame the store hasn't caught up on yet. Handed down as an
// override to the two components that read the playhead from the store.
const [scrubbingTimeSec, setScrubbingTimeSec] = useState(null);
- // The pointer's RULER position while scrubbing, kept apart from the raw one above.
- // Two numbers because they mean different things: the timecode reads the raw clock,
- // like the store, and the playhead has to be able to sit INSIDE an insertion — which
- // takes up none of the recording, so no raw value can address a moment within it.
- const [scrubRulerSec, setScrubRulerSec] = useState(null);
const rafSeekRef = useRef(0);
const pendingSeekTimeRef = useRef(null);
- const pendingSeekRulerRef = useRef(null);
// ── interactions ────────────────────────────────────────────────
const playheadElRef = useRef(null);
@@ -886,13 +843,7 @@ export function V4Timeline({
// supposed to be pointing at, which is what showed as the wrong subtitle under a
// correctly-placed playhead (issue #560).
//
- // Collapsing lands on the insertion's own moment when the pointer is inside one,
- // which is the honest answer: an insertion takes up none of the recording, so
- // there is no raw value inside it to seek the media to. The ruler position goes
- // to the store ALONGSIDE it, which is what lets the playhead stay where it was
- // released instead of snapping to the insertion's near edge.
- const rulerTime = pct * total;
- const { sec: targetTime } = collapseRawSec(rulerTime, inserts);
+ const targetTime = pct * total;
// Direct DOM playhead update (0ms latency, zero React re-render overhead)
if (playheadElRef.current) {
@@ -901,16 +852,14 @@ export function V4Timeline({
// Optimistic local UI state update
setScrubbingTimeSec(targetTime);
- setScrubRulerSec(rulerTime);
pendingSeekTimeRef.current = targetTime;
- pendingSeekRulerRef.current = rulerTime;
if (isImmediate) {
if (rafSeekRef.current !== 0) {
cancelAnimationFrame(rafSeekRef.current);
rafSeekRef.current = 0;
}
- setCurrentTime(targetTime, rulerTime);
+ setCurrentTime(targetTime);
return;
}
@@ -919,7 +868,7 @@ export function V4Timeline({
rafSeekRef.current = requestAnimationFrame(() => {
rafSeekRef.current = 0;
if (pendingSeekTimeRef.current !== null) {
- setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
+ setCurrentTime(pendingSeekTimeRef.current);
}
});
}
@@ -960,15 +909,10 @@ export function V4Timeline({
rafSeekRef.current = 0;
}
if (pendingSeekTimeRef.current !== null) {
- // The RULER second goes with it, or releasing over an insertion drops the
- // only coordinate that could name where the pointer was: the raw second
- // alone lands on the insertion's near edge, which is the snap-back.
- setCurrentTime(pendingSeekTimeRef.current, pendingSeekRulerRef.current ?? undefined);
+ setCurrentTime(pendingSeekTimeRef.current);
pendingSeekTimeRef.current = null;
- pendingSeekRulerRef.current = null;
}
setScrubbingTimeSec(null);
- setScrubRulerSec(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -1750,10 +1694,10 @@ export function V4Timeline({
compact ? ` ${styles.lanePillCompact}` : ""
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
- left: `${pctAt(seg.segStart)}%`,
+ left: `${pctOf(seg.segStart)}%`,
// Measured on the expanded ruler at BOTH ends: a region straddling an insertion
// covers it, so its box has to grow by that insertion and not merely slide.
- width: `${pctOf(expandRawSec(seg.segEnd, inserts) - expandRawSec(seg.segStart, inserts))}%`,
+ width: `${pctOf(seg.segEnd - seg.segStart)}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
? undefined
@@ -2164,7 +2108,7 @@ export function V4Timeline({
{tick.major ? (
{fmtTick(tick.sec, rulerTicks.step)}
@@ -2254,7 +2198,7 @@ export function V4Timeline({
// ruler and the audio pills were drawn on the stored one, so any
// insertion in the film slid the two lanes apart. The take keeps its
// own length — only its head follows the ruler.
- leftPct={pctAt(start)}
+ leftPct={pctOf(start)}
widthPct={pctOf(widthSec)}
row={audioRows.rowOf.get(track.id) ?? 0}
rowHeight={AUDIO_ROW_HEIGHT_PX + AUDIO_ROW_GAP_PX}
@@ -2319,8 +2263,8 @@ export function V4Timeline({
// On the expanded ruler the box also carries whatever insertions fall
// inside it — the film really does stay on this clip's frame for
// them, so they belong to its box rather than between boxes.
- const boxStart = expandRawSec(c.timelineStartSec, inserts);
- const boxEnd = expandRawSec(c.timelineEndSec, inserts);
+ const boxStart = c.timelineStartSec;
+ const boxEnd = c.timelineEndSec;
const boxLen = boxEnd - boxStart;
const asset = tl.assets.find((a) => a.id === c.assetId);
const clipVideoUrl = videoSources.find((v) => v.id === c.assetId)?.src;
@@ -2407,7 +2351,7 @@ export function V4Timeline({
// SOURCE span, in the same ternary — two clocks, one of which the
// box is not drawn in.
const inserted = inserts.find((ins) => ins.wordId === wordId);
- const left = ((expandRawSec(atRawSec, inserts) - boxStart) / boxLen) * 100;
+ const left = ((atRawSec - boxStart) / boxLen) * 100;
const width = inserted ? (inserted.durationSec / boxLen) * 100 : 0;
return (
{
expect(texts(corrected, "voiceover")).toContain("Kubernetes words");
});
- it("measures a pause against the FILM, on either lane", () => {
- // A pause is a held CLIP frame. Measuring it against the take instead would land
- // every voiceover cue early — so the inserts stay clips-derived on both lanes.
+ it("leaves a take's cues alone when the FILM gains an insertion", () => {
+ // An insertion is media inside the clip that carries it, and it lengthens that clip.
+ // A take laid over the film keeps its own position on the timeline — the picture
+ // slides underneath it — so its cues do not move either. Measured per placement,
+ // through the asset the placement actually plays.
const paused = doc({
timeline: {
...doc().timeline,
@@ -164,11 +166,9 @@ describe("captionLane", () => {
});
const before = deriveCaptionCues(doc(), on("voiceover"), {});
const after = deriveCaptionCues(paused, on("voiceover"), {});
- // The line covers the held moment, so its END is pushed out by the second the film
- // gained and it stays on screen through the pause. That it moves AT ALL is the
- // point: the insert names the recording's asset, so a placement-derived ruler
- // would have found nothing to apply and left the voiceover cue where it was.
- expect(after[0].endMs - before[0].endMs).toBeCloseTo(1000, 0);
+ // The insertion names the RECORDING's asset. The take is a different asset laid at
+ // its own timeline position, so nothing about this cue changes.
expect(after[0].startMs).toBe(before[0].startMs);
+ expect(after[0].endMs).toBe(before[0].endMs);
});
});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 78fd9250f..553f1b856 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -22,10 +22,10 @@ import {
splitMergedCaptionsByWordBounds,
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
-import type { AxcutDocument, AxcutTranscript } from "../schema";
+import type { AxcutDocument, AxcutInsertRange, AxcutTranscript } from "../schema";
import { lanePlacements, type TranscriptPlacement } from "../timeline/aggregated-transcript";
import { takeInserts } from "../timeline/insert-mapping";
-import { expandRawSec, type RulerInsert, rulerInserts } from "../timeline/inserted-time";
+import { sourceToTimelineSec } from "../timeline/inserted-time";
import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
@@ -165,7 +165,7 @@ export function sourceSpanToTimelineSpans(
* window and the ruler head, which both providers carry (issue #560). `AxcutClip`
* stays structurally assignable, so every existing caller is unaffected. */
clips: TranscriptPlacement[],
- inserts: readonly RulerInsert[] = [],
+ inserts: readonly AxcutInsertRange[] = [],
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -174,20 +174,15 @@ export function sourceSpanToTimelineSpans(
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
+ // `"closes"` on the end: a line running up to an added word covers the media that
+ // word inserted, so it stays on screen through it instead of going dark over the
+ // one moment the word exists for.
out.push({
- startSec: clip.timelineStartSec + (s - clip.sourceStartSec),
- endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
+ startSec: sourceToTimelineSec(clip, s, inserts, "opens"),
+ endSec: sourceToTimelineSec(clip, e, inserts, "closes"),
});
}
- // Onto the ruler the viewer actually sees. Expanding BOTH ends does the whole job:
- // a line after a pause slides along by it, and a line that covers the held moment
- // has only its end pushed out — so it stays on screen through the pause instead of
- // going dark over the one moment an added word exists for.
- if (inserts.length === 0) return out;
- return out.map((span) => ({
- startSec: expandRawSec(span.startSec, inserts),
- endSec: expandRawSec(span.endSec, inserts),
- }));
+ return out;
}
/**
@@ -222,7 +217,6 @@ export function deriveCaptionCues(
// lengthens the ruler under everything, including a voiceover laid over it. Feeding it
// placements would measure the pause against the take instead of the film, and land
// every voiceover cue early.
- const inserts = rulerInserts(document.timeline.insertRanges ?? [], document.timeline.clips);
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
const linesByAsset = new Map();
@@ -244,7 +238,7 @@ export function deriveCaptionCues(
line.startSec,
line.endSec,
placements,
- inserts,
+ document.timeline.insertRanges ?? [],
)) {
const startMs = Math.round(span.startSec * 1000);
const endMs = Math.max(Math.round(span.endSec * 1000), startMs + 1);
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index d61ce3475..791389d2b 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -21,7 +21,6 @@ import type {
*/
export type PlaybackSegment = AxcutClip & { heldSec?: number };
-import type { RulerInsert } from "../timeline/inserted-time";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -136,6 +135,54 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
+/** How much media this clip's own insertions add to it, in seconds.
+ *
+ * Half-open at the start and closed at the end, matching where an insertion sits: at the
+ * END of the word it follows, which is a moment the clip plays. */
+export function insertedSecForClip(
+ clip: AxcutClip,
+ insertRanges: readonly AxcutInsertRange[],
+): number {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ return insertRanges.reduce(
+ (sum, range) =>
+ range.assetId === clip.assetId &&
+ range.atSec > clip.sourceStartSec &&
+ range.atSec <= sourceEnd + 1e-6
+ ? sum + range.durationSec
+ : sum,
+ 0,
+ );
+}
+
+/**
+ * Clip geometry that accounts for the media inserted inside each clip (issue #560).
+ *
+ * An added word inserts media — a fixed frame and silence, until there is a generator for
+ * it — and a clip carrying it is that much longer, exactly as it would be if the media had
+ * come from a file. This is the ONE place that says so; every reader downstream then works
+ * in a single coordinate, which is what makes the playhead, the native decoder and the
+ * export agree without any of them converting between two rulers.
+ *
+ * Absolute rather than incremental, so it is idempotent: a stored clip's length is always
+ * its source length (every writer above builds it that way), and re-running this on an
+ * already-reflowed document changes nothing. That is what lets it also serve as the
+ * migration for documents written before insertions existed.
+ */
+export function reflowClipsForInserts(
+ clips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
+): AxcutClip[] {
+ return resequenceClips(
+ clips.map((clip) => {
+ const sourceLen = (clip.sourceEndSec ?? clip.sourceStartSec) - clip.sourceStartSec;
+ if (sourceLen <= 0) return clip; // duration not probed yet; leave it to the prober
+ const len = sourceLen + insertedSecForClip(clip, insertRanges);
+ return { ...clip, timelineEndSec: clip.timelineStartSec + len };
+ }),
+ );
+}
+
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
return clips.map((c) => {
@@ -372,14 +419,13 @@ export function projectRawTimelineSecToPlayback(
trimRanges: AxcutTrimRange[],
rawSec: number,
/**
- * The recording lane's insertions, already placed on the raw ruler by `rulerInserts`.
+ * The insertions, so the kept spans below can carry them.
*
- * REQUIRED, not optional, and every call site was migrated with it. An optional
- * parameter would silently keep the early-audio bug alive at every site that had not
- * been touched yet — which is the exact failure this argument exists to fix, and the
- * kind that shows up as "the music starts a beat early" months later.
+ * REQUIRED, not optional: an optional parameter would silently keep the early-audio bug
+ * alive at every site not yet touched — the one that shows up as "the music starts a
+ * beat early" months later.
*/
- filmInserts: readonly RulerInsert[],
+ insertRanges: readonly AxcutInsertRange[],
/**
* Speed regions on the raw ruler. Supplied by the AUDIO paths, which overlay
* a 1x track onto the finished programme and so need its real, speed-adjusted
@@ -393,39 +439,17 @@ export function projectRawTimelineSecToPlayback(
let lastRawEnd = 0; // raw end of the last kept segment, for the trailing overhang
let landed: number | null = null; // output(rawSec), once it falls in/before a kept segment
- // An insertion occupies ZERO raw seconds and D OUTPUT seconds — it is the one
- // thing a flat kept-interval list cannot express, which is why it was left out and why
- // every audio track after an insertion has been landing D seconds early in both the preview
- // and the export. Interleaved here rather than added afterwards, because where the insertion
- // sits inside the kept span decides which side of it `rawSec` falls on.
- const holds = [...filmInserts].sort((a, b) => a.atRawSec - b.atRawSec);
- let nextHold = 0;
-
// The kept stretches come from `keptRawSpans`, which is this walk — it was lifted out of
// here so the transcript lanes and the audio mix could ask the same question and get the
- // same answer (issue #560). Trims only REMOVE, so a kept span's RAW length is what
- // survives; how long it takes to PLAY is a separate question `outputDurationOfRawSpan`
- // answers, because a speed region scales it.
- for (const seg of keptRawSpans(ordered, trimRanges)) {
- let from = seg.startSec;
- // Every insertion this segment carries, in order. One whose moment a trim removed is
- // in no kept span at all and is never reached — the moment it holds is not in the
- // film any more, so neither is the insertion.
- while (nextHold < holds.length && holds[nextHold].atRawSec < seg.startSec) nextHold++;
- while (nextHold < holds.length && holds[nextHold].atRawSec < seg.endSec) {
- const hold = holds[nextHold++];
- const at = Math.max(from, hold.atRawSec);
- if (landed === null && rawSec < at) {
- const within = Math.min(Math.max(rawSec, from), at);
- landed = outCursor + outputDurationOfRawSpan(from, within, speedRegions);
- }
- outCursor += outputDurationOfRawSpan(from, at, speedRegions);
- from = at;
- // Strictly after the insertion's own moment, matching `expandRawSec`: a track whose
- // head sits exactly there starts WITH the insertion, not after it.
- if (landed === null && rawSec <= at) landed = outCursor;
- outCursor += hold.durationSec;
- }
+ // same answer (issue #560). It carries the insertions too: they are timeline seconds like
+ // any other, which is exactly what one clock buys — this walk used to interleave them
+ // itself, and every reader that forgot to had audio landing early.
+ //
+ // Trims only REMOVE, so a kept span's length is what survives; how long it takes to PLAY
+ // is a separate question `outputDurationOfRawSpan` answers, because a speed region scales
+ // it.
+ for (const seg of keptRawSpans(ordered, trimRanges, insertRanges)) {
+ const from = seg.startSec;
if (landed === null && rawSec < seg.endSec) {
// `rawSec` is inside this segment, or before it in a trimmed/gap region (then
// the span clamps to nothing → the output edge just before the gap).
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 87c6b8213..fdc3909d1 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -750,11 +750,29 @@ describe("insert ranges", () => {
expect(insertRangesMatchWords(take)).toBe(true);
});
- // The clips are the thing the first attempt broke. Nothing here may touch them.
- it("leaves the clips exactly as they were", () => {
+ // An insertion is MEDIA inside the clip, so the clip carrying it is exactly that much
+ // longer — the one fact every reader downstream depends on, and the reason none of them
+ // needs a second ruler to convert to. Its source window is untouched: no frame of the
+ // recording was added or removed.
+ it("lengthens the clip that carries the insertion, by the insertion", () => {
const before = docWithClip();
const result = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
- expect(result.timeline.clips).toEqual(before.timeline.clips);
+ const [range] = result.timeline.insertRanges;
+ const was = before.timeline.clips[0];
+ const now = result.timeline.clips[0];
+ expect(now.timelineEndSec - now.timelineStartSec).toBeCloseTo(
+ was.timelineEndSec - was.timelineStartSec + range.durationSec,
+ 5,
+ );
+ expect(now.sourceStartSec).toBe(was.sourceStartSec);
+ expect(now.sourceEndSec).toBe(was.sourceEndSec);
+ });
+
+ it("gives the length back when the word goes", () => {
+ const before = docWithClip();
+ const added = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
+ const removed = removeDocumentWords(added, "asset_1", ["synth_1"]);
+ expect(removed.timeline.clips).toEqual(before.timeline.clips);
});
it("stores nothing when the word fits in silence that is already there", () => {
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 87d6a5800..8d9c49487 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,5 +1,6 @@
import type { AxcutDocument, AxcutInsertRange, AxcutTranscript, AxcutWord } from "../schema";
import { createId } from "./ids";
+import { reflowClipsForInserts } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -385,7 +386,17 @@ function withInsertRangesForWords(document: AxcutDocument, assetId: string): Axc
if (kept.length === existing.length && kept.every((range, i) => range === existing[i])) {
return document;
}
- return { ...document, timeline: { ...document.timeline, insertRanges: kept } };
+ // The clips grow with them. An insertion is media inside the clip, so the clip is that
+ // much longer — the single fact every downstream reader needs, written once, here, where
+ // the ranges themselves are written.
+ return {
+ ...document,
+ timeline: {
+ ...document.timeline,
+ insertRanges: kept,
+ clips: reflowClipsForInserts(document.timeline.clips, kept),
+ },
+ };
}
/**
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index 69d0cd340..fd47da9cc 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -60,20 +60,6 @@ export interface ProjectState {
error: string | null;
sourceDurationSec: number;
currentTimeSec: number;
- /** The playhead's position on the RULER — the timeline the user sees and scrubs.
- *
- * `currentTimeSec` is the stored, RAW second: where the recording is. The two are the
- * same number until an insertion exists, and then they permanently are not. An insertion
- * is media added INSIDE a clip (issue #560), so it takes up ruler seconds while taking up
- * none of the recording — every ruler second inside one names the same raw moment.
- *
- * Which means the raw second cannot say WHERE inside an insertion the playhead is, and a
- * playhead that only had that number fell to the insertion's near edge the instant it
- * entered: playback stalled visually there, and releasing a scrub over one snapped back
- * to its start. Consumers that resolve MEDIA (seek, captions, transcript cue, mix) keep
- * reading `currentTimeSec` — through an insertion the recording really is at that one
- * instant. Only what draws or measures the ruler reads this. */
- currentRulerSec: number;
/** The selected imported audio track (issue #350), or null. In the store — not
* `useTimeline`'s local selection — because the media panel (which imports the
* file) and the inspector (which edits it) sit in different component subtrees
@@ -160,9 +146,7 @@ export interface ProjectState {
opts: DocumentWriteOptions,
) => Promise;
setSourceDuration: (sec: number) => void;
- /** `rulerSec` defaults to `sec`, which is right everywhere no insertion is involved
- * and is what every existing caller means. */
- setCurrentTime: (sec: number, rulerSec?: number) => void;
+ setCurrentTime: (sec: number) => void;
setPlaying: (playing: boolean) => void;
markClean: () => void;
/**
@@ -213,7 +197,6 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
- currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
@@ -566,8 +549,8 @@ export const useProjectStore = create((set, get) => ({
set({ sourceDurationSec: sec });
},
- setCurrentTime(sec, rulerSec) {
- set({ currentTimeSec: sec, currentRulerSec: rulerSec ?? sec });
+ setCurrentTime(sec) {
+ set({ currentTimeSec: sec });
},
setPlaying(playing) {
@@ -598,7 +581,6 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
- currentRulerSec: 0,
selectedAudioTrackId: null,
playing: false,
dirty: false,
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index bd16a698c..7114afc6d 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -8,16 +8,15 @@
import { describe, expect, it } from "vitest";
import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
import {
- collapseRawSec,
- expandRawSec,
insertedWordMarks,
insertionEnteredBetween,
type RulerInsert,
rulerInserts,
- totalInsertedSec,
+ sourceToTimelineSec,
+ timelineToSourceSec,
} from "./inserted-time";
-function clip(overrides: Partial & Pick): AxcutClip {
+function clipFixture(overrides: Partial & Pick): AxcutClip {
return {
assetId: "a1",
sourceStartSec: 0,
@@ -47,7 +46,9 @@ function insert(overrides: Partial = {}): AxcutInsertRange {
describe("rulerInserts", () => {
it("projects a pause through the clip that plays its moment", () => {
// The clip plays source 4–10 starting at ruler 20, so source 6 is ruler 22.
- const clips = [clip({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 })];
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 }),
+ ];
expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([
{ id: "ins_1", wordId: "synth_1", atRawSec: 22, durationSec: 0.5 },
]);
@@ -56,18 +57,22 @@ describe("rulerInserts", () => {
// The word is not on the timeline, so its pause has no place on the ruler and adds
// nothing — the same rule a caption line follows when no clip covers it.
it("drops a pause no clip plays", () => {
- const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 })];
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 }),
+ ];
expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([]);
});
it("counts a pause sitting exactly on a clip's edge", () => {
// A pause sits at the END of the word it follows, which is routinely the boundary.
- const clips = [clip({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 })];
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 }),
+ ];
expect(rulerInserts([insert({ atSec: 4 })], clips)).toHaveLength(1);
});
it("returns them in ruler order, whatever order they were stored in", () => {
- const clips = [clip({ id: "c1" })];
+ const clips = [clipFixture({ id: "c1" })];
const placed = rulerInserts(
[insert({ id: "b", atSec: 8 }), insert({ id: "a", atSec: 2 })],
clips,
@@ -77,87 +82,13 @@ describe("rulerInserts", () => {
it("places a pause only once when two clips could play its moment", () => {
const clips = [
- clip({ id: "c1" }),
- clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
+ clipFixture({ id: "c1" }),
+ clipFixture({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
];
expect(rulerInserts([insert()], clips)).toHaveLength(1);
});
});
-describe("the expanded ruler", () => {
- const INSERTS: RulerInsert[] = [
- { id: "a", wordId: "w_a", atRawSec: 2, durationSec: 0.5 },
- { id: "b", wordId: "w_b", atRawSec: 6, durationSec: 1 },
- ];
-
- it("leaves everything before the first pause where it was", () => {
- expect(expandRawSec(0, INSERTS)).toBe(0);
- expect(expandRawSec(1.9, INSERTS)).toBe(1.9);
- });
-
- // The frame about to be held keeps its own instant; the pause opens after it.
- it("keeps the held moment itself in place", () => {
- expect(expandRawSec(2, INSERTS)).toBe(2);
- });
-
- it("shifts everything after a pause by what it added", () => {
- expect(expandRawSec(3, INSERTS)).toBe(3.5);
- expect(expandRawSec(6, INSERTS)).toBe(6.5);
- expect(expandRawSec(7, INSERTS)).toBe(8.5);
- });
-
- it("grows the ruler by the pauses' total", () => {
- expect(totalInsertedSec(INSERTS)).toBe(1.5);
- expect(expandRawSec(10, INSERTS)).toBe(10 + totalInsertedSec(INSERTS));
- });
-
- it("round-trips every moment that is not inside a pause", () => {
- for (const sec of [0, 1.9, 3, 5.99, 7, 10]) {
- const back = collapseRawSec(expandRawSec(sec, INSERTS), INSERTS);
- expect(back.sec).toBeCloseTo(sec, 9);
- expect(back.heldBy).toBeNull();
- }
- });
-
- // The held moment is the one place the pair is not a clean inverse, and it is not
- // meant to be: source 2 occupies the WHOLE of ruler [2, 2.5) — it is what the pause
- // shows. Expanding picks the start of that stretch; collapsing it back answers with
- // the same source moment and says it is being held, which is the honest reading of a
- // moment that is on screen for half a second.
- it("says the held moment is held, and still names the right source moment", () => {
- const back = collapseRawSec(expandRawSec(2, INSERTS), INSERTS);
- expect(back.sec).toBe(2);
- expect(back.heldBy?.id).toBe("a");
- });
-
- // Not a gap in the model — this IS the pause. A stretch of ruler stands for one held
- // source moment, and the caller is told which pause is holding it so it parks the
- // decoder instead of seeking through content that belongs after.
- it("collapses a moment inside a pause onto the frame being held", () => {
- for (const sec of [2.01, 2.25, 2.49]) {
- const back = collapseRawSec(sec, INSERTS);
- expect(back.sec).toBe(2);
- expect(back.heldBy?.id).toBe("a");
- }
- });
-
- it("resumes on the far side of a pause", () => {
- const back = collapseRawSec(2.5, INSERTS);
- expect(back.sec).toBe(2);
- expect(back.heldBy).toBeNull();
- });
-
- it("counts every earlier pause when collapsing a later moment", () => {
- // Ruler 8.5 is source 7: 0.5s from the first pause and 1s from the second.
- expect(collapseRawSec(8.5, INSERTS)).toEqual({ sec: 7, heldBy: null });
- });
-
- it("is the identity when there are no pauses", () => {
- expect(expandRawSec(4, [])).toBe(4);
- expect(collapseRawSec(4, [])).toEqual({ sec: 4, heldBy: null });
- });
-});
-
// ─── Where an added word's mark goes ─────────────────────────────────────────
// Issue #560. Two defects lived in one ternary in V4Timeline: a word WITH a pause was
// placed on the expanded ruler and one WITHOUT at a fraction of the clip's SOURCE span —
@@ -232,37 +163,84 @@ describe("insertedWordMarks", () => {
});
});
-// ─── The scrub round-trip ───────────────────────────────────────────────────
-// The timeline measures the pointer against the EXPANDED ruler and writes the result into
-// a store every consumer reads as a RAW second — the preview seek, the caption lookup, the
-// transcript cue, the audio mix. Straight through, the playhead sat one accumulated pause
-// AHEAD of everything it pointed at: the right playhead, the wrong subtitle (issue #560).
-
-describe("a scrub survives the round trip", () => {
- const marks: RulerInsert[] = [
- { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
- { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+// ─── Source ↔ timeline, inside one clip ─────────────────────────────────────
+// The whole consequence of an insertion being MEDIA: the clip is longer than its source
+// window, so a moment past an insertion sits that much further along the timeline. Every
+// place that used to convert between a "raw" and an "expanded" ruler is asking this, of
+// one clip — and getting it wrong put a caption, a playhead or a decoder in the wrong
+// place (issue #560).
+
+describe("source ↔ timeline through a clip that carries insertions", () => {
+ // Ten seconds of recording laid at timeline 0, with 0.5s inserted at source 2 and 1s
+ // at source 6 — so the clip is 11.5s long and its source window is untouched.
+ const clip = clipFixture({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11.5,
+ });
+ const ranges: AxcutInsertRange[] = [
+ {
+ id: "a",
+ assetId: "a1",
+ atSec: 2,
+ durationSec: 0.5,
+ wordId: "w_a",
+ reason: "",
+ origin: "user",
+ },
+ {
+ id: "b",
+ assetId: "a1",
+ atSec: 6,
+ durationSec: 1,
+ wordId: "w_b",
+ reason: "",
+ origin: "user",
+ },
];
- it("comes back to the raw second it started from, before and after each pause", () => {
- for (const raw of [0, 1.5, 3.9, 6, 8.5, 12, 30]) {
- expect(collapseRawSec(expandRawSec(raw, marks), marks).sec).toBeCloseTo(raw, 6);
+ it("leaves everything before the first insertion where it was", () => {
+ expect(sourceToTimelineSec(clip, 0, ranges)).toBeCloseTo(0, 6);
+ expect(sourceToTimelineSec(clip, 1.9, ranges)).toBeCloseTo(1.9, 6);
+ });
+
+ it("counts every insertion before the moment, and only those", () => {
+ expect(sourceToTimelineSec(clip, 4, ranges)).toBeCloseTo(4.5, 6);
+ expect(sourceToTimelineSec(clip, 10, ranges)).toBeCloseTo(11.5, 6);
+ });
+
+ it("puts the insertion's own moment where it opens, or where it closes", () => {
+ // The choice is real: a position and a span's START go before the inserted media,
+ // a span's END goes after it, so a caption running up to an added word covers it.
+ expect(sourceToTimelineSec(clip, 2, ranges, "opens")).toBeCloseTo(2, 6);
+ expect(sourceToTimelineSec(clip, 2, ranges, "closes")).toBeCloseTo(2.5, 6);
+ });
+
+ it("comes back to the source moment it started from", () => {
+ for (const source of [0, 1.9, 2, 3, 5.5, 6, 9.99]) {
+ const back = timelineToSourceSec(clip, sourceToTimelineSec(clip, source, ranges), ranges);
+ expect(back.sourceSec).toBeCloseTo(source, 6);
}
});
- it("lands on the held moment for a pointer inside a pause, and says so", () => {
- // Every ruler second inside an insertion is the same raw moment: none of those
- // seconds come from the recording, so there is nothing else it could mean.
- const inside = collapseRawSec(5, marks);
- expect(inside.sec).toBeCloseTo(4, 6);
- expect(inside.heldBy?.id).toBe("i1");
- expect(collapseRawSec(5.9, marks).sec).toBeCloseTo(4, 6);
+ it("has no source moment inside an insertion, and says which one", () => {
+ // There is nothing else it could answer: none of those seconds come from the file.
+ const inside = timelineToSourceSec(clip, 2.25, ranges);
+ expect(inside.sourceSec).toBeCloseTo(2, 6);
+ expect(inside.insideInsert?.id).toBe("a");
+ expect(timelineToSourceSec(clip, 2.5, ranges).insideInsert).toBeNull();
+ });
+
+ it("is the plain shift when the clip carries nothing", () => {
+ expect(sourceToTimelineSec(clip, 4, [])).toBeCloseTo(4, 6);
+ expect(timelineToSourceSec(clip, 4, []).sourceSec).toBeCloseTo(4, 6);
});
- it("counts every pause before the pointer, not just the first", () => {
- // Ruler 13 is past both: 13 − 2 − 1 = raw 10.
- expect(collapseRawSec(13, marks).sec).toBeCloseTo(10, 6);
- expect(collapseRawSec(13, marks).heldBy).toBeNull();
+ it("ignores insertions belonging to another recording", () => {
+ const other = [{ ...ranges[0], assetId: "a2" }];
+ expect(sourceToTimelineSec(clip, 4, other)).toBeCloseTo(4, 6);
});
});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index c6786d25c..c921ce878 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -45,86 +45,119 @@ export function rulerInserts(
clips: readonly AxcutClip[],
): RulerInsert[] {
const placed: RulerInsert[] = [];
- for (const insert of inserts) {
- for (const clip of clips) {
- if (clip.assetId !== insert.assetId) continue;
- const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
- // Inclusive at both edges: an insertion sits at the END of the word it follows, which
- // is routinely a clip's own boundary.
- if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
+ const claimed = new Set();
+ for (const clip of clips) {
+ const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
+ const mine = inserts
+ // Inclusive at both edges: an insertion sits at the END of the word it follows,
+ // which is routinely a clip's own boundary. Claimed once, by the first clip that
+ // plays the moment — two clips over the same recording are two places, not two
+ // insertions.
+ .filter(
+ (insert) =>
+ insert.assetId === clip.assetId &&
+ !claimed.has(insert.id) &&
+ insert.atSec >= clip.sourceStartSec &&
+ insert.atSec <= sourceEnd,
+ )
+ .sort((a, b) => a.atSec - b.atSec);
+ // Each insertion opens after the ones before it in the same clip: the clip's length
+ // already carries all of them, so a plain source-shift would stack them all at the
+ // first one's position.
+ let carriedSec = 0;
+ for (const insert of mine) {
+ claimed.add(insert.id);
placed.push({
id: insert.id,
wordId: insert.wordId,
- atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec),
+ atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + carriedSec,
durationSec: insert.durationSec,
});
- break;
+ carriedSec += insert.durationSec;
}
}
return placed.sort((a, b) => a.atRawSec - b.atRawSec);
}
-/** How much time the insertions add in total — what the ruler grows by. */
-export function totalInsertedSec(inserts: readonly RulerInsert[]): number {
- return inserts.reduce((sum, insert) => sum + insert.durationSec, 0);
-}
-
/**
- * Stored raw seconds → the ruler the user sees.
+ * A clip's own source moment → where it lands on the timeline.
+ *
+ * Not a plain shift, and this is the whole consequence of an insertion being MEDIA: the
+ * clip is longer than its source window by everything inserted inside it, so a moment past
+ * an insertion sits that much further along. Every place that used to convert between a
+ * "raw" and an "expanded" ruler is really asking this, of one clip.
*
- * Monotone and total: every stored moment has exactly one place on the expanded ruler.
- * A moment sitting exactly ON an insertion maps to where the insertion BEGINS, so the last recorded
- * frame keeps its own instant and the inserted media opens after it.
+ * `edge` decides what happens AT an insertion's own moment, which is a real choice and not
+ * a rounding detail. `"opens"` puts the moment before the inserted media — right for a
+ * position, and for the START of a span, so the span does not swallow the insertion that
+ * precedes it. `"closes"` puts it after — right for the END of a span, so a stretch running
+ * up to an insertion covers it rather than stopping short and leaving it orphaned.
*/
-export function expandRawSec(sec: number, inserts: readonly RulerInsert[]): number {
- let out = sec;
+export function sourceToTimelineSec(
+ /** Only the three fields that locate a clip — so a voiceover placement, which carries
+ * the same three, maps through this too (issue #560). */
+ clip: Pick,
+ sourceSec: number,
+ inserts: readonly AxcutInsertRange[],
+ edge: "opens" | "closes" = "opens",
+): number {
+ let added = 0;
for (const insert of inserts) {
- if (insert.atRawSec < sec) out += insert.durationSec;
+ if (insert.assetId !== clip.assetId) continue;
+ if (insert.atSec <= clip.sourceStartSec) continue;
+ if (edge === "opens" ? insert.atSec < sourceSec : insert.atSec <= sourceSec + 1e-6) {
+ added += insert.durationSec;
+ }
}
- return out;
+ return clip.timelineStartSec + (sourceSec - clip.sourceStartSec) + added;
}
/**
- * The ruler the user sees → stored raw seconds.
+ * The inverse: a timeline second → the source moment the clip is showing there.
*
- * The inverse of {@link expandRawSec} outside an insertion. Inside one it cannot be an inverse
- * — a whole stretch of ruler stands for a single held moment — and it returns that moment,
- * flagged, so a caller driving a decoder knows to hold rather than to seek.
+ * Inside an insertion there is no source moment — that is what makes it an insertion — so
+ * it answers with the moment the inserted media follows, and names the insertion. A caller
+ * driving a decoder needs both: where to park, and the fact that it should stay parked.
*/
-export function collapseRawSec(
- sec: number,
- inserts: readonly RulerInsert[],
-): { sec: number; heldBy: RulerInsert | null } {
- let offset = 0;
- for (const insert of inserts) {
- const startsAt = insert.atRawSec + offset;
- if (sec < startsAt) break;
- if (sec < startsAt + insert.durationSec) {
- return { sec: insert.atRawSec, heldBy: insert };
+export function timelineToSourceSec(
+ clip: AxcutClip,
+ timelineSec: number,
+ inserts: readonly AxcutInsertRange[],
+): { sourceSec: number; insideInsert: AxcutInsertRange | null } {
+ const mine = inserts
+ .filter((insert) => insert.assetId === clip.assetId && insert.atSec > clip.sourceStartSec)
+ .sort((a, b) => a.atSec - b.atSec);
+ let added = 0;
+ for (const insert of mine) {
+ const opensAt = clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + added;
+ if (timelineSec < opensAt) break;
+ if (timelineSec < opensAt + insert.durationSec) {
+ return { sourceSec: insert.atSec, insideInsert: insert };
}
- offset += insert.durationSec;
+ added += insert.durationSec;
}
- return { sec: sec - offset, heldBy: null };
+ return {
+ sourceSec: clip.sourceStartSec + (timelineSec - clip.timelineStartSec) - added,
+ insideInsert: null,
+ };
}
/**
* The insertion a frame of playback ran into, if it ran into one.
*
- * Half-open on the LEFT, and that is the whole point: while inserted media is playing, the
- * RAW playhead stands still at exactly `atRawSec` — the recording really is at that one
- * instant, because none of the inserted seconds come from it. `>` is therefore what refuses
- * that same moment on the way out; with `>=` the insertion is re-entered the frame it ends
- * and the film never gets past it. Closed on the right (with the frame epsilon) so an
- * insertion landing precisely on a frame boundary is played rather than skipped.
+ * Half-open on the LEFT, and that is the whole point: a player parks on the insertion's
+ * frame and pins its clock to exactly `atRawSec` for the first frame of it, so `>` is what
+ * refuses that same moment on the way in a second time. Closed on the right (with the frame
+ * epsilon) so an insertion landing precisely on a frame boundary is played, not skipped.
*/
export function insertionEnteredBetween(
- prevRawSec: number,
- nextRawSec: number,
+ prevSec: number,
+ nextSec: number,
inserts: readonly RulerInsert[],
epsilonSec = 1e-6,
): RulerInsert | undefined {
return inserts.find(
- (insert) => insert.atRawSec > prevRawSec && insert.atRawSec <= nextRawSec + epsilonSec,
+ (insert) => insert.atRawSec > prevSec && insert.atRawSec <= nextSec + epsilonSec,
);
}
diff --git a/src/lib/ai-edition/timeline/programme-time.test.ts b/src/lib/ai-edition/timeline/programme-time.test.ts
index 9942fb71d..7b2b35cca 100644
--- a/src/lib/ai-edition/timeline/programme-time.test.ts
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -7,7 +7,7 @@
import { describe, expect, it } from "vitest";
import { projectRawTimelineSecToPlayback, resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import { keptRawSpans, removalAt, removedRawSpans, subtractRemoved } from "./programme-time";
function clip(over: Partial & { id: string }): AxcutClip {
@@ -275,53 +275,71 @@ describe("subtractRemoved", () => {
});
});
-// ─── The pause the projection used to ignore ─────────────────────────────────
-// A pause occupies ZERO raw seconds and D OUTPUT seconds, which a flat kept-interval list
-// cannot express — so it was left out, and every audio track after a pause landed D seconds
-// early in both the preview and the export. `filmInserts` is required precisely so no call
-// site can quietly keep that bug.
+// ─── The insertion the projection has to walk over ──────────────────────────
+// An insertion is media INSIDE a clip, so the clip carrying it is that much longer and the
+// insertion's seconds are timeline seconds like any other. The projection's job is unchanged
+// by that — timeline in, output out — but it has to be TOLD, because it walks each clip's
+// kept SOURCE stretches and those are shorter than the clip.
-describe("projectRawTimelineSecToPlayback with the film's pauses", () => {
- const clips = twoClips();
- const pause = { id: "i1", wordId: "w1", atRawSec: 5, durationSec: 1 };
-
- it("pushes everything after a pause later by exactly what it bought", () => {
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [])).toBeCloseTo(8, 6);
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [pause])).toBeCloseTo(9, 6);
- });
-
- it("leaves everything before it where it was", () => {
- expect(projectRawTimelineSecToPlayback(clips, [], 3, [pause])).toBeCloseTo(3, 6);
- });
+describe("projectRawTimelineSecToPlayback across an insertion", () => {
+ // One second inserted at source 5 of the first clip, so that clip runs 0..11 and the
+ // second one starts at 11.
+ const inserted: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ const clips = [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 11,
+ timelineEndSec: 21,
+ }),
+ ];
- it("starts a track whose head sits exactly on the pause WITH the pause", () => {
- // Strict, matching `expandRawSec`: arriving at the pause's moment is the beginning
- // of the hold, not the end of it.
- expect(projectRawTimelineSecToPlayback(clips, [], 5, [pause])).toBeCloseTo(5, 6);
+ it("is the identity when nothing is cut — the insertion is already in the film", () => {
+ // This is what one clock buys. Under two, the walk had to re-add the insertion here
+ // and every reader that forgot to had its audio landing a second early.
+ expect(projectRawTimelineSecToPlayback(clips, [], 3, inserted)).toBeCloseTo(3, 6);
+ expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted)).toBeCloseTo(8, 6);
+ expect(projectRawTimelineSecToPlayback(clips, [], 15, inserted)).toBeCloseTo(15, 6);
});
- it("counts two pauses, in order", () => {
- const second = { id: "i2", wordId: "w2", atRawSec: 7, durationSec: 0.5 };
- expect(projectRawTimelineSecToPlayback(clips, [], 9, [pause, second])).toBeCloseTo(10.5, 6);
- // Order of the argument must not matter: the walk sorts.
- expect(projectRawTimelineSecToPlayback(clips, [], 9, [second, pause])).toBeCloseTo(10.5, 6);
+ it("keeps the insertion's own seconds when a trim takes the film around it", () => {
+ // Cutting source 0..2 of the first clip removes two seconds of RECORDING. The second
+ // the added word bought is not recording, so it survives.
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 0, endSec: 2 })];
+ expect(projectRawTimelineSecToPlayback(clips, trims, 8, inserted)).toBeCloseTo(6, 6);
});
- it("never reaches a pause a trim removed", () => {
- // The moment it holds is not in the film any more, so neither is the pause — the
- // same rule `resolvePlaybackSegments` already follows.
+ it("loses an insertion whose own moment a trim removed", () => {
+ // The moment it follows is not in the film any more, so neither is it — the same
+ // rule `resolvePlaybackSegments` follows.
const trims = [trim({ id: "t1", clipId: "c1", startSec: 4, endSec: 6 })];
- expect(projectRawTimelineSecToPlayback(clips, trims, 8, [pause])).toBeCloseTo(
- projectRawTimelineSecToPlayback(clips, trims, 8, []),
- 6,
- );
+ const out = projectRawTimelineSecToPlayback(clips, trims, 11, inserted);
+ // 10s of recording, less the 2s cut, and the insertion gone with it.
+ expect(out).toBeCloseTo(8, 6);
});
- it("compresses the film around a pause but never the pause itself", () => {
- // A voice plays at 1x. A 2x region halves the film either side; the second the pause
- // bought is still a second.
- const speed = [{ startMs: 0, endMs: 20_000, speed: 2 }];
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [], speed)).toBeCloseTo(4, 6);
- expect(projectRawTimelineSecToPlayback(clips, [], 8, [pause], speed)).toBeCloseTo(5, 6);
+ it("compresses the film around an insertion, and the insertion with it", () => {
+ // A 2x region halves whatever timeline it covers. The insertion is timeline, so it
+ // is halved too — the film is one thing, and speed is a property of the film.
+ const speed = [{ startMs: 0, endMs: 21_000, speed: 2 }];
+ expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted, speed)).toBeCloseTo(4, 6);
});
});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
index 60c23f032..f2b7cb3ec 100644
--- a/src/lib/ai-edition/timeline/programme-time.ts
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -18,7 +18,8 @@
// Storage does not change: a trim stays source-time anchored to a clip. This is the
// derived READING of those rows, computed on demand and never written back.
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import { sourceToTimelineSec } from "./inserted-time";
import { type Interval, subtractInterval } from "./intervals";
import { trimAppliesToClip } from "./trim-mapping";
@@ -58,11 +59,20 @@ function clipRawExtent(clip: AxcutClip): RawSpan {
};
}
-/** Source interval → raw, through the clip that carries it. */
-function sourceToRaw(clip: AxcutClip, interval: Interval): RawSpan {
+/** Source interval → timeline, through the clip that carries it.
+ *
+ * `"closes"` on the end is what makes an insertion INSIDE a kept stretch part of it: the
+ * film plays those seconds, so they belong to the span. An insertion at the stretch's own
+ * start belongs to whatever came before — and if a trim took that, it is gone with it,
+ * which is right: the moment it follows is not in the film any more. */
+function sourceToRaw(
+ clip: AxcutClip,
+ interval: Interval,
+ insertRanges: readonly AxcutInsertRange[],
+): RawSpan {
return {
- startSec: clip.timelineStartSec + (interval.startSec - clip.sourceStartSec),
- endSec: clip.timelineStartSec + (interval.endSec - clip.sourceStartSec),
+ startSec: sourceToTimelineSec(clip, interval.startSec, insertRanges, "opens"),
+ endSec: sourceToTimelineSec(clip, interval.endSec, insertRanges, "closes"),
};
}
@@ -89,7 +99,11 @@ function keptSourceIntervals(clip: AxcutClip, trimRanges: AxcutTrimRange[]): Int
*
* Zero-length spans are dropped, so a caller can trust `endSec > startSec`.
*/
-export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]): RawSpan[] {
+export function keptRawSpans(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[],
+ insertRanges: readonly AxcutInsertRange[] = [],
+): RawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
const spans: RawSpan[] = [];
for (const clip of ordered) {
@@ -101,7 +115,7 @@ export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]):
continue;
}
for (const iv of keptSourceIntervals(clip, trimRanges)) {
- const span = sourceToRaw(clip, iv);
+ const span = sourceToRaw(clip, iv, insertRanges);
if (span.endSec > span.startSec) spans.push(span);
}
}
@@ -126,6 +140,7 @@ export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]):
export function removedRawSpans(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
+ insertRanges: readonly AxcutInsertRange[] = [],
): RemovedRawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
if (ordered.length === 0) return [];
@@ -154,12 +169,12 @@ export function removedRawSpans(
.filter((trim) => trimAppliesToClip(trim, clip))
.map((trim) => ({
id: trim.id,
- ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }),
+ ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }, insertRanges),
}));
let holeStart = extent.startSec;
for (const iv of kept) {
- const span = sourceToRaw(clip, iv);
+ const span = sourceToRaw(clip, iv, insertRanges);
if (span.startSec > holeStart) {
removed.push(taggedHole(holeStart, span.startSec, applicable));
}
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 507126f5c..4440b4be0 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -18,7 +18,7 @@
// forward by the trimmed duration.
import type { PlaybackSegment } from "../document/timeline";
-import type { AxcutClip } from "../schema";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
@@ -402,8 +402,9 @@ export function anchorRegionsWithDerivedMs<
export function segmentRawSpanSec(
segment: PlaybackSegment,
rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[] = [],
): { startSec: number; endSec: number } {
- const startSec = getRawVirtualStartTime(segment, rawClips);
+ const startSec = getRawVirtualStartTime(segment, rawClips, insertRanges);
// A held segment's source window is the single frame it shows, so its source length
// is zero — its RAW span is the pause it carries. Without this the playhead could
// never be inside it and would step straight over the pause.
@@ -682,9 +683,10 @@ export function resolveNativePosition(
rawSec: number,
visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[] = [],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
// Segment whose RAW extent contains the playhead (last segment's end inclusive).
const index = spans.findIndex((s, i) => {
diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts
index 1cdc5d2f6..8448ae99d 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.ts
@@ -1,7 +1,8 @@
// Ported from axcut/apps/web/src/lib/virtual-preview.ts — pure time-mapping
// functions shared by the VirtualPreview component and the timeline math.
-import type { AxcutClip } from "../schema";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
export type VirtualPosition = {
clip: AxcutClip;
@@ -22,6 +23,7 @@ export function clampVirtualTime(clips: AxcutClip[], value: number): number {
export function locateVirtualPosition(
clips: AxcutClip[],
virtualTimeSec: number,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition | null {
if (clips.length === 0) return null;
const clamped = clampVirtualTime(clips, virtualTimeSec);
@@ -32,7 +34,11 @@ export function locateVirtualPosition(
const resolvedIndex = clipIndex >= 0 ? clipIndex : clips.length - 1;
const clip = clips[resolvedIndex];
const clipDuration = (clip.sourceEndSec ?? 0) - clip.sourceStartSec;
- const clipOffset = Math.max(0, Math.min(clipDuration, clamped - clip.timelineStartSec));
+ // Inside an insertion there is no source moment — none of those seconds came from the
+ // file — so this answers with the one the inserted media follows, which is the frame a
+ // decoder should be parked on.
+ const { sourceSec } = timelineToSourceSec(clip, clamped, insertRanges);
+ const clipOffset = Math.max(0, Math.min(clipDuration, sourceSec - clip.sourceStartSec));
return {
clip,
clipIndex: resolvedIndex,
@@ -72,10 +78,19 @@ export function findRawClipForSegment(
* Maps a kept segment (`AxcutClip` from `resolvePlaybackSegments`) back to its
* exact start position on the raw (untrimmed) document timeline.
*/
-export function getRawVirtualStartTime(segment: AxcutClip, rawClips: AxcutClip[]): number {
+export function getRawVirtualStartTime(
+ segment: AxcutClip,
+ rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[] = [],
+): number {
const rawClip = findRawClipForSegment(segment, rawClips);
if (!rawClip) return segment.timelineStartSec;
- return rawClip.timelineStartSec + (segment.sourceStartSec - rawClip.sourceStartSec);
+ // A HELD segment is the inserted media itself, so it starts where the insertion opens.
+ // Every other segment starting at that same source moment is the film RESUMING, so it
+ // starts where the insertion closes. Same source second, two different places on the
+ // timeline — which is the whole reason an insertion is media and not a marker.
+ const edge = (segment as { heldSec?: number }).heldSec !== undefined ? "opens" : "closes";
+ return sourceToTimelineSec(rawClip, segment.sourceStartSec, insertRanges, edge);
}
/**
@@ -126,6 +141,7 @@ function toPositionAt(
clips: AxcutClip[],
clipIndex: number,
sourceTimeSec: number,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition {
const clip = clips[clipIndex];
const sourceOffset = Math.max(
@@ -135,7 +151,9 @@ function toPositionAt(
return {
clip,
clipIndex,
- virtualTimeSec: clip.timelineStartSec + sourceOffset,
+ // Not `timelineStartSec + offset`: a clip carrying insertions is longer than its
+ // source window, so a moment past one sits that much further along (issue #560).
+ virtualTimeSec: sourceToTimelineSec(clip, clip.sourceStartSec + sourceOffset, insertRanges),
sourceTimeSec,
};
}
@@ -181,6 +199,7 @@ export function locateSourcePosition(
// its id here so it's preferred whenever the source time still falls
// inside it, before falling back to the ambiguous asset-wide scan.
preferredClipId?: string,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition | null {
if (preferredClipId) {
const preferredIndex = clips.findIndex((clip) => clip.id === preferredClipId);
@@ -198,7 +217,7 @@ export function locateSourcePosition(
(!assetId || clips[preferredIndex].assetId === assetId) &&
isWithinClipBounds(clips[preferredIndex], sourceTimeSec, epsilon, "inclusive")
) {
- return toPositionAt(clips, preferredIndex, sourceTimeSec);
+ return toPositionAt(clips, preferredIndex, sourceTimeSec, insertRanges);
}
}
const scan = (closingEdge: ClosingEdge) =>
@@ -219,7 +238,7 @@ export function locateSourcePosition(
const strict = scan("exclusive");
const clipIndex = strict >= 0 ? strict : scan("inclusive");
if (clipIndex < 0) return null;
- return toPositionAt(clips, clipIndex, sourceTimeSec);
+ return toPositionAt(clips, clipIndex, sourceTimeSec, insertRanges);
}
/**
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 887b63d79..7fa4f2f03 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -42,7 +42,6 @@ import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { takeInserts } from "@/lib/ai-edition/timeline/insert-mapping";
-import { rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { projectRegionsToSource } from "@/lib/ai-edition/timeline/timelineMap";
@@ -575,7 +574,7 @@ export function buildSceneDescription(
// question, and it does not depend on the track.
// Placed once: the projection below counts them, so a track after a pause lands where
// the ruler says rather than D seconds early.
- const filmInserts = rulerInserts(document.timeline.insertRanges ?? [], projectedClips);
+ const filmInserts = document.timeline.insertRanges ?? [];
const removed = removedRawSpans(projectedClips, document.timeline.trimRanges);
// The take's pills, keyed by group. A voiceover is walked ONCE per pill and never per
// stored fragment: the document keeps one fragment per clip a take covers, so walking
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index f1ab884ed..f9d23c12c 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -18,7 +18,7 @@
* re-aligns them.
*/
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
-import type { AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
getCurrentNativeViewId,
@@ -34,10 +34,13 @@ export function useNativePlaybackSync(
visibleSegments: readonly AxcutClip[],
/** RAW clip layout (`document.timeline.clips`) `currentTimeSec` is expressed against. */
rawClips: readonly AxcutClip[],
+ /** The insertions those clips carry — a clip is longer than its source window by them,
+ * so a segment's place on the timeline cannot be found without them (issue #560). */
+ insertRanges: readonly AxcutInsertRange[] = [],
): void {
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips]),
- [visibleSegments, rawClips, currentTimeSec],
+ () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips], insertRanges),
+ [visibleSegments, rawClips, currentTimeSec, insertRanges],
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
From eddcbe22a2ded6cdbec9e5f422df63d64924cc4d Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Thu, 3 Sep 2026 21:53:38 +0200
Subject: [PATCH 084/113] fix(captions): place a cue past an insertion on the
source it names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A caption cue is built fresh on every derive and carries no clip anchor, so it is placed
by intersecting its TIMELINE span with each segment's extent. Those extents came from
`segmentRawSpanSec` with no insertions, so the segment that RESUMES after one was taken
to start where the insertion opens rather than where it closes — and every cue past an
insertion landed a full insertion early on the source. That is what the desynchronised
subtitles were.
The insertions now reach `projectRegionsToSource`, and through it the four region kinds
the scene projects: captions and annotations, zoom, speed, camera-fullscreen. Pinned in
`timelineMap.test.ts` — timeline 7..9 over a clip with a second inserted at source 5 is
source 6..8, and a cue before the insertion does not move.
---
.../ai-edition/timeline/timelineMap.test.ts | 64 ++++++++++++++++++-
src/lib/ai-edition/timeline/timelineMap.ts | 7 +-
src/native/sceneDescription.ts | 4 ++
3 files changed, 73 insertions(+), 2 deletions(-)
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ea3f6c61b..b06ac8fb9 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -5,7 +5,7 @@
import { describe, expect, it } from "vitest";
import { resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import {
anchorRawRegionsToClips,
anchorRegionsWithDerivedMs,
@@ -805,3 +805,65 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
+
+// ─── Placing an unanchored region past an insertion ─────────────────────────
+// A caption cue is built fresh on every derive and carries no clip anchor, so it is placed
+// by intersecting its TIMELINE span with each segment's extent. A clip carrying insertions
+// is longer than its source window, so the segment that resumes after one starts that much
+// further along — and a projection blind to that put every caption after an insertion on
+// the wrong stretch of source. That is what "the subtitles are out of sync" was (#560).
+
+describe("projectRegionsToSource past an insertion", () => {
+ const raw = clip({
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11, // 10s of recording + 1s inserted at source 5
+ });
+ const inserts: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ // What `resolvePlaybackSegments` produces: the clip split at the insertion, with the
+ // inserted media between the halves.
+ const segments = [
+ clip({ id: "c1_seg1", assetId: "a1", sourceStartSec: 0, sourceEndSec: 5 }),
+ clip({ id: "c1_seg2", assetId: "a1", sourceStartSec: 5, sourceEndSec: 10 }),
+ ];
+
+ it("lands a region on the source it actually names", () => {
+ // Timeline 7..9 is one second past the insertion, so it is source 6..8.
+ const [out] = projectRegionsToSource(
+ [region("cue", 7, 9)],
+ segments,
+ [raw],
+ () => "x",
+ inserts,
+ );
+ expect(out.startMs).toBe(6000);
+ expect(out.endMs).toBe(8000);
+ expect(out.clipIndex).toBe(1);
+ });
+
+ it("leaves a region before the insertion where it was", () => {
+ const [out] = projectRegionsToSource(
+ [region("cue", 1, 3)],
+ segments,
+ [raw],
+ () => "x",
+ inserts,
+ );
+ expect(out.startMs).toBe(1000);
+ expect(out.endMs).toBe(3000);
+ expect(out.clipIndex).toBe(0);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 4440b4be0..2f3749ae7 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -568,11 +568,16 @@ export function projectRegionsToSource<
visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
makeId: () => string,
+ /** The insertions the clips carry. A segment after one starts that much further along
+ * the timeline, and an UNANCHORED region — a caption cue, which is built fresh each
+ * time and has no clip anchor — is placed by intersecting with exactly that extent.
+ * Without them the caption landed on the wrong stretch of source (issue #560). */
+ insertRanges: readonly AxcutInsertRange[] = [],
): (T & { clipIndex?: number; underTrim?: boolean })[] {
// RAW extents + owning raw clip per visible segment. Both are only consulted by the
// path that needs them (raw fallback / anchor match), but resolving them once keeps
// the per-region loop free of repeated lookups.
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
const segmentRawClipIds = visibleSegments.map((seg) => findRawClipForSegment(seg, rawClips)?.id);
const out: (T & { clipIndex?: number; underTrim?: boolean })[] = [];
for (const region of regions) {
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 7fa4f2f03..683066b69 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -777,6 +777,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("zoom"),
+ document.timeline.insertRanges ?? [],
);
// Same raw→source projection as the zoom regions above, for the same reason: annotations are
// authored in RAW document time and the compositor matches each frame's SOURCE time.
@@ -813,6 +814,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("ann"),
+ document.timeline.insertRanges ?? [],
);
const projectedCameraFullscreenRegions = projectRegionsToSource(
((document.legacyEditor as Record | null)?.cameraFullscreenRegions as
@@ -821,6 +823,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("camfull"),
+ document.timeline.insertRanges ?? [],
);
// Speed regions carry an extra `speed` field the standard `rangeSchema` does not, so we
// can't read from `document.timeline.speedRanges` today (see SceneDescription.speedRegions
@@ -835,6 +838,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("speed"),
+ document.timeline.insertRanges ?? [],
);
// Webcam rect, single source of truth between preview & native :
From ed8216bb944d0ca580c972139ca0b585c02a707d Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 07:54:23 +0200
Subject: [PATCH 085/113] fix(document): reconcile clip geometry with the
insertions on every load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The refactor that made a clip longer by the media inserted inside it only ever ran when a
transcript word was WRITTEN. A project the user merely OPENS kept its old geometry while
every reader around it assumed the new one — so on the live project the ruler stopped at
20.928s instead of 24.908s, the second insertion was drawn at its source position rather
than 3.6s further along, and every subtitle past an insertion slid out of step. Measured
on disk: a clip with 20.928s of source, 20.928s of timeline, and 3.98s of insertions it
does not account for.
`parseStoredDocument` is now the one way to turn a document on disk into one in memory,
and it says the three steps in order: upgrade, validate, reconcile. Every load path goes
through it or through `reconcileClipsWithInserts` directly — the main process's two
readers, the renderer's two importers, and the store's own gate. It is idempotent by
construction (the reflow is absolute, not incremental), so it costs nothing on a document
already in step and repairs anything that writes clip geometry without allowing for
insertions.
Writing the test for it surfaced a second, older defect: `insertedSecForClip` gave an
insertion to EVERY clip playing its source moment while `rulerInserts` gave it to the
FIRST — so two clips over one recording grew the film twice for a pill drawn once. Both
now go through `assignInsertsToClips`, one definition of who owns what, because the
geometry and the drawing answering that differently is the shape of every bug in this
area.
---
electron/ai-edition/document-service.ts | 10 ++-
.../ai-edition/EditorEmptyState.tsx | 9 +-
src/components/ai-edition/NewEditorShell.tsx | 9 +-
src/lib/ai-edition/document/load.test.ts | 87 +++++++++++++++++++
src/lib/ai-edition/document/load.ts | 46 ++++++++++
src/lib/ai-edition/document/migrate.ts | 7 +-
src/lib/ai-edition/document/timeline.ts | 32 +++----
src/lib/ai-edition/store/projectStore.ts | 6 +-
src/lib/ai-edition/timeline/inserted-time.ts | 41 ++++++---
9 files changed, 195 insertions(+), 52 deletions(-)
create mode 100644 src/lib/ai-edition/document/load.test.ts
create mode 100644 src/lib/ai-edition/document/load.ts
diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts
index 9c9b958b9..1199bee50 100644
--- a/electron/ai-edition/document-service.ts
+++ b/electron/ai-edition/document-service.ts
@@ -13,6 +13,10 @@
import fs, { type FileHandle } from "node:fs/promises";
import path from "node:path";
import { createId } from "../../src/lib/ai-edition/document/ids";
+import {
+ parseStoredDocument,
+ reconcileClipsWithInserts,
+} from "../../src/lib/ai-edition/document/load";
import { removeClip } from "../../src/lib/ai-edition/document/timeline";
import {
type AxcutAsset,
@@ -122,7 +126,7 @@ function safeProjectId(raw: string): string {
// `getProject` spells the same two steps out inline because it relinks moved
// media between them; keep the order (upgrade, then validate) in step.
function parseLoadedDocument(raw: string): AxcutDocument {
- return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw)));
+ return parseStoredDocument(JSON.parse(raw));
}
/**
@@ -273,7 +277,9 @@ export class DocumentService {
// back, and it is not persisted from here: the renderer saves the document
// it was given, as it does for any other load-time repair.
const migrated = migrateRawDocumentToCurrent(JSON.parse(raw));
- return documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir));
+ return reconcileClipsWithInserts(
+ documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir)),
+ );
}
async createProject(title: string): Promise {
diff --git a/src/components/ai-edition/EditorEmptyState.tsx b/src/components/ai-edition/EditorEmptyState.tsx
index 226171dca..18101e699 100644
--- a/src/components/ai-edition/EditorEmptyState.tsx
+++ b/src/components/ai-edition/EditorEmptyState.tsx
@@ -14,11 +14,8 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
-import {
- migrateProjectDataToAxcutDocument,
- migrateRawDocumentToCurrent,
-} from "@/lib/ai-edition/document/migrate";
-import { documentSchema } from "@/lib/ai-edition/schema";
+import { parseStoredDocument } from "@/lib/ai-edition/document/load";
+import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { nativeBridgeClient } from "@/native";
import styles from "./NewEditorShell.module.css";
@@ -82,7 +79,7 @@ export function EditorEmptyState({
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
- ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
+ ? parseStoredDocument(raw) // disk-load: upgrade, validate, reconcile clip geometry
: migrateProjectDataToAxcutDocument(raw as never);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (!saved.success || !saved.document) return false;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 93c5c7eb4..385df2a3d 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -6,10 +6,8 @@ import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { createId } from "@/lib/ai-edition/document/ids";
-import {
- migrateProjectDataToAxcutDocument,
- migrateRawDocumentToCurrent,
-} from "@/lib/ai-edition/document/migrate";
+import { parseStoredDocument } from "@/lib/ai-edition/document/load";
+import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
import {
applyProbedDuration,
replaceTimeline as replaceTimelineOp,
@@ -25,7 +23,6 @@ import {
type AxcutAudioTrack,
type AxcutClip,
type AxcutInsertRange,
- documentSchema,
} from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
@@ -590,7 +587,7 @@ export function NewEditorShell() {
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
- ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
+ ? parseStoredDocument(raw) // disk-load: upgrade, validate, reconcile clip geometry
: migrateProjectDataToAxcutDocument(raw as EditorProjectData);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (saved.success && saved.document) {
diff --git a/src/lib/ai-edition/document/load.test.ts b/src/lib/ai-edition/document/load.test.ts
new file mode 100644
index 000000000..589d49ca0
--- /dev/null
+++ b/src/lib/ai-edition/document/load.test.ts
@@ -0,0 +1,87 @@
+// A document written before insertions took up time on the timeline.
+//
+// Nothing else reconciles it: `withInsertRangesForWords` only runs when a transcript word is
+// written, so a project the user merely OPENS keeps its old geometry while all the code
+// around it assumes the new — the film's ruler stops short, the insertion pills are drawn at
+// their source position, and the subtitles slide further out of step with every insertion
+// passed (issue #560).
+
+import { describe, expect, it } from "vitest";
+import type { AxcutClip, AxcutDocument, AxcutInsertRange } from "../schema";
+import { reconcileClipsWithInserts } from "./load";
+
+function clip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ };
+}
+
+const insert = (over: Partial & { id: string }): AxcutInsertRange => ({
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ ...over,
+});
+
+function doc(clips: AxcutClip[], insertRanges: AxcutInsertRange[]): AxcutDocument {
+ return { timeline: { clips, insertRanges } } as unknown as AxcutDocument;
+}
+
+describe("reconcileClipsWithInserts", () => {
+ it("gives a short clip back the time its insertions take", () => {
+ const before = doc([clip({ id: "c1" })], [insert({ id: "i1" })]);
+ const [after] = reconcileClipsWithInserts(before).timeline.clips;
+ expect(after.timelineEndSec - after.timelineStartSec).toBeCloseTo(11, 6);
+ // The recording is untouched: no frame was added to or taken from the file.
+ expect(after.sourceStartSec).toBe(0);
+ expect(after.sourceEndSec).toBe(10);
+ });
+
+ it("pushes every later clip along by what the one before it gained", () => {
+ const before = doc(
+ [clip({ id: "c1" }), clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 })],
+ [insert({ id: "i1" })],
+ );
+ const [, second] = reconcileClipsWithInserts(before).timeline.clips;
+ expect(second.timelineStartSec).toBeCloseTo(11, 6);
+ expect(second.timelineEndSec).toBeCloseTo(21, 6);
+ });
+
+ it("is idempotent, so it can run on every load", () => {
+ const before = doc([clip({ id: "c1" })], [insert({ id: "i1" })]);
+ const once = reconcileClipsWithInserts(before);
+ const twice = reconcileClipsWithInserts(once);
+ expect(twice.timeline.clips).toEqual(once.timeline.clips);
+ // And a document already in step is returned as-is, not rebuilt.
+ expect(twice).toBe(once);
+ });
+
+ it("leaves a document with no insertions completely alone", () => {
+ const before = doc([clip({ id: "c1" })], []);
+ expect(reconcileClipsWithInserts(before)).toBe(before);
+ });
+
+ it("counts several insertions in one clip, and only that clip's", () => {
+ const before = doc(
+ [
+ clip({ id: "c1" }),
+ clip({ id: "c2", assetId: "a2", timelineStartSec: 10, timelineEndSec: 20 }),
+ ],
+ [insert({ id: "i1", atSec: 3 }), insert({ id: "i2", atSec: 7, durationSec: 0.5 })],
+ );
+ const [first, second] = reconcileClipsWithInserts(before).timeline.clips;
+ expect(first.timelineEndSec).toBeCloseTo(11.5, 6);
+ expect(second.timelineEndSec - second.timelineStartSec).toBeCloseTo(10, 6);
+ });
+});
diff --git a/src/lib/ai-edition/document/load.ts b/src/lib/ai-edition/document/load.ts
new file mode 100644
index 000000000..e4a4b3087
--- /dev/null
+++ b/src/lib/ai-edition/document/load.ts
@@ -0,0 +1,46 @@
+// The one way to turn a document on disk into a document in memory.
+//
+// Three steps, in this order, and no caller may do two of them and skip the third:
+//
+// 1. UPGRADE — `migrateRawDocumentToCurrent` walks the vN → vN+1 chain.
+// 2. VALIDATE — `documentSchema.parse` is a pure current-version shape check.
+// 3. RECONCILE — clip geometry is brought back in line with the insert ranges.
+//
+// Step 3 is the one that is easy to forget and impossible to notice. An insertion is MEDIA
+// inside a clip (issue #560), so a clip carrying one is longer than its source window by
+// exactly that much — every reader downstream depends on it, and a document written before
+// that was true carries SHORT clips. Nothing else reconciles them: `withInsertRangesForWords`
+// only runs when a transcript word is written, so a project the user merely OPENS keeps its
+// old geometry while all the code around it assumes the new. The visible result is a film
+// whose ruler stops short, insertion pills drawn at their source position instead of their
+// timeline one, and subtitles sliding further out of step with every insertion passed.
+//
+// `reflowClipsForInserts` is absolute rather than incremental, so this is idempotent: a
+// document already in step is returned unchanged, and running it on every load costs nothing
+// while also repairing anything that writes clip geometry without allowing for insertions.
+
+import type { AxcutDocument } from "../schema";
+import { documentSchema, migrateRawDocumentToCurrent } from "../schema";
+import { reflowClipsForInserts } from "./timeline";
+
+/** Clip geometry brought back in line with the document's insert ranges. Idempotent. */
+export function reconcileClipsWithInserts(document: AxcutDocument): AxcutDocument {
+ const insertRanges = document.timeline.insertRanges ?? [];
+ if (insertRanges.length === 0) return document;
+ const clips = reflowClipsForInserts(document.timeline.clips, insertRanges);
+ const unchanged =
+ clips.length === document.timeline.clips.length &&
+ clips.every((clip, i) => {
+ const was = document.timeline.clips[i];
+ return (
+ Math.abs(clip.timelineStartSec - was.timelineStartSec) < 1e-9 &&
+ Math.abs(clip.timelineEndSec - was.timelineEndSec) < 1e-9
+ );
+ });
+ return unchanged ? document : { ...document, timeline: { ...document.timeline, clips } };
+}
+
+/** Raw JSON (any stored version) → a validated, reconciled document. */
+export function parseStoredDocument(raw: unknown): AxcutDocument {
+ return reconcileClipsWithInserts(documentSchema.parse(migrateRawDocumentToCurrent(raw)));
+}
diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts
index 2b360806a..8d06f6b85 100644
--- a/src/lib/ai-edition/document/migrate.ts
+++ b/src/lib/ai-edition/document/migrate.ts
@@ -28,10 +28,9 @@ import {
type AxcutLegacyEditor,
type AxcutTrimRange,
type AxcutZoomRegion,
- documentSchema,
- migrateRawDocumentToCurrent,
} from "../schema";
import { createId } from "./ids";
+import { parseStoredDocument } from "./load";
const MS_TO_SEC = 1 / 1000;
const SEC_TO_MS = 1000;
@@ -63,7 +62,7 @@ function clampSec(sec: number): number {
* v2 inputs are not handled by it — `migrateProjectDataToAxcutDocument` below
* still owns the legacy EditorProjectData → AxcutDocument translation.
*/
-export { migrateRawDocumentToCurrent };
+export { migrateRawDocumentToCurrent } from "../schema";
function toLegacyMedia(input: ProjectMedia | undefined): ProjectMedia | null {
if (!input) return null;
@@ -250,7 +249,7 @@ export function migrateProjectDataToAxcutDocument(
legacyEditor,
};
- return documentSchema.parse(migrateRawDocumentToCurrent(draft));
+ return parseStoredDocument(draft);
}
/**
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 791389d2b..1ba70d480 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -21,6 +21,7 @@ import type {
*/
export type PlaybackSegment = AxcutClip & { heldSec?: number };
+import { assignInsertsToClips } from "../timeline/inserted-time";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -135,26 +136,6 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
-/** How much media this clip's own insertions add to it, in seconds.
- *
- * Half-open at the start and closed at the end, matching where an insertion sits: at the
- * END of the word it follows, which is a moment the clip plays. */
-export function insertedSecForClip(
- clip: AxcutClip,
- insertRanges: readonly AxcutInsertRange[],
-): number {
- const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
- return insertRanges.reduce(
- (sum, range) =>
- range.assetId === clip.assetId &&
- range.atSec > clip.sourceStartSec &&
- range.atSec <= sourceEnd + 1e-6
- ? sum + range.durationSec
- : sum,
- 0,
- );
-}
-
/**
* Clip geometry that accounts for the media inserted inside each clip (issue #560).
*
@@ -173,12 +154,19 @@ export function reflowClipsForInserts(
clips: AxcutClip[],
insertRanges: readonly AxcutInsertRange[],
): AxcutClip[] {
+ // Through `assignInsertsToClips`, so the length a clip gains and the pills drawn inside it
+ // come from the same assignment. They used to be computed separately, and with two clips
+ // over one recording the film grew twice for an insertion drawn once.
+ const byClip = assignInsertsToClips(clips, insertRanges);
return resequenceClips(
clips.map((clip) => {
const sourceLen = (clip.sourceEndSec ?? clip.sourceStartSec) - clip.sourceStartSec;
if (sourceLen <= 0) return clip; // duration not probed yet; leave it to the prober
- const len = sourceLen + insertedSecForClip(clip, insertRanges);
- return { ...clip, timelineEndSec: clip.timelineStartSec + len };
+ const owed = (byClip.get(clip.id) ?? []).reduce((sum, r) => sum + r.durationSec, 0);
+ return {
+ ...clip,
+ timelineEndSec: clip.timelineStartSec + sourceLen + owed,
+ };
}),
);
}
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index fd47da9cc..cee4be4ad 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -5,6 +5,7 @@ import { toastText } from "@/i18n/toastText";
import { nativeBridgeClient } from "@/native/client";
import { placeAudioTrackInDocument } from "../document/audioTracks";
import { createId } from "../document/ids";
+import { reconcileClipsWithInserts } from "../document/load";
import { type Interval, replaceTimeline as replaceTimelineOp } from "../document/timeline";
import { type AxcutAsset, type AxcutDocument, createAudioTrack, documentSchema } from "../schema";
import { probeAudioDuration, probeVideoDimensions } from "../timeline/duration";
@@ -158,7 +159,10 @@ export interface ProjectState {
}
function parseDocument(value: unknown): AxcutDocument {
- return documentSchema.parse(value);
+ // Reconciled here too, not only in the main process: this is the renderer's own gate on
+ // every document it accepts, and it is idempotent, so a document that arrived correct
+ // passes through untouched.
+ return reconcileClipsWithInserts(documentSchema.parse(value));
}
/**
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index c921ce878..c061b5ff0 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -32,7 +32,7 @@ export interface RulerInsert {
}
/**
- * Project each insert onto the raw ruler through the clip that plays its source moment.
+ * Project each insert onto the ruler through the clip that owns it.
*
* A range whose moment no clip plays yields nothing: the insertion exists for a word that is
* not on the timeline, so there is no ruler position for it and nothing to add. Same rule
@@ -40,19 +40,26 @@ export interface RulerInsert {
*
* Ordered by ruler position, which is what lets the accumulation below be a single pass.
*/
-export function rulerInserts(
- inserts: readonly AxcutInsertRange[],
+/**
+ * Which clip owns each insertion.
+ *
+ * ONE definition, because the geometry and the drawing must not answer this differently.
+ * An insertion is anchored to an ASSET and a source moment, not to a clip, so two clips over
+ * the same recording could both claim it — and when they did, the film grew twice while the
+ * pill was drawn once. It is claimed by the FIRST clip that plays its moment, in timeline
+ * order: the insertion is stored once and the word exists once, so it happens once.
+ */
+export function assignInsertsToClips(
clips: readonly AxcutClip[],
-): RulerInsert[] {
- const placed: RulerInsert[] = [];
+ inserts: readonly AxcutInsertRange[],
+): Map {
+ const byClip = new Map();
const claimed = new Set();
- for (const clip of clips) {
+ for (const clip of [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec)) {
const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const mine = inserts
// Inclusive at both edges: an insertion sits at the END of the word it follows,
- // which is routinely a clip's own boundary. Claimed once, by the first clip that
- // plays the moment — two clips over the same recording are two places, not two
- // insertions.
+ // which is routinely a clip's own boundary.
.filter(
(insert) =>
insert.assetId === clip.assetId &&
@@ -61,12 +68,24 @@ export function rulerInserts(
insert.atSec <= sourceEnd,
)
.sort((a, b) => a.atSec - b.atSec);
+ for (const insert of mine) claimed.add(insert.id);
+ if (mine.length > 0) byClip.set(clip.id, mine);
+ }
+ return byClip;
+}
+
+export function rulerInserts(
+ inserts: readonly AxcutInsertRange[],
+ clips: readonly AxcutClip[],
+): RulerInsert[] {
+ const byClip = assignInsertsToClips(clips, inserts);
+ const placed: RulerInsert[] = [];
+ for (const clip of clips) {
// Each insertion opens after the ones before it in the same clip: the clip's length
// already carries all of them, so a plain source-shift would stack them all at the
// first one's position.
let carriedSec = 0;
- for (const insert of mine) {
- claimed.add(insert.id);
+ for (const insert of byClip.get(clip.id) ?? []) {
placed.push({
id: insert.id,
wordId: insert.wordId,
From 6d5a60fdf4e824cdd73ad7f3bd7665647e36d7fe Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 07:58:52 +0200
Subject: [PATCH 086/113] fix(captions): an added word is subtitled over the
media it inserted
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Caption lines are grouped by word count and by silences, in SOURCE time. An added word
barely takes up source time — the seconds it is actually spoken in are the INSERTION that
follows it — so it was swallowed into the line of the words before it and inherited their
start. On screen the added words appeared while the recorded picture was still playing, a
whole insertion early. That is the offset visible at playhead 0:04.8 with the insertion
opening at 5.3: the burnt-in line already read "…moi, je dis ça, je dis rien…".
A line now never mixes recorded words with added ones. The stream is cut into maximal
all-recorded / all-added runs and each is grouped on its own, so the added run's source
span maps — through `sourceToTimelineSec`, opening edge to closing edge — onto exactly the
window the inserted media occupies. Membership is decided by overlap with the added word's
source span rather than by id, because a translated stream is rebuilt as pseudo-words and
the spans are the one thing both streams keep.
Mutation-checked: forcing the split off fails both new assertions.
---
src/lib/ai-edition/captions/captions.test.ts | 78 ++++++++++++++++++++
src/lib/ai-edition/captions/cues.ts | 48 +++++++++++-
2 files changed, 125 insertions(+), 1 deletion(-)
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 837736af3..8599bdb55 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -699,3 +699,81 @@ describe("captions and a pause", () => {
expect(deriveCaptionCues(doc(), ON, {})).toEqual(deriveCaptionCues(doc(), ON, {}));
});
});
+
+// ─── An added word is spoken over the media it inserted ─────────────────────
+// Lines are grouped by word count and by silences, in SOURCE time. An added word barely
+// takes up source time — the seconds it is spoken in are the INSERTION that follows it —
+// so it was swallowed into the line of the words before it and inherited their start. On
+// screen the added words appeared while the recorded picture was still playing, a whole
+// insertion early (issue #560).
+
+describe("a caption line never mixes recorded words with added ones", () => {
+ function docWithAddedWord(): AxcutDocument {
+ const t = transcript();
+ // "really" typed in after "friend", which ends at source 2. It fits in 0.2s of the
+ // silence that is already there and buys 1.8s of inserted media for the rest.
+ t.words = [
+ ...t.words.slice(0, 3),
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 2,
+ endSec: 2.2,
+ text: "really",
+ source: "synth",
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ } as any,
+ ...t.words.slice(3),
+ ];
+ const base = doc();
+ return {
+ ...base,
+ transcripts: [t],
+ timeline: {
+ ...base.timeline,
+ clips: [{ ...base.timeline.clips[0], timelineEndSec: 11.8 }],
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ insertRanges: [
+ {
+ id: "i1",
+ assetId: "asset-1",
+ atSec: 2.2,
+ durationSec: 1.8,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user",
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ ] as any,
+ },
+ };
+ }
+
+ const settings: CaptionSettings = { ...DEFAULT_CAPTION_SETTINGS, enabled: true };
+
+ it("gives the added word its own cue, starting where the recorded words stop", () => {
+ const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
+ const added = cues.filter((c) => c.text.includes("really"));
+ expect(added).toHaveLength(1);
+ // It must not carry the recorded words with it — that is the whole defect.
+ expect(added[0].text.toLowerCase()).not.toContain("hello");
+ // And it opens at the recorded words' end, not at their start.
+ expect(added[0].startMs).toBeGreaterThanOrEqual(2000);
+ });
+
+ it("leaves the recorded line ending before the added one begins", () => {
+ const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
+ const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
+ const added = cues.find((c) => c.text.includes("really"));
+ expect(recorded).toBeDefined();
+ expect(added).toBeDefined();
+ expect(recorded?.text).not.toContain("really");
+ expect(added?.startMs ?? 0).toBeGreaterThanOrEqual((recorded?.startMs ?? 0) + 1);
+ });
+
+ it("changes nothing when the transcript has no added words", () => {
+ const before = deriveCaptionCues(doc(), settings, {});
+ expect(before.some((c) => c.text.toLowerCase().includes("hello"))).toBe(true);
+ expect(before.every((c) => !c.text.includes("really"))).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 553f1b856..76472112f 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -117,7 +117,53 @@ export function captionLinesForAsset(
: translatedWordStream(transcript, translations, settings.language);
if (stream.length === 0) return [];
- return polish(groupTimedCaptionWordsIntoLines(stream, minWords, maxWords));
+ // Grouped per RUN, never across one. A run is a maximal stretch of words that are all
+ // recorded or all added: the two are spoken over different media — the recording, and the
+ // insertion that added word bought — so a line holding both would have to be in two places
+ // at once, and resolved as one it lands on the recording, an insertion too early.
+ return polish(
+ captionRuns(stream, addedWordSpans(transcript)).flatMap((run) =>
+ groupTimedCaptionWordsIntoLines(run, minWords, maxWords),
+ ),
+ );
+}
+
+/** Where the transcript's ADDED words sit, in source time. */
+function addedWordSpans(transcript: AxcutTranscript): Array<{ startSec: number; endSec: number }> {
+ return transcript.words
+ .filter((word) => word.source === "synth" && word.text.trim().length > 0)
+ .map((word) => ({ startSec: word.startSec, endSec: word.endSec }))
+ .sort((a, b) => a.startSec - b.startSec);
+}
+
+/**
+ * The stream cut into maximal all-recorded / all-added runs.
+ *
+ * Membership is decided by OVERLAP with an added word's source span rather than by identity,
+ * because a translated stream is rebuilt as pseudo-words and no longer carries the original
+ * ids — the spans are the one thing both streams keep.
+ */
+function captionRuns(
+ stream: CaptionSegment[],
+ added: Array<{ startSec: number; endSec: number }>,
+): CaptionSegment[][] {
+ if (added.length === 0) return [stream];
+ const isAdded = (word: CaptionSegment) =>
+ added.some((span) => word.startSec < span.endSec && word.endSec > span.startSec);
+ const runs: CaptionSegment[][] = [];
+ let current: CaptionSegment[] = [];
+ let currentIsAdded: boolean | null = null;
+ for (const word of stream) {
+ const flag = isAdded(word);
+ if (currentIsAdded !== null && flag !== currentIsAdded) {
+ runs.push(current);
+ current = [];
+ }
+ currentIsAdded = flag;
+ current.push(word);
+ }
+ if (current.length > 0) runs.push(current);
+ return runs;
}
function originalWordStream(transcript: AxcutTranscript): CaptionSegment[] {
From d610e739a26aca3d78114001fa89ff0c6629452c Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 08:27:21 +0200
Subject: [PATCH 087/113] fix(timeline): the insertions are a required
argument, and the compiler found the rest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Defaulting them to `[]` was the mistake behind this whole run of desynchronisations. Every
one of these functions maps between a clip's source window and the timeline, and a clip
carrying insertions is longer than its source window — so a caller that omitted them got a
plausible answer, wrong by exactly the inserted time, with nothing anywhere to catch it.
That is how the playback head ended up computed one way and the seek head another, five
lines apart in the same file: my own threading pass hit one block and missed the next
because their indentation differed.
Required on `keptRawSpans`, `removedRawSpans`, `locateSourcePosition`,
`locateVirtualPosition`, `getRawVirtualStartTime`, `findNextKeptSegment`,
`segmentRawSpanSec`, `resolveNativePosition`, `projectRegionsToSource`,
`resolveActiveCameraTrack` — and `tsc` then enumerated twenty call sites across eleven
files for free. Among them, and each its own visible fault: the playback tick's own
position lookup, the seek target it jumps to, the transcript pane's removed spans, the
scene description's, the take walks in the timeline and the preview, the caption cues',
the cursor track's source→timeline map, and the camera/webcam overlays that decide which
clip is under the playhead.
`clipRawExtent` was measuring a clip by its SOURCE window, so a clip carrying insertions
ended short by exactly the inserted time and `removedRawSpans` reported the difference as
a gap nothing had removed — a voiceover crossing it went silent for the insertion's
length, in the preview and in the exported mix. It is now computed the way
`reflowClipsForInserts` writes it, through the same ownership rule, so stale stored
geometry cannot make it lie either. Mutation-checked: dropping the allowance fails two of
the three new assertions.
---
src/components/ai-edition/PreviewCanvas.tsx | 12 ++-
src/components/ai-edition/RightPanes.tsx | 10 +-
src/components/ai-edition/VirtualPreview.tsx | 17 +++-
src/components/ai-edition/WebcamOverlay.tsx | 22 +++--
src/components/ai-edition/v4/V4Timeline.tsx | 2 +-
src/lib/ai-edition/captions/cues.ts | 6 +-
.../aggregated-transcript.lanes.test.ts | 6 +-
.../timeline/aggregated-transcript.test.ts | 17 ++--
src/lib/ai-edition/timeline/camera.test.ts | 6 +-
src/lib/ai-edition/timeline/camera.ts | 7 +-
src/lib/ai-edition/timeline/cursor-track.ts | 10 +-
.../timeline/programme-time.test.ts | 92 ++++++++++++++++---
src/lib/ai-edition/timeline/programme-time.ts | 30 ++++--
.../timeline/sharedMediaTrim.test.ts | 2 +-
.../timeline/take-programme.test.ts | 12 +--
.../ai-edition/timeline/timelineMap.test.ts | 56 ++++++-----
src/lib/ai-edition/timeline/timelineMap.ts | 21 +++--
.../timeline/virtual-preview.test.ts | 47 +++++-----
.../ai-edition/timeline/virtual-preview.ts | 38 +++++---
src/native/sceneDescription.ts | 2 +-
src/native/useNativePlaybackSync.ts | 2 +-
21 files changed, 287 insertions(+), 130 deletions(-)
diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 2fcd15d86..07a526f45 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -63,6 +63,9 @@ import { type VideoSource, VirtualPreview } from "./VirtualPreview";
import { WebcamOverlay } from "./WebcamOverlay";
import { ZoomFocusOverlay } from "./ZoomFocusOverlay";
+/** Stable identity, so the memos are not invalidated every render by a fresh `[]`. */
+const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
+
type BlurData = NonNullable;
interface PreviewCanvasProps {
@@ -221,17 +224,18 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
// clip the playhead is currently inside, the same lookup VirtualPreview
// itself uses to map playback position back to a clip. `undefined` (no
// crop stored) normalises to the identity region.
+ const previewInserts = props.insertRanges ?? EMPTY_INSERT_RANGES;
const activeClip = useMemo(
- () => locateVirtualPosition(props.clips, props.currentTimeSec)?.clip ?? null,
- [props.clips, props.currentTimeSec],
+ () => locateVirtualPosition(props.clips, props.currentTimeSec, previewInserts)?.clip ?? null,
+ [props.clips, props.currentTimeSec, previewInserts],
);
const cropRegion: CropRegion = activeClip?.cropRegion ?? DEFAULT_CROP_REGION;
// P4 — the layout preset is global (one panel for the whole timeline) but the camera
// is per clip, so the layout has to be resolved against the clip under the playhead.
const activeCameraTrack = useMemo(
- () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec),
- [assets, props.clips, props.currentTimeSec],
+ () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec, previewInserts),
+ [assets, props.clips, props.currentTimeSec, previewInserts],
);
const activeClipHasCamera = Boolean(activeCameraTrack?.visible && activeCameraTrack.sourcePath);
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 4e1e395c0..6892bdda7 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -51,6 +51,7 @@ import type {
AxcutAsset,
AxcutAudioTrack,
AxcutClip,
+ AxcutInsertRange,
AxcutTranscript,
AxcutTrimRange,
AxcutWord,
@@ -111,6 +112,9 @@ import styles from "./NewEditorShell.module.css";
import { useTranscriptionLabel } from "./TranscriptionStatus";
import { transcriptionBusyLabel } from "./transcriptionBusyLabel";
+/** Stable identity, so the memos below are not invalidated every render by a fresh `[]`. */
+const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
+
interface PaneProps {
title: string;
icon: ReactNode;
@@ -884,7 +888,11 @@ export function TranscriptPane({
// From the RECORDING clips and the whole trim set, never from `placements`: the
// programme is one thing, and the voiceover lane is asking whether the film still
// contains a moment — not whether some trim happens to name an audio fragment.
- const removed = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
+ const insertRanges = document?.timeline.insertRanges ?? EMPTY_INSERT_RANGES;
+ const removed = useMemo(
+ () => removedRawSpans(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
+ );
// The take's placements are fed the cuts AND its own insertions, so a word after a pause
// is struck through — and highlighted — at the moment it is actually heard (issue #560).
const insertsFor = useCallback(
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index a68d759d3..cefbc3082 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -615,12 +615,15 @@ export function VirtualPreview({
);
const takePiecesRef = useRef>(new Map());
const takeHeadsRef = useRef>(new Map());
- const removedRef = useRef(removedRawSpans(clips, trimRanges));
- removedRef.current = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
+ const removedRef = useRef(removedRawSpans(clips, trimRanges, insertRanges));
+ removedRef.current = useMemo(
+ () => removedRawSpans(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
+ );
const takeWalks = useMemo(() => {
const pieces = new Map();
const heads = new Map();
- const removed = removedRawSpans(clips, trimRanges);
+ const removed = removedRawSpans(clips, trimRanges, insertRanges);
for (const pill of collapseTracksToPills(audioTracks)) {
if (pill.kind !== "voiceover" || pill.loop) continue;
const groupId = trackGroupId(pill);
@@ -916,6 +919,7 @@ export function VirtualPreview({
activeSourceId,
v.currentTime,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (nextKeptSegment) {
// `findRawClipForSegment` is the ONE definition of the segment-id
@@ -926,7 +930,11 @@ export function VirtualPreview({
if (rawClip) {
activeClipIdRef.current = rawClip.id;
}
- const rawTargetTime = getRawVirtualStartTime(nextKeptSegment, clipsRef.current);
+ const rawTargetTime = getRawVirtualStartTime(
+ nextKeptSegment,
+ clipsRef.current,
+ insertRangesRef.current,
+ );
seekToVirtualTimeRef.current?.(rawTargetTime, true);
return;
}
@@ -997,6 +1005,7 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (!position) {
// ponytail: fall back to timeline order so cross-asset / reordered
diff --git a/src/components/ai-edition/WebcamOverlay.tsx b/src/components/ai-edition/WebcamOverlay.tsx
index d9b256149..9a9dad3dd 100644
--- a/src/components/ai-edition/WebcamOverlay.tsx
+++ b/src/components/ai-edition/WebcamOverlay.tsx
@@ -17,7 +17,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import type { WebcamLayoutPreset, WebcamMaskShape } from "@/components/video-editor/types";
-import type { AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera";
@@ -31,8 +31,14 @@ import { getCssClipPath } from "@/lib/webcamMaskShapes";
import { setWebcamNativeSize } from "@/native/webcamSizeCache";
import styles from "./NewEditorShell.module.css";
+/** Stable identity, so the memos are not invalidated every render by a fresh `[]`. */
+const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
+
interface WebcamOverlayProps {
clips: AxcutClip[];
+ /** The insertions those clips carry — the camera follows the clip under the playhead,
+ * and which clip that is cannot be answered without them (issue #560). */
+ insertRanges?: readonly AxcutInsertRange[];
currentTimeSec: number;
onTimeChange: (sec: number) => void;
isPlaying: boolean;
@@ -61,14 +67,15 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
// Fallback (pre-clockRef / first paint) position from props, used only for
// the initial correction on loadedmetadata before the rAF loop below has
// had a chance to run.
+ const overlayInserts = props.insertRanges ?? EMPTY_INSERT_RANGES;
const position = useMemo(
- () => locateVirtualPosition(props.clips, props.currentTimeSec),
- [props.clips, props.currentTimeSec],
+ () => locateVirtualPosition(props.clips, props.currentTimeSec, overlayInserts),
+ [props.clips, props.currentTimeSec, overlayInserts],
);
const cameraTrack = useMemo(
- () => resolveActiveCameraTrack(assets ?? [], props.clips, props.currentTimeSec),
- [assets, props.clips, props.currentTimeSec],
+ () => resolveActiveCameraTrack(assets ?? [], props.clips, props.currentTimeSec, overlayInserts),
+ [assets, props.clips, props.currentTimeSec, overlayInserts],
);
const cameraTime = useMemo(() => {
@@ -81,6 +88,8 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
// re-creating the loop on every document mutation.
const clipsRef = useRef(props.clips);
clipsRef.current = props.clips;
+ const insertsRef = useRef(overlayInserts);
+ insertsRef.current = overlayInserts;
const assetsRef = useRef(assets);
assetsRef.current = assets;
@@ -97,11 +106,12 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
raf = window.requestAnimationFrame(tick);
const clock = clockRef.current;
const clipsNow = clipsRef.current;
- const positionNow = locateVirtualPosition(clipsNow, clock.virtualTimeSec);
+ const positionNow = locateVirtualPosition(clipsNow, clock.virtualTimeSec, insertsRef.current);
const trackNow = resolveActiveCameraTrack(
assetsRef.current ?? [],
clipsNow,
clock.virtualTimeSec,
+ insertsRef.current,
);
const target = resolveCameraSyncTarget(
clock,
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index ef487f931..b4db553ec 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -1095,7 +1095,7 @@ export function V4Timeline({
// so a notch cannot appear where the voice does not actually stop.
const takePieces = useMemo(() => {
const clipAssetIds = new Set(clips.map((c) => c.assetId));
- const removed = removedRawSpans(clips, tl.trimRanges);
+ const removed = removedRawSpans(clips, tl.trimRanges, tl.insertRanges ?? []);
const out = new Map();
for (const pill of audioPills) {
if (pill.kind !== "voiceover" || pill.loop) continue;
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 76472112f..3eeaf08e8 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -251,7 +251,11 @@ export function deriveCaptionCues(
// `?? []` for the same reason `insertRanges` has one: the key is additive, so a
// document written before it — or hand-built, never through the schema — has none.
document.audioTracks ?? [],
- removedRawSpans(document.timeline.clips, document.timeline.trimRanges),
+ removedRawSpans(
+ document.timeline.clips,
+ document.timeline.trimRanges,
+ document.timeline.insertRanges ?? [],
+ ),
(groupId) => takeInserts(document, groupId),
);
if (placements.length === 0) return [];
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
index 012d45947..08d63460d 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -215,7 +215,7 @@ describe("one programme, two lanes", () => {
const VO = track({ id: "vo_1", startMs: 0, endMs: 12000, offsetMs: 0, durationSec: 12 });
function lanes(trims: (typeof TRIM)[]) {
- const removed = removedRawSpans(CLIPS_2, trims);
+ const removed = removedRawSpans(CLIPS_2, trims, []);
const transcripts = [secondsTranscript("asset_rec", 12), secondsTranscript("asset_vo", 12)];
const build = (lane: "recording" | "voiceover") =>
buildAggregatedSections(
@@ -256,7 +256,7 @@ describe("one programme, two lanes", () => {
it("removes a word over an inter-clip gap, with nothing to restore", () => {
const gapped = [CLIPS_2[0], { ...CLIPS_2[1], timelineStartSec: 8, timelineEndSec: 14 }];
- const removed = removedRawSpans(gapped, []);
+ const removed = removedRawSpans(gapped, [], []);
const sections = buildAggregatedSections(
voiceoverPlacements([track({ id: "vo_1", startMs: 0, endMs: 14000, durationSec: 14 })]),
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
@@ -280,7 +280,7 @@ describe("one programme, two lanes", () => {
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
[secondsTranscript("asset_vo", 20)] as any,
[],
- removedRawSpans(CLIPS_2, []),
+ removedRawSpans(CLIPS_2, [], []),
);
const w15 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w15");
expect(w15?.kept).toBe(true);
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index 1a62d7e4c..987b5a3a5 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -83,7 +83,7 @@ describe("buildClipSection", () => {
clip,
transcript,
makeAsset(),
- removedRawSpans([clip], [trim]),
+ removedRawSpans([clip], [trim], []),
);
expect(section.words.map((cw) => cw.kept)).toEqual([true, false, false, false, true]);
expect(section.words.map((cw) => cw.trimIds)).toEqual([
@@ -117,7 +117,12 @@ describe("buildClipSection", () => {
makeTrim({ id: "trim_b", startSec: 3, endSec: 4 }),
];
- const section = buildClipSection(clip, transcript, makeAsset(), removedRawSpans([clip], trims));
+ const section = buildClipSection(
+ clip,
+ transcript,
+ makeAsset(),
+ removedRawSpans([clip], trims, []),
+ );
expect(section.trimRuns).toHaveLength(2);
expect(section.trimRuns[0]).toMatchObject({
trimIds: ["trim_a"],
@@ -157,7 +162,7 @@ describe("buildClipSection", () => {
clips,
[makeTranscript(words())],
[makeAsset()],
- removedRawSpans(clips, [trim]),
+ removedRawSpans(clips, [trim], []),
);
expect(sections[0].words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -176,7 +181,7 @@ describe("buildClipSection", () => {
clips,
[makeTranscript(words())],
[makeAsset()],
- removedRawSpans(clips, [trim]),
+ removedRawSpans(clips, [trim], []),
);
expect(sections[0].trimRuns).toHaveLength(1);
expect(sections[1].trimRuns).toHaveLength(1);
@@ -195,7 +200,7 @@ describe("buildClipSection", () => {
clip,
transcript,
makeAsset(),
- removedRawSpans([clip], [trim]),
+ removedRawSpans([clip], [trim], []),
);
// Trailing gap 2s→3s is a silence — the different-asset trim doesn't
// cover any of the three entries, so all stay kept.
@@ -278,7 +283,7 @@ describe("silence gaps", () => {
clip,
transcript,
makeAsset(),
- removedRawSpans([clip], [trim]),
+ removedRawSpans([clip], [trim], []),
);
const silence = section.words.find((cw) => isSilenceWord(cw.word));
expect(silence?.kept).toBe(false);
diff --git a/src/lib/ai-edition/timeline/camera.test.ts b/src/lib/ai-edition/timeline/camera.test.ts
index 1f7fea91b..e1caa03a5 100644
--- a/src/lib/ai-edition/timeline/camera.test.ts
+++ b/src/lib/ai-edition/timeline/camera.test.ts
@@ -56,6 +56,7 @@ describe("resolveActiveCameraTrack", () => {
[assetWithCamera, assetWithoutCamera],
[clipWithCamera, clipWithoutCamera],
2,
+ [],
);
expect(track?.sourcePath).toBe("/cam-1.mp4");
});
@@ -65,17 +66,18 @@ describe("resolveActiveCameraTrack", () => {
[assetWithCamera, assetWithoutCamera],
[clipWithCamera, clipWithoutCamera],
7,
+ [],
);
expect(track).toBeNull();
});
it("returns null when there are no clips", () => {
- expect(resolveActiveCameraTrack([assetWithCamera], [], 0)).toBeNull();
+ expect(resolveActiveCameraTrack([assetWithCamera], [], 0, [])).toBeNull();
});
it("returns null when the active clip references an unknown asset", () => {
const orphanClip: AxcutClip = { ...clipWithCamera, assetId: "missing" };
- expect(resolveActiveCameraTrack([assetWithCamera], [orphanClip], 2)).toBeNull();
+ expect(resolveActiveCameraTrack([assetWithCamera], [orphanClip], 2, [])).toBeNull();
});
});
diff --git a/src/lib/ai-edition/timeline/camera.ts b/src/lib/ai-edition/timeline/camera.ts
index 4fc576952..0f11ed0ce 100644
--- a/src/lib/ai-edition/timeline/camera.ts
+++ b/src/lib/ai-edition/timeline/camera.ts
@@ -4,15 +4,18 @@
// timeline, and whether the timeline has ANY camera at all — used to gate
// camera-only preview chrome and settings controls.
-import type { AxcutAsset, AxcutCameraTrack, AxcutClip } from "../schema";
+import type { AxcutAsset, AxcutCameraTrack, AxcutClip, AxcutInsertRange } from "../schema";
import { locateVirtualPosition } from "./virtual-preview";
export function resolveActiveCameraTrack(
assets: AxcutAsset[],
clips: AxcutClip[],
currentTimeSec: number,
+ /** REQUIRED: a clip carrying insertions is longer than its source window, so which clip
+ * a timeline second falls on cannot be answered without them (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): AxcutCameraTrack | null {
- const position = locateVirtualPosition(clips, currentTimeSec);
+ const position = locateVirtualPosition(clips, currentTimeSec, insertRanges);
if (!position) return null;
const activeAsset = assets.find((a) => a.id === position.clip.assetId);
return activeAsset?.cameraTrack ?? null;
diff --git a/src/lib/ai-edition/timeline/cursor-track.ts b/src/lib/ai-edition/timeline/cursor-track.ts
index aec4324a4..f190828d6 100644
--- a/src/lib/ai-edition/timeline/cursor-track.ts
+++ b/src/lib/ai-edition/timeline/cursor-track.ts
@@ -16,7 +16,7 @@
// sample, nothing is summarised, and every pointer-shape change survives the
// reduction because a shape change is an observed event, not a verdict about it.
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import { locateSourcePosition } from "./virtual-preview";
/**
@@ -158,6 +158,9 @@ export interface CursorTrackOptions {
durationSec: number;
clips: AxcutClip[];
trimRanges?: AxcutTrimRange[];
+ /** The insertions those clips carry: a clip carrying one is longer than its source
+ * window, so a capture timestamp's place on the timeline moves with them (issue #560). */
+ insertRanges?: readonly AxcutInsertRange[];
hz?: number;
maxPoints?: number;
/** Movement threshold in frame fractions; see DEFAULT_TRACK_EPSILON. */
@@ -169,6 +172,7 @@ export interface CursorTrackOptions {
export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
const { assetId, samples, durationSec, clips } = options;
const trimRanges = options.trimRanges ?? [];
+ const insertRanges = options.insertRanges ?? [];
const maxPoints = options.maxPoints ?? DEFAULT_MAX_TRACK_POINTS;
const ceilingMs = Math.max(0, durationSec) * 1000 || Number.POSITIVE_INFINITY;
@@ -294,7 +298,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
// not told twice.
const shifted = keep.some((s) => {
const atSec = s.timeMs / 1000;
- const position = locateSourcePosition(clips, atSec, assetId);
+ const position = locateSourcePosition(clips, atSec, assetId, 0.05, undefined, insertRanges);
return !position || Math.abs(position.virtualTimeSec - atSec) > 0.005;
});
@@ -303,7 +307,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
// `locateSourcePosition` is the existing source→virtual mapping, exact here
// because trims do NOT compact the document's virtual axis — a trim is a hole
// in playback, not a shortening of the ruler (see timeline/trim-mapping.ts).
- const position = locateSourcePosition(clips, atSec, assetId);
+ const position = locateSourcePosition(clips, atSec, assetId, 0.05, undefined, insertRanges);
const point: CursorTrackPoint = {
atSec: round2(atSec),
cx: round3(s.cx),
diff --git a/src/lib/ai-edition/timeline/programme-time.test.ts b/src/lib/ai-edition/timeline/programme-time.test.ts
index 7b2b35cca..e302703dd 100644
--- a/src/lib/ai-edition/timeline/programme-time.test.ts
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -114,7 +114,7 @@ describe("keptRawSpans agrees with playback", () => {
(sum, seg) => sum + ((seg.sourceEndSec ?? seg.sourceStartSec) - seg.sourceStartSec),
0,
);
- const kept = keptRawSpans(clips, trims);
+ const kept = keptRawSpans(clips, trims, []);
expect(total(kept), `seed ${seed} total`).toBeCloseTo(played, 6);
// The sum alone is blind to ORDER, and order is the whole reason this walk was
@@ -153,7 +153,7 @@ describe("keptRawSpans agrees with playback", () => {
timelineEndSec: 6,
}),
];
- expect(keptRawSpans(clips, []).map((s) => s.startSec)).toEqual([0, 6]);
+ expect(keptRawSpans(clips, [], []).map((s) => s.startSec)).toEqual([0, 6]);
});
it("leaves the projection identical to what it produced before the lift", () => {
@@ -178,8 +178,8 @@ describe("removedRawSpans", () => {
trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 }),
trim({ id: "t2", clipId: "c2", startSec: 15, endSec: 16 }),
];
- const kept = [...keptRawSpans(clips, trims)].sort((a, b) => a.startSec - b.startSec);
- const removed = removedRawSpans(clips, trims);
+ const kept = [...keptRawSpans(clips, trims, [])].sort((a, b) => a.startSec - b.startSec);
+ const removed = removedRawSpans(clips, trims, []);
const all = [...kept, ...removed].sort((a, b) => a.startSec - b.startSec);
let cursor = 0;
@@ -201,7 +201,7 @@ describe("removedRawSpans", () => {
timelineEndSec: 23,
}),
];
- const gap = removedRawSpans(clips, []).find((s) => s.startSec === 10);
+ const gap = removedRawSpans(clips, [], []).find((s) => s.startSec === 10);
expect(gap).toMatchObject({ startSec: 10, endSec: 13 });
// No trim took it, so the pane must not offer a restore.
expect(gap?.trimIds).toEqual([]);
@@ -210,7 +210,7 @@ describe("removedRawSpans", () => {
it("removes a trimmed tail of the last clip but never the time past it", () => {
const clips = [twoClips()[0]];
const trims = [trim({ id: "t1", clipId: "c1", startSec: 8, endSec: 10 })];
- const removed = removedRawSpans(clips, trims);
+ const removed = removedRawSpans(clips, trims, []);
expect(removed).toEqual([{ startSec: 8, endSec: 10, trimIds: ["t1"] }]);
// Raw 12 is unfilmed, not removed — the distinction a voiceover overhanging the
// programme depends on.
@@ -225,7 +225,7 @@ describe("removedRawSpans", () => {
// playback walk cuts on overlap, per clip, and this must match it.
const clips = twoClips();
const trims = [trim({ id: "t1", startSec: 5, endSec: 15 })]; // no clipId
- const removed = removedRawSpans(clips, trims);
+ const removed = removedRawSpans(clips, trims, []);
expect(removalAt(removed, 6)).toMatchObject({ trimIds: ["t1"] }); // inside c1
expect(removalAt(removed, 12)).toMatchObject({ trimIds: ["t1"] }); // inside c2
expect(removalAt(removed, 2)).toBeNull();
@@ -240,22 +240,24 @@ describe("removedRawSpans", () => {
];
// `subtractInterval` merges the two into one hole; both ids come with it, so
// restoring from the pane can drop the whole pill.
- expect(removedRawSpans(clips, trims)).toEqual([
+ expect(removedRawSpans(clips, trims, [])).toEqual([
{ startSec: 2, endSec: 7, trimIds: ["t1", "t2"] },
]);
});
it("returns nothing for a document with no clips", () => {
- expect(removedRawSpans([], [trim({ id: "t1" })])).toEqual([]);
+ expect(removedRawSpans([], [trim({ id: "t1" })], [])).toEqual([]);
});
});
describe("subtractRemoved", () => {
it("splits a span that crosses a cut into the pieces that survive", () => {
const clips = twoClips();
- const removed = removedRawSpans(clips, [
- trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 5 }),
- ]);
+ const removed = removedRawSpans(
+ clips,
+ [trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 5 })],
+ [],
+ );
// A voiceover from raw 1 to raw 8 plays as two pieces, not as one take cut short.
expect(subtractRemoved(1, 8, removed)).toEqual([
{ startSec: 1, endSec: 3 },
@@ -265,9 +267,11 @@ describe("subtractRemoved", () => {
it("yields nothing for a span buried inside a cut, and the whole span when untouched", () => {
const clips = twoClips();
- const removed = removedRawSpans(clips, [
- trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 8 }),
- ]);
+ const removed = removedRawSpans(
+ clips,
+ [trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 8 })],
+ [],
+ );
expect(subtractRemoved(4, 6, removed)).toEqual([]);
expect(subtractRemoved(10, 14, removed)).toEqual([{ startSec: 10, endSec: 14 }]);
// Past the programme is not removed, so an overhanging take keeps its tail.
@@ -343,3 +347,61 @@ describe("projectRawTimelineSecToPlayback across an insertion", () => {
expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted, speed)).toBeCloseTo(4, 6);
});
});
+
+// ─── The hole an insertion is not ────────────────────────────────────────────
+// `clipRawExtent` measured a clip by its SOURCE window, so a clip carrying insertions
+// ended short by exactly the inserted time — and `removedRawSpans`, walking clip to clip,
+// reported the difference as a gap nothing had removed. Everything that cuts on removed
+// spans then cut there: a voiceover crossing it went silent for the insertion's length, in
+// the preview and in the exported mix, and its words were struck through in the transcript
+// pane. The user heard the voice drop out exactly where they added a word (issue #560).
+
+describe("an insertion is not a hole in the programme", () => {
+ const inserted: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ const clips = [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 11,
+ timelineEndSec: 21,
+ }),
+ ];
+
+ it("reports nothing removed when nothing was trimmed", () => {
+ expect(removedRawSpans(clips, [], inserted)).toEqual([]);
+ });
+
+ it("covers the insertion's own seconds as kept programme", () => {
+ const spans = keptRawSpans(clips, [], inserted);
+ const covers = (sec: number) => spans.some((s) => sec >= s.startSec && sec < s.endSec);
+ // 5.5 is inside the inserted media, 10.5 is the first clip's last second.
+ expect(covers(5.5)).toBe(true);
+ expect(covers(10.5)).toBe(true);
+ });
+
+ it("still reports a real gap between two clips", () => {
+ const apart = [clips[0], clip({ ...clips[1], timelineStartSec: 13, timelineEndSec: 23 })];
+ const removed = removedRawSpans(apart, [], inserted);
+ expect(removed).toHaveLength(1);
+ expect(removed[0].startSec).toBeCloseTo(11, 6);
+ expect(removed[0].endSec).toBeCloseTo(13, 6);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
index f2b7cb3ec..d9bfd77a7 100644
--- a/src/lib/ai-edition/timeline/programme-time.ts
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -19,7 +19,7 @@
// derived READING of those rows, computed on demand and never written back.
import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
-import { sourceToTimelineSec } from "./inserted-time";
+import { assignInsertsToClips, sourceToTimelineSec } from "./inserted-time";
import { type Interval, subtractInterval } from "./intervals";
import { trimAppliesToClip } from "./trim-mapping";
@@ -48,14 +48,22 @@ export interface RemovedRawSpan extends RawSpan {
* source length to measure, and falls back to the ruler geometry it was given — matching
* the pass-through branch `resolvePlaybackSegments` takes for the same clips.
*/
-function clipRawExtent(clip: AxcutClip): RawSpan {
+/** A clip's whole stretch of timeline — its source window PLUS the media inserted inside it.
+ *
+ * Computed, not read off `timelineEndSec`: derived the same way `reflowClipsForInserts`
+ * writes it, so a clip whose stored geometry is stale or half-written cannot make this lie.
+ * Leaving the insertions out was its own bug — the extent then ended short by exactly the
+ * inserted time, and `removedRawSpans` reported a phantom hole at the tail of every clip
+ * carrying one, which the audio paths cut as if a trim had taken it. */
+function clipRawExtent(clip: AxcutClip, ownInserts: readonly AxcutInsertRange[]): RawSpan {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
return { startSec: clip.timelineStartSec, endSec: clip.timelineEndSec };
}
+ const owed = ownInserts.reduce((sum, insert) => sum + insert.durationSec, 0);
return {
startSec: clip.timelineStartSec,
- endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec),
+ endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec) + owed,
};
}
@@ -102,15 +110,19 @@ function keptSourceIntervals(clip: AxcutClip, trimRanges: AxcutTrimRange[]): Int
export function keptRawSpans(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
- insertRanges: readonly AxcutInsertRange[] = [],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): RawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ const owners = assignInsertsToClips(ordered, insertRanges);
const spans: RawSpan[] = [];
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
// Duration not probed yet — the whole raw clip passes through unnarrowed.
- const extent = clipRawExtent(clip);
+ const extent = clipRawExtent(clip, owners.get(clip.id) ?? []);
if (extent.endSec > extent.startSec) spans.push(extent);
continue;
}
@@ -140,16 +152,20 @@ export function keptRawSpans(
export function removedRawSpans(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
- insertRanges: readonly AxcutInsertRange[] = [],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): RemovedRawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
if (ordered.length === 0) return [];
+ const owners = assignInsertsToClips(ordered, insertRanges);
const removed: RemovedRawSpan[] = [];
let cursor = 0; // raw end of the programme walked so far
for (const clip of ordered) {
- const extent = clipRawExtent(clip);
+ const extent = clipRawExtent(clip, owners.get(clip.id) ?? []);
// The unfilmed stretch before this clip. `max` rather than a bare subtraction so
// two clips overlapping on the ruler contribute no negative gap.
if (extent.startSec > cursor) {
diff --git a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
index 5bbe7ac44..b132308ef 100644
--- a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
+++ b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
@@ -74,7 +74,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
next.timeline.clips,
next.transcripts,
next.assets,
- removedRawSpans(next.timeline.clips, next.timeline.trimRanges),
+ removedRawSpans(next.timeline.clips, next.timeline.trimRanges, []),
);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].trimRuns).toHaveLength(1);
diff --git a/src/lib/ai-edition/timeline/take-programme.test.ts b/src/lib/ai-edition/timeline/take-programme.test.ts
index 37c7d595a..7cf3cce0c 100644
--- a/src/lib/ai-edition/timeline/take-programme.test.ts
+++ b/src/lib/ai-edition/timeline/take-programme.test.ts
@@ -64,7 +64,7 @@ describe("takeProgramme", () => {
[trim(8, 12)],
[trim(2, 3), trim(6, 7, "t2")],
]) {
- const removed = removedRawSpans(CLIPS, cuts);
+ const removed = removedRawSpans(CLIPS, cuts, []);
const played = takeProgramme(TAKE, removed, [])
.filter((p) => p.kind === "play")
.map((p) => [p.rawStartSec, p.rawEndSec]);
@@ -107,7 +107,7 @@ describe("takeProgramme", () => {
});
it("gives nothing to an insertion a cut swallowed", () => {
- const removed = removedRawSpans(CLIPS, [trim(3, 6)]);
+ const removed = removedRawSpans(CLIPS, [trim(3, 6)], []);
const withIt = takeProgramme(TAKE, removed, [ins(4, 1)]);
const without = takeProgramme(TAKE, removed, []);
// The moment it holds is not in the film any more, so it buys no time.
@@ -116,7 +116,7 @@ describe("takeProgramme", () => {
});
it("keeps an insertion on a cut's far edge, which is what follows the cut", () => {
- const removed = removedRawSpans(CLIPS, [trim(3, 6)]);
+ const removed = removedRawSpans(CLIPS, [trim(3, 6)], []);
const pieces = takeProgramme(TAKE, removed, [ins(6, 1)]);
expect(pieces.map((p) => p.kind)).toEqual(["play", "removed", "hold", "play"]);
});
@@ -159,7 +159,7 @@ describe("rawSpanForOutDuration", () => {
});
describe("takePlaybackAt", () => {
- const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)]), [ins(4, 1)]);
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)], []), [ins(4, 1)]);
it("plays the file where the file plays", () => {
expect(takePlaybackAt(pieces, 2)).toMatchObject({ targetTimeSec: 2, shouldPlay: true });
@@ -189,7 +189,7 @@ describe("takePlaybackAt", () => {
// play are the entries the export emits, piece for piece.
describe("preview and export agree over a take with a cut and a pause", () => {
- const removed = removedRawSpans(CLIPS, [trim(7, 8)]);
+ const removed = removedRawSpans(CLIPS, [trim(7, 8)], []);
const pieces = takeProgramme(TAKE, removed, [ins(4, 1)]);
it("plays exactly the play pieces, and nothing between them", () => {
@@ -263,7 +263,7 @@ describe("the pieces a pill draws", () => {
});
it("covers the pill end to end, with no overlap and no hole", () => {
- const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)]), [ins(4, 1)]);
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)], []), [ins(4, 1)]);
let cursor = TAKE.startMs / 1000;
for (const piece of pieces) {
expect(piece.rawStartSec).toBeCloseTo(cursor, 6);
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index b06ac8fb9..608350a49 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -48,7 +48,7 @@ const region = (id: string, startSec: number, endSec: number, payload?: string):
describe("projectRegionsToSource", () => {
it("passes a region through unchanged (no clipIndex) when there are no segments", () => {
- const out = projectRegionsToSource([region("r", 1.5, 4.25)], [], [], () => "x");
+ const out = projectRegionsToSource([region("r", 1.5, 4.25)], [], [], () => "x", []);
expect(out).toEqual([{ id: "r", startMs: 1500, endMs: 4250 }]);
});
@@ -60,7 +60,7 @@ describe("projectRegionsToSource", () => {
sourceEndSec: 10,
timelineEndSec: 10,
});
- const out = projectRegionsToSource([region("r", 3, 5)], [c], [c], () => "x");
+ const out = projectRegionsToSource([region("r", 3, 5)], [c], [c], () => "x", []);
expect(out).toEqual([{ id: "r", startMs: 3000, endMs: 5000, clipIndex: 0 }]);
});
@@ -75,7 +75,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
- const out = projectRegionsToSource([region("r", 6, 8)], segments, [c], () => "x");
+ const out = projectRegionsToSource([region("r", 6, 8)], segments, [c], () => "x", []);
expect(out).toEqual([{ id: "r", startMs: 6000, endMs: 8000, clipIndex: 1 }]);
});
@@ -96,7 +96,13 @@ describe("projectRegionsToSource", () => {
timelineStartSec: 5,
timelineEndSec: 10,
});
- const out = projectRegionsToSource([region("r", 3, 7, "keep")], [c1, c2], [c1, c2], () => "r2");
+ const out = projectRegionsToSource(
+ [region("r", 3, 7, "keep")],
+ [c1, c2],
+ [c1, c2],
+ () => "r2",
+ [],
+ );
expect(out).toEqual([
{ id: "r", startMs: 103000, endMs: 105000, clipIndex: 0, payload: "keep" },
{ id: "r2", startMs: 200000, endMs: 202000, clipIndex: 1, payload: "keep" },
@@ -114,7 +120,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]);
- const out = projectRegionsToSource([region("r", 3, 8)], segments, [c], () => "r2");
+ const out = projectRegionsToSource([region("r", 3, 8)], segments, [c], () => "r2", []);
expect(out).toEqual([
{ id: "r", startMs: 3000, endMs: 4000, clipIndex: 0 },
{ id: "r2", startMs: 6000, endMs: 8000, clipIndex: 1 },
@@ -149,7 +155,7 @@ describe("projectRegionsToSource", () => {
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 2, 8)]);
// segments: c1[0,2] (0), c1[8,10] (1), c2[0,10] (2).
const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 };
- expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([
+ expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x", [])).toEqual([
{ ...anchored, startMs: 3000, endMs: 5000, clipIndex: 0, underTrim: true },
]);
});
@@ -175,7 +181,7 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 0, 10)]);
const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 };
- expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([]);
+ expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x", [])).toEqual([]);
});
it("keeps an unanchored region a trim removes entirely, mapped through its raw clip", () => {
@@ -191,7 +197,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]);
- expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x")).toEqual([
+ expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x", [])).toEqual([
{ id: "r", startMs: 4500, endMs: 5500, clipIndex: 0, underTrim: true },
]);
});
@@ -208,7 +214,7 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]);
const anchored = { ...region("r", 1, 2), clipId: "c1", sourceStartSec: 1, sourceEndSec: 2 };
- expect(projectRegionsToSource([anchored], segments, [c], () => "x")).toEqual([
+ expect(projectRegionsToSource([anchored], segments, [c], () => "x", [])).toEqual([
{ ...anchored, startMs: 1000, endMs: 2000, clipIndex: 0, underTrim: true },
]);
});
@@ -234,9 +240,9 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 6, 10)]);
const anchored = { ...region("r", 7, 9), clipId: "c1", sourceStartSec: 7, sourceEndSec: 9 };
- const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x");
+ const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x", []);
// raw 8 is inside c1's removed tail; the region covering source [7,9] is that same cut.
- expect(resolveNativePosition(8, segments, [c1, c2])?.clipIndex).toBe(projected.clipIndex);
+ expect(resolveNativePosition(8, segments, [c1, c2], [])?.clipIndex).toBe(projected.clipIndex);
});
// --- anchored path: the anchor is the SSOT, `startMs`/`endMs` are not consulted ---
@@ -258,7 +264,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 6,
sourceEndSec: 8,
};
- const out = projectRegionsToSource([stale], [c], [c], () => "x");
+ const out = projectRegionsToSource([stale], [c], [c], () => "x", []);
expect(out).toEqual([{ ...stale, startMs: 6000, endMs: 8000, clipIndex: 0 }]);
});
@@ -278,7 +284,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 3,
sourceEndSec: 8,
};
- const out = projectRegionsToSource([anchored], segments, [c], () => "r2");
+ const out = projectRegionsToSource([anchored], segments, [c], () => "r2", []);
expect(out).toEqual([
{ ...anchored, id: "r", startMs: 3000, endMs: 4000, clipIndex: 0 },
{ ...anchored, id: "r2", startMs: 6000, endMs: 8000, clipIndex: 1 },
@@ -310,7 +316,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 1,
sourceEndSec: 2,
};
- const out = projectRegionsToSource([anchored], [c1, c2], [c1, c2], () => "x");
+ const out = projectRegionsToSource([anchored], [c1, c2], [c1, c2], () => "x", []);
expect(out).toEqual([{ ...anchored, startMs: 1000, endMs: 2000, clipIndex: 1 }]);
});
@@ -325,7 +331,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const partial = { ...region("r", 3, 5), clipId: "c1" }; // no source span
- const out = projectRegionsToSource([partial], [c], [c], () => "x");
+ const out = projectRegionsToSource([partial], [c], [c], () => "x", []);
expect(out).toEqual([{ ...partial, startMs: 3000, endMs: 5000, clipIndex: 0 }]);
});
});
@@ -351,12 +357,12 @@ describe("resolveNativePosition", () => {
timelineEndSec: 12,
}),
];
- expect(resolveNativePosition(6.5, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(6.5, clips, clips, [])).toMatchObject({
clip: { id: "c2" },
clipIndex: 1,
sourceTimeSec: 22.5,
});
- expect(resolveNativePosition(10, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(10, clips, clips, [])).toMatchObject({
clip: { id: "c3" },
clipIndex: 2,
sourceTimeSec: 42,
@@ -383,12 +389,12 @@ describe("resolveNativePosition", () => {
timelineEndSec: 12,
}),
];
- expect(resolveNativePosition(7.25, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(7.25, clips, clips, [])).toMatchObject({
clip: { assetId: "asset-b" },
clipIndex: 1,
sourceTimeSec: 103.25,
});
- expect(resolveNativePosition(11, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(11, clips, clips, [])).toMatchObject({
clip: { assetId: "asset-c" },
clipIndex: 2,
sourceTimeSec: 14,
@@ -405,11 +411,11 @@ describe("resolveNativePosition", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
// raw 1 → source 1 on seg1; raw 6 → source 6 on seg2 (NOT 8).
- expect(resolveNativePosition(1, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(1, segments, [c], [])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 1,
});
- expect(resolveNativePosition(6, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(6, segments, [c], [])).toMatchObject({
clipIndex: 1,
sourceTimeSec: 6,
});
@@ -430,7 +436,7 @@ describe("resolveNativePosition", () => {
// points at, and would incrust any modifier under the cut on someone else's image (#216).
// The segment it borrows is the one the cut interrupts (seg1), so a modifier under that
// cut — addressed the same way — survives `belongs()`.
- expect(resolveNativePosition(3, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(3, segments, [c], [])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 3,
});
@@ -445,7 +451,7 @@ describe("resolveNativePosition", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]);
- expect(resolveNativePosition(1, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(1, segments, [c], [])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 1,
});
@@ -461,11 +467,11 @@ describe("resolveNativePosition", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
// No raw clip owns raw 99 — nothing to present, so the historical clamp stands.
- expect(resolveNativePosition(99, segments, [c])).toMatchObject({ clipIndex: 1 });
+ expect(resolveNativePosition(99, segments, [c], [])).toMatchObject({ clipIndex: 1 });
});
it("returns null when there are no segments", () => {
- expect(resolveNativePosition(1, [], [])).toBeNull();
+ expect(resolveNativePosition(1, [], [], [])).toBeNull();
});
});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index 2f3749ae7..b27103d6a 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -402,7 +402,10 @@ export function anchorRegionsWithDerivedMs<
export function segmentRawSpanSec(
segment: PlaybackSegment,
rawClips: AxcutClip[],
- insertRanges: readonly AxcutInsertRange[] = [],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips, insertRanges);
// A held segment's source window is the single frame it shows, so its source length
@@ -571,8 +574,10 @@ export function projectRegionsToSource<
/** The insertions the clips carry. A segment after one starts that much further along
* the timeline, and an UNANCHORED region — a caption cue, which is built fresh each
* time and has no clip anchor — is placed by intersecting with exactly that extent.
- * Without them the caption landed on the wrong stretch of source (issue #560). */
- insertRanges: readonly AxcutInsertRange[] = [],
+ * Without them the caption landed on the wrong stretch of source (issue #560).
+ *
+ * REQUIRED for the same reason as its neighbours: omitting it is silently wrong. */
+ insertRanges: readonly AxcutInsertRange[],
): (T & { clipIndex?: number; underTrim?: boolean })[] {
// RAW extents + owning raw clip per visible segment. Both are only consulted by the
// path that needs them (raw fallback / anchor match), but resolving them once keeps
@@ -688,7 +693,10 @@ export function resolveNativePosition(
rawSec: number,
visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
- insertRanges: readonly AxcutInsertRange[] = [],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
@@ -698,7 +706,7 @@ export function resolveNativePosition(
const isLast = i === spans.length - 1;
return rawSec >= s.startSec && (rawSec < s.endSec || (isLast && rawSec <= s.endSec));
});
- if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips);
+ if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips, insertRanges);
const seg = visibleSegments[index];
// Inside a pause the source clock does not advance: the whole point of the segment
@@ -733,6 +741,7 @@ function positionUnderCut(
rawSec: number,
visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
): NativePosition {
const rawClip = rawClipAt(rawSec, rawClips);
if (rawClip) {
@@ -757,7 +766,7 @@ function positionUnderCut(
}
}
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
const next = spans.findIndex((s) => s.startSec >= rawSec);
const index = next >= 0 ? next : visibleSegments.length - 1;
const seg = visibleSegments[index];
diff --git a/src/lib/ai-edition/timeline/virtual-preview.test.ts b/src/lib/ai-edition/timeline/virtual-preview.test.ts
index f3dd64888..37bac870b 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.test.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.test.ts
@@ -51,24 +51,24 @@ describe("virtual-preview pure functions", () => {
});
it("locateVirtualPosition maps virtual time to source time", () => {
- const pos = locateVirtualPosition(clips, 12);
+ const pos = locateVirtualPosition(clips, 12, []);
expect(pos).not.toBeNull();
expect(pos?.clipIndex).toBe(1);
expect(pos?.sourceTimeSec).toBe(22);
});
it("locateVirtualPosition returns null for empty clips", () => {
- expect(locateVirtualPosition([], 0)).toBeNull();
+ expect(locateVirtualPosition([], 0, [])).toBeNull();
});
it("locateSourcePosition maps source time back to virtual time", () => {
- const pos = locateSourcePosition(clips, 25);
+ const pos = locateSourcePosition(clips, 25, undefined, 0.05, undefined, []);
expect(pos).not.toBeNull();
expect(pos?.virtualTimeSec).toBe(15);
});
it("locateSourcePosition returns null for source time in a cut", () => {
- expect(locateSourcePosition(clips, 15)).toBeNull();
+ expect(locateSourcePosition(clips, 15, undefined, 0.05, undefined, [])).toBeNull();
});
it("keptWordIdSet flattens wordRefs from all clips", () => {
@@ -106,13 +106,13 @@ describe("virtual-preview pure functions", () => {
reason: "",
},
];
- const pos1 = locateSourcePosition(multiClips, 5, "a1");
+ const pos1 = locateSourcePosition(multiClips, 5, "a1", 0.05, undefined, []);
expect(pos1?.clip.id).toBe("clip_1");
- const pos2 = locateSourcePosition(multiClips, 5, "a2");
+ const pos2 = locateSourcePosition(multiClips, 5, "a2", 0.05, undefined, []);
expect(pos2?.clip.id).toBe("clip_2");
- const posNone = locateSourcePosition(multiClips, 5, "a3");
+ const posNone = locateSourcePosition(multiClips, 5, "a3", 0.05, undefined, []);
expect(posNone).toBeNull();
});
@@ -148,19 +148,19 @@ describe("virtual-preview pure functions", () => {
// the earliest matching clip — this is the bug: playing back the
// second clip's segment would still report position/identity for the
// first.
- const ambiguous = locateSourcePosition(duplicateClips, 5, "a1");
+ const ambiguous = locateSourcePosition(duplicateClips, 5, "a1", 0.05, undefined, []);
expect(ambiguous?.clip.id).toBe("clip_1");
// With the currently-active clip id passed through, it's preferred
// even though clip_1 also matches (assetId, sourceTime).
- const disambiguated = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_2");
+ const disambiguated = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_2", []);
expect(disambiguated?.clip.id).toBe("clip_2");
expect(disambiguated?.virtualTimeSec).toBe(15);
// A preferred clip id that no longer applies (source time moved
// outside its range) falls back to the ambiguous scan rather than
// forcing a stale match.
- const outOfRange = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_3");
+ const outOfRange = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_3", []);
expect(outOfRange?.clip.id).toBe("clip_1");
});
@@ -213,6 +213,7 @@ describe("virtual-preview pure functions", () => {
playingClip.assetId,
0.05,
playingClip.id,
+ [],
);
expect(pos?.clip.id).toBe(playing);
expect(pos?.virtualTimeSec).toBeCloseTo(playingClip.timelineStartSec + sourceTimeSec, 6);
@@ -222,8 +223,8 @@ describe("virtual-preview pure functions", () => {
// The scan cannot know which twin is playing — but its answer must at least not
// depend on which twin happens to sit last in the array, which is what
// `index === clips.length - 1` made it do.
- const forward = locateSourcePosition(twins([a1, a2]), 9.96, "a1");
- const reversed = locateSourcePosition(twins([a2, a1]), 9.96, "a1");
+ const forward = locateSourcePosition(twins([a1, a2]), 9.96, "a1", 0.05, undefined, []);
+ const reversed = locateSourcePosition(twins([a2, a1]), 9.96, "a1", 0.05, undefined, []);
expect(forward?.clip.id).toBe("clip_a1");
expect(reversed?.clip.id).toBe("clip_a2");
// i.e. both resolve to the FIRST clip of the asset — the documented behaviour of
@@ -246,17 +247,17 @@ describe("virtual-preview pure functions", () => {
timelineEndSec: 20,
},
];
- expect(locateSourcePosition(split, 10, "a1")?.clip.id).toBe("clip_a2");
- expect(locateSourcePosition(split, 9.9, "a1")?.clip.id).toBe("clip_a1");
+ expect(locateSourcePosition(split, 10, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a2");
+ expect(locateSourcePosition(split, 9.9, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a1");
// …and the very end of the timeline still resolves rather than falling off it.
- expect(locateSourcePosition(split, 20, "a1")?.clip.id).toBe("clip_a2");
+ expect(locateSourcePosition(split, 20, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a2");
});
it("ignores a named clip whose asset is not the one playing", () => {
// A stale id during an asset swap must fall through to the scan rather than
// mapping the time through media that is not on screen.
const clips = twins([a1, c3]);
- const pos = locateSourcePosition(clips, 5, "a1", 0.05, "clip_c3");
+ const pos = locateSourcePosition(clips, 5, "a1", 0.05, "clip_c3", []);
expect(pos?.clip.id).toBe("clip_a1");
});
});
@@ -367,8 +368,8 @@ describe("virtual-preview pure functions", () => {
timelineEndSec: 13.8,
};
- expect(getRawVirtualStartTime(segClip1Part2, rawClips)).toBe(6);
- expect(getRawVirtualStartTime(segClip2Part1, rawClips)).toBe(13.2);
+ expect(getRawVirtualStartTime(segClip1Part2, rawClips, [])).toBe(6);
+ expect(getRawVirtualStartTime(segClip2Part1, rawClips, [])).toBe(13.2);
});
it("findNextKeptSegment finds next kept segment across multi-clip trim boundary", () => {
@@ -421,11 +422,11 @@ describe("virtual-preview pure functions", () => {
];
// At current raw virtual time 2.5s (end of seg 0), next kept segment is seg 1 (clip_2)
- const nextSeg = findNextKeptSegment(playbackClips, rawClips, 2.5, "a1", 2.5);
+ const nextSeg = findNextKeptSegment(playbackClips, rawClips, 2.5, "a1", 2.5, undefined, []);
expect(nextSeg).toBeDefined();
expect(nextSeg?.id).toBe("clip_2");
expect(nextSeg?.assetId).toBe("a2");
- expect(getRawVirtualStartTime(nextSeg!, rawClips)).toBe(10.7);
+ expect(getRawVirtualStartTime(nextSeg!, rawClips, [])).toBe(10.7);
});
describe("findNextKeptSegment never goes backwards", () => {
@@ -473,9 +474,9 @@ describe("virtual-preview pure functions", () => {
// clip_1 starts at source 30, which IS "later in source time", and its raw start
// is 0: answering it sent playback back to the beginning, straight into the same
// cut again, forever.
- const next = findNextKeptSegment(playbackClips, rawClips, 17, "a1", 7, "clip_2");
+ const next = findNextKeptSegment(playbackClips, rawClips, 17, "a1", 7, "clip_2", []);
expect(next).toBeDefined();
- expect(getRawVirtualStartTime(next!, rawClips)).toBe(20);
+ expect(getRawVirtualStartTime(next!, rawClips, [])).toBe(20);
expect(next?.sourceStartSec).toBe(10);
});
@@ -483,7 +484,7 @@ describe("virtual-preview pure functions", () => {
// Same moment, but the raw position has not caught up (still reads 10, the start
// of clip_2). The ruler test alone would answer clip_2's FIRST kept segment —
// the stretch already played. The clip-scoped source test carries it past the cut.
- const next = findNextKeptSegment(playbackClips, rawClips, 10, "a1", 7, "clip_2");
+ const next = findNextKeptSegment(playbackClips, rawClips, 10, "a1", 7, "clip_2", []);
expect(next?.sourceStartSec).toBe(10);
});
});
diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts
index 8448ae99d..ba27e8cfb 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.ts
@@ -23,7 +23,10 @@ export function clampVirtualTime(clips: AxcutClip[], value: number): number {
export function locateVirtualPosition(
clips: AxcutClip[],
virtualTimeSec: number,
- insertRanges: readonly AxcutInsertRange[] = [],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): VirtualPosition | null {
if (clips.length === 0) return null;
const clamped = clampVirtualTime(clips, virtualTimeSec);
@@ -81,7 +84,10 @@ export function findRawClipForSegment(
export function getRawVirtualStartTime(
segment: AxcutClip,
rawClips: AxcutClip[],
- insertRanges: readonly AxcutInsertRange[] = [],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): number {
const rawClip = findRawClipForSegment(segment, rawClips);
if (!rawClip) return segment.timelineStartSec;
@@ -114,12 +120,13 @@ export function findNextKeptSegment(
playbackClips: AxcutClip[],
rawClips: AxcutClip[],
currentRawTime: number,
- activeSourceId?: string,
- currentSourceTime?: number,
- activeClipId?: string,
+ activeSourceId: string | undefined,
+ currentSourceTime: number | undefined,
+ activeClipId: string | undefined,
+ insertRanges: readonly AxcutInsertRange[],
): AxcutClip | undefined {
for (const seg of playbackClips) {
- const segRawStart = getRawVirtualStartTime(seg, rawClips);
+ const segRawStart = getRawVirtualStartTime(seg, rawClips, insertRanges);
if (segRawStart > currentRawTime + 0.001) {
return seg;
}
@@ -188,8 +195,8 @@ function isWithinClipBounds(
export function locateSourcePosition(
clips: AxcutClip[],
sourceTimeSec: number,
- assetId?: string,
- epsilon = 0.05,
+ assetId: string | undefined,
+ epsilon: number,
// When two clips share the same source asset (and possibly overlapping
// source ranges — a duplicated clip, or simply not trimmed yet), scanning
// by (assetId, sourceTime) alone is ambiguous and always resolves to the
@@ -198,8 +205,11 @@ export function locateSourcePosition(
// which clip they're tracking (VirtualPreview, mid-playback) should pass
// its id here so it's preferred whenever the source time still falls
// inside it, before falling back to the ambiguous asset-wide scan.
- preferredClipId?: string,
- insertRanges: readonly AxcutInsertRange[] = [],
+ preferredClipId: string | undefined,
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): VirtualPosition | null {
if (preferredClipId) {
const preferredIndex = clips.findIndex((clip) => clip.id === preferredClipId);
@@ -266,8 +276,12 @@ export function locateKeptSegment(
const ownSegments = activeClipId
? playbackClips.filter((seg) => findRawClipForSegment(seg, rawClips)?.id === activeClipId)
: [];
- if (ownSegments.length > 0) return locateSourcePosition(ownSegments, sourceTimeSec, assetId);
- return locateSourcePosition(playbackClips, sourceTimeSec, assetId);
+ // The SEGMENTS are already split at every insertion, so within one of them source → its
+ // own start is a plain shift again. `[]` here is the honest answer, not a forgotten
+ // argument: there is no insertion inside a segment to account for.
+ if (ownSegments.length > 0)
+ return locateSourcePosition(ownSegments, sourceTimeSec, assetId, 0.05, undefined, []);
+ return locateSourcePosition(playbackClips, sourceTimeSec, assetId, 0.05, undefined, []);
}
export function keptWordIdSet(clips: AxcutClip[]): Set {
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 683066b69..6ede99478 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -575,7 +575,7 @@ export function buildSceneDescription(
// Placed once: the projection below counts them, so a track after a pause lands where
// the ruler says rather than D seconds early.
const filmInserts = document.timeline.insertRanges ?? [];
- const removed = removedRawSpans(projectedClips, document.timeline.trimRanges);
+ const removed = removedRawSpans(projectedClips, document.timeline.trimRanges, filmInserts);
// The take's pills, keyed by group. A voiceover is walked ONCE per pill and never per
// stored fragment: the document keeps one fragment per clip a take covers, so walking
// them separately would emit overlapping entries and `overlay_track_pcm` sums with `+=`
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index f9d23c12c..7263d14a0 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -36,7 +36,7 @@ export function useNativePlaybackSync(
rawClips: readonly AxcutClip[],
/** The insertions those clips carry — a clip is longer than its source window by them,
* so a segment's place on the timeline cannot be found without them (issue #560). */
- insertRanges: readonly AxcutInsertRange[] = [],
+ insertRanges: readonly AxcutInsertRange[],
): void {
const activePosition = useMemo(
() => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips], insertRanges),
From f4bdb25ab99c3deb3b3f7e44dc810ee58d40ad3b Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 09:32:02 +0200
Subject: [PATCH 088/113] fix(transcript): the pane follows the voice across an
insertion, and highlights the added word
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two hand-written shifts in one file, both blind to the media inserted inside a clip.
`placementRawSec` mapped source → ruler and `findCueWordId` mapped back, so past an
insertion the pane ran ahead of the voice by exactly the inserted time: the highlight sat
on the word AFTER the one being spoken, which is what "it focuses on the following ones,
as if nothing had been shifted" was. `placementRawExtent` ended short for the same reason,
so the last seconds of every clip carrying an insertion belonged to no section and the
highlight simply went out there.
The added word itself could never be highlighted at all. There is no source second inside
an insertion — that is what makes it an insertion — so the search had nothing to match.
`timelineToSourceSec` now names the insertion the playhead is inside, and the word that
bought it is the answer, which is the only word those seconds exist for.
LOAD now enforces the whole chain rather than its last link. A word is ADDED, an added
word has an INSERTION, and a clip carrying insertions is LONGER; each feeds the next, and
reconciling only the last left the live project carrying `synth_1` — an added word minted
before the `source` field existed, so nothing recognised it, no insertion was ever created
for it, and its text played over the recording while everything after it drifted. The id
is the evidence: `nextSynthWordId` has always minted `synth_N`, and the numbering scan
already reads it back with that same pattern.
Also removed: an `overInsertedMedia` edge parameter I added to the caption placement an
hour earlier. Mutation-testing it showed it changes nothing — the line finaliser has
already trimmed a recorded line's end back to just before the added word's moment, so
both edges agree there. Less code, same behaviour, and now a reason on record for why the
unconditional edge is correct.
---
electron/ai-edition/document-service.ts | 7 +-
src/components/ai-edition/NewEditorShell.tsx | 1 +
src/components/ai-edition/RightPanes.tsx | 21 ++--
src/components/ai-edition/VirtualPreview.tsx | 4 +-
src/components/ai-edition/v4/V4Timeline.tsx | 4 +-
.../ai-edition/captions/captionLane.test.ts | 2 +-
src/lib/ai-edition/captions/captions.test.ts | 34 ++++--
src/lib/ai-edition/captions/cues.ts | 24 +++-
src/lib/ai-edition/document/load.test.ts | 53 ++++++++-
src/lib/ai-edition/document/load.ts | 16 ++-
src/lib/ai-edition/document/transcript.ts | 43 +++++++
src/lib/ai-edition/store/projectStore.ts | 4 +-
.../aggregated-transcript.lanes.test.ts | 19 +--
.../timeline/aggregated-transcript.test.ts | 108 ++++++++++++++----
.../timeline/aggregated-transcript.ts | 55 +++++++--
src/lib/ai-edition/timeline/inserted-time.ts | 4 +-
.../timeline/sharedMediaTrim.test.ts | 1 +
.../ai-edition/timeline/voiceoverCut.test.ts | 2 +-
src/native/sceneDescription.test.ts | 2 +-
19 files changed, 330 insertions(+), 74 deletions(-)
diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts
index 1199bee50..d3eaa01d3 100644
--- a/electron/ai-edition/document-service.ts
+++ b/electron/ai-edition/document-service.ts
@@ -13,10 +13,7 @@
import fs, { type FileHandle } from "node:fs/promises";
import path from "node:path";
import { createId } from "../../src/lib/ai-edition/document/ids";
-import {
- parseStoredDocument,
- reconcileClipsWithInserts,
-} from "../../src/lib/ai-edition/document/load";
+import { parseStoredDocument, reconcileInsertions } from "../../src/lib/ai-edition/document/load";
import { removeClip } from "../../src/lib/ai-edition/document/timeline";
import {
type AxcutAsset,
@@ -277,7 +274,7 @@ export class DocumentService {
// back, and it is not persisted from here: the renderer saves the document
// it was given, as it does for any other load-time repair.
const migrated = migrateRawDocumentToCurrent(JSON.parse(raw));
- return reconcileClipsWithInserts(
+ return reconcileInsertions(
documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir)),
);
}
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 385df2a3d..d34184954 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -1347,6 +1347,7 @@ export function NewEditorShell() {
isMac,
togglePlay,
handleSeek,
+ openVoiceoverFlow,
]);
const showTimeline = mode !== "rec";
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 6892bdda7..83927970d 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -913,8 +913,8 @@ export function TranscriptPane({
const placements = activeLane === "voiceover" ? voiceover : clips;
const sections = useMemo(
- () => buildAggregatedSections(placements, transcripts, assets, removed),
- [placements, transcripts, assets, removed],
+ () => buildAggregatedSections(placements, transcripts, assets, removed, insertRanges),
+ [placements, transcripts, assets, removed, insertRanges],
);
// `currentTimeSec` is the RAW/document timeline (same referential as the ruler, see
@@ -923,8 +923,8 @@ export function TranscriptPane({
// id is something only the recording lane has — so the voiceover lane never
// highlighted. Raw seconds are the coordinate both lanes share.
const cueWordId = useMemo(
- () => findCueWordId(sections, currentTimeSec),
- [sections, currentTimeSec],
+ () => findCueWordId(sections, currentTimeSec, insertRanges),
+ [sections, currentTimeSec, insertRanges],
);
const laneSwitch =
@@ -1035,6 +1035,7 @@ export function TranscriptPane({
undefined
}
cueWordId={cueWordId}
+ insertRanges={insertRanges}
onSeek={onSeek}
onTrimTimelineSpan={onTrimTimelineSpan}
onRemoveTrimRanges={onRemoveTrimRanges}
@@ -1066,6 +1067,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
busyLabel,
lane,
cueWordId,
+ insertRanges,
onSeek,
onTrimTimelineSpan,
onRemoveTrimRanges,
@@ -1081,6 +1083,10 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
* CLIP frame, and a voiceover placement has no clip to hold. */
lane: TranscriptLane;
cueWordId: string | null;
+ /** The insertions this placement carries: the block maps its words' SOURCE spans onto
+ * the ruler to author a cut, and a clip carrying insertions is longer than its source
+ * window (issue #560). */
+ insertRanges: readonly AxcutInsertRange[];
onSeek: (sec: number) => void;
onTrimTimelineSpan: (startSec: number, endSec: number, reason: string) => void;
onRemoveTrimRanges: (trimIds: string[]) => void;
@@ -1100,13 +1106,14 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
// to do with the word the user deleted.
const toRawSpan = useCallback(
(startSec: number, endSec: number): [number, number] => {
- const extent = placementRawExtent(clip);
+ const extent = placementRawExtent(clip, insertRanges);
const lo = extent?.startSec ?? clip.timelineStartSec;
const hi = extent?.endSec ?? Number.POSITIVE_INFINITY;
- const clamp = (sec: number) => Math.min(Math.max(placementRawSec(clip, sec), lo), hi);
+ const clamp = (sec: number) =>
+ Math.min(Math.max(placementRawSec(clip, sec, insertRanges), lo), hi);
return [clamp(startSec), clamp(endSec)];
},
- [clip],
+ [clip, insertRanges],
);
const filename = asset?.label ?? clip.assetId;
const sourceRangeLabel =
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index cefbc3082..143051b85 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -631,7 +631,7 @@ export function VirtualPreview({
pieces.set(groupId, takeProgramme(pill, removed, takeInsertsByGroup(groupId)));
}
return { pieces, heads };
- }, [audioTracks, clips, trimRanges, takeInsertsByGroup]);
+ }, [audioTracks, clips, trimRanges, takeInsertsByGroup, insertRanges]);
takePiecesRef.current = takeWalks.pieces;
takeHeadsRef.current = takeWalks.heads;
// Trim-narrowed (`resolvePlaybackSegments`) — used ONLY to detect "has the 's own
@@ -1238,7 +1238,7 @@ export function VirtualPreview({
});
}
},
- [applySourceTime, clips, videoSources, sourceIndex, updateVirtualTime],
+ [applySourceTime, clips, videoSources, sourceIndex, updateVirtualTime, insertRanges],
);
const seekToSourceTime = useCallback(
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index b4db553ec..21e8ce26c 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -699,7 +699,7 @@ export function V4Timeline({
1,
clips.reduce((m, c) => Math.max(m, c.timelineEndSec), 0),
),
- [clips, inserts],
+ [clips],
);
const pctOf = useCallback((sec: number) => (sec / total) * 100, [total]);
const showLanes = variant === "edit";
@@ -873,7 +873,7 @@ export function V4Timeline({
});
}
},
- [setCurrentTime, total, inserts],
+ [setCurrentTime, total],
);
// Mousedown anywhere on the empty timeline (ruler, lanes background, or
diff --git a/src/lib/ai-edition/captions/captionLane.test.ts b/src/lib/ai-edition/captions/captionLane.test.ts
index 776578709..016f95f18 100644
--- a/src/lib/ai-edition/captions/captionLane.test.ts
+++ b/src/lib/ai-edition/captions/captionLane.test.ts
@@ -150,7 +150,6 @@ describe("captionLane", () => {
const paused = doc({
timeline: {
...doc().timeline,
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
insertRanges: [
{
id: "i1",
@@ -161,6 +160,7 @@ describe("captionLane", () => {
reason: "",
origin: "user",
},
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
] as any,
},
});
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 8599bdb55..9b0485479 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -710,15 +710,16 @@ describe("captions and a pause", () => {
describe("a caption line never mixes recorded words with added ones", () => {
function docWithAddedWord(): AxcutDocument {
const t = transcript();
- // "really" typed in after "friend", which ends at source 2. It fits in 0.2s of the
- // silence that is already there and buys 1.8s of inserted media for the rest.
+ // "really" typed in after "friend", which ends at source 2. An added word takes up NO
+ // source time — the seconds it is spoken in are the insertion it buys, which is how
+ // the real documents store it — so its span is degenerate at the moment it follows.
t.words = [
...t.words.slice(0, 3),
{
id: "synth_1",
segmentId: "seg_1",
startSec: 2,
- endSec: 2.2,
+ endSec: 2,
text: "really",
source: "synth",
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
@@ -731,14 +732,13 @@ describe("a caption line never mixes recorded words with added ones", () => {
transcripts: [t],
timeline: {
...base.timeline,
- clips: [{ ...base.timeline.clips[0], timelineEndSec: 11.8 }],
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ clips: [{ ...base.timeline.clips[0], timelineEndSec: 12 }],
insertRanges: [
{
id: "i1",
assetId: "asset-1",
- atSec: 2.2,
- durationSec: 1.8,
+ atSec: 2,
+ durationSec: 2,
wordId: "synth_1",
reason: "",
origin: "user",
@@ -761,6 +761,26 @@ describe("a caption line never mixes recorded words with added ones", () => {
expect(added[0].startMs).toBeGreaterThanOrEqual(2000);
});
+ it("lays the three lines out end to end across the insertion", () => {
+ // The whole rule in one assertion set: the recorded line stops where the added one
+ // begins, the added one spans the media it bought, and what follows is pushed along
+ // by exactly that length.
+ const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
+ const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
+ const added = cues.find((c) => c.text.includes("really"));
+ const after = cues.find((c) => c.text.includes("goodbye"));
+ expect(recorded).toBeDefined();
+ expect(added).toBeDefined();
+ expect(after).toBeDefined();
+ // The insertion opens at ruler 2000 and runs 2000ms. Three consecutive facts:
+ // the recorded line STOPS there, the added line COVERS it, and what follows is
+ // pushed along by exactly its length.
+ expect(recorded?.endMs).toBe(2000);
+ expect(added?.startMs).toBe(2000);
+ expect(added?.endMs).toBeGreaterThan(3900);
+ expect(after?.startMs).toBe(6000);
+ });
+
it("leaves the recorded line ending before the added one begins", () => {
const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 3eeaf08e8..9176faa84 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -128,6 +128,19 @@ export function captionLinesForAsset(
);
}
+function overlapsAdded(
+ word: CaptionSegment,
+ added: Array<{ startSec: number; endSec: number }>,
+): boolean {
+ return added.some(
+ (span) =>
+ (word.startSec < span.endSec && word.endSec > span.startSec) ||
+ // An added word's source span is DEGENERATE — the seconds it is spoken in are the
+ // insertion, not the recording — so a strict overlap never matches it.
+ (word.startSec >= span.startSec && word.endSec <= span.endSec),
+ );
+}
+
/** Where the transcript's ADDED words sit, in source time. */
function addedWordSpans(transcript: AxcutTranscript): Array<{ startSec: number; endSec: number }> {
return transcript.words
@@ -148,8 +161,7 @@ function captionRuns(
added: Array<{ startSec: number; endSec: number }>,
): CaptionSegment[][] {
if (added.length === 0) return [stream];
- const isAdded = (word: CaptionSegment) =>
- added.some((span) => word.startSec < span.endSec && word.endSec > span.startSec);
+ const isAdded = (word: CaptionSegment) => overlapsAdded(word, added);
const runs: CaptionSegment[][] = [];
let current: CaptionSegment[] = [];
let currentIsAdded: boolean | null = null;
@@ -220,11 +232,13 @@ export function sourceSpanToTimelineSpans(
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
- // `"closes"` on the end: a line running up to an added word covers the media that
- // word inserted, so it stays on screen through it instead of going dark over the
- // one moment the word exists for.
out.push({
startSec: sourceToTimelineSec(clip, s, inserts, "opens"),
+ // `"closes"` on the end, unconditionally. For an ADDED line that is what gives it
+ // the width of the media it bought — its source span is degenerate. For a RECORDED
+ // line the two edges agree: `finalizeCaptionSegmentsForPlayback` has already
+ // trimmed its end back to just before the next line begins, which is the added
+ // word's own moment, so there is no insertion at or past it to count.
endSec: sourceToTimelineSec(clip, e, inserts, "closes"),
});
}
diff --git a/src/lib/ai-edition/document/load.test.ts b/src/lib/ai-edition/document/load.test.ts
index 589d49ca0..e6f8b3b2f 100644
--- a/src/lib/ai-edition/document/load.test.ts
+++ b/src/lib/ai-edition/document/load.test.ts
@@ -8,7 +8,7 @@
import { describe, expect, it } from "vitest";
import type { AxcutClip, AxcutDocument, AxcutInsertRange } from "../schema";
-import { reconcileClipsWithInserts } from "./load";
+import { reconcileClipsWithInserts, reconcileInsertions } from "./load";
function clip(over: Partial & { id: string }): AxcutClip {
return {
@@ -85,3 +85,54 @@ describe("reconcileClipsWithInserts", () => {
expect(second.timelineEndSec - second.timelineStartSec).toBeCloseTo(10, 6);
});
});
+
+// ─── An added word nobody marked ────────────────────────────────────────────
+// `source: "synth"` is how the whole pipeline recognises a word the user typed: it decides
+// whether the word gets an insertion, whether the film makes room for it, and whether the
+// caption line breaks around it. A row minted before that field existed answers no to all
+// three — its text plays over the recording and everything after it drifts. Found in the
+// live project: `synth_1`, zero-width at source 9.15, with no insertion at all (issue #560).
+
+describe("reconcileInsertions", () => {
+ function docWithUnmarkedWord(): AxcutDocument {
+ return {
+ assets: [{ id: "a1", kind: "video" }],
+ transcripts: [
+ {
+ assetId: "a1",
+ language: "en",
+ segments: [{ id: "s1", kind: "speech", startSec: 0, endSec: 6, text: "x", wordIds: [] }],
+ words: [
+ { id: "w1", segmentId: "s1", startSec: 0, endSec: 1, text: "hello" },
+ // Minted as an added word — the id says so — but never marked.
+ { id: "synth_1", segmentId: "s1", startSec: 1, endSec: 1, text: "a much longer thing" },
+ ],
+ },
+ ],
+ timeline: { clips: [clip({ id: "c1" })], insertRanges: [] },
+ } as unknown as AxcutDocument;
+ }
+
+ it("marks it, gives it an insertion, and makes room for it", () => {
+ const out = reconcileInsertions(docWithUnmarkedWord());
+ const word = out.transcripts[0].words.find((w) => w.id === "synth_1");
+ expect(word?.source).toBe("synth");
+ const range = out.timeline.insertRanges?.find((r) => r.wordId === "synth_1");
+ expect(range).toBeDefined();
+ expect(range?.durationSec ?? 0).toBeGreaterThan(0);
+ const [c] = out.timeline.clips;
+ expect(c.timelineEndSec - c.timelineStartSec).toBeCloseTo(10 + (range?.durationSec ?? 0), 6);
+ });
+
+ it("leaves a word that was never added alone", () => {
+ const out = reconcileInsertions(docWithUnmarkedWord());
+ expect(out.transcripts[0].words.find((w) => w.id === "w1")?.source).toBeUndefined();
+ });
+
+ it("is idempotent", () => {
+ const once = reconcileInsertions(docWithUnmarkedWord());
+ const twice = reconcileInsertions(once);
+ expect(twice.timeline.clips).toEqual(once.timeline.clips);
+ expect(twice.timeline.insertRanges).toEqual(once.timeline.insertRanges);
+ });
+});
diff --git a/src/lib/ai-edition/document/load.ts b/src/lib/ai-edition/document/load.ts
index e4a4b3087..a71b81e95 100644
--- a/src/lib/ai-edition/document/load.ts
+++ b/src/lib/ai-edition/document/load.ts
@@ -22,6 +22,20 @@
import type { AxcutDocument } from "../schema";
import { documentSchema, migrateRawDocumentToCurrent } from "../schema";
import { reflowClipsForInserts } from "./timeline";
+import { withInsertRangesForAllWords, withMarkedAddedWords } from "./transcript";
+
+/**
+ * The whole insertion invariant, in dependency order.
+ *
+ * A word is ADDED, an added word has an INSERTION, and a clip carrying insertions is
+ * LONGER. Each step feeds the next, and reconciling only the last one left a document
+ * carrying an unmarked added word looking perfectly consistent while playing its text over
+ * the recording. Every step is idempotent, so this runs on every load and changes nothing
+ * for a document already in step.
+ */
+export function reconcileInsertions(document: AxcutDocument): AxcutDocument {
+ return reconcileClipsWithInserts(withInsertRangesForAllWords(withMarkedAddedWords(document)));
+}
/** Clip geometry brought back in line with the document's insert ranges. Idempotent. */
export function reconcileClipsWithInserts(document: AxcutDocument): AxcutDocument {
@@ -42,5 +56,5 @@ export function reconcileClipsWithInserts(document: AxcutDocument): AxcutDocumen
/** Raw JSON (any stored version) → a validated, reconciled document. */
export function parseStoredDocument(raw: unknown): AxcutDocument {
- return reconcileClipsWithInserts(documentSchema.parse(migrateRawDocumentToCurrent(raw)));
+ return reconcileInsertions(documentSchema.parse(migrateRawDocumentToCurrent(raw)));
}
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 8d9c49487..82df1928d 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -332,6 +332,49 @@ const MIN_PAUSE_SEC = 0.05;
* so no caller has to remember any of the three. `insertRangesMatchWords` is the same rule
* read back, for a test to hold this to.
*/
+/** `synth_N` — the id every added word has been minted with, and the one thing a row
+ * written before the `source` field carries to say what it is. */
+const SYNTH_WORD_ID = /^synth_\d+$/;
+
+/**
+ * Added words that never got marked as such, marked.
+ *
+ * `source: "synth"` is how the whole pipeline recognises a word the user typed: it decides
+ * whether the word gets an insertion, whether the film makes room for it, and whether the
+ * caption line breaks around it. A row minted before that field existed answers no to all
+ * three, so its text plays over the recording and everything after it drifts. The id is the
+ * evidence — `nextSynthWordId` has always minted exactly this shape, and the numbering scan
+ * already reads it back with the same pattern.
+ */
+export function withMarkedAddedWords(document: AxcutDocument): AxcutDocument {
+ let touched = false;
+ const transcripts = document.transcripts.map((transcript) => {
+ let changed = false;
+ const words = transcript.words.map((word) => {
+ if (word.source !== undefined || !SYNTH_WORD_ID.test(word.id)) return word;
+ changed = true;
+ return { ...word, source: "synth" as const };
+ });
+ if (!changed) return transcript;
+ touched = true;
+ return { ...transcript, words };
+ });
+ return touched ? { ...document, transcripts } : document;
+}
+
+/**
+ * Every asset's insert ranges brought back in line with its words.
+ *
+ * The per-asset reconciler applied to the whole document, so a load can enforce the
+ * invariant it maintains rather than waiting for the next word write to notice.
+ */
+export function withInsertRangesForAllWords(document: AxcutDocument): AxcutDocument {
+ return document.transcripts.reduce(
+ (doc, transcript) => withInsertRangesForWords(doc, transcript.assetId),
+ document,
+ );
+}
+
function withInsertRangesForWords(document: AxcutDocument, assetId: string): AxcutDocument {
// The reason is user-visible on the region, and it is not the same fact on both lanes:
// the film holds a FRAME, a take holds nothing but silence — no picture is involved
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index cee4be4ad..4af906cd4 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -5,7 +5,7 @@ import { toastText } from "@/i18n/toastText";
import { nativeBridgeClient } from "@/native/client";
import { placeAudioTrackInDocument } from "../document/audioTracks";
import { createId } from "../document/ids";
-import { reconcileClipsWithInserts } from "../document/load";
+import { reconcileInsertions } from "../document/load";
import { type Interval, replaceTimeline as replaceTimelineOp } from "../document/timeline";
import { type AxcutAsset, type AxcutDocument, createAudioTrack, documentSchema } from "../schema";
import { probeAudioDuration, probeVideoDimensions } from "../timeline/duration";
@@ -162,7 +162,7 @@ function parseDocument(value: unknown): AxcutDocument {
// Reconciled here too, not only in the main process: this is the renderer's own gate on
// every document it accepts, and it is idempotent, so a document that arrived correct
// passes through untouched.
- return reconcileClipsWithInserts(documentSchema.parse(value));
+ return reconcileInsertions(documentSchema.parse(value));
}
/**
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
index 08d63460d..d4aa314e5 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -143,6 +143,7 @@ describe("lanePlacements", () => {
[transcript as any],
[],
[],
+ [],
);
expect(sections).toHaveLength(1);
expect(sections[0].words.filter((w) => !w.word.id.startsWith("silence_"))).toHaveLength(2);
@@ -224,6 +225,7 @@ describe("one programme, two lanes", () => {
transcripts as any,
[],
removed,
+ [],
);
return { recording: build("recording"), voiceover: build("voiceover") };
}
@@ -263,6 +265,7 @@ describe("one programme, two lanes", () => {
[secondsTranscript("asset_vo", 14)] as any,
[],
removed,
+ [],
);
const w6 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w6"); // raw 6..7
expect(w6?.kept).toBe(false);
@@ -281,6 +284,7 @@ describe("one programme, two lanes", () => {
[secondsTranscript("asset_vo", 20)] as any,
[],
removedRawSpans(CLIPS_2, [], []),
+ [],
);
const w15 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w15");
expect(w15?.kept).toBe(true);
@@ -290,14 +294,14 @@ describe("one programme, two lanes", () => {
// The cue used to be resolved into a clip id, which only the recording lane has —
// so this returned null for every moment of every voiceover.
const { voiceover } = lanes([]);
- expect(findCueWordId(voiceover, 4.5)).toBe("vo_1:w4");
- expect(findCueWordId(voiceover, 0.5)).toBe("vo_1:w0");
+ expect(findCueWordId(voiceover, 4.5, [])).toBe("vo_1:w4");
+ expect(findCueWordId(voiceover, 0.5, [])).toBe("vo_1:w0");
});
it("reads a word's raw moment through its own placement", () => {
// A take starting 3s along the ruler, 5s into its file: its source 6 is raw 4.
const placement = { id: "p", assetId: "a", sourceStartSec: 5, timelineStartSec: 3 };
- expect(placementRawSec(placement, 6)).toBe(4);
+ expect(placementRawSec(placement, 6, [])).toBe(4);
});
it("contributes no placement for a looping take", () => {
@@ -338,25 +342,26 @@ describe("the karaoke highlight after a pause", () => {
[WORDS as any],
[],
[],
+ [],
);
it("tracks the voice with no insertion", () => {
- expect(findCueWordId(sectionsWith([]), 4.5)).toBe("vo:w4");
+ expect(findCueWordId(sectionsWith([]), 4.5, [])).toBe("vo:w4");
});
it("follows the word D later once a pause has pushed it there", () => {
// A one-second pause at source 3: source 4 is now heard at ruler 5.
const inserts = [{ id: "i1", wordId: "w3", atSourceSec: 3, durationSec: 1 }];
const sections = sectionsWith(inserts);
- expect(findCueWordId(sections, 5.5)).toBe("vo#1:w4");
+ expect(findCueWordId(sections, 5.5, [])).toBe("vo#1:w4");
// And it is NOT still answering with the pre-pause mapping.
- expect(findCueWordId(sections, 4.5)).not.toBe("vo:w4");
+ expect(findCueWordId(sections, 4.5, [])).not.toBe("vo:w4");
});
it("highlights nothing while the voice is parked", () => {
// No word is being said during the pause, so the karaoke goes quiet rather than
// leaving a word lit that has already been spoken.
const inserts = [{ id: "i1", wordId: "w3", atSourceSec: 3, durationSec: 1 }];
- expect(findCueWordId(sectionsWith(inserts), 3.5)).toBeNull();
+ expect(findCueWordId(sectionsWith(inserts), 3.5, [])).toBeNull();
});
});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index 987b5a3a5..085435091 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -63,7 +63,7 @@ describe("buildClipSection", () => {
{ id: "w3", segmentId: "s1", startSec: 2, endSec: 3, text: "friend" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
expect(section.trimRuns).toEqual([]);
});
@@ -84,6 +84,7 @@ describe("buildClipSection", () => {
transcript,
makeAsset(),
removedRawSpans([clip], [trim], []),
+ [],
);
expect(section.words.map((cw) => cw.kept)).toEqual([true, false, false, false, true]);
expect(section.words.map((cw) => cw.trimIds)).toEqual([
@@ -122,6 +123,7 @@ describe("buildClipSection", () => {
transcript,
makeAsset(),
removedRawSpans([clip], trims, []),
+ [],
);
expect(section.trimRuns).toHaveLength(2);
expect(section.trimRuns[0]).toMatchObject({
@@ -163,6 +165,7 @@ describe("buildClipSection", () => {
[makeTranscript(words())],
[makeAsset()],
removedRawSpans(clips, [trim], []),
+ [],
);
expect(sections[0].words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -182,6 +185,7 @@ describe("buildClipSection", () => {
[makeTranscript(words())],
[makeAsset()],
removedRawSpans(clips, [trim], []),
+ [],
);
expect(sections[0].trimRuns).toHaveLength(1);
expect(sections[1].trimRuns).toHaveLength(1);
@@ -201,6 +205,7 @@ describe("buildClipSection", () => {
transcript,
makeAsset(),
removedRawSpans([clip], [trim], []),
+ [],
);
// Trailing gap 2s→3s is a silence — the different-asset trim doesn't
// cover any of the three entries, so all stay kept.
@@ -215,7 +220,7 @@ describe("buildClipSection", () => {
{ id: "w3", segmentId: "s1", startSec: 2, endSec: 3, text: "Um," },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
// ponytail: the LLM (not the renderer) decides what is a filler. Every
// word renders as plain text in the right pane.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -224,7 +229,7 @@ describe("buildClipSection", () => {
it("returns an empty words list when the clip has no matching transcript", () => {
const clip = makeClip({ sourceStartSec: 0, sourceEndSec: 5 });
- const section = buildClipSection(clip, null, makeAsset(), []);
+ const section = buildClipSection(clip, null, makeAsset(), [], []);
expect(section.words).toEqual([]);
expect(section.trimRuns).toEqual([]);
@@ -239,7 +244,7 @@ describe("buildClipSection", () => {
{ id: "w_after", segmentId: "s1", startSec: 5, endSec: 6, text: "trim" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
// Leading (2s→2.5s) and trailing (3.5s→4s) gaps are both silences.
expect(section.words.map((cw) => cw.word.id)).toEqual(["silence_1", "w_mid", "silence_2"]);
});
@@ -253,7 +258,7 @@ describe("silence gaps", () => {
{ id: "w2", segmentId: "s1", startSec: 1.3, endSec: 2, text: "there" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
const ids = section.words.map((cw) => cw.word.id);
expect(ids).toEqual(["w1", "silence_1", "w2", "silence_2"]);
expect(section.words.filter((cw) => isSilenceWord(cw.word))).toHaveLength(2);
@@ -267,7 +272,7 @@ describe("silence gaps", () => {
{ id: "w2", segmentId: "s1", startSec: 1.1, endSec: 2, text: "there" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
expect(section.words.map((cw) => cw.word.id)).toEqual(["w1", "w2"]);
});
@@ -284,6 +289,7 @@ describe("silence gaps", () => {
transcript,
makeAsset(),
removedRawSpans([clip], [trim], []),
+ [],
);
const silence = section.words.find((cw) => isSilenceWord(cw.word));
expect(silence?.kept).toBe(false);
@@ -324,7 +330,7 @@ describe("buildAggregatedSections", () => {
makeAsset({ id: "asset_2", label: "second.mp4" }),
];
- const sections = buildAggregatedSections(clips, transcripts, assets, []);
+ const sections = buildAggregatedSections(clips, transcripts, assets, [], []);
expect(sections).toHaveLength(2);
expect(sections[0]?.clip.id).toBe("c1");
expect(sections[1]?.clip.id).toBe("c2");
@@ -337,7 +343,7 @@ describe("buildAggregatedSections", () => {
const transcripts = [makeTranscript([])];
const assets = [makeAsset(), makeAsset({ id: "asset_2" })];
- const sections = buildAggregatedSections(clips, transcripts, assets, []);
+ const sections = buildAggregatedSections(clips, transcripts, assets, [], []);
expect(sections).toHaveLength(2);
expect(sections[0]?.transcript).toBeTruthy();
expect(sections[1]?.transcript).toBeNull();
@@ -381,7 +387,7 @@ describe("findCueWordId", () => {
it("returns null when there is no playhead", () => {
const section = makeSection("c1", "asset_1", [["w1", 0, 1]]);
- expect(findCueWordId([section], null)).toBeNull();
+ expect(findCueWordId([section], null, [])).toBeNull();
});
it("returns null when the head is before every section", () => {
@@ -389,7 +395,7 @@ describe("findCueWordId", () => {
timelineStartSec: 10,
timelineEndSec: 110,
});
- expect(findCueWordId([section], 2)).toBeNull();
+ expect(findCueWordId([section], 2, [])).toBeNull();
});
it("returns the word containing the head", () => {
@@ -398,7 +404,7 @@ describe("findCueWordId", () => {
["w2", 1, 2],
["w3", 2, 3],
]);
- expect(findCueWordId([section], 1.5)).toBe("c1:w2");
+ expect(findCueWordId([section], 1.5, [])).toBe("c1:w2");
});
it("returns the previous word when the head is between two words", () => {
@@ -406,7 +412,7 @@ describe("findCueWordId", () => {
["w1", 0, 1],
["w2", 2, 3],
]);
- expect(findCueWordId([section], 1.5)).toBe("c1:w1");
+ expect(findCueWordId([section], 1.5, [])).toBe("c1:w1");
});
it("returns null when the head is before the first word", () => {
@@ -414,7 +420,7 @@ describe("findCueWordId", () => {
["w1", 5, 6],
["w2", 7, 8],
]);
- expect(findCueWordId([section], 0.5)).toBeNull();
+ expect(findCueWordId([section], 0.5, [])).toBeNull();
});
it("returns the last word when the head is past the last word", () => {
@@ -422,7 +428,7 @@ describe("findCueWordId", () => {
["w1", 0, 1],
["w2", 1, 2],
]);
- expect(findCueWordId([section], 99)).toBe("c1:w2");
+ expect(findCueWordId([section], 99, [])).toBe("c1:w2");
});
it("reads the head through the section's own source clock", () => {
@@ -433,8 +439,8 @@ describe("findCueWordId", () => {
timelineStartSec: 20,
timelineEndSec: 30,
});
- expect(findCueWordId([section], 22)).toBe("c1:w1");
- expect(findCueWordId([section], 2)).toBeNull();
+ expect(findCueWordId([section], 22, [])).toBe("c1:w1");
+ expect(findCueWordId([section], 2, [])).toBeNull();
});
// Two clips over the same media project the SAME transcript words twice, so the cue
@@ -463,12 +469,12 @@ describe("findCueWordId", () => {
it("resolves the head against the clip that is playing", () => {
// Source 1.5 in both, but raw 4.5 is only inside c2.
- expect(findCueWordId(sections(), 4.5)).toBe("c2:w2");
- expect(findCueWordId(sections(), 1.5)).toBe("c1:w2");
+ expect(findCueWordId(sections(), 4.5, [])).toBe("c2:w2");
+ expect(findCueWordId(sections(), 1.5, [])).toBe("c1:w2");
});
it("returns an id that cannot match the other clip's copy of the same word", () => {
- const cue = findCueWordId(sections(), 4.5);
+ const cue = findCueWordId(sections(), 4.5, []);
// The whole point: `word.id` is "w2" in BOTH sections, so a bare word id lit up
// both blocks. Exactly one rendered word may claim the cue.
const claiming = sections().flatMap((s) => s.words.filter((cw) => cw.id === cue));
@@ -485,7 +491,7 @@ describe("findCueWordId", () => {
}),
];
// c2 has no words, and borrowing c1's would point at the wrong text.
- expect(findCueWordId(withEmptyC2, 4.5)).toBeNull();
+ expect(findCueWordId(withEmptyC2, 4.5, [])).toBeNull();
});
it("runs an open-ended placement up to the next one", () => {
@@ -502,8 +508,8 @@ describe("findCueWordId", () => {
timelineEndSec: 6,
}),
];
- expect(findCueWordId(open, 2)).toBe("c1:w1");
- expect(findCueWordId(open, 3.5)).toBe("c2:w1");
+ expect(findCueWordId(open, 2, [])).toBe("c1:w1");
+ expect(findCueWordId(open, 3.5, [])).toBe("c2:w1");
});
});
});
@@ -539,6 +545,7 @@ describe("clipWordId", () => {
[transcript],
[makeAsset()],
[],
+ [],
);
const rawIds = sections.flatMap((s) => s.words.map((cw) => cw.word.id));
const scopedIds = sections.flatMap((s) => s.words.map((cw) => cw.id));
@@ -547,3 +554,60 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
+
+// ─── The word an insertion is spoken over ───────────────────────────────────
+// The pane mapped source → ruler and back with two hand-written shifts that ignored the
+// media inserted inside the clip. Past an insertion the highlight ran ahead of the voice by
+// exactly the inserted time — it sat on the word AFTER the one being spoken — and the added
+// word itself was never highlighted at all, because the only seconds it is spoken in are the
+// insertion, and no source second exists inside one (issue #560).
+
+describe("the cue word across an insertion", () => {
+ // "b" is typed in after "a". It fits in 0.1s of existing silence and buys 2s of media.
+ const words = () => [
+ { id: "w_a", segmentId: "s1", startSec: 0, endSec: 1, text: "a" },
+ { id: "w_b", segmentId: "s1", startSec: 1, endSec: 1.1, text: "b", source: "synth" as const },
+ { id: "w_c", segmentId: "s1", startSec: 2, endSec: 3, text: "c" },
+ ];
+ // The clip carries the insertion, so it runs 0..12 for 10s of recording.
+ const clips = () => [makeClip({ sourceStartSec: 0, sourceEndSec: 10, timelineEndSec: 12 })];
+ const inserted = [
+ {
+ id: "i1",
+ assetId: "asset_1",
+ atSec: 1.1,
+ durationSec: 2,
+ wordId: "w_b",
+ reason: "",
+ origin: "user" as const,
+ },
+ ];
+ const sections = () =>
+ buildAggregatedSections(
+ clips(),
+ [makeTranscript(words())],
+ [makeAsset()],
+ removedRawSpans(clips(), [], inserted),
+ inserted,
+ );
+
+ it("highlights the added word for the whole stretch its insertion occupies", () => {
+ // The insertion opens at ruler 1.1 and closes at 3.1.
+ for (const at of [1.2, 2, 3.0]) {
+ expect(findCueWordId(sections(), at, inserted)).toBe(clipWordId("clip_1", "w_b"));
+ }
+ });
+
+ it("does not run ahead of the voice after the insertion", () => {
+ // Source 2..3 is "c", which the insertion has pushed to ruler 4..5.
+ expect(findCueWordId(sections(), 4.5, inserted)).toBe(clipWordId("clip_1", "w_c"));
+ // Without the shift this same moment resolved to source 4.5 — past "c" entirely.
+ expect(findCueWordId(sections(), 0.5, inserted)).toBe(clipWordId("clip_1", "w_a"));
+ });
+
+ it("still finds a word at the very end of the clip", () => {
+ // The extent used to stop at the source length, so the last 2s belonged to no
+ // section and the highlight simply went out.
+ expect(findCueWordId(sections(), 11.5, inserted)).not.toBeNull();
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 71e55583d..27dcbb5cd 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -15,7 +15,15 @@
// kept word; the user or the LLM decides what to mark as skipped.
import { collapseTracksToPills, trackGroupId } from "../document/audioTracks";
-import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
+import type {
+ AxcutAsset,
+ AxcutAudioTrack,
+ AxcutClip,
+ AxcutInsertRange,
+ AxcutTranscript,
+ AxcutWord,
+} from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
import { type TakeInsert, takeProgramme } from "./take-programme";
@@ -55,16 +63,28 @@ export type TranscriptLane = "recording" | "voiceover";
* whether two things coincide; raw time can, which is why kept-or-removed is asked here
* and not in source time (issue #560).
*/
-export function placementRawSec(placement: TranscriptPlacement, sourceSec: number): number {
- return placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
+export function placementRawSec(
+ placement: TranscriptPlacement,
+ sourceSec: number,
+ insertRanges: readonly AxcutInsertRange[],
+ edge: "opens" | "closes" = "opens",
+): number {
+ return sourceToTimelineSec(placement, sourceSec, insertRanges, edge);
}
/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
-export function placementRawExtent(placement: TranscriptPlacement): RawSpan | null {
+export function placementRawExtent(
+ placement: TranscriptPlacement,
+ insertRanges: readonly AxcutInsertRange[],
+): RawSpan | null {
if (placement.sourceEndSec === undefined) return null;
return {
startSec: placement.timelineStartSec,
- endSec: placementRawSec(placement, placement.sourceEndSec),
+ // `"closes"` on the end: the media inserted inside this placement is part of its
+ // stretch of ruler, so the extent has to reach past the last one. Ending short left
+ // the final seconds of every such clip belonging to no section at all, and the
+ // highlight simply went out there.
+ endSec: placementRawSec(placement, placement.sourceEndSec, insertRanges, "closes"),
};
}
@@ -214,6 +234,7 @@ export function buildClipSection(
transcript: AxcutTranscript | null,
asset: AxcutAsset | null,
removed: RemovedRawSpan[],
+ insertRanges: readonly AxcutInsertRange[],
): ClipSection {
const words = transcript
? withSilenceGaps(
@@ -225,7 +246,10 @@ export function buildClipSection(
const tagged: ClipWord[] = words.map((word) => {
// The word's CENTRE, mirroring the rule the identity filter used, so the recording
// lane's tagging does not shift under this change.
- const covering = removalAt(removed, placementRawSec(clip, (word.startSec + word.endSec) / 2));
+ const covering = removalAt(
+ removed,
+ placementRawSec(clip, (word.startSec + word.endSec) / 2, insertRanges),
+ );
return {
id: clipWordId(clip.id, word.id),
word,
@@ -291,6 +315,7 @@ export function buildAggregatedSections(
transcripts: AxcutTranscript[],
assets: AxcutAsset[],
removed: RemovedRawSpan[],
+ insertRanges: readonly AxcutInsertRange[],
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
@@ -300,6 +325,7 @@ export function buildAggregatedSections(
transcriptById.get(clip.assetId) ?? null,
assetById.get(clip.assetId) ?? null,
removed,
+ insertRanges,
),
);
}
@@ -379,7 +405,11 @@ export function lanePlacements(
* clip whose media has not been probed) has no extent of its own and runs to the next
* section's head, then to the end of time.
*/
-export function findCueWordId(sections: ClipSection[], rawSec: number | null): string | null {
+export function findCueWordId(
+ sections: ClipSection[],
+ rawSec: number | null,
+ insertRanges: readonly AxcutInsertRange[],
+): string | null {
if (rawSec === null || !Number.isFinite(rawSec)) return null;
// No fallback to a neighbouring section: a placement with no transcript simply has no
// cue word, and borrowing another's would point at the wrong text.
@@ -390,7 +420,7 @@ export function findCueWordId(sections: ClipSection[], rawSec: number | null): s
let match: ClipSection | null = null;
for (const [i, section] of withWords.entries()) {
if (rawSec < section.clip.timelineStartSec) break;
- const extent = placementRawExtent(section.clip);
+ const extent = placementRawExtent(section.clip, insertRanges);
const endSec =
extent?.endSec ?? withWords[i + 1]?.clip.timelineStartSec ?? Number.POSITIVE_INFINITY;
if (rawSec < endSec) {
@@ -401,7 +431,14 @@ export function findCueWordId(sections: ClipSection[], rawSec: number | null): s
if (!match) return null;
// Back to the placement's own source clock, which is what the words are stamped in.
- const t = match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
+ const { sourceSec: t, insideInsert } = timelineToSourceSec(match.clip, rawSec, insertRanges);
+ // Inside an insertion, the word being spoken is the one that bought it — those seconds
+ // exist for no other reason. There is no source second in there to find it by, which is
+ // why the added word could never be highlighted before.
+ if (insideInsert) {
+ const own = match.words.find((cw) => cw.word.id === insideInsert.wordId);
+ if (own) return own.id;
+ }
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index c061b5ff0..ea0cb9489 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -139,7 +139,9 @@ export function sourceToTimelineSec(
* driving a decoder needs both: where to park, and the fact that it should stay parked.
*/
export function timelineToSourceSec(
- clip: AxcutClip,
+ /** The same three fields `sourceToTimelineSec` needs, so a voiceover placement maps
+ * through this too. */
+ clip: Pick,
timelineSec: number,
inserts: readonly AxcutInsertRange[],
): { sourceSec: number; insideInsert: AxcutInsertRange | null } {
diff --git a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
index b132308ef..118495949 100644
--- a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
+++ b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
@@ -75,6 +75,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
next.transcripts,
next.assets,
removedRawSpans(next.timeline.clips, next.timeline.trimRanges, []),
+ [],
);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].trimRuns).toHaveLength(1);
diff --git a/src/lib/ai-edition/timeline/voiceoverCut.test.ts b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
index 7e121be61..00892553c 100644
--- a/src/lib/ai-edition/timeline/voiceoverCut.test.ts
+++ b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
@@ -84,7 +84,7 @@ describe("a cut authored from the voiceover lane", () => {
// wrote `clipId: "vo"` here and removed nothing at all.
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
const [placement] = voiceoverPlacements([VOICE as any]);
- const rows = cut(placementRawSec(placement, 2), placementRawSec(placement, 3));
+ const rows = cut(placementRawSec(placement, 2, []), placementRawSec(placement, 3, []));
expect(rows).toHaveLength(1);
expect(CLIPS.map((c) => c.id)).toContain(rows[0].clipId);
expect(filmSec([])).toBeCloseTo(12, 6);
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index c51c4988d..cda53e559 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -2016,7 +2016,6 @@ describe("buildSceneDescription.holdSec", () => {
}),
],
timeline: {
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
insertRanges: [
{
id: "i1",
@@ -2027,6 +2026,7 @@ describe("buildSceneDescription.holdSec", () => {
reason: "",
origin: "user",
},
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
] as any,
},
});
From 42d9fd7a572466ebf0daba78e80d236111917590 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 10:13:32 +0200
Subject: [PATCH 089/113] =?UTF-8?q?fix(captions):=20an=20added=20word=20an?=
=?UTF-8?q?d=20the=20word=20after=20it=20share=20a=20second=20=E2=80=94=20?=
=?UTF-8?q?order=20them=20on=20the=20ruler?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two subtitles printed on top of each other. An added word is anchored at the END of the
word it follows, and the transcript's next word begins at that very second — 5.3 for both
insertions in the live project. Source space therefore cannot order them, and mapping the
recorded line's START with the opening edge put it at ruler 5.3, inside the insertion,
underneath the added line.
On the ruler they are unambiguous, and the two kinds of line sit on opposite sides of that
one second:
added start OPENS (before the inserted media) end CLOSES (after it) → covers it
recorded start CLOSES (after any insertion there) end OPENS (before it) → avoids it
I had this flag an hour ago and removed it, having mutation-tested only the END — where
the line finaliser already separates the two, so it genuinely changed nothing. The START
is where it was load-bearing. Pinned now by a fixture with the real adjacency and by the
property itself: no cue may end after the next one starts.
The end edge is kept but is honestly not pinned by any test — with the start correct there
is no observable difference. It stays because a recorded line whose added neighbour
produced no cue at all would otherwise stretch over media it has nothing to do with.
---
src/lib/ai-edition/captions/captions.test.ts | 23 ++++++++--
src/lib/ai-edition/captions/cues.ts | 45 ++++++++++++++------
2 files changed, 51 insertions(+), 17 deletions(-)
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 9b0485479..46b04de83 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -713,6 +713,9 @@ describe("a caption line never mixes recorded words with added ones", () => {
// "really" typed in after "friend", which ends at source 2. An added word takes up NO
// source time — the seconds it is spoken in are the insertion it buys, which is how
// the real documents store it — so its span is degenerate at the moment it follows.
+ // The next recorded word begins at the added word's own second — the shape every
+ // real document has, because an added word is anchored at the END of the word it
+ // follows and the transcript's next word starts there.
t.words = [
...t.words.slice(0, 3),
{
@@ -724,7 +727,8 @@ describe("a caption line never mixes recorded words with added ones", () => {
source: "synth",
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
} as any,
- ...t.words.slice(3),
+ // Begins at the added word's own second — that adjacency is the whole defect.
+ ...t.words.slice(3).map((w) => ({ ...w, startSec: w.startSec - 2, endSec: w.endSec - 2 })),
];
const base = doc();
return {
@@ -777,8 +781,8 @@ describe("a caption line never mixes recorded words with added ones", () => {
// pushed along by exactly its length.
expect(recorded?.endMs).toBe(2000);
expect(added?.startMs).toBe(2000);
- expect(added?.endMs).toBeGreaterThan(3900);
- expect(after?.startMs).toBe(6000);
+ expect(added?.endMs).toBe(4000);
+ expect(after?.startMs).toBe(4000);
});
it("leaves the recorded line ending before the added one begins", () => {
@@ -791,6 +795,19 @@ describe("a caption line never mixes recorded words with added ones", () => {
expect(added?.startMs ?? 0).toBeGreaterThanOrEqual((recorded?.startMs ?? 0) + 1);
});
+ it("never prints two cues at once", () => {
+ // The symptom this whole rule exists for: the recorded line that follows an added
+ // word begins at the added word's own source second, so mapped before the insertion
+ // it landed inside it and the two were drawn on top of each other.
+ const cues = [...deriveCaptionCues(docWithAddedWord(), settings, {})].sort(
+ (a, b) => a.startMs - b.startMs,
+ );
+ expect(cues.length).toBeGreaterThan(1);
+ for (const [i, cue] of cues.slice(0, -1).entries()) {
+ expect(cue.endMs).toBeLessThanOrEqual(cues[i + 1].startMs);
+ }
+ });
+
it("changes nothing when the transcript has no added words", () => {
const before = deriveCaptionCues(doc(), settings, {});
expect(before.some((c) => c.text.toLowerCase().includes("hello"))).toBe(true);
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 9176faa84..87e9e3f1b 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -100,7 +100,7 @@ export function captionLinesForAsset(
transcript: AxcutTranscript,
settings: CaptionSettings,
translations: CaptionTranslations,
-): CaptionSegment[] {
+): CaptionLine[] {
const minWords = settings.minWordsPerLine;
const maxWords = settings.maxWordsPerLine;
@@ -121,11 +121,18 @@ export function captionLinesForAsset(
// recorded or all added: the two are spoken over different media — the recording, and the
// insertion that added word bought — so a line holding both would have to be in two places
// at once, and resolved as one it lands on the recording, an insertion too early.
- return polish(
- captionRuns(stream, addedWordSpans(transcript)).flatMap((run) =>
- groupTimedCaptionWordsIntoLines(run, minWords, maxWords),
- ),
- );
+ const added = addedWordSpans(transcript);
+ // Polished per RUN, so the flag survives and so the finaliser's neighbours are all the
+ // same kind. Across a run boundary the two lines share a source second and the finaliser
+ // cannot tell them apart; `deoverlapCues` settles that on the ruler instead, where the
+ // insertion has a width.
+ return captionRuns(stream, added).flatMap((run) => {
+ const isAdded = run.length > 0 && overlapsAdded(run[0], added);
+ return polish(groupTimedCaptionWordsIntoLines(run, minWords, maxWords)).map((line) => ({
+ ...line,
+ added: isAdded,
+ }));
+ });
}
function overlapsAdded(
@@ -141,6 +148,10 @@ function overlapsAdded(
);
}
+/** A caption line, and whether it is spoken over inserted media rather than the recording.
+ * Only the grouping pass knows which is which, and every edge below depends on it. */
+export type CaptionLine = CaptionSegment & { added: boolean };
+
/** Where the transcript's ADDED words sit, in source time. */
function addedWordSpans(transcript: AxcutTranscript): Array<{ startSec: number; endSec: number }> {
return transcript.words
@@ -224,6 +235,9 @@ export function sourceSpanToTimelineSpans(
* stays structurally assignable, so every existing caller is unaffected. */
clips: TranscriptPlacement[],
inserts: readonly AxcutInsertRange[] = [],
+ /** True for a span spoken over INSERTED media rather than the recording. The two occupy
+ * opposite sides of the same source second, so every edge flips with it. */
+ overInsertedMedia = false,
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -233,13 +247,15 @@ export function sourceSpanToTimelineSpans(
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
out.push({
- startSec: sourceToTimelineSec(clip, s, inserts, "opens"),
- // `"closes"` on the end, unconditionally. For an ADDED line that is what gives it
- // the width of the media it bought — its source span is degenerate. For a RECORDED
- // line the two edges agree: `finalizeCaptionSegmentsForPlayback` has already
- // trimmed its end back to just before the next line begins, which is the added
- // word's own moment, so there is no insertion at or past it to count.
- endSec: sourceToTimelineSec(clip, e, inserts, "closes"),
+ // An ADDED span IS the insertion: it opens before the inserted media and closes
+ // after it, which is exactly the stretch of ruler that media occupies. A RECORDED
+ // span lives BETWEEN insertions, so both its edges are the other way round — and
+ // its START is the one that matters, because an added word is anchored at the END
+ // of the word it follows and the next recorded word begins at that very second.
+ // Mapped with the opening edge, that line landed inside the insertion, printed on
+ // top of the added one.
+ startSec: sourceToTimelineSec(clip, s, inserts, overInsertedMedia ? "opens" : "closes"),
+ endSec: sourceToTimelineSec(clip, e, inserts, overInsertedMedia ? "closes" : "opens"),
});
}
return out;
@@ -283,7 +299,7 @@ export function deriveCaptionCues(
// every voiceover cue early.
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
- const linesByAsset = new Map();
+ const linesByAsset = new Map();
const cues: CaptionCue[] = [];
let n = 0;
@@ -303,6 +319,7 @@ export function deriveCaptionCues(
line.endSec,
placements,
document.timeline.insertRanges ?? [],
+ line.added,
)) {
const startMs = Math.round(span.startSec * 1000);
const endMs = Math.max(Math.round(span.endSec * 1000), startMs + 1);
From 023f414946bae3cf23ff4162a7bb065aadb767fa Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 11:19:39 +0200
Subject: [PATCH 090/113] refactor(timeline): delete the duplicate answer,
route the rest through the one mapping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`resolveInsertPlacement`'s recording branch was a second answer to "where is this
insertion on the timeline", written as a plain shift the clip's own insertions made
wrong — and its only caller threw it away. Deleted, along with the placement variant and
the two tests that pinned it. `rulerInserts` is the one place that places an insertion.
The remaining hand-written shifts now go through `sourceToTimelineSec` /
`timelineToSourceSec`: the added-word marks drawn inside a clip, the trim pill's ruler
span, and the ruler→source map that authors a trim. Each was wrong past an insertion by
exactly the inserted time — a cut authored on the wrong footage, a pill drawn left of what
it removes.
Left deliberately: `anchoredToRawSpanSec`, which places zoom/annotation/speed pills. Same
defect, but threading the ranges through `anchorRegionsWithDerivedMs` reaches fifteen
callers across migration paths that have no document in scope. Marked with a `ponytail:`
comment naming the ceiling and the upgrade path rather than half-done.
---
src/components/ai-edition/NewEditorShell.tsx | 9 +++-
.../ai-edition/v4/FloatingInspector.tsx | 2 +-
src/components/ai-edition/v4/V4Timeline.tsx | 10 ++--
src/lib/ai-edition/document/timeline.ts | 9 ++--
src/lib/ai-edition/store/useTimeline.ts | 8 ++-
.../timeline/insert-mapping.test.ts | 12 ++---
src/lib/ai-edition/timeline/insert-mapping.ts | 48 ++++++-----------
.../ai-edition/timeline/inserted-time.test.ts | 11 ++--
src/lib/ai-edition/timeline/inserted-time.ts | 3 +-
.../timeline/sharedMediaTrim.test.ts | 2 +-
src/lib/ai-edition/timeline/timelineMap.ts | 5 ++
.../ai-edition/timeline/trim-mapping.test.ts | 51 +++++++++++--------
src/lib/ai-edition/timeline/trim-mapping.ts | 25 ++++++---
.../ai-edition/timeline/voiceoverCut.test.ts | 4 +-
14 files changed, 112 insertions(+), 87 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index d34184954..fbf7a2a2e 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -666,7 +666,12 @@ export function NewEditorShell() {
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
- const next = dropTrimPillsByIds(doc.timeline.trimRanges, doc.timeline.clips, trimIds);
+ const next = dropTrimPillsByIds(
+ doc.timeline.trimRanges,
+ doc.timeline.clips,
+ trimIds,
+ doc.timeline.insertRanges ?? [],
+ );
if (next.length === doc.timeline.trimRanges.length) return;
await saveDocument(
{ ...doc, timeline: { ...doc.timeline, trimRanges: next } },
@@ -1085,7 +1090,7 @@ export function NewEditorShell() {
// (properties kept, position taken from the playhead).
if (sel.kind === "trim") {
const { coalescedTrimGroups } = await import("@/lib/ai-edition/timeline/trim-mapping");
- const group = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) =>
+ const group = coalescedTrimGroups(tl.trimRanges, tl.clips, tl.insertRanges ?? []).find((g) =>
g.ids.includes(sel.id),
);
if (!group) return;
diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx
index 254da35f2..2700087af 100644
--- a/src/components/ai-edition/v4/FloatingInspector.tsx
+++ b/src/components/ai-edition/v4/FloatingInspector.tsx
@@ -990,7 +990,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
// the group's, not the clicked row's. Deleting no longer needs the same expansion here:
// `removeRegion` drops the whole pill for every kind (`dropTrimPillsByIds`), which is
// what this pane used to have to arrange for itself.
- const trimGroup = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) =>
+ const trimGroup = coalescedTrimGroups(tl.trimRanges, tl.clips, tl.insertRanges ?? []).find((g) =>
g.ids.includes(selection.id),
);
if (!trimGroup) return null;
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 21e8ce26c..1a3b97fab 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -770,7 +770,7 @@ export function V4Timeline({
// the clip through a reorder for free, with no ruler arithmetic of its own.
const insertedWordsByClip = useMemo(() => {
const out = new Map();
- for (const mark of insertedWordMarks(tl.transcripts, clips)) {
+ for (const mark of insertedWordMarks(tl.transcripts, clips, tl.insertRanges ?? [])) {
const list = out.get(mark.clipId);
if (list) list.push(mark);
else out.set(mark.clipId, [mark]);
@@ -783,7 +783,11 @@ export function V4Timeline({
// coalesced into one pill. This is what makes growing a trim across a
// junction look like one continuously-growing pill instead of visibly
// splitting, aligning trims with how zoom/speed/annotation already behave.
- const trimPills: LanePill[] = coalescedTrimGroups(tl.trimRanges, clips).map((g) => ({
+ const trimPills: LanePill[] = coalescedTrimGroups(
+ tl.trimRanges,
+ clips,
+ tl.insertRanges ?? [],
+ ).map((g) => ({
id: g.ids[0],
kind: "trim",
start: g.start,
@@ -1009,7 +1013,7 @@ export function V4Timeline({
let ranges = ventilateTimelineSpanToTrims(s, en, clips);
if (ranges.length === 0) {
// Span sits in a gap / past the end: fall back to the nearest clip.
- const resolved = resolveTimelineSpanToTrim(s, en, clips);
+ const resolved = resolveTimelineSpanToTrim(s, en, clips, tl.insertRanges ?? []);
if (!resolved) return;
ranges = [resolved];
}
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 1ba70d480..c52ae3b6f 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -1161,9 +1161,12 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri
...document,
timeline: {
...document.timeline,
- trimRanges: dropTrimPillsByIds(document.timeline.trimRanges, document.timeline.clips, [
- id,
- ]),
+ trimRanges: dropTrimPillsByIds(
+ document.timeline.trimRanges,
+ document.timeline.clips,
+ [id],
+ document.timeline.insertRanges ?? [],
+ ),
},
};
case "speed": {
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 025b7c4a4..79e4340f6 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -371,7 +371,12 @@ export function useTimeline() {
// the trim at the wrong source position.
const playhead = playheadSec();
const end = playhead + durationSec;
- const resolved = resolveTimelineSpanToTrim(playhead, end, document.timeline.clips);
+ const resolved = resolveTimelineSpanToTrim(
+ playhead,
+ end,
+ document.timeline.clips,
+ document.timeline.insertRanges ?? [],
+ );
const asset =
document.assets.find((a) => a.id === document.project.primaryAssetId) ?? document.assets[0];
if (!resolved && !asset) return;
@@ -952,6 +957,7 @@ export function useTimeline() {
document.timeline.trimRanges,
document.timeline.clips,
trimIds,
+ document.timeline.insertRanges ?? [],
),
},
legacyEditor:
diff --git a/src/lib/ai-edition/timeline/insert-mapping.test.ts b/src/lib/ai-edition/timeline/insert-mapping.test.ts
index 137c66332..8e75b2d11 100644
--- a/src/lib/ai-edition/timeline/insert-mapping.test.ts
+++ b/src/lib/ai-edition/timeline/insert-mapping.test.ts
@@ -110,15 +110,11 @@ function doc(inserts: AxcutInsertRange[], over: Partial = {}): Ax
}
describe("resolveInsertPlacement", () => {
- it("gives a recording insert its raw moment, through the clip that plays it", () => {
+ it("leaves a recording insert to `rulerInserts`, the one place that places one", () => {
+ // It used to answer with a raw second of its own, from a plain shift the clip's own
+ // insertions made wrong — a second, contradictory answer that nothing read.
const row = insert({ id: "i1", atSec: 3 });
- expect(resolveInsertPlacement(row, doc([row]))).toEqual({ lane: "recording", atRawSec: 3 });
- });
-
- it("resolves through the SECOND clip when that is the one holding the source moment", () => {
- // Two clips over one media at different source positions: source 22 is raw 8.
- const row = insert({ id: "i1", atSec: 22 });
- expect(resolveInsertPlacement(row, doc([row]))).toEqual({ lane: "recording", atRawSec: 8 });
+ expect(resolveInsertPlacement(row, doc([row]))).toBeNull();
});
it("leaves a voiceover insert UNPROJECTED, naming the take and a source second", () => {
diff --git a/src/lib/ai-edition/timeline/insert-mapping.ts b/src/lib/ai-edition/timeline/insert-mapping.ts
index b21f2d31c..66f2fdf0c 100644
--- a/src/lib/ai-edition/timeline/insert-mapping.ts
+++ b/src/lib/ai-edition/timeline/insert-mapping.ts
@@ -23,14 +23,7 @@
import { trackGroupId } from "../document/audioTracks";
import type { AxcutDocument, AxcutInsertRange } from "../schema";
-/** A pause in the film: the clip holds a frame at this raw moment. */
-export interface RecordingInsertPlacement {
- lane: "recording";
- /** Raw ruler second — insertions occupy zero raw time, so this is a point. */
- atRawSec: number;
-}
-
-/** A silence inside a take: no picture, no ruler growth of its own. */
+/** A silence inside a take: no picture of its own. */
export interface VoiceoverInsertPlacement {
lane: "voiceover";
/** The user-visible take, not one of its stored fragments. */
@@ -39,11 +32,12 @@ export interface VoiceoverInsertPlacement {
atSourceSec: number;
}
-export type InsertPlacement = RecordingInsertPlacement | VoiceoverInsertPlacement;
+export type InsertPlacement = VoiceoverInsertPlacement;
/**
- * Where this insertion belongs, or null when nothing carries it any more — a clip whose
- * source range no longer contains the moment, or a take that has been deleted.
+ * Which TAKE carries this insertion, or null — a recording's insertion, or a take that has
+ * been deleted. Only takes need answering here: an insertion in the film is placed by
+ * `rulerInserts`, which is the one definition of where an insertion sits on the timeline.
*
* The lane is read from the ASSET, never from the row: `kind: "audio"` is the only thing
* that distinguishes a take's transcript from the film's, and it is already the
@@ -54,28 +48,16 @@ export function resolveInsertPlacement(
document: AxcutDocument,
): InsertPlacement | null {
const asset = document.assets.find((a) => a.id === insert.assetId);
- if (asset?.kind === "audio") {
- // The first take drawing on this asset whose source window contains the moment.
- // Inclusive at both edges, matching `rulerInserts`: a pause sits at the END of the
- // word it follows, which is routinely a window's own boundary.
- for (const track of document.audioTracks ?? []) {
- if (track.kind !== "voiceover" || track.assetId !== insert.assetId) continue;
- const startSec = track.offsetMs / 1000;
- const endSec = startSec + Math.max(0, track.endMs - track.startMs) / 1000;
- if (insert.atSec < startSec || insert.atSec > endSec) continue;
- return { lane: "voiceover", trackGroupId: trackGroupId(track), atSourceSec: insert.atSec };
- }
- return null;
- }
-
- for (const clip of document.timeline.clips) {
- if (clip.assetId !== insert.assetId) continue;
- const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
- if (insert.atSec < clip.sourceStartSec || insert.atSec > sourceEnd) continue;
- return {
- lane: "recording",
- atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec),
- };
+ if (asset?.kind !== "audio") return null;
+ // The first take drawing on this asset whose source window contains the moment.
+ // Inclusive at both edges, matching `rulerInserts`: an insertion sits at the END of the
+ // word it follows, which is routinely a window's own boundary.
+ for (const track of document.audioTracks ?? []) {
+ if (track.kind !== "voiceover" || track.assetId !== insert.assetId) continue;
+ const startSec = track.offsetMs / 1000;
+ const endSec = startSec + Math.max(0, track.endMs - track.startMs) / 1000;
+ if (insert.atSec < startSec || insert.atSec > endSec) continue;
+ return { lane: "voiceover", trackGroupId: trackGroupId(track), atSourceSec: insert.atSec };
}
return null;
}
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
index 7114afc6d..b1037f94c 100644
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -131,7 +131,7 @@ describe("insertedWordMarks", () => {
];
it("paints a word on a split boundary exactly once", () => {
- const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_edge", 5)] }], split);
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_edge", 5)] }], split, []);
expect(marks).toHaveLength(1);
expect(marks[0]).toMatchObject({ clipId: "c2", atRawSec: 5 });
});
@@ -140,6 +140,7 @@ describe("insertedWordMarks", () => {
const marks = insertedWordMarks(
[{ assetId: "a1", words: [synth("early", 2), synth("late", 7)] }],
split,
+ [],
);
expect(marks.map((m) => [m.clipId, m.atRawSec])).toEqual([
["c1", 2],
@@ -149,17 +150,19 @@ describe("insertedWordMarks", () => {
it("keeps a word at the very end of the last clip", () => {
// Half-open everywhere but the tail, or the final word of a project vanishes.
- const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_end", 10)] }], split);
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_end", 10)] }], split, []);
expect(marks.map((m) => m.wordId)).toEqual(["w_end"]);
});
it("ignores words nobody added", () => {
const spoken = { id: "w1", segmentId: "s", text: "w1", startSec: 2, endSec: 3 } as AxcutWord;
- expect(insertedWordMarks([{ assetId: "a1", words: [spoken] }], split)).toEqual([]);
+ expect(insertedWordMarks([{ assetId: "a1", words: [spoken] }], split, [])).toEqual([]);
});
it("ignores a transcript no clip draws on", () => {
- expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split)).toEqual([]);
+ expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split, [])).toEqual(
+ [],
+ );
});
});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
index ea0cb9489..8b3e7950e 100644
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -204,6 +204,7 @@ export interface InsertedWordMark {
export function insertedWordMarks(
transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>,
clips: readonly AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
): InsertedWordMark[] {
const byAsset = new Map>();
for (const transcript of transcripts) {
@@ -228,7 +229,7 @@ export function insertedWordMarks(
clipId: clip.id,
wordId: word.id,
text: word.text,
- atRawSec: clip.timelineStartSec + (word.startSec - clip.sourceStartSec),
+ atRawSec: sourceToTimelineSec(clip, word.startSec, insertRanges),
});
}
});
diff --git a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
index 118495949..4edd43902 100644
--- a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
+++ b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
@@ -85,7 +85,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
]);
// 2. Ruler — one pill, over clip 2 (timeline 11.8 + 8.4 = 20.2 … 22.2).
- const pills = coalescedTrimGroups(next.timeline.trimRanges, next.timeline.clips);
+ const pills = coalescedTrimGroups(next.timeline.trimRanges, next.timeline.clips, []);
expect(pills).toHaveLength(1);
expect(pills[0].start).toBeCloseTo(20.2, 6);
expect(pills[0].end).toBeCloseTo(22.2, 6);
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index b27103d6a..e344c2398 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -96,6 +96,11 @@ export function anchoredToRawSpanSec(
): { startSec: number; endSec: number } | null {
const clip = clips.find((c) => c.id === fragment.clipId);
if (!clip) return null;
+ // ponytail: plain shift, wrong by the clip's own insertions for a region anchored past
+ // one — the pill is drawn early while `projectRegionsToSource` exports it on the right
+ // frames. Route through `sourceToTimelineSec` when zoom/annotation pills need to agree
+ // with the effect; it means threading the ranges through `anchorRegionsWithDerivedMs`
+ // and its 15 callers, which is its own change.
return {
startSec: clip.timelineStartSec + (fragment.sourceStartSec - clip.sourceStartSec),
endSec: clip.timelineStartSec + (fragment.sourceEndSec - clip.sourceStartSec),
diff --git a/src/lib/ai-edition/timeline/trim-mapping.test.ts b/src/lib/ai-edition/timeline/trim-mapping.test.ts
index 3ac5cba00..0e003351e 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.test.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.test.ts
@@ -38,7 +38,7 @@ describe("trimToTimelineSpan", () => {
timelineEndSec: 42,
}),
];
- expect(trimToTimelineSpan({ assetId: "a", startSec: 5, endSec: 7 }, clips)).toEqual({
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 5, endSec: 7 }, clips, [])).toEqual({
start: 5,
end: 7,
});
@@ -65,7 +65,7 @@ describe("trimToTimelineSpan", () => {
}),
];
// A trim at source 20..22 lives in c2 → timeline 14 + (20-16) = 18..20.
- expect(trimToTimelineSpan({ assetId: "a", startSec: 20, endSec: 22 }, clips)).toEqual({
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 20, endSec: 22 }, clips, [])).toEqual({
start: 18,
end: 20,
});
@@ -73,8 +73,8 @@ describe("trimToTimelineSpan", () => {
it("returns null when no clip carries the trim's source region", () => {
const clips = [clip({ id: "c1", assetId: "a", sourceStartSec: 0, sourceEndSec: 10 })];
- expect(trimToTimelineSpan({ assetId: "a", startSec: 40, endSec: 42 }, clips)).toBeNull();
- expect(trimToTimelineSpan({ assetId: "b", startSec: 2, endSec: 4 }, clips)).toBeNull();
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 40, endSec: 42 }, clips, [])).toBeNull();
+ expect(trimToTimelineSpan({ assetId: "b", startSec: 2, endSec: 4 }, clips, [])).toBeNull();
});
});
@@ -99,7 +99,7 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Timeline 18..20 falls in c2 (asset b) → source 16 + (18-14)=20 .. 22.
- expect(resolveTimelineSpanToTrim(18, 20, clips)).toEqual({
+ expect(resolveTimelineSpanToTrim(18, 20, clips, [])).toEqual({
assetId: "b",
clipId: "c2",
sourceStartSec: 20,
@@ -127,8 +127,8 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Start in c1 → asset a, start in c2 → asset b.
- expect(resolveTimelineSpanToTrim(2, 4, clips)?.assetId).toBe("a");
- expect(resolveTimelineSpanToTrim(20, 22, clips)?.assetId).toBe("b");
+ expect(resolveTimelineSpanToTrim(2, 4, clips, [])?.assetId).toBe("a");
+ expect(resolveTimelineSpanToTrim(20, 22, clips, [])?.assetId).toBe("b");
});
it("clamps the span to the carrier clip's extent (no straddling)", () => {
@@ -151,7 +151,7 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Span 10..20 starts in c1; end clamps to c1's end (timeline 14 → source 14).
- expect(resolveTimelineSpanToTrim(10, 20, clips)).toEqual({
+ expect(resolveTimelineSpanToTrim(10, 20, clips, [])).toEqual({
assetId: "a",
clipId: "c1",
sourceStartSec: 10,
@@ -178,7 +178,7 @@ describe("resolveTimelineSpanToTrim", () => {
timelineEndSec: 28,
}),
];
- const resolved = resolveTimelineSpanToTrim(18, 21, clips);
+ const resolved = resolveTimelineSpanToTrim(18, 21, clips, []);
expect(resolved).not.toBeNull();
if (!resolved) return;
const back = trimToTimelineSpan(
@@ -188,12 +188,13 @@ describe("resolveTimelineSpanToTrim", () => {
endSec: resolved.sourceEndSec,
},
clips,
+ [],
);
expect(back).toEqual({ start: 18, end: 21 });
});
it("returns null with no clips", () => {
- expect(resolveTimelineSpanToTrim(1, 2, [])).toBeNull();
+ expect(resolveTimelineSpanToTrim(1, 2, [], [])).toBeNull();
});
});
@@ -270,7 +271,9 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 8, endSec: 14 }), // -> timeline 8..14
trim({ id: "t2", assetId: "a", startSec: 16, endSec: 22 }), // -> timeline 14..20
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1", "t2"], start: 8, end: 20 }]);
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([
+ { ids: ["t1", "t2"], start: 8, end: 20 },
+ ]);
});
it("groups two independently-created trims snapped to touching clip boundaries", () => {
@@ -299,7 +302,9 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 7, endSec: 10 }), // -> timeline 7..10
trim({ id: "t2", assetId: "b", startSec: 0, endSec: 2 }), // -> timeline 10..12
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1", "t2"], start: 7, end: 12 }]);
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([
+ { ids: ["t1", "t2"], start: 7, end: 12 },
+ ]);
});
it("keeps a trim separated by a real gap in its own group", () => {
@@ -317,7 +322,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 2, endSec: 4 }),
trim({ id: "t2", assetId: "a", startSec: 10, endSec: 12 }),
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([
{ ids: ["t1"], start: 2, end: 4 },
{ ids: ["t2"], start: 10, end: 12 },
]);
@@ -338,7 +343,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "gone", assetId: "b", startSec: 0, endSec: 2 }), // no clip carries asset b
trim({ id: "t1", assetId: "a", startSec: 3, endSec: 5 }),
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1"], start: 3, end: 5 }]);
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([{ ids: ["t1"], start: 3, end: 5 }]);
});
});
@@ -370,15 +375,17 @@ describe("two clips sharing one asset over the same source window", () => {
// Without the anchor this returned {3,5} — the first clip — because the loop
// stopped at the first clip whose asset and source window matched.
expect(
- trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, sharedClips()),
+ trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, sharedClips(), []),
).toEqual({ start: 15, end: 17 });
});
it("keeps mapping an un-anchored trim to the first matching clip (pre-v7 behaviour)", () => {
- expect(trimToTimelineSpan({ assetId: "a", startSec: 3, endSec: 5 }, sharedClips())).toEqual({
- start: 3,
- end: 5,
- });
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 3, endSec: 5 }, sharedClips(), [])).toEqual(
+ {
+ start: 3,
+ end: 5,
+ },
+ );
});
it("draws one pill per clip when each clip carries its own trim", () => {
@@ -387,7 +394,7 @@ describe("two clips sharing one asset over the same source window", () => {
trim({ id: "t2", assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }),
];
// Two pills, 12s apart — not one merged pill, and not two stacked on c1.
- expect(coalescedTrimGroups(trims, sharedClips())).toEqual([
+ expect(coalescedTrimGroups(trims, sharedClips(), [])).toEqual([
{ ids: ["t1"], start: 3, end: 5 },
{ ids: ["t2"], start: 15, end: 17 },
]);
@@ -397,7 +404,7 @@ describe("two clips sharing one asset over the same source window", () => {
// The twin still uses the same asset over the same source range, so an asset-only
// match would resurrect the cut on it.
const trims = [trim({ id: "orphan", assetId: "a", clipId: "c2", startSec: 3, endSec: 5 })];
- expect(coalescedTrimGroups(trims, [sharedClips()[0]])).toEqual([]);
+ expect(coalescedTrimGroups(trims, [sharedClips()[0]], [])).toEqual([]);
});
it("still shows a pill when the anchor clip was re-cut past the trim's start", () => {
@@ -414,7 +421,7 @@ describe("two clips sharing one asset over the same source window", () => {
}),
];
expect(
- trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, clips),
+ trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, clips, []),
).toEqual({ start: 0, end: 1 });
});
});
diff --git a/src/lib/ai-edition/timeline/trim-mapping.ts b/src/lib/ai-edition/timeline/trim-mapping.ts
index 0dee56d7d..701b575d0 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.ts
@@ -14,7 +14,8 @@
// clip) share a coordinate space: without the anchor, "which clip is this cut on?"
// had no answer and each caller invented its own. See `trimAppliesToClip`.
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
import { type CoalescedSpan, ventilateSpanAcrossClips } from "./region-ventilation";
import { coalesceByIdentity, regionIdentityKey } from "./timelineMap";
@@ -68,6 +69,9 @@ export function trimAppliesToClip(
export function trimToTimelineSpan(
trim: TrimAnchor,
clips: AxcutClip[],
+ /** REQUIRED: a clip carrying insertions is longer than its source window, so this is not
+ * a plain shift (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): { start: number; end: number } | null {
for (const c of clips) {
if (!trimAppliesToClip(trim, c)) continue;
@@ -78,7 +82,7 @@ export function trimToTimelineSpan(
: trim.startSec >= c.sourceStartSec && trim.startSec <= srcEnd;
if (carries) {
const map = (s: number) =>
- c.timelineStartSec + (Math.min(Math.max(s, c.sourceStartSec), srcEnd) - c.sourceStartSec);
+ sourceToTimelineSec(c, Math.min(Math.max(s, c.sourceStartSec), srcEnd), insertRanges);
return { start: map(trim.startSec), end: map(trim.endSec) };
}
}
@@ -140,11 +144,12 @@ export function ventilateTimelineSpanToTrims(
export function coalescedTrimGroups(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): CoalescedSpan[] {
const spans = trimRanges
.map((t) => {
- const mapped = trimToTimelineSpan(t, clips);
+ const mapped = trimToTimelineSpan(t, clips, insertRanges);
return mapped
? {
id: t.id,
@@ -183,10 +188,12 @@ export function resolveTrimPillIds(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
id: string,
+ insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): string[] {
return (
- coalescedTrimGroups(trimRanges, clips, epsilonSec).find((g) => g.ids.includes(id))?.ids ?? [id]
+ coalescedTrimGroups(trimRanges, clips, insertRanges, epsilonSec).find((g) => g.ids.includes(id))
+ ?.ids ?? [id]
);
}
@@ -201,11 +208,14 @@ export function dropTrimPillsByIds(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
ids: Iterable,
+ insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): AxcutTrimRange[] {
const under = new Set();
for (const id of ids) {
- for (const member of resolveTrimPillIds(trimRanges, clips, id, epsilonSec)) under.add(member);
+ for (const member of resolveTrimPillIds(trimRanges, clips, id, insertRanges, epsilonSec)) {
+ under.add(member);
+ }
}
if (under.size === 0) return trimRanges;
return trimRanges.filter((t) => !under.has(t.id));
@@ -225,6 +235,9 @@ export function resolveTimelineSpanToTrim(
startSec: number,
endSec: number,
clips: AxcutClip[],
+ /** REQUIRED: a clip carrying insertions is longer than its source window, so this is not
+ * a plain shift (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): TrimSourceRange | null {
if (clips.length === 0) return null;
const lo = Math.min(startSec, endSec);
@@ -246,7 +259,7 @@ export function resolveTimelineSpanToTrim(
Math.min(lo, carrier.timelineStartSec + srcLen),
);
const tEnd = Math.max(tStart, Math.min(hi, carrier.timelineStartSec + srcLen));
- const toSrc = (t: number) => carrier.sourceStartSec + (t - carrier.timelineStartSec);
+ const toSrc = (t: number) => timelineToSourceSec(carrier, t, insertRanges).sourceSec;
return {
assetId: carrier.assetId,
clipId: carrier.id,
diff --git a/src/lib/ai-edition/timeline/voiceoverCut.test.ts b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
index 00892553c..701b403ef 100644
--- a/src/lib/ai-edition/timeline/voiceoverCut.test.ts
+++ b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
@@ -101,7 +101,7 @@ describe("a cut authored from the voiceover lane", () => {
[5, 6],
[20, 21],
]);
- expect(coalescedTrimGroups(rows, CLIPS)).toHaveLength(1);
+ expect(coalescedTrimGroups(rows, CLIPS, [])).toHaveLength(1);
expect(filmSec(rows)).toBeCloseTo(10, 6);
});
@@ -109,7 +109,7 @@ describe("a cut authored from the voiceover lane", () => {
const rows = cut(5, 7);
// Restoring must not leave half the cut behind, with the word still gone and
// nothing on the ruler to click.
- expect(dropTrimPillsByIds(rows, CLIPS, [rows[0].id])).toEqual([]);
+ expect(dropTrimPillsByIds(rows, CLIPS, [rows[0].id], [])).toEqual([]);
});
it("writes nothing where there is no film", () => {
From 913deb8e90a4a47d76b3df5970ee29238cf09be5 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 12:56:29 +0200
Subject: [PATCH 091/113] revert(insertions): remove the added-word machinery,
keep the gesture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We iterated a dozen times on this feature without converging, and the reason was
architectural: an insertion was stored at the TIMELINE level, beside a trim, while what it
really is is MEDIA inside a clip. The asymmetry poisoned everything downstream — a trim
removes time whose source still exists, an insertion adds time whose source exists
nowhere — so 64 files had to know, 30 functions took the ranges as a parameter, and 61
sites carried a special case for "am I inside one". Every fix revealed another consumer
with its own answer.
Removed: the ruler arithmetic (`inserted-time`), the placement (`insert-mapping`), the
load-time reconciliation (`document/load`), `insertRanges` from the schema, the caption
run-splitting, the take walk's insert half, the preview's parking, `heldSec` on segments
and `hold_sec` through the whole compositor down to `walk_composited_timeline`.
Kept: typing a word into the transcript, the lane switch, the takes, the trims, the
caption lane, word correction. A word added today lands in the transcript and does not yet
extend the film — which is exactly the zero the new architecture starts from.
Method: git restored each file to its last non-insertion commit wherever every commit
since was insertion work, so no lane or take work was lost to a regex. The rest was hand
edits with `tsc` enumerating them.
The state we are leaving is tagged `insertions-v1-abandoned`.
---
crates/compositor-view-napi/src/lib.rs | 6 -
crates/compositor/src/pipeline_linux.rs | 2 -
crates/compositor/src/pipeline_macos.rs | 2 -
crates/compositor/src/pipeline_windows.rs | 2 -
crates/compositor/src/scene.rs | 24 +-
crates/compositor/src/timeline_walk.rs | 51 +--
crates/compositor/tests/compose_linux.rs | 1 -
crates/compositor/tests/export_timing.rs | 1 -
electron/ai-edition/document-service.ts | 7 +-
src/cli/CliExportRunner.tsx | 1 -
.../ai-edition/EditorEmptyState.test.tsx | 1 -
.../ai-edition/EditorEmptyState.tsx | 9 +-
.../ExportDialog.showInFolder.test.tsx | 1 -
.../ai-edition/ExportDialog.test.ts | 1 -
src/components/ai-edition/ExportDialog.tsx | 1 -
.../ai-edition/NativeCompositorOverlay.tsx | 8 +-
src/components/ai-edition/NewEditorShell.tsx | 35 +-
src/components/ai-edition/Preview.tsx | 4 -
src/components/ai-edition/PreviewCanvas.tsx | 14 +-
src/components/ai-edition/RightPanes.tsx | 48 +--
src/components/ai-edition/VirtualPreview.tsx | 175 ++-------
.../ai-edition/WebcamOverlay.test.tsx | 1 -
src/components/ai-edition/WebcamOverlay.tsx | 22 +-
.../ai-edition/v4/FloatingInspector.tsx | 2 +-
src/components/ai-edition/v4/V4Timeline.tsx | 190 +++-------
.../ai-edition/captions/captionLane.test.ts | 30 --
src/lib/ai-edition/captions/captions.test.ts | 164 ---------
src/lib/ai-edition/captions/cues.ts | 104 +-----
src/lib/ai-edition/document/load.test.ts | 138 -------
src/lib/ai-edition/document/load.ts | 60 ----
src/lib/ai-edition/document/migrate.ts | 5 +-
.../ai-edition/document/outputFormat.test.ts | 1 -
src/lib/ai-edition/document/timeline.test.ts | 120 +------
src/lib/ai-edition/document/timeline.ts | 180 +---------
.../ai-edition/document/transcribe.test.ts | 1 -
.../ai-edition/document/transcript.test.ts | 340 +-----------------
src/lib/ai-edition/document/transcript.ts | 190 +---------
src/lib/ai-edition/schema/index.ts | 31 --
.../ai-edition/store/editorSettings.test.ts | 1 -
src/lib/ai-edition/store/projectStore.test.ts | 1 -
src/lib/ai-edition/store/projectStore.ts | 6 +-
.../ai-edition/store/undo.modalGuard.test.tsx | 1 -
src/lib/ai-edition/store/useCaptions.test.ts | 1 -
.../store/useEditorSettings.test.ts | 1 -
src/lib/ai-edition/store/useTimeline.test.ts | 1 -
src/lib/ai-edition/store/useTimeline.ts | 9 +-
.../aggregated-transcript.lanes.test.ts | 80 +----
.../timeline/aggregated-transcript.test.ts | 125 ++-----
.../timeline/aggregated-transcript.ts | 65 +---
src/lib/ai-edition/timeline/camera.test.ts | 6 +-
src/lib/ai-edition/timeline/camera.ts | 7 +-
src/lib/ai-edition/timeline/cursor-track.ts | 10 +-
.../timeline/insert-mapping.test.ts | 192 ----------
src/lib/ai-edition/timeline/insert-mapping.ts | 82 -----
.../ai-edition/timeline/inserted-time.test.ts | 281 ---------------
src/lib/ai-edition/timeline/inserted-time.ts | 237 ------------
.../timeline/programme-time.test.ts | 175 ++-------
src/lib/ai-edition/timeline/programme-time.ts | 57 +--
.../timeline/sharedMediaTrim.test.ts | 5 +-
.../timeline/take-programme.test.ts | 201 +----------
src/lib/ai-edition/timeline/take-programme.ts | 80 +----
.../ai-edition/timeline/timelineMap.test.ts | 120 ++-----
src/lib/ai-edition/timeline/timelineMap.ts | 55 +--
.../ai-edition/timeline/trim-mapping.test.ts | 51 ++-
src/lib/ai-edition/timeline/trim-mapping.ts | 25 +-
.../timeline/virtual-preview.test.ts | 47 ++-
.../ai-edition/timeline/virtual-preview.ts | 65 +---
.../ai-edition/timeline/voiceoverCut.test.ts | 6 +-
.../ai-edition/transcription/status.test.ts | 1 -
src/native/contracts.ts | 10 -
src/native/sceneDescription.test.ts | 67 ----
src/native/sceneDescription.ts | 26 +-
src/native/useNativePlaybackSync.ts | 31 +-
73 files changed, 391 insertions(+), 3710 deletions(-)
delete mode 100644 src/lib/ai-edition/document/load.test.ts
delete mode 100644 src/lib/ai-edition/document/load.ts
delete mode 100644 src/lib/ai-edition/timeline/insert-mapping.test.ts
delete mode 100644 src/lib/ai-edition/timeline/insert-mapping.ts
delete mode 100644 src/lib/ai-edition/timeline/inserted-time.test.ts
delete mode 100644 src/lib/ai-edition/timeline/inserted-time.ts
diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs
index 95700a6cb..69476ffc2 100644
--- a/crates/compositor-view-napi/src/lib.rs
+++ b/crates/compositor-view-napi/src/lib.rs
@@ -364,10 +364,6 @@ pub struct ClipInput {
pub webcam_offset_sec: f64,
/// `false` évite une ouverture ffmpeg vouée à échouer et réserve du silence à ce clip.
pub has_audio: bool,
- /// Secondes de sortie pendant lesquelles ce clip TIENT sa dernière image, en silence.
- /// Une pause achetée par un mot ajouté (issue #560) : le ruler et la preview la
- /// respectaient déjà, l'export l'ignorait, faute d'une fenêtre source non vide.
- pub hold_sec: f64,
}
/// Taille/cadence/codec de sortie voulus par l'app (modale d'export). Tous optionnels :
@@ -504,7 +500,6 @@ pub fn export_multi(
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
- hold_sec: c.hold_sec,
})
.collect();
Ok(AsyncTask::new(ExportMultiTask {
@@ -662,7 +657,6 @@ pub fn export_gif(
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
- hold_sec: c.hold_sec,
})
.collect();
let gif_params = params
diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs
index 5e8ae3970..bc54c3843 100644
--- a/crates/compositor/src/pipeline_linux.rs
+++ b/crates/compositor/src/pipeline_linux.rs
@@ -54,8 +54,6 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
- /// Secondes de sortie tenues sur la dernière image, en silence (issue #560).
- pub hold_sec: f64,
}
/// Codec cible. Memes variantes que `pipeline_macos::ExportCodec`.
diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs
index 1adeb8816..1b3f75798 100644
--- a/crates/compositor/src/pipeline_macos.rs
+++ b/crates/compositor/src/pipeline_macos.rs
@@ -614,8 +614,6 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
- /// Secondes de sortie tenues sur la dernière image, en silence (issue #560).
- pub hold_sec: f64,
}
/// Codec cible pour l'export. Identique à `pipeline_windows::ExportCodec`.
diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs
index 49eabf806..43e6fc826 100644
--- a/crates/compositor/src/pipeline_windows.rs
+++ b/crates/compositor/src/pipeline_windows.rs
@@ -976,8 +976,6 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
- /// Secondes de sortie tenues sur la dernière image, en silence (issue #560).
- pub hold_sec: f64,
}
/// Export **multiclip** : rend la timeline (clips ordonnés, avec trims) en un seul MP4.
diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs
index a53aba3cf..a42241672 100644
--- a/crates/compositor/src/scene.rs
+++ b/crates/compositor/src/scene.rs
@@ -19,11 +19,6 @@ pub struct SceneClip {
/// Une source sans piste audio décodable garde sa durée via du silence natif.
#[serde(default)]
pub has_audio: bool,
- /// Secondes de sortie pendant lesquelles ce clip TIENT sa dernière image, en silence.
- /// `#[serde(default)]` comme `has_audio` juste au-dessus, et pour la même raison : un
- /// document écrit avant ce champ se charge sans lui, à 0.0.
- #[serde(default)]
- pub hold_sec: f64,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -618,26 +613,9 @@ impl Scene {
mod tests {
use super::*;
- /// Un document écrit avant `holdSec` doit se charger, à 0.0 — comme `hasAudio` avant
/// lui. Sans ce défaut, ouvrir un projet fait par une version antérieure échouerait au
/// parse au lieu de simplement ne rien tenir (issue #560).
- #[test]
- fn a_clip_without_hold_sec_deserializes_to_zero() {
- let json = r##"{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":0,"hasAudio":true}"##;
- let clip: SceneClip = serde_json::from_str(json).expect("clip sans holdSec");
- assert_eq!(clip.hold_sec, 0.0);
- }
-
- #[test]
- fn a_clip_carries_its_hold_when_the_document_states_one() {
- let json = r##"{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":2,"sourceEndSec":2,"webcamOffsetSec":0,"hasAudio":true,"holdSec":1.5}"##;
- let clip: SceneClip = serde_json::from_str(json).expect("clip tenu");
- assert_eq!(clip.hold_sec, 1.5);
- // Fenêtre source vide ET pause positive : c'est exactement la forme que la marche
- // laisse désormais passer.
- assert!(clip.source_end_sec <= clip.source_start_sec);
- }
-
+
#[test]
fn parses_a_minimal_scene_json() {
let json = r##"{
diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs
index 4a0cab4ef..fe3a2e795 100644
--- a/crates/compositor/src/timeline_walk.rs
+++ b/crates/compositor/src/timeline_walk.rs
@@ -248,11 +248,7 @@ pub(crate) unsafe fn walk_composited_timeline(
clip.webcam,
);
}
- // Une fenêtre source vide ne veut plus dire « rien à faire » : un segment TENU
- // (issue #560) n'a par construction aucune source à décoder et n'existe que pour
- // ses images tenues. Sans cette porte, une pause achetée par un mot ajouté était
- // honorée par le ruler et par la preview et n'exportait rien du tout.
- if source_end_sec <= clip.source_start_sec && clip.hold_sec <= 0.0 {
+ if source_end_sec <= clip.source_start_sec {
continue;
}
@@ -306,10 +302,6 @@ pub(crate) unsafe fn walk_composited_timeline(
}
let frames_before_clip = frames;
- // La dernière paire composée, gardée hors de la boucle : `'clip_frames` peut casser,
- // et un clip qui n'existe QUE pour tenir une image n'entre jamais dedans — il faut
- // pourtant une image à tenir dans les deux cas.
- let mut last_pair: Option<(*mut AVFrame, *mut AVFrame)> = None;
'clip_frames: for segment in &speed_segments {
for segment_frame in 0..segment.frame_count {
let target_source_time =
@@ -340,53 +332,12 @@ pub(crate) unsafe fn walk_composited_timeline(
let _p = crate::export_probe::scope(crate::export_probe::Stage::Compose);
comp.compose_frame(sf, wf, frames as f32, cfg)?;
}
- last_pair = Some((sf, wf));
on_frame(frames)?;
frames += 1;
}
}
- // Les images tenues, APRÈS les segments de vitesse et volontairement en dehors
- // d'eux. Dedans, `stretch_pcm_to_length` étirerait le vrai son du clip à travers le
- // gel (WSOLA sur une voix, audible). Dehors, le créneau audio est plus long que le
- // PCM et `min(source.len())` laisse des zéros : le silence est gratuit.
- //
- // `ceil`, pas `round` : arrondir perd jusqu'à une demi-image, prise sur la fin de la
- // narration même que la pause existe pour loger.
- if clip.hold_sec > 0.0 {
- if last_pair.is_none() {
- // Clip tenu seul : rien n'a été composé, alors on va chercher une image une
- // fois. Un seek qui échoue laisse `last_pair` vide et on n'émet rien plutôt
- // que de composer du vide.
- if advance_decoder_to(sdec, clip.source_start_sec, 0.0)?
- && advance_decoder_to(wdec, clip.source_start_sec, clip.webcam_offset_sec)?
- {
- let sf = sdec.cur_frame();
- let wf = wdec.cur_frame();
- if !sf.is_null() && !wf.is_null() {
- last_pair = Some((sf, wf));
- }
- }
- }
- match last_pair {
- Some((sf, wf)) => {
- let held = ((clip.hold_sec * out_fps as f64).ceil()) as u64;
- // Le temps timeline est ÉPINGLÉ sur l'instant tenu : c'est ce qui fige
- // aussi les modificateurs (zoom, curseur) au lieu de les laisser courir.
- comp.set_timeline_time(Some(clip.source_start_sec as f32));
- for _ in 0..held {
- comp.compose_frame(sf, wf, frames as f32, cfg)?;
- on_frame(frames)?;
- frames += 1;
- }
- }
- None => eprintln!(
- "[pipeline] warning: clip #{}: pause de {:.3}s ignorée, aucune image à tenir (screen=\"{}\")",
- clip_index, clip.hold_sec, clip.screen,
- ),
- }
- }
on_clip_end(
clip_index,
source_end_sec,
diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs
index 22ea8bc3f..62361572d 100644
--- a/crates/compositor/tests/compose_linux.rs
+++ b/crates/compositor/tests/compose_linux.rs
@@ -792,7 +792,6 @@ fn export_linux_mp4() {
source_end_sec: 1.0,
webcam_offset_sec: 0.0,
has_audio: true,
- hold_sec: 0.0,
}];
let params = ExportParams {
width: 640,
diff --git a/crates/compositor/tests/export_timing.rs b/crates/compositor/tests/export_timing.rs
index 11a100626..f0070794b 100644
--- a/crates/compositor/tests/export_timing.rs
+++ b/crates/compositor/tests/export_timing.rs
@@ -117,7 +117,6 @@ fn whole_clip(dir: &PathBuf) -> ClipSource {
source_end_sec: SOURCE_SEC,
webcam_offset_sec: 0.0,
has_audio: false,
- hold_sec: 0.0,
}
}
diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts
index d3eaa01d3..9c9b958b9 100644
--- a/electron/ai-edition/document-service.ts
+++ b/electron/ai-edition/document-service.ts
@@ -13,7 +13,6 @@
import fs, { type FileHandle } from "node:fs/promises";
import path from "node:path";
import { createId } from "../../src/lib/ai-edition/document/ids";
-import { parseStoredDocument, reconcileInsertions } from "../../src/lib/ai-edition/document/load";
import { removeClip } from "../../src/lib/ai-edition/document/timeline";
import {
type AxcutAsset,
@@ -123,7 +122,7 @@ function safeProjectId(raw: string): string {
// `getProject` spells the same two steps out inline because it relinks moved
// media between them; keep the order (upgrade, then validate) in step.
function parseLoadedDocument(raw: string): AxcutDocument {
- return parseStoredDocument(JSON.parse(raw));
+ return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw)));
}
/**
@@ -274,9 +273,7 @@ export class DocumentService {
// back, and it is not persisted from here: the renderer saves the document
// it was given, as it does for any other load-time repair.
const migrated = migrateRawDocumentToCurrent(JSON.parse(raw));
- return reconcileInsertions(
- documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir)),
- );
+ return documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir));
}
async createProject(title: string): Promise {
diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx
index 5da415596..9a7f1b590 100644
--- a/src/cli/CliExportRunner.tsx
+++ b/src/cli/CliExportRunner.tsx
@@ -95,7 +95,6 @@ function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
- holdSec: clip.heldSec ?? 0,
},
];
});
diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx
index 0756bc0dd..be7f130b7 100644
--- a/src/components/ai-edition/EditorEmptyState.test.tsx
+++ b/src/components/ai-edition/EditorEmptyState.test.tsx
@@ -47,7 +47,6 @@ const sampleDoc = vi.hoisted(
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/EditorEmptyState.tsx b/src/components/ai-edition/EditorEmptyState.tsx
index 18101e699..226171dca 100644
--- a/src/components/ai-edition/EditorEmptyState.tsx
+++ b/src/components/ai-edition/EditorEmptyState.tsx
@@ -14,8 +14,11 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
-import { parseStoredDocument } from "@/lib/ai-edition/document/load";
-import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
+import {
+ migrateProjectDataToAxcutDocument,
+ migrateRawDocumentToCurrent,
+} from "@/lib/ai-edition/document/migrate";
+import { documentSchema } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { nativeBridgeClient } from "@/native";
import styles from "./NewEditorShell.module.css";
@@ -79,7 +82,7 @@ export function EditorEmptyState({
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
- ? parseStoredDocument(raw) // disk-load: upgrade, validate, reconcile clip geometry
+ ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
: migrateProjectDataToAxcutDocument(raw as never);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (!saved.success || !saved.document) return false;
diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
index d2ec1adbd..52eb7b2ad 100644
--- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
+++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx
@@ -71,7 +71,6 @@ const DOC: AxcutDocument = {
],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts
index 1fb005750..ed2f1ab1a 100644
--- a/src/components/ai-edition/ExportDialog.test.ts
+++ b/src/components/ai-edition/ExportDialog.test.ts
@@ -51,7 +51,6 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx
index 6a581c1ec..5dff50acf 100644
--- a/src/components/ai-edition/ExportDialog.tsx
+++ b/src/components/ai-edition/ExportDialog.tsx
@@ -103,7 +103,6 @@ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
- holdSec: clip.heldSec ?? 0,
},
];
});
diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx
index 18b63f72e..e4af64102 100644
--- a/src/components/ai-edition/NativeCompositorOverlay.tsx
+++ b/src/components/ai-edition/NativeCompositorOverlay.tsx
@@ -74,13 +74,7 @@ export function NativeCompositorOverlay() {
return resolveVisibleClips(document);
}, [document]);
const activePosition = useMemo(
- () =>
- resolveNativePosition(
- currentTimeSec,
- nativeClips,
- document?.timeline.clips ?? [],
- document?.timeline.insertRanges ?? [],
- ),
+ () => resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
[nativeClips, currentTimeSec, document],
);
const activeClip = activePosition?.clip ?? null;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index fbf7a2a2e..2d24ed32e 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -6,8 +6,10 @@ import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { createId } from "@/lib/ai-edition/document/ids";
-import { parseStoredDocument } from "@/lib/ai-edition/document/load";
-import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
+import {
+ migrateProjectDataToAxcutDocument,
+ migrateRawDocumentToCurrent,
+} from "@/lib/ai-edition/document/migrate";
import {
applyProbedDuration,
replaceTimeline as replaceTimelineOp,
@@ -19,11 +21,7 @@ import {
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
-import {
- type AxcutAudioTrack,
- type AxcutClip,
- type AxcutInsertRange,
-} from "@/lib/ai-edition/schema";
+import { type AxcutAudioTrack, type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
@@ -94,17 +92,15 @@ const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
function NativePlaybackSync({
visibleClips,
clips,
- insertRanges,
}: {
visibleClips: AxcutClip[];
clips: AxcutClip[];
- insertRanges: readonly AxcutInsertRange[];
}) {
const playing = useProjectStore((s) => s.playing);
const currentTimeSec = useProjectStore((s) => s.currentTimeSec);
// visibleClips = trim-compressed native stream; `clips` = RAW layout currentTimeSec
// is measured against. resolveNativePosition needs both (see timelineMap).
- useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips, insertRanges);
+ useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips);
return null;
}
@@ -587,7 +583,7 @@ export function NewEditorShell() {
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
- ? parseStoredDocument(raw) // disk-load: upgrade, validate, reconcile clip geometry
+ ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
: migrateProjectDataToAxcutDocument(raw as EditorProjectData);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (saved.success && saved.document) {
@@ -666,12 +662,7 @@ export function NewEditorShell() {
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
- const next = dropTrimPillsByIds(
- doc.timeline.trimRanges,
- doc.timeline.clips,
- trimIds,
- doc.timeline.insertRanges ?? [],
- );
+ const next = dropTrimPillsByIds(doc.timeline.trimRanges, doc.timeline.clips, trimIds);
if (next.length === doc.timeline.trimRanges.length) return;
await saveDocument(
{ ...doc, timeline: { ...doc.timeline, trimRanges: next } },
@@ -1090,7 +1081,7 @@ export function NewEditorShell() {
// (properties kept, position taken from the playhead).
if (sel.kind === "trim") {
const { coalescedTrimGroups } = await import("@/lib/ai-edition/timeline/trim-mapping");
- const group = coalescedTrimGroups(tl.trimRanges, tl.clips, tl.insertRanges ?? []).find((g) =>
+ const group = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) =>
g.ids.includes(sel.id),
);
if (!group) return;
@@ -1352,7 +1343,6 @@ export function NewEditorShell() {
isMac,
togglePlay,
handleSeek,
- openVoiceoverFlow,
]);
const showTimeline = mode !== "rec";
@@ -1438,11 +1428,7 @@ export function NewEditorShell() {
className={v4.app}
style={{ gridTemplateRows: `58px 1fr ${showTimeline ? timelineRow : "0px"}` }}
>
-
+ void tl.commitZoomFocus()}
diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx
index 2753f9635..cd6868e75 100644
--- a/src/components/ai-edition/Preview.tsx
+++ b/src/components/ai-edition/Preview.tsx
@@ -5,7 +5,6 @@ import type {
AxcutAnnotationRegion,
AxcutAudioTrack,
AxcutClip,
- AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -34,7 +33,6 @@ interface PreviewProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
- insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -68,7 +66,6 @@ export function Preview({
speedRegions,
cameraFullscreenRegions,
trimRanges,
- insertRanges,
selectedZoomRegionId,
onZoomFocusChange,
onZoomFocusCommit,
@@ -197,7 +194,6 @@ export function Preview({
speedRegions={speedRegions}
cameraFullscreenRegions={cameraFullscreenRegions}
trimRanges={trimRanges}
- insertRanges={insertRanges}
selectedZoomRegionId={selectedZoomRegionId}
onZoomFocusChange={onZoomFocusChange}
onZoomFocusCommit={onZoomFocusCommit}
diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 07a526f45..150012b95 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -36,7 +36,6 @@ import type {
AxcutAnnotationRegion,
AxcutAudioTrack,
AxcutClip,
- AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -63,9 +62,6 @@ import { type VideoSource, VirtualPreview } from "./VirtualPreview";
import { WebcamOverlay } from "./WebcamOverlay";
import { ZoomFocusOverlay } from "./ZoomFocusOverlay";
-/** Stable identity, so the memos are not invalidated every render by a fresh `[]`. */
-const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
-
type BlurData = NonNullable;
interface PreviewCanvasProps {
@@ -79,7 +75,6 @@ interface PreviewCanvasProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
- insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -224,18 +219,17 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
// clip the playhead is currently inside, the same lookup VirtualPreview
// itself uses to map playback position back to a clip. `undefined` (no
// crop stored) normalises to the identity region.
- const previewInserts = props.insertRanges ?? EMPTY_INSERT_RANGES;
const activeClip = useMemo(
- () => locateVirtualPosition(props.clips, props.currentTimeSec, previewInserts)?.clip ?? null,
- [props.clips, props.currentTimeSec, previewInserts],
+ () => locateVirtualPosition(props.clips, props.currentTimeSec)?.clip ?? null,
+ [props.clips, props.currentTimeSec],
);
const cropRegion: CropRegion = activeClip?.cropRegion ?? DEFAULT_CROP_REGION;
// P4 — the layout preset is global (one panel for the whole timeline) but the camera
// is per clip, so the layout has to be resolved against the clip under the playhead.
const activeCameraTrack = useMemo(
- () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec, previewInserts),
- [assets, props.clips, props.currentTimeSec, previewInserts],
+ () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec),
+ [assets, props.clips, props.currentTimeSec],
);
const activeClipHasCamera = Boolean(activeCameraTrack?.visible && activeCameraTrack.sourcePath);
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 83927970d..79f9f2e00 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -51,7 +51,6 @@ import type {
AxcutAsset,
AxcutAudioTrack,
AxcutClip,
- AxcutInsertRange,
AxcutTranscript,
AxcutTrimRange,
AxcutWord,
@@ -79,7 +78,6 @@ import {
} from "@/lib/ai-edition/timeline/aggregated-transcript";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatMs } from "@/lib/ai-edition/timeline/format";
-import { takeInserts } from "@/lib/ai-edition/timeline/insert-mapping";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import type {
AssetTranscriptionView,
@@ -112,9 +110,6 @@ import styles from "./NewEditorShell.module.css";
import { useTranscriptionLabel } from "./TranscriptionStatus";
import { transcriptionBusyLabel } from "./transcriptionBusyLabel";
-/** Stable identity, so the memos below are not invalidated every render by a fresh `[]`. */
-const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
-
interface PaneProps {
title: string;
icon: ReactNode;
@@ -888,20 +883,11 @@ export function TranscriptPane({
// From the RECORDING clips and the whole trim set, never from `placements`: the
// programme is one thing, and the voiceover lane is asking whether the film still
// contains a moment — not whether some trim happens to name an audio fragment.
- const insertRanges = document?.timeline.insertRanges ?? EMPTY_INSERT_RANGES;
- const removed = useMemo(
- () => removedRawSpans(clips, trimRanges, insertRanges),
- [clips, trimRanges, insertRanges],
- );
+ const removed = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
// The take's placements are fed the cuts AND its own insertions, so a word after a pause
- // is struck through — and highlighted — at the moment it is actually heard (issue #560).
- const insertsFor = useCallback(
- (groupId: string) => (document ? takeInserts(document, groupId) : []),
- [document],
- );
const voiceover = useMemo(
- () => voiceoverPlacements(audioTracks, removed, insertsFor),
- [audioTracks, removed, insertsFor],
+ () => voiceoverPlacements(audioTracks, removed),
+ [audioTracks, removed],
);
const activeLane = resolveCaptionLane(document, captionSettings);
const setLane = useCallback(
@@ -913,8 +899,8 @@ export function TranscriptPane({
const placements = activeLane === "voiceover" ? voiceover : clips;
const sections = useMemo(
- () => buildAggregatedSections(placements, transcripts, assets, removed, insertRanges),
- [placements, transcripts, assets, removed, insertRanges],
+ () => buildAggregatedSections(placements, transcripts, assets, removed),
+ [placements, transcripts, assets, removed],
);
// `currentTimeSec` is the RAW/document timeline (same referential as the ruler, see
@@ -923,8 +909,8 @@ export function TranscriptPane({
// id is something only the recording lane has — so the voiceover lane never
// highlighted. Raw seconds are the coordinate both lanes share.
const cueWordId = useMemo(
- () => findCueWordId(sections, currentTimeSec, insertRanges),
- [sections, currentTimeSec, insertRanges],
+ () => findCueWordId(sections, currentTimeSec),
+ [sections, currentTimeSec],
);
const laneSwitch =
@@ -1035,7 +1021,6 @@ export function TranscriptPane({
undefined
}
cueWordId={cueWordId}
- insertRanges={insertRanges}
onSeek={onSeek}
onTrimTimelineSpan={onTrimTimelineSpan}
onRemoveTrimRanges={onRemoveTrimRanges}
@@ -1067,7 +1052,6 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
busyLabel,
lane,
cueWordId,
- insertRanges,
onSeek,
onTrimTimelineSpan,
onRemoveTrimRanges,
@@ -1083,10 +1067,6 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
* CLIP frame, and a voiceover placement has no clip to hold. */
lane: TranscriptLane;
cueWordId: string | null;
- /** The insertions this placement carries: the block maps its words' SOURCE spans onto
- * the ruler to author a cut, and a clip carrying insertions is longer than its source
- * window (issue #560). */
- insertRanges: readonly AxcutInsertRange[];
onSeek: (sec: number) => void;
onTrimTimelineSpan: (startSec: number, endSec: number, reason: string) => void;
onRemoveTrimRanges: (trimIds: string[]) => void;
@@ -1106,14 +1086,13 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
// to do with the word the user deleted.
const toRawSpan = useCallback(
(startSec: number, endSec: number): [number, number] => {
- const extent = placementRawExtent(clip, insertRanges);
+ const extent = placementRawExtent(clip);
const lo = extent?.startSec ?? clip.timelineStartSec;
const hi = extent?.endSec ?? Number.POSITIVE_INFINITY;
- const clamp = (sec: number) =>
- Math.min(Math.max(placementRawSec(clip, sec, insertRanges), lo), hi);
+ const clamp = (sec: number) => Math.min(Math.max(placementRawSec(clip, sec), lo), hi);
return [clamp(startSec), clamp(endSec)];
},
- [clip, insertRanges],
+ [clip],
);
const filename = asset?.label ?? clip.assetId;
const sourceRangeLabel =
@@ -1281,6 +1260,13 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
// timeline time (the pause gesture) it is a silent freeze frame. Drop this gate
// when TTS lands.
if (!import.meta.env.DEV) return;
+ // Not on the voiceover lane. `insertRangeSchema` has no `clipId`, and the pause an
+ // a voiceover placement has no clip to hold. Refused with a reason rather than
+ // left to write a record nothing can read.
+ if (lane === "voiceover") {
+ toast.error(ts("transcript.insertRecordingOnly"));
+ return;
+ }
if (busy || !seed.trim()) return;
const editor = editorRef.current;
const selection = globalThis.getSelection();
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 143051b85..feb174ff8 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -16,19 +16,16 @@ import {
import type {
AxcutAudioTrack,
AxcutClip,
- AxcutInsertRange,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
-import { insertionEnteredBetween, rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
import {
consumedSourceSec,
- type TakeInsert,
type TakePiece,
takePlaybackAt,
takeProgramme,
@@ -215,8 +212,6 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
- /** The media added words inserted — it lengthens playback, it does not cut it. */
- insertRanges?: AxcutInsertRange[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -263,7 +258,6 @@ export function VirtualPreview({
zoomRegions = [],
speedRegions = [],
trimRanges = [],
- insertRanges = [],
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -562,76 +556,26 @@ export function VirtualPreview({
trimRangesRef.current = trimRanges;
// What the film no longer contains, recomputed only when the cuts move — the rAF asks
// it once per voiceover per frame, and walking every trim there would be wasteful.
- // The film's insertions, placed on the raw ruler once. The projection needs them or every
- // track after one lands D seconds early — the bug this argument exists to close.
- /** The insertion currently playing, if any.
- *
- * An added word inserts MEDIA inside the clip (issue #560). There is no generator for it
- * yet, so the stand-in is a fixed frame and silence — but it is a piece of media on the
- * timeline like any other, and playback runs THROUGH it rather than around it.
- *
- * The `` cannot supply those seconds: they are not in the file. So it is PARKED
- * for the insertion's duration — paused, which holds the frame the insertion stands for
- * and silences the recording under it — and a wall clock runs the insertion out.
- *
- * Parked, not re-seeked: writing `currentTime` every frame to a still-playing element
- * is a seek storm the decoder never settles out of, and that is what "playback stops at
- * the insertion" actually was. The cost is that `.paused` stops answering "is the
- * film stopped?" — see `filmPlaying` in the tick. */
- const insertionRef = useRef<{ rawSec: number; durationSec: number; startedAtMs: number } | null>(
- null,
- );
- /** How far into the insertion the wall clock has run, in seconds. Read by
- * `updateVirtualTime` so the RULER position it publishes crosses the insertion while the
- * RAW second it publishes alongside stands still at the insertion's own moment. */
- const insertionElapsedRef = useRef(0);
- // The ranges themselves for anything that maps through a clip; their timeline positions
- // for the one thing that asks "did this frame run into one".
- const insertRangesRef = useRef(insertRanges);
- insertRangesRef.current = insertRanges;
- const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
- filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
+ // The film's pauses, placed on the raw ruler once. The projection needs them or every
+ // track after a pause lands D seconds early — the bug this argument exists to close.
// One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
// it every frame per track, and walking on each would be wasteful.
- // The take's own insertions, resolved from the ranges this component already receives.
- // `resolveInsertPlacement` needs assets to tell the lanes apart, and the preview has
- // none — but a range naming an AUDIO asset is exactly one whose asset is not a clip's,
- // which is the same test, available here.
- const clipAssetIds = useMemo(() => new Set(clips.map((c) => c.assetId)), [clips]);
- const takeInsertsByGroup = useCallback(
- (groupId: string): TakeInsert[] => {
- const pill = collapseTracksToPills(audioTracks).find((t) => trackGroupId(t) === groupId);
- if (!pill) return [];
- return insertRanges
- .filter((range) => range.assetId === pill.assetId && !clipAssetIds.has(range.assetId))
- .map((range) => ({
- id: range.id,
- wordId: range.wordId,
- atSourceSec: range.atSec,
- durationSec: range.durationSec,
- }));
- },
- [audioTracks, insertRanges, clipAssetIds],
- );
const takePiecesRef = useRef>(new Map());
const takeHeadsRef = useRef>(new Map());
- const removedRef = useRef(removedRawSpans(clips, trimRanges, insertRanges));
- removedRef.current = useMemo(
- () => removedRawSpans(clips, trimRanges, insertRanges),
- [clips, trimRanges, insertRanges],
- );
+ const removedRef = useRef(removedRawSpans(clips, trimRanges));
+ removedRef.current = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]);
const takeWalks = useMemo(() => {
const pieces = new Map();
const heads = new Map();
- const removed = removedRawSpans(clips, trimRanges, insertRanges);
+ const removed = removedRawSpans(clips, trimRanges);
for (const pill of collapseTracksToPills(audioTracks)) {
if (pill.kind !== "voiceover" || pill.loop) continue;
const groupId = trackGroupId(pill);
heads.set(groupId, pill.id);
- pieces.set(groupId, takeProgramme(pill, removed, takeInsertsByGroup(groupId)));
+ pieces.set(groupId, takeProgramme(pill, removed));
}
return { pieces, heads };
- }, [audioTracks, clips, trimRanges, takeInsertsByGroup, insertRanges]);
+ }, [audioTracks, clips, trimRanges]);
takePiecesRef.current = takeWalks.pieces;
takeHeadsRef.current = takeWalks.heads;
// Trim-narrowed (`resolvePlaybackSegments`) — used ONLY to detect "has the 's own
@@ -645,8 +589,8 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- () => resolvePlaybackSegments(clips, trimRanges, insertRanges),
- [clips, trimRanges, insertRanges],
+ () => resolvePlaybackSegments(clips, trimRanges),
+ [clips, trimRanges],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
@@ -695,25 +639,6 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
- // Run the insertion out FIRST: everything below is positioned against a clock that
- // crosses it — the imported tracks especially, which sit on the programme clock
- // exactly as they do in the render's output stream.
- const insertion = insertionRef.current;
- if (insertion) {
- const elapsedSec = (performance.now() - insertion.startedAtMs) / 1000;
- // `!v.paused` means something un-parked the element under us — the transport.
- if (elapsedSec >= insertion.durationSec || !v.paused) {
- insertionRef.current = null;
- insertionElapsedRef.current = 0;
- if (v.paused) void v.play().catch(() => undefined);
- } else {
- insertionElapsedRef.current = elapsedSec;
- }
- }
- // A PARKED element is not a stopped film, and this is the question every gate
- // below actually means: the picture is held on the insertion's frame on purpose
- // while the programme keeps running over it.
- const filmPlaying = !v.paused || insertionRef.current !== null;
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -750,20 +675,12 @@ export function VirtualPreview({
// was never given a faster `playbackRate`, but seeking it twice as fast
// amounts to the same thing. Dividing raw time by the rate turns that
// back into 1x wall-clock, which is what the render does too.
- // `+ insertionElapsedSec`, and only here: the RAW playhead stands still at the
- // insertion's moment for its whole duration — none of those seconds come from the
- // recording — and the projection of that moment is where the insertion OPENS
- // (`expandRawSec` and this walk both give the last recorded frame its own instant).
- // Adding the elapsed walks the programme through the inserted media, which is what
- // the mixer downstream is doing over the same seconds.
- const outputTimeSec =
- projectRawTimelineSecToPlayback(
- clipsRef.current,
- trimRangesRef.current,
- virtualTimeSecRef.current,
- insertRangesRef.current,
- speedRegionsRef.current,
- ) + insertionElapsedRef.current;
+ const outputTimeSec = projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ virtualTimeSecRef.current,
+ speedRegionsRef.current,
+ );
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
@@ -771,7 +688,6 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
track.startMs / 1000,
- insertRangesRef.current,
speedRegionsRef.current,
);
// Length is measured WITHOUT speed, position WITH it. A trim REMOVES
@@ -786,13 +702,11 @@ export function VirtualPreview({
clipsRef.current,
trimRangesRef.current,
track.endMs / 1000,
- insertRangesRef.current,
) -
projectRawTimelineSecToPlayback(
clipsRef.current,
trimRangesRef.current,
track.startMs / 1000,
- insertRangesRef.current,
),
);
// A voiceover follows the cuts AND its own insertions, through one walk over
@@ -855,7 +769,7 @@ export function VirtualPreview({
// media metadata not ready yet
}
}
- if (filmPlaying && trackTarget.shouldPlay && el.paused) {
+ if (!v.paused && trackTarget.shouldPlay && el.paused) {
// Resume a context suspended by autoplay policy, exactly as the primary
// loop does above — otherwise a track that starts while the primary
// element is silent (its span is over, or a recording with no separate
@@ -865,7 +779,7 @@ export function VirtualPreview({
}
const playback = el.play();
if (playback) void playback.catch(() => undefined);
- } else if ((!filmPlaying || !trackTarget.shouldPlay) && !el.paused) {
+ } else if ((v.paused || !trackTarget.shouldPlay) && !el.paused) {
el.pause();
}
}
@@ -874,7 +788,7 @@ export function VirtualPreview({
// bypasses React state entirely.
if (clockRef) {
clockRef.current.sourceTimeSec = v.currentTime;
- clockRef.current.isPlaying = filmPlaying;
+ clockRef.current.isPlaying = !v.paused;
clockRef.current.playbackRate = v.playbackRate;
clockRef.current.virtualTimeSec = virtualTimeSecRef.current;
}
@@ -919,7 +833,6 @@ export function VirtualPreview({
activeSourceId,
v.currentTime,
activeClipIdRef.current ?? undefined,
- insertRangesRef.current,
);
if (nextKeptSegment) {
// `findRawClipForSegment` is the ONE definition of the segment-id
@@ -930,11 +843,7 @@ export function VirtualPreview({
if (rawClip) {
activeClipIdRef.current = rawClip.id;
}
- const rawTargetTime = getRawVirtualStartTime(
- nextKeptSegment,
- clipsRef.current,
- insertRangesRef.current,
- );
+ const rawTargetTime = getRawVirtualStartTime(nextKeptSegment, clipsRef.current);
seekToVirtualTimeRef.current?.(rawTargetTime, true);
return;
}
@@ -970,7 +879,7 @@ export function VirtualPreview({
// `clockRef` et `setSourceTimeSec` ci-dessus continuent d'être publiés : la webcam
// et le calque curseur ont besoin du temps source même à l'arrêt. Seule la
// position de la TIMELINE cesse d'être dictée par le média.
- if (!filmPlaying) {
+ if (v.paused) {
return;
}
if (clipsRef.current.length === 0) {
@@ -991,7 +900,6 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
- insertRangesRef.current,
);
if (pos) {
activeClipIdRef.current = pos.clip.id;
@@ -1005,7 +913,6 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
- insertRangesRef.current,
);
if (!position) {
// ponytail: fall back to timeline order so cross-asset / reordered
@@ -1041,32 +948,7 @@ export function VirtualPreview({
seekToVirtualTimeRef.current?.(nextClip.timelineStartSec, true);
return;
}
- const nextRawTime = clampVirtualTime(clipsRef.current, position.virtualTimeSec);
- // The first insertion this frame ran into — the rule, and why it is half-open,
- // lives with the other ruler arithmetic.
- const entering = insertionRef.current
- ? undefined
- : insertionEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
- if (entering) {
- insertionRef.current = {
- rawSec: entering.atRawSec,
- durationSec: entering.durationSec,
- startedAtMs: performance.now(),
- };
- insertionElapsedRef.current = 0;
- // Parks the picture on the frame the insertion stands for, and silences the
- // recording under it. Both are what the insertion IS.
- v.pause();
- // The insertion's own moment, not the frame we happened to land on: the
- // transcript cue, the caption lookup and the audio mix all read this, and
- // through the insertion the RECORDING really is at that one instant.
- updateVirtualTime(entering.atRawSec);
- return;
- }
- // While an insertion plays the element is parked, so the position it reports stands
- // still at where the insertion opens. Its own wall clock is what carries the
- // playhead across it — nothing else is moving. Zero the rest of the time.
- updateVirtualTime(nextRawTime + insertionElapsedRef.current);
+ updateVirtualTime(clampVirtualTime(clipsRef.current, position.virtualTimeSec));
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
@@ -1165,12 +1047,7 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
- // A seek ends the insertion that was playing: the playhead is somewhere else now,
- // so the frame it parked on is not the frame any more. The rAF's own seeks (clip
- // advance, trim skip) are gated on `!v.paused` and so never land here mid-insertion.
- insertionRef.current = null;
- insertionElapsedRef.current = 0;
- const position = locateVirtualPosition(clips, nextVirtualTimeSec, insertRanges);
+ const position = locateVirtualPosition(clips, nextVirtualTimeSec);
if (!position) {
videoRef.current?.pause();
updateVirtualTime(0);
@@ -1238,7 +1115,7 @@ export function VirtualPreview({
});
}
},
- [applySourceTime, clips, videoSources, sourceIndex, updateVirtualTime, insertRanges],
+ [applySourceTime, clips, videoSources, sourceIndex, updateVirtualTime],
);
const seekToSourceTime = useCallback(
@@ -1277,11 +1154,7 @@ export function VirtualPreview({
// the one that has to come back. An asset switch queued in the
// meantime is newer intent still, so it wins outright.
if (!pendingSeekRef.current) {
- const position = locateVirtualPosition(
- clipsRef.current,
- virtualTimeSecRef.current,
- insertRangesRef.current,
- );
+ const position = locateVirtualPosition(clipsRef.current, virtualTimeSecRef.current);
// `locateVirtualPosition` answers for whatever clip the playhead
// is on, which after a boundary advance can belong to a DIFFERENT
// asset — its source time would be a meaningless offset into the
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx
index ffd13f30a..0e5575b6a 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -68,7 +68,6 @@ function makeDocument(): AxcutDocument {
clips: [CLIP_WITH_CAMERA, CLIP_WITHOUT_CAMERA],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/components/ai-edition/WebcamOverlay.tsx b/src/components/ai-edition/WebcamOverlay.tsx
index 9a9dad3dd..d9b256149 100644
--- a/src/components/ai-edition/WebcamOverlay.tsx
+++ b/src/components/ai-edition/WebcamOverlay.tsx
@@ -17,7 +17,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import type { WebcamLayoutPreset, WebcamMaskShape } from "@/components/video-editor/types";
-import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
+import type { AxcutClip } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera";
@@ -31,14 +31,8 @@ import { getCssClipPath } from "@/lib/webcamMaskShapes";
import { setWebcamNativeSize } from "@/native/webcamSizeCache";
import styles from "./NewEditorShell.module.css";
-/** Stable identity, so the memos are not invalidated every render by a fresh `[]`. */
-const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
-
interface WebcamOverlayProps {
clips: AxcutClip[];
- /** The insertions those clips carry — the camera follows the clip under the playhead,
- * and which clip that is cannot be answered without them (issue #560). */
- insertRanges?: readonly AxcutInsertRange[];
currentTimeSec: number;
onTimeChange: (sec: number) => void;
isPlaying: boolean;
@@ -67,15 +61,14 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
// Fallback (pre-clockRef / first paint) position from props, used only for
// the initial correction on loadedmetadata before the rAF loop below has
// had a chance to run.
- const overlayInserts = props.insertRanges ?? EMPTY_INSERT_RANGES;
const position = useMemo(
- () => locateVirtualPosition(props.clips, props.currentTimeSec, overlayInserts),
- [props.clips, props.currentTimeSec, overlayInserts],
+ () => locateVirtualPosition(props.clips, props.currentTimeSec),
+ [props.clips, props.currentTimeSec],
);
const cameraTrack = useMemo(
- () => resolveActiveCameraTrack(assets ?? [], props.clips, props.currentTimeSec, overlayInserts),
- [assets, props.clips, props.currentTimeSec, overlayInserts],
+ () => resolveActiveCameraTrack(assets ?? [], props.clips, props.currentTimeSec),
+ [assets, props.clips, props.currentTimeSec],
);
const cameraTime = useMemo(() => {
@@ -88,8 +81,6 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
// re-creating the loop on every document mutation.
const clipsRef = useRef(props.clips);
clipsRef.current = props.clips;
- const insertsRef = useRef(overlayInserts);
- insertsRef.current = overlayInserts;
const assetsRef = useRef(assets);
assetsRef.current = assets;
@@ -106,12 +97,11 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
raf = window.requestAnimationFrame(tick);
const clock = clockRef.current;
const clipsNow = clipsRef.current;
- const positionNow = locateVirtualPosition(clipsNow, clock.virtualTimeSec, insertsRef.current);
+ const positionNow = locateVirtualPosition(clipsNow, clock.virtualTimeSec);
const trackNow = resolveActiveCameraTrack(
assetsRef.current ?? [],
clipsNow,
clock.virtualTimeSec,
- insertsRef.current,
);
const target = resolveCameraSyncTarget(
clock,
diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx
index 2700087af..254da35f2 100644
--- a/src/components/ai-edition/v4/FloatingInspector.tsx
+++ b/src/components/ai-edition/v4/FloatingInspector.tsx
@@ -990,7 +990,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
// the group's, not the clicked row's. Deleting no longer needs the same expansion here:
// `removeRegion` drops the whole pill for every kind (`dropTrimPillsByIds`), which is
// what this pane used to have to arrange for itself.
- const trimGroup = coalescedTrimGroups(tl.trimRanges, tl.clips, tl.insertRanges ?? []).find((g) =>
+ const trimGroup = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) =>
g.ids.includes(selection.id),
);
if (!trimGroup) return null;
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 1a3b97fab..81b79d80b 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -41,7 +41,7 @@ import {
} from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe";
-import type { AxcutAudioTrack, AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutAudioTrack, AxcutClip, AxcutWord } from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionStore";
@@ -50,18 +50,11 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
-import {
- type InsertedWordMark,
- insertedWordMarks,
- rulerInserts,
-} from "@/lib/ai-edition/timeline/inserted-time";
import {
newRegionDurationSec,
setTimelineScale,
} from "@/lib/ai-edition/timeline/newRegionDuration";
-import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation";
-import { type TakePiece, takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap";
import {
coalescedTrimGroups,
@@ -226,7 +219,7 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
- /** Live scrub position in RAW seconds, when a drag is in flight. */
+ /** Live scrub position, when a drag is in flight. Takes precedence over the store. */
overrideTimeSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
@@ -257,7 +250,7 @@ const PlayheadOverlay = memo(function PlayheadOverlay({
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const pct = (((overrideTimeSec ?? storeTimeSec) / totalSec) * 100) as number;
+ const pct = ((overrideTimeSec ?? storeTimeSec) / totalSec) * 100;
return (
@@ -391,7 +384,6 @@ const AudioLanePill = memo(function AudioLanePill({
slipArmed,
outputGain,
ghost,
- pieces,
}: {
track: AxcutAudioTrack;
url: string | undefined;
@@ -432,18 +424,8 @@ const AudioLanePill = memo(function AudioLanePill({
sourceStartSec: number;
sourceEndSec: number;
} | null;
- /** The take's own walk, when it has one. Absent for music, for a looping take, and for
- * a take with no insertion — all of which draw one unbroken waveform, exactly as
- * before. */
- pieces?: readonly TakePiece[] | null;
}) {
const duration = assetDurationSec ?? track.durationSec;
- // Only a take that actually holds somewhere is drawn in pieces. Everything else keeps
- // the single waveform it has always had, so the common pill is untouched.
- const notched = pieces?.some((piece) => piece.kind === "hold") ? pieces : null;
- const pillRawStart = track.startMs / 1000;
- const pillRawSpan = Math.max(1e-6, track.endMs / 1000 - pillRawStart);
- const atPctOfPill = (rawSec: number) => ((rawSec - pillRawStart) / pillRawSpan) * 100;
return (
<>
{/* The rest of the tape, dimmed and unclickable, behind the pill — so the pill
@@ -498,50 +480,15 @@ const AudioLanePill = memo(function AudioLanePill({
style={{ left: 0 }}
onPointerDown={(e) => onStartDrag(e, track, "l")}
/>
- {notched ? (
- // A notch cut out of the fill, inside ONE outline. The take is still one
- // take — one draggable, slippable object — and the eye should read "the
- // voice stops here", not "two takes". The opposite polarity of the clip
- // lane's band, which means "the picture freezes here" (issue #560).
- notched.map((piece) => {
- const left = atPctOfPill(piece.rawStartSec);
- const width = atPctOfPill(piece.rawEndSec) - left;
- return piece.kind === "hold" ? (
-
- ) : (
-
-
-
- );
- })
- ) : (
-
- )}
+
{/* Where the file starts over, so a looping bed reads as one deliberate
repeat rather than a mystery. Only drawn when the pill actually
outruns its source — otherwise there is nothing to repeat. */}
@@ -684,15 +631,9 @@ export function V4Timeline({
// clicked instead of looking like it worked. Same question, same helper as the Layout
// pane: is a camera attached anywhere on this timeline?
const hasAnyCamera = useMemo(() => hasAnyClipWithCamera(tl.assets, clips), [tl.assets, clips]);
- // The media added words inserted, placed on the ruler. Everything below measures the
- // EXPANDED ruler — stored clip geometry plus the time those insertions add — because that
+ // The pauses added words created, placed on the ruler. Everything below measures the
+ // EXPANDED ruler — stored clip geometry plus the time those pauses add — because that
// is the film's real length and the one the playhead runs along. Stored geometry is
- // never rewritten for this: only what is drawn moves.
- // `?? []` because the key is additive: a document written before it has no insertions.
- const inserts = useMemo(
- () => rulerInserts(tl.insertRanges ?? [], clips),
- [tl.insertRanges, clips],
- );
const total = useMemo(
() =>
Math.max(
@@ -702,6 +643,8 @@ export function V4Timeline({
[clips],
);
const pctOf = useCallback((sec: number) => (sec / total) * 100, [total]);
+ /** Stored raw seconds → a percentage of the expanded ruler. */
+ const pctAt = pctOf;
const showLanes = variant === "edit";
// The visible fraction of the timeline, and what one second is worth on screen
@@ -769,11 +712,22 @@ export function V4Timeline({
// clip because each mark is positioned inside its clip's own box — it then travels with
// the clip through a reorder for free, with no ruler arithmetic of its own.
const insertedWordsByClip = useMemo(() => {
- const out = new Map();
- for (const mark of insertedWordMarks(tl.transcripts, clips, tl.insertRanges ?? [])) {
- const list = out.get(mark.clipId);
- if (list) list.push(mark);
- else out.set(mark.clipId, [mark]);
+ const byAsset = new Map();
+ for (const transcript of tl.transcripts) {
+ const added = transcript.words.filter((word) => word.source === "synth");
+ if (added.length > 0) byAsset.set(transcript.assetId, added);
+ }
+ if (byAsset.size === 0) return new Map>();
+ const out = new Map>();
+ for (const clip of clips) {
+ const words = byAsset.get(clip.assetId);
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ const span = sourceEnd - clip.sourceStartSec;
+ if (!words || span <= 0) continue;
+ const marks = words
+ .filter((word) => word.startSec >= clip.sourceStartSec && word.startSec <= sourceEnd)
+ .map((word) => ({ word, atPct: ((word.startSec - clip.sourceStartSec) / span) * 100 }));
+ if (marks.length > 0) out.set(clip.id, marks);
}
return out;
}, [tl.transcripts, clips]);
@@ -783,11 +737,7 @@ export function V4Timeline({
// coalesced into one pill. This is what makes growing a trim across a
// junction look like one continuously-growing pill instead of visibly
// splitting, aligning trims with how zoom/speed/annotation already behave.
- const trimPills: LanePill[] = coalescedTrimGroups(
- tl.trimRanges,
- clips,
- tl.insertRanges ?? [],
- ).map((g) => ({
+ const trimPills: LanePill[] = coalescedTrimGroups(tl.trimRanges, clips).map((g) => ({
id: g.ids[0],
kind: "trim",
start: g.start,
@@ -840,13 +790,6 @@ export function V4Timeline({
if (!el) return;
const r = el.getBoundingClientRect();
const pct = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
- // `total` is the EXPANDED ruler, so `pct * total` is a ruler second — and
- // `setCurrentTime` is read as a RAW one by every consumer: the preview seek, the
- // caption lookup, the transcript cue, the audio mix. Writing the ruler value
- // straight in put the playhead one accumulated insertion AHEAD of everything it was
- // supposed to be pointing at, which is what showed as the wrong subtitle under a
- // correctly-placed playhead (issue #560).
- //
const targetTime = pct * total;
// Direct DOM playhead update (0ms latency, zero React re-render overhead)
@@ -1013,7 +956,7 @@ export function V4Timeline({
let ranges = ventilateTimelineSpanToTrims(s, en, clips);
if (ranges.length === 0) {
// Span sits in a gap / past the end: fall back to the nearest clip.
- const resolved = resolveTimelineSpanToTrim(s, en, clips, tl.insertRanges ?? []);
+ const resolved = resolveTimelineSpanToTrim(s, en, clips);
if (!resolved) return;
ranges = [resolved];
}
@@ -1095,30 +1038,6 @@ export function V4Timeline({
// it keeps a document written before that rule legible instead of stacking its pills on
// top of each other. A kind with no tracks takes no row, so the common single-bed
// project stays exactly as tall as it was.
- // One walk per take, for the lane to draw. Same inputs the preview and the export use,
- // so a notch cannot appear where the voice does not actually stop.
- const takePieces = useMemo(() => {
- const clipAssetIds = new Set(clips.map((c) => c.assetId));
- const removed = removedRawSpans(clips, tl.trimRanges, tl.insertRanges ?? []);
- const out = new Map();
- for (const pill of audioPills) {
- if (pill.kind !== "voiceover" || pill.loop) continue;
- // A range naming an asset that is no clip's is a take's — the same test
- // `resolveInsertPlacement` makes, available here without a document.
- const inserts = (tl.insertRanges ?? [])
- .filter((range) => range.assetId === pill.assetId && !clipAssetIds.has(range.assetId))
- .map((range) => ({
- id: range.id,
- wordId: range.wordId,
- atSourceSec: range.atSec,
- durationSec: range.durationSec,
- }));
- if (inserts.length === 0) continue;
- out.set(pill.id, takeProgramme(pill, removed, inserts));
- }
- return out;
- }, [audioPills, clips, tl.trimRanges, tl.insertRanges]);
-
const audioRows = useMemo(() => {
const voice = audioPills.filter((p) => p.kind === "voiceover");
const music = audioPills.filter((p) => p.kind !== "voiceover");
@@ -1698,9 +1617,9 @@ export function V4Timeline({
compact ? ` ${styles.lanePillCompact}` : ""
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
- left: `${pctOf(seg.segStart)}%`,
- // Measured on the expanded ruler at BOTH ends: a region straddling an insertion
- // covers it, so its box has to grow by that insertion and not merely slide.
+ left: `${pctAt(seg.segStart)}%`,
+ // Measured on the expanded ruler at BOTH ends: a region straddling a pause
+ // covers it, so its box has to grow by that pause and not merely slide.
width: `${pctOf(seg.segEnd - seg.segStart)}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
@@ -2112,7 +2031,7 @@ export function V4Timeline({
{tick.major ? (
{fmtTick(tick.sec, rulerTicks.step)}
@@ -2198,10 +2117,6 @@ export function V4Timeline({
track={track}
url={asset ? toFileUrl(asset.originalPath) : undefined}
assetDurationSec={duration}
- // `pctAt`, not `pctOf`: the clip boxes are drawn on the EXPANDED
- // ruler and the audio pills were drawn on the stored one, so any
- // insertion in the film slid the two lanes apart. The take keeps its
- // own length — only its head follows the ruler.
leftPct={pctOf(start)}
widthPct={pctOf(widthSec)}
row={audioRows.rowOf.get(track.id) ?? 0}
@@ -2219,7 +2134,6 @@ export function V4Timeline({
slipHint={ts("audioTrack.slipHint")}
slipArmed={slipArmed}
outputGain={audioGainScalar(settings.audioGainDb)}
- pieces={takePieces.get(track.id) ?? null}
ghost={((g) =>
g
? {
@@ -2264,7 +2178,7 @@ export function V4Timeline({
>
{clips.map((c, i) => {
const dur = c.timelineEndSec - c.timelineStartSec;
- // On the expanded ruler the box also carries whatever insertions fall
+ // On the expanded ruler the box also carries whatever pauses fall
// inside it — the film really does stay on this clip's frame for
// them, so they belong to its box rather than between boxes.
const boxStart = c.timelineStartSec;
@@ -2345,39 +2259,29 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
- {(insertedWordsByClip.get(c.id) ?? []).map(({ wordId, text, atRawSec }) => {
- // A word whose insertion the film plays gets a BAND as wide as the time
- // it adds — that width IS the added time, drawn. One that fitted in
- // silence already there adds nothing and stays a hairline.
- //
- // Both ends on ONE clock. The mark used to place a paused word on
- // the expanded ruler and an unpaused one at a fraction of the clip's
- // SOURCE span, in the same ternary — two clocks, one of which the
- // box is not drawn in.
- const inserted = inserts.find((ins) => ins.wordId === wordId);
- const left = ((atRawSec - boxStart) / boxLen) * 100;
- const width = inserted ? (inserted.durationSec / boxLen) * 100 : 0;
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => {
+ const left = atPct;
+ const width = 0;
return (
0
? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
: { left: `${left}%` }
}
- title={t("toolbar.addedWord", { word: text })}
- aria-label={t("toolbar.addedWord", { word: text })}
+ title={t("toolbar.addedWord", { word: word.text })}
+ aria-label={t("toolbar.addedWord", { word: word.text })}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
// Jump to the moment the added text sits on. The clip box
// underneath would otherwise take this as a selection.
e.stopPropagation();
- setCurrentTime(atRawSec);
+ setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
}}
/>
);
diff --git a/src/lib/ai-edition/captions/captionLane.test.ts b/src/lib/ai-edition/captions/captionLane.test.ts
index 016f95f18..dfc3f5d88 100644
--- a/src/lib/ai-edition/captions/captionLane.test.ts
+++ b/src/lib/ai-edition/captions/captionLane.test.ts
@@ -141,34 +141,4 @@ describe("captionLane", () => {
});
expect(texts(corrected, "voiceover")).toContain("Kubernetes words");
});
-
- it("leaves a take's cues alone when the FILM gains an insertion", () => {
- // An insertion is media inside the clip that carries it, and it lengthens that clip.
- // A take laid over the film keeps its own position on the timeline — the picture
- // slides underneath it — so its cues do not move either. Measured per placement,
- // through the asset the placement actually plays.
- const paused = doc({
- timeline: {
- ...doc().timeline,
- insertRanges: [
- {
- id: "i1",
- assetId: "rec",
- atSec: 1,
- durationSec: 1,
- wordId: "x",
- reason: "",
- origin: "user",
- },
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
- ] as any,
- },
- });
- const before = deriveCaptionCues(doc(), on("voiceover"), {});
- const after = deriveCaptionCues(paused, on("voiceover"), {});
- // The insertion names the RECORDING's asset. The take is a different asset laid at
- // its own timeline position, so nothing about this cue changes.
- expect(after[0].startMs).toBe(before[0].startMs);
- expect(after[0].endMs).toBe(before[0].endMs);
- });
});
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 46b04de83..ce3d4a961 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,167 +650,3 @@ describe("translated caption layout", () => {
);
});
});
-
-// ─── Captions across an added word's pause ───────────────────────
-// The pause lengthens the ruler, so every line after it slides — and the line the pause
-// exists FOR has to stay on screen through it rather than going dark over the one moment
-// an added word is there for.
-
-describe("captions and a pause", () => {
- function withPause(): AxcutDocument {
- const base = doc();
- return {
- ...base,
- timeline: {
- ...base.timeline,
- insertRanges: [
- {
- id: "ins_1",
- assetId: "asset-1",
- // Inside "hello there friend" (0–2s), so the line covers it.
- atSec: 1.2,
- durationSec: 0.5,
- wordId: "synth_1",
- reason: "",
- origin: "user" as const,
- },
- ],
- },
- };
- }
-
- it("keeps the covering line up through the pause instead of cutting it short", () => {
- const before = deriveCaptionCues(doc(), ON, {});
- const after = deriveCaptionCues(withPause(), ON, {});
- const line = (cues: typeof before) => cues.find((cue) => cue.text.includes("hello"));
- expect(line(after)?.startMs).toBe(line(before)?.startMs);
- // Half a second longer: exactly the pause it now spans.
- expect((line(after)?.endMs ?? 0) - (line(before)?.endMs ?? 0)).toBe(500);
- });
-
- it("slides everything after the pause along by it", () => {
- const before = deriveCaptionCues(doc(), ON, {});
- const after = deriveCaptionCues(withPause(), ON, {});
- const later = (cues: typeof before) => cues.find((cue) => cue.text.includes("goodbye"));
- expect((later(after)?.startMs ?? 0) - (later(before)?.startMs ?? 0)).toBe(500);
- });
-
- it("is unchanged when the project has no pauses", () => {
- expect(deriveCaptionCues(doc(), ON, {})).toEqual(deriveCaptionCues(doc(), ON, {}));
- });
-});
-
-// ─── An added word is spoken over the media it inserted ─────────────────────
-// Lines are grouped by word count and by silences, in SOURCE time. An added word barely
-// takes up source time — the seconds it is spoken in are the INSERTION that follows it —
-// so it was swallowed into the line of the words before it and inherited their start. On
-// screen the added words appeared while the recorded picture was still playing, a whole
-// insertion early (issue #560).
-
-describe("a caption line never mixes recorded words with added ones", () => {
- function docWithAddedWord(): AxcutDocument {
- const t = transcript();
- // "really" typed in after "friend", which ends at source 2. An added word takes up NO
- // source time — the seconds it is spoken in are the insertion it buys, which is how
- // the real documents store it — so its span is degenerate at the moment it follows.
- // The next recorded word begins at the added word's own second — the shape every
- // real document has, because an added word is anchored at the END of the word it
- // follows and the transcript's next word starts there.
- t.words = [
- ...t.words.slice(0, 3),
- {
- id: "synth_1",
- segmentId: "seg_1",
- startSec: 2,
- endSec: 2,
- text: "really",
- source: "synth",
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
- } as any,
- // Begins at the added word's own second — that adjacency is the whole defect.
- ...t.words.slice(3).map((w) => ({ ...w, startSec: w.startSec - 2, endSec: w.endSec - 2 })),
- ];
- const base = doc();
- return {
- ...base,
- transcripts: [t],
- timeline: {
- ...base.timeline,
- clips: [{ ...base.timeline.clips[0], timelineEndSec: 12 }],
- insertRanges: [
- {
- id: "i1",
- assetId: "asset-1",
- atSec: 2,
- durationSec: 2,
- wordId: "synth_1",
- reason: "",
- origin: "user",
- },
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
- ] as any,
- },
- };
- }
-
- const settings: CaptionSettings = { ...DEFAULT_CAPTION_SETTINGS, enabled: true };
-
- it("gives the added word its own cue, starting where the recorded words stop", () => {
- const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
- const added = cues.filter((c) => c.text.includes("really"));
- expect(added).toHaveLength(1);
- // It must not carry the recorded words with it — that is the whole defect.
- expect(added[0].text.toLowerCase()).not.toContain("hello");
- // And it opens at the recorded words' end, not at their start.
- expect(added[0].startMs).toBeGreaterThanOrEqual(2000);
- });
-
- it("lays the three lines out end to end across the insertion", () => {
- // The whole rule in one assertion set: the recorded line stops where the added one
- // begins, the added one spans the media it bought, and what follows is pushed along
- // by exactly that length.
- const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
- const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
- const added = cues.find((c) => c.text.includes("really"));
- const after = cues.find((c) => c.text.includes("goodbye"));
- expect(recorded).toBeDefined();
- expect(added).toBeDefined();
- expect(after).toBeDefined();
- // The insertion opens at ruler 2000 and runs 2000ms. Three consecutive facts:
- // the recorded line STOPS there, the added line COVERS it, and what follows is
- // pushed along by exactly its length.
- expect(recorded?.endMs).toBe(2000);
- expect(added?.startMs).toBe(2000);
- expect(added?.endMs).toBe(4000);
- expect(after?.startMs).toBe(4000);
- });
-
- it("leaves the recorded line ending before the added one begins", () => {
- const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
- const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
- const added = cues.find((c) => c.text.includes("really"));
- expect(recorded).toBeDefined();
- expect(added).toBeDefined();
- expect(recorded?.text).not.toContain("really");
- expect(added?.startMs ?? 0).toBeGreaterThanOrEqual((recorded?.startMs ?? 0) + 1);
- });
-
- it("never prints two cues at once", () => {
- // The symptom this whole rule exists for: the recorded line that follows an added
- // word begins at the added word's own source second, so mapped before the insertion
- // it landed inside it and the two were drawn on top of each other.
- const cues = [...deriveCaptionCues(docWithAddedWord(), settings, {})].sort(
- (a, b) => a.startMs - b.startMs,
- );
- expect(cues.length).toBeGreaterThan(1);
- for (const [i, cue] of cues.slice(0, -1).entries()) {
- expect(cue.endMs).toBeLessThanOrEqual(cues[i + 1].startMs);
- }
- });
-
- it("changes nothing when the transcript has no added words", () => {
- const before = deriveCaptionCues(doc(), settings, {});
- expect(before.some((c) => c.text.toLowerCase().includes("hello"))).toBe(true);
- expect(before.every((c) => !c.text.includes("really"))).toBe(true);
- });
-});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 87e9e3f1b..4d7395063 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -22,10 +22,8 @@ import {
splitMergedCaptionsByWordBounds,
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
-import type { AxcutDocument, AxcutInsertRange, AxcutTranscript } from "../schema";
+import type { AxcutDocument, AxcutTranscript } from "../schema";
import { lanePlacements, type TranscriptPlacement } from "../timeline/aggregated-transcript";
-import { takeInserts } from "../timeline/insert-mapping";
-import { sourceToTimelineSec } from "../timeline/inserted-time";
import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
@@ -100,7 +98,7 @@ export function captionLinesForAsset(
transcript: AxcutTranscript,
settings: CaptionSettings,
translations: CaptionTranslations,
-): CaptionLine[] {
+): CaptionSegment[] {
const minWords = settings.minWordsPerLine;
const maxWords = settings.maxWordsPerLine;
@@ -117,76 +115,7 @@ export function captionLinesForAsset(
: translatedWordStream(transcript, translations, settings.language);
if (stream.length === 0) return [];
- // Grouped per RUN, never across one. A run is a maximal stretch of words that are all
- // recorded or all added: the two are spoken over different media — the recording, and the
- // insertion that added word bought — so a line holding both would have to be in two places
- // at once, and resolved as one it lands on the recording, an insertion too early.
- const added = addedWordSpans(transcript);
- // Polished per RUN, so the flag survives and so the finaliser's neighbours are all the
- // same kind. Across a run boundary the two lines share a source second and the finaliser
- // cannot tell them apart; `deoverlapCues` settles that on the ruler instead, where the
- // insertion has a width.
- return captionRuns(stream, added).flatMap((run) => {
- const isAdded = run.length > 0 && overlapsAdded(run[0], added);
- return polish(groupTimedCaptionWordsIntoLines(run, minWords, maxWords)).map((line) => ({
- ...line,
- added: isAdded,
- }));
- });
-}
-
-function overlapsAdded(
- word: CaptionSegment,
- added: Array<{ startSec: number; endSec: number }>,
-): boolean {
- return added.some(
- (span) =>
- (word.startSec < span.endSec && word.endSec > span.startSec) ||
- // An added word's source span is DEGENERATE — the seconds it is spoken in are the
- // insertion, not the recording — so a strict overlap never matches it.
- (word.startSec >= span.startSec && word.endSec <= span.endSec),
- );
-}
-
-/** A caption line, and whether it is spoken over inserted media rather than the recording.
- * Only the grouping pass knows which is which, and every edge below depends on it. */
-export type CaptionLine = CaptionSegment & { added: boolean };
-
-/** Where the transcript's ADDED words sit, in source time. */
-function addedWordSpans(transcript: AxcutTranscript): Array<{ startSec: number; endSec: number }> {
- return transcript.words
- .filter((word) => word.source === "synth" && word.text.trim().length > 0)
- .map((word) => ({ startSec: word.startSec, endSec: word.endSec }))
- .sort((a, b) => a.startSec - b.startSec);
-}
-
-/**
- * The stream cut into maximal all-recorded / all-added runs.
- *
- * Membership is decided by OVERLAP with an added word's source span rather than by identity,
- * because a translated stream is rebuilt as pseudo-words and no longer carries the original
- * ids — the spans are the one thing both streams keep.
- */
-function captionRuns(
- stream: CaptionSegment[],
- added: Array<{ startSec: number; endSec: number }>,
-): CaptionSegment[][] {
- if (added.length === 0) return [stream];
- const isAdded = (word: CaptionSegment) => overlapsAdded(word, added);
- const runs: CaptionSegment[][] = [];
- let current: CaptionSegment[] = [];
- let currentIsAdded: boolean | null = null;
- for (const word of stream) {
- const flag = isAdded(word);
- if (currentIsAdded !== null && flag !== currentIsAdded) {
- runs.push(current);
- current = [];
- }
- currentIsAdded = flag;
- current.push(word);
- }
- if (current.length > 0) runs.push(current);
- return runs;
+ return polish(groupTimedCaptionWordsIntoLines(stream, minWords, maxWords));
}
function originalWordStream(transcript: AxcutTranscript): CaptionSegment[] {
@@ -234,10 +163,6 @@ export function sourceSpanToTimelineSpans(
* window and the ruler head, which both providers carry (issue #560). `AxcutClip`
* stays structurally assignable, so every existing caller is unaffected. */
clips: TranscriptPlacement[],
- inserts: readonly AxcutInsertRange[] = [],
- /** True for a span spoken over INSERTED media rather than the recording. The two occupy
- * opposite sides of the same source second, so every edge flips with it. */
- overInsertedMedia = false,
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -247,17 +172,11 @@ export function sourceSpanToTimelineSpans(
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
out.push({
- // An ADDED span IS the insertion: it opens before the inserted media and closes
- // after it, which is exactly the stretch of ruler that media occupies. A RECORDED
- // span lives BETWEEN insertions, so both its edges are the other way round — and
- // its START is the one that matters, because an added word is anchored at the END
- // of the word it follows and the next recorded word begins at that very second.
- // Mapped with the opening edge, that line landed inside the insertion, printed on
- // top of the added one.
- startSec: sourceToTimelineSec(clip, s, inserts, overInsertedMedia ? "opens" : "closes"),
- endSec: sourceToTimelineSec(clip, e, inserts, overInsertedMedia ? "closes" : "opens"),
+ startSec: clip.timelineStartSec + (s - clip.sourceStartSec),
+ endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
});
}
+ // Onto the ruler the viewer actually sees. Expanding BOTH ends does the whole job:
return out;
}
@@ -281,12 +200,7 @@ export function deriveCaptionCues(
// `?? []` for the same reason `insertRanges` has one: the key is additive, so a
// document written before it — or hand-built, never through the schema — has none.
document.audioTracks ?? [],
- removedRawSpans(
- document.timeline.clips,
- document.timeline.trimRanges,
- document.timeline.insertRanges ?? [],
- ),
- (groupId) => takeInserts(document, groupId),
+ removedRawSpans(document.timeline.clips, document.timeline.trimRanges),
);
if (placements.length === 0) return [];
@@ -299,7 +213,7 @@ export function deriveCaptionCues(
// every voiceover cue early.
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
- const linesByAsset = new Map();
+ const linesByAsset = new Map();
const cues: CaptionCue[] = [];
let n = 0;
@@ -318,8 +232,6 @@ export function deriveCaptionCues(
line.startSec,
line.endSec,
placements,
- document.timeline.insertRanges ?? [],
- line.added,
)) {
const startMs = Math.round(span.startSec * 1000);
const endMs = Math.max(Math.round(span.endSec * 1000), startMs + 1);
diff --git a/src/lib/ai-edition/document/load.test.ts b/src/lib/ai-edition/document/load.test.ts
deleted file mode 100644
index e6f8b3b2f..000000000
--- a/src/lib/ai-edition/document/load.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-// A document written before insertions took up time on the timeline.
-//
-// Nothing else reconciles it: `withInsertRangesForWords` only runs when a transcript word is
-// written, so a project the user merely OPENS keeps its old geometry while all the code
-// around it assumes the new — the film's ruler stops short, the insertion pills are drawn at
-// their source position, and the subtitles slide further out of step with every insertion
-// passed (issue #560).
-
-import { describe, expect, it } from "vitest";
-import type { AxcutClip, AxcutDocument, AxcutInsertRange } from "../schema";
-import { reconcileClipsWithInserts, reconcileInsertions } from "./load";
-
-function clip(over: Partial & { id: string }): AxcutClip {
- return {
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user",
- reason: "",
- ...over,
- };
-}
-
-const insert = (over: Partial & { id: string }): AxcutInsertRange => ({
- assetId: "a1",
- atSec: 5,
- durationSec: 1,
- wordId: "w1",
- reason: "",
- origin: "user",
- ...over,
-});
-
-function doc(clips: AxcutClip[], insertRanges: AxcutInsertRange[]): AxcutDocument {
- return { timeline: { clips, insertRanges } } as unknown as AxcutDocument;
-}
-
-describe("reconcileClipsWithInserts", () => {
- it("gives a short clip back the time its insertions take", () => {
- const before = doc([clip({ id: "c1" })], [insert({ id: "i1" })]);
- const [after] = reconcileClipsWithInserts(before).timeline.clips;
- expect(after.timelineEndSec - after.timelineStartSec).toBeCloseTo(11, 6);
- // The recording is untouched: no frame was added to or taken from the file.
- expect(after.sourceStartSec).toBe(0);
- expect(after.sourceEndSec).toBe(10);
- });
-
- it("pushes every later clip along by what the one before it gained", () => {
- const before = doc(
- [clip({ id: "c1" }), clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 })],
- [insert({ id: "i1" })],
- );
- const [, second] = reconcileClipsWithInserts(before).timeline.clips;
- expect(second.timelineStartSec).toBeCloseTo(11, 6);
- expect(second.timelineEndSec).toBeCloseTo(21, 6);
- });
-
- it("is idempotent, so it can run on every load", () => {
- const before = doc([clip({ id: "c1" })], [insert({ id: "i1" })]);
- const once = reconcileClipsWithInserts(before);
- const twice = reconcileClipsWithInserts(once);
- expect(twice.timeline.clips).toEqual(once.timeline.clips);
- // And a document already in step is returned as-is, not rebuilt.
- expect(twice).toBe(once);
- });
-
- it("leaves a document with no insertions completely alone", () => {
- const before = doc([clip({ id: "c1" })], []);
- expect(reconcileClipsWithInserts(before)).toBe(before);
- });
-
- it("counts several insertions in one clip, and only that clip's", () => {
- const before = doc(
- [
- clip({ id: "c1" }),
- clip({ id: "c2", assetId: "a2", timelineStartSec: 10, timelineEndSec: 20 }),
- ],
- [insert({ id: "i1", atSec: 3 }), insert({ id: "i2", atSec: 7, durationSec: 0.5 })],
- );
- const [first, second] = reconcileClipsWithInserts(before).timeline.clips;
- expect(first.timelineEndSec).toBeCloseTo(11.5, 6);
- expect(second.timelineEndSec - second.timelineStartSec).toBeCloseTo(10, 6);
- });
-});
-
-// ─── An added word nobody marked ────────────────────────────────────────────
-// `source: "synth"` is how the whole pipeline recognises a word the user typed: it decides
-// whether the word gets an insertion, whether the film makes room for it, and whether the
-// caption line breaks around it. A row minted before that field existed answers no to all
-// three — its text plays over the recording and everything after it drifts. Found in the
-// live project: `synth_1`, zero-width at source 9.15, with no insertion at all (issue #560).
-
-describe("reconcileInsertions", () => {
- function docWithUnmarkedWord(): AxcutDocument {
- return {
- assets: [{ id: "a1", kind: "video" }],
- transcripts: [
- {
- assetId: "a1",
- language: "en",
- segments: [{ id: "s1", kind: "speech", startSec: 0, endSec: 6, text: "x", wordIds: [] }],
- words: [
- { id: "w1", segmentId: "s1", startSec: 0, endSec: 1, text: "hello" },
- // Minted as an added word — the id says so — but never marked.
- { id: "synth_1", segmentId: "s1", startSec: 1, endSec: 1, text: "a much longer thing" },
- ],
- },
- ],
- timeline: { clips: [clip({ id: "c1" })], insertRanges: [] },
- } as unknown as AxcutDocument;
- }
-
- it("marks it, gives it an insertion, and makes room for it", () => {
- const out = reconcileInsertions(docWithUnmarkedWord());
- const word = out.transcripts[0].words.find((w) => w.id === "synth_1");
- expect(word?.source).toBe("synth");
- const range = out.timeline.insertRanges?.find((r) => r.wordId === "synth_1");
- expect(range).toBeDefined();
- expect(range?.durationSec ?? 0).toBeGreaterThan(0);
- const [c] = out.timeline.clips;
- expect(c.timelineEndSec - c.timelineStartSec).toBeCloseTo(10 + (range?.durationSec ?? 0), 6);
- });
-
- it("leaves a word that was never added alone", () => {
- const out = reconcileInsertions(docWithUnmarkedWord());
- expect(out.transcripts[0].words.find((w) => w.id === "w1")?.source).toBeUndefined();
- });
-
- it("is idempotent", () => {
- const once = reconcileInsertions(docWithUnmarkedWord());
- const twice = reconcileInsertions(once);
- expect(twice.timeline.clips).toEqual(once.timeline.clips);
- expect(twice.timeline.insertRanges).toEqual(once.timeline.insertRanges);
- });
-});
diff --git a/src/lib/ai-edition/document/load.ts b/src/lib/ai-edition/document/load.ts
deleted file mode 100644
index a71b81e95..000000000
--- a/src/lib/ai-edition/document/load.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-// The one way to turn a document on disk into a document in memory.
-//
-// Three steps, in this order, and no caller may do two of them and skip the third:
-//
-// 1. UPGRADE — `migrateRawDocumentToCurrent` walks the vN → vN+1 chain.
-// 2. VALIDATE — `documentSchema.parse` is a pure current-version shape check.
-// 3. RECONCILE — clip geometry is brought back in line with the insert ranges.
-//
-// Step 3 is the one that is easy to forget and impossible to notice. An insertion is MEDIA
-// inside a clip (issue #560), so a clip carrying one is longer than its source window by
-// exactly that much — every reader downstream depends on it, and a document written before
-// that was true carries SHORT clips. Nothing else reconciles them: `withInsertRangesForWords`
-// only runs when a transcript word is written, so a project the user merely OPENS keeps its
-// old geometry while all the code around it assumes the new. The visible result is a film
-// whose ruler stops short, insertion pills drawn at their source position instead of their
-// timeline one, and subtitles sliding further out of step with every insertion passed.
-//
-// `reflowClipsForInserts` is absolute rather than incremental, so this is idempotent: a
-// document already in step is returned unchanged, and running it on every load costs nothing
-// while also repairing anything that writes clip geometry without allowing for insertions.
-
-import type { AxcutDocument } from "../schema";
-import { documentSchema, migrateRawDocumentToCurrent } from "../schema";
-import { reflowClipsForInserts } from "./timeline";
-import { withInsertRangesForAllWords, withMarkedAddedWords } from "./transcript";
-
-/**
- * The whole insertion invariant, in dependency order.
- *
- * A word is ADDED, an added word has an INSERTION, and a clip carrying insertions is
- * LONGER. Each step feeds the next, and reconciling only the last one left a document
- * carrying an unmarked added word looking perfectly consistent while playing its text over
- * the recording. Every step is idempotent, so this runs on every load and changes nothing
- * for a document already in step.
- */
-export function reconcileInsertions(document: AxcutDocument): AxcutDocument {
- return reconcileClipsWithInserts(withInsertRangesForAllWords(withMarkedAddedWords(document)));
-}
-
-/** Clip geometry brought back in line with the document's insert ranges. Idempotent. */
-export function reconcileClipsWithInserts(document: AxcutDocument): AxcutDocument {
- const insertRanges = document.timeline.insertRanges ?? [];
- if (insertRanges.length === 0) return document;
- const clips = reflowClipsForInserts(document.timeline.clips, insertRanges);
- const unchanged =
- clips.length === document.timeline.clips.length &&
- clips.every((clip, i) => {
- const was = document.timeline.clips[i];
- return (
- Math.abs(clip.timelineStartSec - was.timelineStartSec) < 1e-9 &&
- Math.abs(clip.timelineEndSec - was.timelineEndSec) < 1e-9
- );
- });
- return unchanged ? document : { ...document, timeline: { ...document.timeline, clips } };
-}
-
-/** Raw JSON (any stored version) → a validated, reconciled document. */
-export function parseStoredDocument(raw: unknown): AxcutDocument {
- return reconcileInsertions(documentSchema.parse(migrateRawDocumentToCurrent(raw)));
-}
diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts
index 8d06f6b85..9315eb8c7 100644
--- a/src/lib/ai-edition/document/migrate.ts
+++ b/src/lib/ai-edition/document/migrate.ts
@@ -28,9 +28,10 @@ import {
type AxcutLegacyEditor,
type AxcutTrimRange,
type AxcutZoomRegion,
+ documentSchema,
+ migrateRawDocumentToCurrent,
} from "../schema";
import { createId } from "./ids";
-import { parseStoredDocument } from "./load";
const MS_TO_SEC = 1 / 1000;
const SEC_TO_MS = 1000;
@@ -249,7 +250,7 @@ export function migrateProjectDataToAxcutDocument(
legacyEditor,
};
- return parseStoredDocument(draft);
+ return documentSchema.parse(migrateRawDocumentToCurrent(draft));
}
/**
diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts
index 2a614edd0..c7e75ab40 100644
--- a/src/lib/ai-edition/document/outputFormat.test.ts
+++ b/src/lib/ai-edition/document/outputFormat.test.ts
@@ -61,7 +61,6 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index eabccabd8..9cbdbc5ec 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
type AxcutClip,
type AxcutDocument,
- type AxcutInsertRange,
type AxcutTrimRange,
axcutSchemaVersion,
} from "../schema";
@@ -53,7 +52,6 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -214,7 +212,6 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -312,7 +309,6 @@ describe("timeline pure functions", () => {
clips: [],
gaps: [],
trimRanges: [makeTrim({ id: "trim_other", assetId: "asset_2", startSec: 1, endSec: 2 })],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -414,7 +410,6 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -540,7 +535,6 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -617,7 +611,6 @@ describe("timeline pure functions", () => {
trimRanges: [
{ id: "s1", assetId: "asset_1", startSec: 10, endSec: 20, origin: "user", reason: "" },
],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -655,7 +648,6 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -861,22 +853,22 @@ describe("projectRawTimelineSecToPlayback (issue #350 audio-track/trim sync)", (
const trim = makeTrim({ startSec: 2, endSec: 4 });
it("is the identity when there are no trims", () => {
- expect(projectRawTimelineSecToPlayback([clip], [], 6, [])).toBeCloseTo(6, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 6)).toBeCloseTo(6, 6);
});
it("pulls a raw position after a cut earlier by the removed duration", () => {
// Raw 6 sits 2s past the 2s cut → output 4. This is the exact bug: the track was
// landing at 6 (delayed by the trim) instead of 4.
- expect(projectRawTimelineSecToPlayback([clip], [trim], 6, [])).toBeCloseTo(4, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [trim], 6)).toBeCloseTo(4, 6);
});
it("is unaffected for a position before the cut", () => {
- expect(projectRawTimelineSecToPlayback([clip], [trim], 1, [])).toBeCloseTo(1, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [trim], 1)).toBeCloseTo(1, 6);
});
it("collapses a position inside the trimmed gap to the end of the kept content before it", () => {
// Raw 3 is inside the removed 2..4 span → the next audible sample is at output 2.
- expect(projectRawTimelineSecToPlayback([clip], [trim], 3, [])).toBeCloseTo(2, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [trim], 3)).toBeCloseTo(2, 6);
});
it("counts overlapping trims once (union, not sum)", () => {
@@ -886,7 +878,7 @@ describe("projectRawTimelineSecToPlayback (issue #350 audio-track/trim sync)", (
makeTrim({ id: "t1", startSec: 2, endSec: 5 }),
makeTrim({ id: "t2", startSec: 3, endSec: 4 }),
];
- expect(projectRawTimelineSecToPlayback([clip], trims, 6, [])).toBeCloseTo(3, 6);
+ expect(projectRawTimelineSecToPlayback([clip], trims, 6)).toBeCloseTo(3, 6);
});
it("removes a raw gap between clips (concatenated, like the programme)", () => {
@@ -906,7 +898,7 @@ describe("projectRawTimelineSecToPlayback (issue #350 audio-track/trim sync)", (
timelineStartSec: 15,
timelineEndSec: 25,
});
- expect(projectRawTimelineSecToPlayback([clipA, clipB], [], 20, [])).toBeCloseTo(15, 6);
+ expect(projectRawTimelineSecToPlayback([clipA, clipB], [], 20)).toBeCloseTo(15, 6);
});
it("sums cuts across multiple clips", () => {
@@ -930,7 +922,7 @@ describe("projectRawTimelineSecToPlayback (issue #350 audio-track/trim sync)", (
makeTrim({ id: "t2", startSec: 12, endSec: 14 }),
];
// Raw 18 is past both cuts (3s removed) → output 15.
- expect(projectRawTimelineSecToPlayback([clipA, clipB], trims, 18, [])).toBeCloseTo(15, 6);
+ expect(projectRawTimelineSecToPlayback([clipA, clipB], trims, 18)).toBeCloseTo(15, 6);
});
});
@@ -971,7 +963,6 @@ describe("duplicateClip / moveClip", () => {
...makeDoc().timeline,
clips: [makeClip({ id: "clip_a", sourceStartSec: 0, sourceEndSec: 10 })],
trimRanges: [makeTrim({ id: "t1", clipId: "clip_a", startSec: 2, endSec: 4 })],
- insertRanges: [],
},
});
const next = duplicateClip(doc, "clip_a");
@@ -1325,7 +1316,6 @@ describe("removeRegion — the one shared region-delete mutator", () => {
timeline: {
...makeDoc().timeline,
trimRanges: [makeTrim({ id: "trim_1" }), makeTrim({ id: "trim_2" })],
- insertRanges: [],
},
});
const next = removeRegion(doc, "trim", "trim_1");
@@ -1714,16 +1704,16 @@ describe("projectRawTimelineSecToPlayback with speed regions", () => {
it("halves the time a 2x stretch takes to play", () => {
// Raw 4..8 at 2x plays in 2s, so raw 8 lands at output 6.
const speed = [{ startMs: 4000, endMs: 8000, speed: 2 }];
- expect(projectRawTimelineSecToPlayback([clip], [], 4, [], speed)).toBeCloseTo(4, 6);
- expect(projectRawTimelineSecToPlayback([clip], [], 6, [], speed)).toBeCloseTo(5, 6);
- expect(projectRawTimelineSecToPlayback([clip], [], 8, [], speed)).toBeCloseTo(6, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 4, speed)).toBeCloseTo(4, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 6, speed)).toBeCloseTo(5, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 8, speed)).toBeCloseTo(6, 6);
// Everything after carries the compression with it.
- expect(projectRawTimelineSecToPlayback([clip], [], 12, [], speed)).toBeCloseTo(10, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 12, speed)).toBeCloseTo(10, 6);
});
it("stretches a slow-motion region instead", () => {
const speed = [{ startMs: 0, endMs: 4000, speed: 0.5 }];
- expect(projectRawTimelineSecToPlayback([clip], [], 4, [], speed)).toBeCloseTo(8, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 4, speed)).toBeCloseTo(8, 6);
});
it("composes with trims", () => {
@@ -1738,97 +1728,15 @@ describe("projectRawTimelineSecToPlayback with speed regions", () => {
reason: "",
};
const speed = [{ startMs: 6000, endMs: 10_000, speed: 2 }];
- expect(projectRawTimelineSecToPlayback([clip], [trim], 12, [], speed)).toBeCloseTo(8, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [trim], 12, speed)).toBeCloseTo(8, 6);
});
it("ignores a nonsense rate rather than dividing by it", () => {
const speed = [{ startMs: 0, endMs: 4000, speed: 0 }];
- expect(projectRawTimelineSecToPlayback([clip], [], 4, [], speed)).toBeCloseTo(4, 6);
+ expect(projectRawTimelineSecToPlayback([clip], [], 4, speed)).toBeCloseTo(4, 6);
});
});
// ─── The pause an added word bought ──────────────────────────────
// Created time only exists once playback honours it. These pin the one thing the record
// is for: the stream really does stay on the held frame, and the film really is longer.
-
-describe("resolvePlaybackSegments with insert ranges", () => {
- const CLIPS: AxcutClip[] = [
- {
- id: "c1",
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- ];
- const insert = (overrides: Partial = {}): AxcutInsertRange => ({
- id: "ins_1",
- assetId: "a1",
- atSec: 10,
- durationSec: 0.5,
- wordId: "synth_1",
- reason: "held",
- origin: "user",
- ...overrides,
- });
-
- it("holds the frame where the pause sits, and lengthens the stream by it", () => {
- const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
- expect(segments).toHaveLength(2);
- expect(segments[1]).toMatchObject({
- sourceStartSec: 10,
- sourceEndSec: 10,
- heldSec: 0.5,
- timelineStartSec: 10,
- timelineEndSec: 10.5,
- });
- });
-
- it("changes nothing when there is no pause", () => {
- expect(resolvePlaybackSegments(CLIPS, [], [])).toHaveLength(1);
- });
-
- // The usual case, and the one the first cut of this missed: a pause sits at the end of
- // the word it follows, which is almost never a boundary a trim happened to leave.
- it("cuts the clip open where a pause falls in the MIDDLE of it", () => {
- const segments = resolvePlaybackSegments(CLIPS, [], [insert({ atSec: 2.5 })]);
- expect(segments.map((s) => [s.sourceStartSec, s.sourceEndSec, s.heldSec])).toEqual([
- [0, 2.5, undefined],
- [2.5, 2.5, 0.5],
- [2.5, 10, undefined],
- ]);
- // 10s of film plus half a second of held frame.
- expect(segments[2].timelineEndSec).toBeCloseTo(10.5, 5);
- });
-
- // The moment the pause holds is not in the film any more, so neither is the pause.
- it("drops a pause whose moment a trim removed", () => {
- const trims: AxcutTrimRange[] = [
- { id: "t1", assetId: "a1", startSec: 4, endSec: 10, origin: "user", reason: "" },
- ];
- const segments = resolvePlaybackSegments(CLIPS, trims, [insert()]);
- expect(segments.some((s) => s.heldSec !== undefined)).toBe(false);
- });
-
- it("places a pause inside a clip between the halves a trim left", () => {
- const trims: AxcutTrimRange[] = [
- { id: "t1", assetId: "a1", startSec: 4, endSec: 6, origin: "user", reason: "" },
- ];
- const segments = resolvePlaybackSegments(CLIPS, trims, [insert({ atSec: 4 })]);
- expect(segments.map((s) => s.heldSec)).toEqual([undefined, 0.5, undefined]);
- // The stream is the kept film plus the pause: 4s + 0.5s + 4s.
- expect(segments[segments.length - 1].timelineEndSec).toBeCloseTo(8.5, 5);
- });
-
- it("never writes the held flag onto a stored clip", () => {
- // The field lives on the derived segment only; that is the whole difference from
- // the attempt that made clips for it.
- const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
- expect(CLIPS[0]).not.toHaveProperty("heldSec");
- expect(segments[0]).not.toHaveProperty("heldSec");
- });
-});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index c52ae3b6f..ae26d7fb7 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -3,25 +3,18 @@
// (store, exporter, agent) feeds an AxcutDocument and gets back intervals
// or a new document with updated clips.
-import type {
- AxcutClip,
- AxcutDocument,
- AxcutInsertRange,
- AxcutTranscript,
- AxcutTrimRange,
-} from "../schema";
+import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "../schema";
/**
* What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
- * one thing a stored clip can never carry — `heldSec`, the media an added word inserted.
+ * one thing a stored clip can never carry — `heldSec`, the pause an added word created.
*
* A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
* The field lives only on this derived shape, never on `clipSchema`, so nothing can write
* one to disk — which is the whole difference from the attempt that made clips for it.
*/
-export type PlaybackSegment = AxcutClip & { heldSec?: number };
+export type PlaybackSegment = AxcutClip;
-import { assignInsertsToClips } from "../timeline/inserted-time";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -136,41 +129,6 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
-/**
- * Clip geometry that accounts for the media inserted inside each clip (issue #560).
- *
- * An added word inserts media — a fixed frame and silence, until there is a generator for
- * it — and a clip carrying it is that much longer, exactly as it would be if the media had
- * come from a file. This is the ONE place that says so; every reader downstream then works
- * in a single coordinate, which is what makes the playhead, the native decoder and the
- * export agree without any of them converting between two rulers.
- *
- * Absolute rather than incremental, so it is idempotent: a stored clip's length is always
- * its source length (every writer above builds it that way), and re-running this on an
- * already-reflowed document changes nothing. That is what lets it also serve as the
- * migration for documents written before insertions existed.
- */
-export function reflowClipsForInserts(
- clips: AxcutClip[],
- insertRanges: readonly AxcutInsertRange[],
-): AxcutClip[] {
- // Through `assignInsertsToClips`, so the length a clip gains and the pills drawn inside it
- // come from the same assignment. They used to be computed separately, and with two clips
- // over one recording the film grew twice for an insertion drawn once.
- const byClip = assignInsertsToClips(clips, insertRanges);
- return resequenceClips(
- clips.map((clip) => {
- const sourceLen = (clip.sourceEndSec ?? clip.sourceStartSec) - clip.sourceStartSec;
- if (sourceLen <= 0) return clip; // duration not probed yet; leave it to the prober
- const owed = (byClip.get(clip.id) ?? []).reduce((sum, r) => sum + r.durationSec, 0);
- return {
- ...clip,
- timelineEndSec: clip.timelineStartSec + sourceLen + owed,
- };
- }),
- );
-}
-
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
return clips.map((c) => {
@@ -202,32 +160,10 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
- insertRanges: readonly AxcutInsertRange[] = [],
): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
const result: PlaybackSegment[] = [];
let timelineCursor = 0;
- // The media added words insert, in the order they will be met. Consumed as the walk
- // passes each one's moment, so an insertion inside a span a trim removed is never reached —
- // which is right: the moment it holds is not in the film any more.
- const pending = [...insertRanges].sort((a, b) => a.atSec - b.atSec);
- const holdAt = (clip: AxcutClip, atSec: number): PlaybackSegment | null => {
- const insert = pending.find(
- (range) => range.assetId === clip.assetId && Math.abs(range.atSec - atSec) < 1e-6,
- );
- if (!insert) return null;
- pending.splice(pending.indexOf(insert), 1);
- return {
- ...clip,
- id: `${clip.id}__hold_${insert.id}`,
- sourceStartSec: atSec,
- sourceEndSec: atSec,
- timelineStartSec: 0,
- timelineEndSec: 0,
- heldSec: insert.durationSec,
- reason: insert.reason,
- };
- };
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
@@ -246,30 +182,7 @@ export function resolvePlaybackSegments(
if (!trimAppliesToClip(trim, clip)) continue;
kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
}
- // An insertion sits at the END of the word it follows, which is almost never a boundary a
- // trim happened to leave. So each kept span is cut at the moments it holds, and the
- // held frame goes between the halves: the stream plays up to that frame, stays on it
- // for the insertion, then carries on — which is what makes the film longer.
- const pieces: Array<{ startSec: number; endSec: number; holdAtEnd: boolean }> = [];
- for (const iv of kept) {
- const moments = pending
- .filter(
- (range) =>
- range.assetId === clip.assetId &&
- range.atSec > iv.startSec + 1e-6 &&
- range.atSec <= iv.endSec + 1e-6,
- )
- .map((range) => range.atSec)
- .sort((a, b) => a - b);
- let from = iv.startSec;
- for (const at of moments) {
- pieces.push({ startSec: from, endSec: Math.min(at, iv.endSec), holdAtEnd: true });
- from = Math.min(at, iv.endSec);
- }
- if (iv.endSec - from > 1e-6 || pieces.length === 0) {
- pieces.push({ startSec: from, endSec: iv.endSec, holdAtEnd: false });
- }
- }
+ const pieces = kept.map((iv) => ({ startSec: iv.startSec, endSec: iv.endSec }));
pieces.forEach((piece, i) => {
const dur = piece.endSec - piece.startSec;
if (dur > 0) {
@@ -283,15 +196,6 @@ export function resolvePlaybackSegments(
});
timelineCursor += dur;
}
- if (!piece.holdAtEnd) return;
- const hold = holdAt(clip, piece.endSec);
- if (!hold) return;
- result.push({
- ...hold,
- timelineStartSec: timelineCursor,
- timelineEndSec: timelineCursor + (hold.heldSec ?? 0),
- });
- timelineCursor += hold.heldSec ?? 0;
});
}
return result;
@@ -362,58 +266,10 @@ function outputDurationOfRawSpan(
return out;
}
-/**
- * The raw span that plays for `outSec` OUTPUT seconds starting at `fromRawSec` — the
- * inverse of {@link outputDurationOfRawSpan}, and the identity when nothing is sped up.
- *
- * A voice-over plays at 1x in the mix, so an insertion for a spoken word is D seconds of the
- * take's own clock. Under a 2x region that is 2 raw seconds, not 1, and getting it wrong
- * puts the resumed narration half an insertion out of step with the picture.
- */
-export function rawSpanForOutDuration(
- fromRawSec: number,
- outSec: number,
- speedRegions: PlaybackSpeedRegion[] = [],
-): number {
- if (!(outSec > 0)) return 0;
- if (speedRegions.length === 0) return outSec;
- // Walk the regions from the head, spending output budget piecewise.
- const edges = [
- ...new Set(
- speedRegions
- .flatMap((r) => [r.startMs / 1000, r.endMs / 1000])
- .filter((edge) => edge > fromRawSec),
- ),
- ].sort((a, b) => a - b);
- let raw = fromRawSec;
- let left = outSec;
- for (const edge of [...edges, Number.POSITIVE_INFINITY]) {
- const mid = raw + Math.min(1e-6, (edge - raw) / 2);
- const region = speedRegions.find(
- (r) => mid >= r.startMs / 1000 && mid < r.endMs / 1000 && r.speed > 0,
- );
- const speed = region?.speed ?? 1;
- const rawAvailable = edge - raw;
- const outAvailable = rawAvailable / speed;
- if (outAvailable >= left) return raw + left * speed - fromRawSec;
- raw = edge;
- left -= outAvailable;
- }
- return raw - fromRawSec;
-}
-
export function projectRawTimelineSecToPlayback(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
rawSec: number,
- /**
- * The insertions, so the kept spans below can carry them.
- *
- * REQUIRED, not optional: an optional parameter would silently keep the early-audio bug
- * alive at every site not yet touched — the one that shows up as "the music starts a
- * beat early" months later.
- */
- insertRanges: readonly AxcutInsertRange[],
/**
* Speed regions on the raw ruler. Supplied by the AUDIO paths, which overlay
* a 1x track onto the finished programme and so need its real, speed-adjusted
@@ -429,22 +285,17 @@ export function projectRawTimelineSecToPlayback(
// The kept stretches come from `keptRawSpans`, which is this walk — it was lifted out of
// here so the transcript lanes and the audio mix could ask the same question and get the
- // same answer (issue #560). It carries the insertions too: they are timeline seconds like
- // any other, which is exactly what one clock buys — this walk used to interleave them
- // itself, and every reader that forgot to had audio landing early.
- //
- // Trims only REMOVE, so a kept span's length is what survives; how long it takes to PLAY
- // is a separate question `outputDurationOfRawSpan` answers, because a speed region scales
- // it.
- for (const seg of keptRawSpans(ordered, trimRanges, insertRanges)) {
- const from = seg.startSec;
+ // same answer (issue #560). Trims only REMOVE, so a kept span's RAW length is what
+ // survives; how long it takes to PLAY is a separate question `outputDurationOfRawSpan`
+ // answers, because a speed region scales it.
+ for (const seg of keptRawSpans(ordered, trimRanges)) {
if (landed === null && rawSec < seg.endSec) {
// `rawSec` is inside this segment, or before it in a trimmed/gap region (then
// the span clamps to nothing → the output edge just before the gap).
- const within = Math.min(Math.max(rawSec, from), seg.endSec);
- landed = outCursor + outputDurationOfRawSpan(from, within, speedRegions);
+ const within = Math.min(Math.max(rawSec, seg.startSec), seg.endSec);
+ landed = outCursor + outputDurationOfRawSpan(seg.startSec, within, speedRegions);
}
- outCursor += outputDurationOfRawSpan(from, seg.endSec, speedRegions);
+ outCursor += outputDurationOfRawSpan(seg.startSec, seg.endSec, speedRegions);
lastRawEnd = seg.endSec;
}
// Past every kept frame: programme end plus whatever raw time hangs off the end (identity when
@@ -1161,12 +1012,9 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri
...document,
timeline: {
...document.timeline,
- trimRanges: dropTrimPillsByIds(
- document.timeline.trimRanges,
- document.timeline.clips,
- [id],
- document.timeline.insertRanges ?? [],
- ),
+ trimRanges: dropTrimPillsByIds(document.timeline.trimRanges, document.timeline.clips, [
+ id,
+ ]),
},
};
case "speed": {
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 6a0656c23..0ca221a8c 100644
--- a/src/lib/ai-edition/document/transcribe.test.ts
+++ b/src/lib/ai-edition/document/transcribe.test.ts
@@ -51,7 +51,6 @@ function makeDoc(): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index fdc3909d1..1bc1f5451 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,16 +1,6 @@
import { describe, expect, it } from "vitest";
-import { type AxcutTranscript, createEmptyDocument, documentSchema } from "../schema";
-import {
- carryOverWordEdits,
- insertDocumentWord,
- insertRangesMatchWords,
- insertWord,
- removeDocumentWords,
- removeWord,
- setDocumentWordText,
- setWordText,
- withTranscript,
-} from "./transcript";
+import { type AxcutTranscript, createEmptyDocument } from "../schema";
+import { carryOverWordEdits, setDocumentWordText, setWordText, withTranscript } from "./transcript";
function fixture(language = "en"): AxcutTranscript {
return {
@@ -493,329 +483,3 @@ describe("carryOverWordEdits", () => {
expect(carryOverWordEdits(null, next).transcript).toBe(next);
});
});
-
-// ─── Inserting a word nobody said ────────────────────────────────
-// The word carries no audio, so what it may occupy is the silence around it and nothing
-// else. These pin that boundary: never over a spoken word, never a duration invented out
-// of nothing when there is no pause to take.
-
-describe("insertWord", () => {
- // "I"(1–2) "use"(2–3) "OpenScreen"(3–4), then a gap, then segment 2 at 5.
- it("takes the silence after the word it follows, up to what its text needs", () => {
- const result = insertWord(fixture(), "word_3", "after", "everywhere");
- const inserted = result.words.find((w) => w.source === "synth");
- expect(inserted?.startSec).toBe(4);
- // 10 characters at 15/s = 0.67s, and the next word is a full second away.
- expect(inserted?.endSec).toBeCloseTo(4 + 10 / 15, 5);
- });
-
- it("never runs over the word that comes next", () => {
- // "use" ends at 3 and "OpenScreen" starts there: a long word gets no room at all.
- const inserted = insertWord(fixture(), "word_2", "after", "a very long addition").words.find(
- (w) => w.source === "synth",
- );
- expect(inserted).toMatchObject({ startSec: 3, endSec: 3 });
- });
-
- it("borrows backwards when it goes before the first word", () => {
- const inserted = insertWord(fixture(), "word_1", "before", "Well").words.find(
- (w) => w.source === "synth",
- );
- // "word_1" starts at 1, and nothing precedes it — the floor is the media's own start.
- expect(inserted?.endSec).toBe(1);
- expect(inserted?.startSec).toBeCloseTo(1 - 0.4, 5);
- });
-
- it("marks it synthesized, with an id no transcription run can reuse", () => {
- const inserted = insertWord(fixture(), "word_3", "after", "indeed").words.find(
- (w) => w.source === "synth",
- );
- expect(inserted).toMatchObject({ text: "indeed", source: "synth", segmentId: "segment_1" });
- expect(inserted?.id).toMatch(/^synth_\d+$/);
- expect(inserted).not.toHaveProperty("originalText");
- });
-
- it("numbers past the inserts already there", () => {
- const once = insertWord(fixture(), "word_3", "after", "one");
- const twice = insertWord(once, "word_3", "after", "two");
- const ids = twice.words.filter((w) => w.source === "synth").map((w) => w.id);
- expect(new Set(ids).size).toBe(2);
- expect(ids).toContain("synth_2");
- });
-
- it("lands in the segment's reading order, and rebuilds its text", () => {
- const transcript = fixture();
- const result = insertWord(transcript, "word_2", "after", "really");
- const segment = result.segments.find((seg) => seg.id === "segment_1");
- expect(segment?.wordIds).toEqual(["word_1", "word_2", "synth_1", "word_3"]);
- expect(segment?.text).toBe("I use really OpenScreen");
- // The segment the insert did not land in is carried over untouched, not rebuilt.
- expect(result.segments[1]).toBe(transcript.segments[1]);
- });
-
- it("sits beside its anchor in the words array, which is what orders a zero-length insert", () => {
- const result = insertWord(fixture(), "word_2", "after", "really");
- const ids = result.words.map((w) => w.id);
- expect(ids.indexOf("synth_1")).toBe(ids.indexOf("word_2") + 1);
- });
-
- it("refuses empty text and unknown anchors", () => {
- expect(() => insertWord(fixture(), "word_2", "after", " ")).toThrow(/empty/);
- expect(() => insertWord(fixture(), "nope", "after", "x")).toThrow(/missing/);
- });
-
- it("keeps the input transcript untouched", () => {
- const transcript = fixture();
- const before = JSON.stringify(transcript);
- insertWord(transcript, "word_2", "after", "really");
- expect(JSON.stringify(transcript)).toBe(before);
- });
-});
-
-describe("removeWord", () => {
- const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
-
- it("takes the word out of the array, the segment, and its text", () => {
- const result = removeWord(withInsert(), "synth_1");
- expect(result.words.some((w) => w.id === "synth_1")).toBe(false);
- const segment = result.segments.find((seg) => seg.id === "segment_1");
- expect(segment?.wordIds).toEqual(["word_1", "word_2", "word_3"]);
- expect(segment?.text).toBe("I use OpenScreen");
- });
-
- // Deleting a transcribed word would leave the film saying something the transcript
- // denies. The operation for making a spoken word go away is a trim.
- it("refuses a word that was actually spoken", () => {
- expect(() => removeWord(fixture(), "word_2")).toThrow(/Refusing to remove transcribed word/);
- });
-
- it("refuses a word that is not there", () => {
- expect(() => removeWord(fixture(), "nope")).toThrow(/missing/);
- });
-});
-
-describe("insertDocumentWord / removeDocumentWords", () => {
- it("writes both the per-asset transcript and the legacy mirror", () => {
- const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
- expect(result.transcript?.words.some((w) => w.id === "synth_1")).toBe(true);
- expect(result.transcript).toBe(result.transcripts.find((t) => t.assetId === "asset_1"));
- });
-
- // One save for the whole set: a Backspace over three inserted words must be one Ctrl+Z.
- it("removes several inserted words in a single document", () => {
- let doc = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "one");
- doc = insertDocumentWord(doc, "asset_1", "word_3", "after", "two");
- const result = removeDocumentWords(doc, "asset_1", ["synth_1", "synth_2"]);
- expect(result.transcripts[0].words.some((w) => w.source === "synth")).toBe(false);
- });
-
- it("rejects an asset with no transcript", () => {
- expect(() => insertDocumentWord(makeDoc(), "nope", "word_2", "after", "x")).toThrow(
- /no transcript/,
- );
- });
-});
-
-describe("carryOverWordEdits with inserted words", () => {
- const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
-
- it("puts an insert back after whatever the new run now ends last before it", () => {
- // The insert sits at 3s. The new transcript says "I"(1–2) "used"(2–3) "it"(3.5–4).
- const next = retranscribed([
- ["n1", "I", 1, 2],
- ["n2", "used", 2, 3],
- ["n3", "it", 3.5, 4],
- ]);
- const result = carryOverWordEdits(withInsert(), next);
- expect(result).toMatchObject({ carried: 1, dropped: 0 });
- const ids = result.transcript.words.map((w) => w.id);
- expect(ids.indexOf("synth_1")).toBe(ids.indexOf("n2") + 1);
- expect(result.transcript.words.find((w) => w.id === "synth_1")).toMatchObject({
- text: "really",
- source: "synth",
- });
- });
-
- it("puts it at the head when the new run has nothing before it", () => {
- const carried = carryOverWordEdits(
- insertWord(fixture(), "word_1", "before", "Well"),
- retranscribed([["n1", "I", 1, 2]]),
- );
- expect(carried.carried).toBe(1);
- expect(carried.transcript.words[0].text).toBe("Well");
- });
-
- it("counts an insert it could not place, rather than losing it quietly", () => {
- const empty: AxcutTranscript = { assetId: "asset_1", language: "en", segments: [], words: [] };
- expect(carryOverWordEdits(withInsert(), empty)).toMatchObject({ carried: 0, dropped: 1 });
- });
-
- it("carries corrections and inserts together", () => {
- const both = insertWord(
- setWordText(fixture(), "word_3", "OpenScreenApp"),
- "word_2",
- "after",
- "really",
- );
- const next = retranscribed([
- ["n1", "I", 1, 2],
- ["n2", "use", 2, 3],
- ["n3", "OpenScreen", 3, 4],
- ]);
- const result = carryOverWordEdits(both, next);
- expect(result).toMatchObject({ carried: 2, dropped: 0 });
- expect(result.transcript.words.find((w) => w.id === "n3")?.text).toBe("OpenScreenApp");
- expect(result.transcript.words.some((w) => w.text === "really")).toBe(true);
- });
-});
-
-// ─── The pause an added word needs ───────────────────────────────
-// Created time is STORED, as a region beside the trims. Something has to keep those
-// records true against the words they belong to, and `withInsertRangesForWords` is the one
-// writer — these hold it to the invariant it maintains. The first attempt at this made
-// CLIPS instead, and every other writer of `timeline.clips` disagreed with them.
-
-describe("insert ranges", () => {
- function docWithClip() {
- const doc = makeDoc();
- return {
- ...doc,
- timeline: {
- ...doc.timeline,
- clips: [
- {
- id: "clip_1",
- assetId: "asset_1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user" as const,
- reason: "",
- },
- ],
- },
- };
- }
-
- it("stores a pause when the free silence does not cover the word", () => {
- // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
- // borrows nothing and needs its whole reading time — max(0.4, 6/15) = 0.4s.
- const result = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
- expect(result.timeline.insertRanges).toHaveLength(1);
- expect(result.timeline.insertRanges[0]).toMatchObject({
- assetId: "asset_1",
- wordId: "synth_1",
- atSec: 3,
- durationSec: 0.4,
- origin: "user",
- });
- expect(insertRangesMatchWords(result)).toBe(true);
- });
-
- // The reason is user-visible on the region, and the two lanes do not hold the same
- // thing: the film holds a FRAME, a take holds silence and no picture is involved
- // (issue #560). The keying above is lane-agnostic and stays that way — one row per
- // word per asset, whichever lane the asset is on.
- it("names what is actually held, per lane", () => {
- const film = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
- expect(film.timeline.insertRanges[0].reason).toContain("Held frame");
-
- // `createEmptyDocument` carries no assets, so the lane has to be given one to read.
- const base = docWithClip();
- const take = insertDocumentWord(
- {
- ...base,
- assets: [
- {
- id: "asset_1",
- kind: "audio" as const,
- label: "take.mp3",
- originalPath: "/take.mp3",
- durationSec: 30,
- cameraTrack: null,
- },
- ],
- },
- "asset_1",
- "word_2",
- "after",
- "really",
- );
- expect(take.timeline.insertRanges[0].reason).toContain("Silence");
- expect(take.timeline.insertRanges[0].reason).not.toContain("frame");
- // Same row otherwise, and the invariant still holds on an audio asset.
- expect(take.timeline.insertRanges[0]).toMatchObject({ atSec: 3, durationSec: 0.4 });
- expect(insertRangesMatchWords(take)).toBe(true);
- });
-
- // An insertion is MEDIA inside the clip, so the clip carrying it is exactly that much
- // longer — the one fact every reader downstream depends on, and the reason none of them
- // needs a second ruler to convert to. Its source window is untouched: no frame of the
- // recording was added or removed.
- it("lengthens the clip that carries the insertion, by the insertion", () => {
- const before = docWithClip();
- const result = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
- const [range] = result.timeline.insertRanges;
- const was = before.timeline.clips[0];
- const now = result.timeline.clips[0];
- expect(now.timelineEndSec - now.timelineStartSec).toBeCloseTo(
- was.timelineEndSec - was.timelineStartSec + range.durationSec,
- 5,
- );
- expect(now.sourceStartSec).toBe(was.sourceStartSec);
- expect(now.sourceEndSec).toBe(was.sourceEndSec);
- });
-
- it("gives the length back when the word goes", () => {
- const before = docWithClip();
- const added = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
- const removed = removeDocumentWords(added, "asset_1", ["synth_1"]);
- expect(removed.timeline.clips).toEqual(before.timeline.clips);
- });
-
- it("stores nothing when the word fits in silence that is already there", () => {
- // word_3 ends at 4 and word_4 starts at 5: a full second, more than "really" needs.
- const result = insertDocumentWord(docWithClip(), "asset_1", "word_3", "after", "really");
- expect(result.timeline.insertRanges).toEqual([]);
- expect(insertRangesMatchWords(result)).toBe(true);
- });
-
- it("resizes the pause when the word is rewritten longer", () => {
- const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
- const longer = setDocumentWordText(added, "asset_1", "synth_1", "really quite genuinely so");
- const [range] = longer.timeline.insertRanges;
- expect(range.durationSec).toBeCloseTo(25 / 15, 5);
- expect(range.id).toBe(added.timeline.insertRanges[0].id);
- expect(insertRangesMatchWords(longer)).toBe(true);
- });
-
- it("drops the pause with the word", () => {
- const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
- const gone = removeDocumentWords(added, "asset_1", ["synth_1"]);
- expect(gone.timeline.insertRanges).toEqual([]);
- expect(insertRangesMatchWords(gone)).toBe(true);
- });
-
- it("keeps one pause per added word, and no more", () => {
- let doc = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
- doc = insertDocumentWord(doc, "asset_1", "word_1", "after", "personally");
- expect(doc.timeline.insertRanges).toHaveLength(2);
- expect(new Set(doc.timeline.insertRanges.map((r) => r.wordId)).size).toBe(2);
- expect(insertRangesMatchWords(doc)).toBe(true);
- });
-
- // Correcting a SPOKEN word must not invent a pause: it has audio behind it already.
- it("stores nothing for an ordinary correction", () => {
- const result = setDocumentWordText(docWithClip(), "asset_1", "word_3", "OpenScreenApp");
- expect(result.timeline.insertRanges).toEqual([]);
- });
-
- it("survives the document schema", () => {
- const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
- const parsed = documentSchema.parse(JSON.parse(JSON.stringify(added)));
- expect(parsed.timeline.insertRanges).toHaveLength(1);
- expect(insertRangesMatchWords(parsed)).toBe(true);
- });
-});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 82df1928d..dec291474 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,6 +1,4 @@
-import type { AxcutDocument, AxcutInsertRange, AxcutTranscript, AxcutWord } from "../schema";
-import { createId } from "./ids";
-import { reflowClipsForInserts } from "./timeline";
+import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -146,12 +144,7 @@ export function setDocumentWordText(
if (!transcript) {
throw new Error(`Cannot edit a word of asset "${assetId}": it has no transcript`);
}
- // Rewriting an added word changes how long it takes to read, so its pause is resized
- // here too — the one writer, whatever the edit was.
- return withInsertRangesForWords(
- withTranscript(document, setWordText(transcript, wordId, text)),
- assetId,
- );
+ return withTranscript(document, setWordText(transcript, wordId, text));
}
/** Where a new word goes relative to the word the caret was resting on. */
@@ -308,171 +301,8 @@ export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTr
};
}
-/**
- * How much created time an added word still needs, on top of the silence it borrowed.
- *
- * Zero when the pause it landed in was already long enough — an added word between two
- * sentences costs the film nothing.
- */
-function pauseDeficitSec(word: AxcutWord): number {
- const borrowed = word.endSec - word.startSec;
- return Math.max(0, readingSeconds(word.text) - borrowed);
-}
-
-/** Below this, a pause is not worth a record — a few milliseconds of held frame is a
- * stutter, not a slot to speak in. */
-const MIN_PAUSE_SEC = 0.05;
-
-/**
- * Bring the document's insert ranges back in line with its words.
- *
- * The ranges are STORED, so something has to keep them true; this is that something, and
- * it is the only writer. Called after every word write, it adds the pause an added word
- * needs, resizes one whose text changed length, and drops the ones whose word is gone —
- * so no caller has to remember any of the three. `insertRangesMatchWords` is the same rule
- * read back, for a test to hold this to.
- */
-/** `synth_N` — the id every added word has been minted with, and the one thing a row
- * written before the `source` field carries to say what it is. */
-const SYNTH_WORD_ID = /^synth_\d+$/;
-
-/**
- * Added words that never got marked as such, marked.
- *
- * `source: "synth"` is how the whole pipeline recognises a word the user typed: it decides
- * whether the word gets an insertion, whether the film makes room for it, and whether the
- * caption line breaks around it. A row minted before that field existed answers no to all
- * three, so its text plays over the recording and everything after it drifts. The id is the
- * evidence — `nextSynthWordId` has always minted exactly this shape, and the numbering scan
- * already reads it back with the same pattern.
- */
-export function withMarkedAddedWords(document: AxcutDocument): AxcutDocument {
- let touched = false;
- const transcripts = document.transcripts.map((transcript) => {
- let changed = false;
- const words = transcript.words.map((word) => {
- if (word.source !== undefined || !SYNTH_WORD_ID.test(word.id)) return word;
- changed = true;
- return { ...word, source: "synth" as const };
- });
- if (!changed) return transcript;
- touched = true;
- return { ...transcript, words };
- });
- return touched ? { ...document, transcripts } : document;
-}
-
-/**
- * Every asset's insert ranges brought back in line with its words.
- *
- * The per-asset reconciler applied to the whole document, so a load can enforce the
- * invariant it maintains rather than waiting for the next word write to notice.
- */
-export function withInsertRangesForAllWords(document: AxcutDocument): AxcutDocument {
- return document.transcripts.reduce(
- (doc, transcript) => withInsertRangesForWords(doc, transcript.assetId),
- document,
- );
-}
-
-function withInsertRangesForWords(document: AxcutDocument, assetId: string): AxcutDocument {
- // The reason is user-visible on the region, and it is not the same fact on both lanes:
- // the film holds a FRAME, a take holds nothing but silence — no picture is involved
- // (issue #560). Keying, below, stays lane-agnostic: one row per word per asset.
- const isTake = document.assets.find((a) => a.id === assetId)?.kind === "audio";
- const transcript = document.transcripts.find((t) => t.assetId === assetId);
- const words = transcript?.words ?? [];
- const wanted = new Map();
- for (const word of words) {
- if (word.source !== "synth") continue;
- const deficit = pauseDeficitSec(word);
- if (deficit >= MIN_PAUSE_SEC) wanted.set(word.id, deficit);
- }
-
- const existing = document.timeline.insertRanges;
- const kept: AxcutInsertRange[] = [];
- const seen = new Set();
- for (const range of existing) {
- // Ranges for OTHER assets are none of this call's business.
- if (range.assetId !== assetId) {
- kept.push(range);
- continue;
- }
- const durationSec = wanted.get(range.wordId);
- if (durationSec === undefined) continue; // its word is gone, or needs no pause now
- seen.add(range.wordId);
- const word = words.find((w) => w.id === range.wordId);
- const atSec = word?.endSec ?? range.atSec;
- kept.push(
- durationSec === range.durationSec && atSec === range.atSec
- ? range
- : { ...range, atSec, durationSec },
- );
- }
- for (const [wordId, durationSec] of wanted) {
- if (seen.has(wordId)) continue;
- const word = words.find((w) => w.id === wordId);
- if (!word) continue;
- kept.push({
- id: createId("insert"),
- assetId,
- atSec: word.endSec,
- durationSec,
- wordId,
- reason: isTake
- ? `Silence for the added word "${word.text}".`
- : `Held frame for the added word "${word.text}".`,
- origin: "user",
- });
- }
-
- if (kept.length === existing.length && kept.every((range, i) => range === existing[i])) {
- return document;
- }
- // The clips grow with them. An insertion is media inside the clip, so the clip is that
- // much longer — the single fact every downstream reader needs, written once, here, where
- // the ranges themselves are written.
- return {
- ...document,
- timeline: {
- ...document.timeline,
- insertRanges: kept,
- clips: reflowClipsForInserts(document.timeline.clips, kept),
- },
- };
-}
-
-/**
- * The invariant {@link withInsertRangesForWords} maintains, read back: every stored pause
- * belongs to an added word that still needs one, sits where that word ends, and lasts what
- * its text needs. Exported for the test that holds the writer to it.
- */
-export function insertRangesMatchWords(document: AxcutDocument): boolean {
- const byAsset = new Map(document.transcripts.map((t) => [t.assetId, t]));
- const expected = new Set();
- for (const transcript of document.transcripts) {
- for (const word of transcript.words) {
- if (word.source === "synth" && pauseDeficitSec(word) >= MIN_PAUSE_SEC) {
- expected.add(`${transcript.assetId}::${word.id}`);
- }
- }
- }
- const seen = new Set();
- for (const range of document.timeline.insertRanges) {
- const key = `${range.assetId}::${range.wordId}`;
- if (!expected.has(key) || seen.has(key)) return false;
- seen.add(key);
- const word = byAsset.get(range.assetId)?.words.find((w) => w.id === range.wordId);
- if (!word) return false;
- if (range.atSec !== word.endSec) return false;
- if (Math.abs(range.durationSec - pauseDeficitSec(word)) > 1e-9) return false;
- }
- return seen.size === expected.size;
-}
-
/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does, and leaves behind the pause the new word
- * needs — see {@link withInsertRangesForWords}. */
+ * reason {@link setDocumentWordText} does. */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -484,10 +314,7 @@ export function insertDocumentWord(
if (!transcript) {
throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
}
- return withInsertRangesForWords(
- withTranscript(document, insertWord(transcript, anchorWordId, side, text)),
- assetId,
- );
+ return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
}
/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
@@ -502,12 +329,9 @@ export function removeDocumentWords(
if (!transcript) {
throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
}
- return withInsertRangesForWords(
- withTranscript(
- document,
- wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
- ),
- assetId,
+ return withTranscript(
+ document,
+ wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
);
}
diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts
index 8a715ab0e..d37f500b3 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -274,34 +274,6 @@ export const trimRangeSchema = endGteStart(
"startSec",
);
-/**
- * Time the film does NOT have: the pause an added word needs so a synthesized voice will
- * have somewhere to speak. The film holds the frame at `atSec` for `durationSec`, screen
- * and webcam together, and everything after it shifts.
- *
- * The exact inverse of a trim, and stored the same way and for the same reason. The first
- * attempt created CLIPS for this; every other writer of `timeline.clips` — the duration
- * probe, the recording import, resequencing — is entitled to disagree with a clip it did
- * not make, and they did: a project came back split twice, both pauses gone, and the words
- * they belonged to with them. A region is the shape this timeline already carries safely.
- *
- * `wordId` is what makes it derived-in-spirit while stored in fact: `document/transcript.ts`
- * is the only writer, it creates the range with the word and drops it with the word, and
- * `insertRangesMatchWords` is the invariant a test holds it to. Nothing else may write one.
- */
-export const insertRangeSchema = z.object({
- id: z.string().min(1),
- assetId: z.string().min(1),
- /** Source moment the film holds on. */
- atSec: z.number().nonnegative(),
- /** Timeline time created. Always positive — a pause of zero is simply not stored. */
- durationSec: z.number().positive(),
- /** The transcript word this pause exists for. */
- wordId: z.string().min(1),
- reason: z.string().default(""),
- origin: z.enum(["system", "agent", "user"]),
-});
-
export const timelineSchema = z.preprocess(
// Back-compat: the field was renamed skipRanges → trimRanges. Old persisted
// documents (disk + browser-shim localStorage) still carry `skipRanges`;
@@ -322,7 +294,6 @@ export const timelineSchema = z.preprocess(
// Additive, like every optional field before it: absent on every document written
// before this, so no schema bump — an older build simply drops the key on save, and
// the words it belonged to keep their text and lose only their pause.
- insertRanges: z.array(insertRangeSchema).default([]),
muteRanges: z.array(rangeSchema).default([]),
speedRanges: z.array(rangeSchema).default([]),
captionRanges: z.array(rangeSchema).default([]),
@@ -618,7 +589,6 @@ const documentSchemaShape = z.object({
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -1116,7 +1086,6 @@ export type AxcutClip = z.infer;
export type AxcutClipCropRegion = z.infer;
export type AxcutGap = z.infer;
export type AxcutTrimRange = z.infer;
-export type AxcutInsertRange = z.infer;
export type AxcutTimeline = z.infer;
export type AxcutTimelineOperation = z.infer;
export type AxcutAnnotationRegion = z.infer;
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts
index 1e8cc43de..ec37d7176 100644
--- a/src/lib/ai-edition/store/editorSettings.test.ts
+++ b/src/lib/ai-edition/store/editorSettings.test.ts
@@ -23,7 +23,6 @@ const baseDoc: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts
index 18df17063..2b75f0a04 100644
--- a/src/lib/ai-edition/store/projectStore.test.ts
+++ b/src/lib/ai-edition/store/projectStore.test.ts
@@ -67,7 +67,6 @@ const sampleDoc = {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index 4af906cd4..fd47da9cc 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -5,7 +5,6 @@ import { toastText } from "@/i18n/toastText";
import { nativeBridgeClient } from "@/native/client";
import { placeAudioTrackInDocument } from "../document/audioTracks";
import { createId } from "../document/ids";
-import { reconcileInsertions } from "../document/load";
import { type Interval, replaceTimeline as replaceTimelineOp } from "../document/timeline";
import { type AxcutAsset, type AxcutDocument, createAudioTrack, documentSchema } from "../schema";
import { probeAudioDuration, probeVideoDimensions } from "../timeline/duration";
@@ -159,10 +158,7 @@ export interface ProjectState {
}
function parseDocument(value: unknown): AxcutDocument {
- // Reconciled here too, not only in the main process: this is the renderer's own gate on
- // every document it accepts, and it is idempotent, so a document that arrived correct
- // passes through untouched.
- return reconcileInsertions(documentSchema.parse(value));
+ return documentSchema.parse(value);
}
/**
diff --git a/src/lib/ai-edition/store/undo.modalGuard.test.tsx b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
index 834103e60..46e083b0b 100644
--- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx
+++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
@@ -29,7 +29,6 @@ function doc(title: string): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts
index 84694923c..0cd641328 100644
--- a/src/lib/ai-edition/store/useCaptions.test.ts
+++ b/src/lib/ai-edition/store/useCaptions.test.ts
@@ -65,7 +65,6 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useEditorSettings.test.ts b/src/lib/ai-edition/store/useEditorSettings.test.ts
index fe433f070..4122c05bf 100644
--- a/src/lib/ai-edition/store/useEditorSettings.test.ts
+++ b/src/lib/ai-edition/store/useEditorSettings.test.ts
@@ -68,7 +68,6 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index ecb5dced9..7b77e9c46 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -106,7 +106,6 @@ const sampleDoc: AxcutDocument = {
],
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 79e4340f6..06a4e6b90 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -371,12 +371,7 @@ export function useTimeline() {
// the trim at the wrong source position.
const playhead = playheadSec();
const end = playhead + durationSec;
- const resolved = resolveTimelineSpanToTrim(
- playhead,
- end,
- document.timeline.clips,
- document.timeline.insertRanges ?? [],
- );
+ const resolved = resolveTimelineSpanToTrim(playhead, end, document.timeline.clips);
const asset =
document.assets.find((a) => a.id === document.project.primaryAssetId) ?? document.assets[0];
if (!resolved && !asset) return;
@@ -957,7 +952,6 @@ export function useTimeline() {
document.timeline.trimRanges,
document.timeline.clips,
trimIds,
- document.timeline.insertRanges ?? [],
),
},
legacyEditor:
@@ -1460,7 +1454,6 @@ export function useTimeline() {
audioTracks: document?.audioTracks ?? [],
// The pauses added words created. The ruler counts them; nothing else in the
// timeline store writes them (see `document/transcript.ts`).
- insertRanges: document?.timeline.insertRanges ?? [],
annotationRegions: (document?.annotations ?? []) as unknown as AnnotationRegion[],
speedRegions,
cameraFullscreenRegions,
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
index d4aa314e5..9cfebde27 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -88,20 +88,6 @@ describe("voiceoverPlacements", () => {
[4, 4, 6],
]);
});
-
- it("maps the words after an insertion to the moment they actually occupy", () => {
- // The reason this stopped being per-fragment. Source 4 is heard at ruler 5, because
- // the pause before it took a second of the take's span.
- const placements = voiceoverPlacements(
- [track({ id: "f1", trackId: "T", startMs: 0, endMs: 6000, offsetMs: 0 })],
- [],
- () => [{ id: "i1", wordId: "w1", atSourceSec: 4, durationSec: 1 }],
- );
- expect(placements.map((p) => [p.timelineStartSec, p.sourceStartSec, p.sourceEndSec])).toEqual([
- [0, 0, 4],
- [5, 4, 5],
- ]);
- });
});
describe("lanePlacements", () => {
@@ -143,7 +129,6 @@ describe("lanePlacements", () => {
[transcript as any],
[],
[],
- [],
);
expect(sections).toHaveLength(1);
expect(sections[0].words.filter((w) => !w.word.id.startsWith("silence_"))).toHaveLength(2);
@@ -216,7 +201,7 @@ describe("one programme, two lanes", () => {
const VO = track({ id: "vo_1", startMs: 0, endMs: 12000, offsetMs: 0, durationSec: 12 });
function lanes(trims: (typeof TRIM)[]) {
- const removed = removedRawSpans(CLIPS_2, trims, []);
+ const removed = removedRawSpans(CLIPS_2, trims);
const transcripts = [secondsTranscript("asset_rec", 12), secondsTranscript("asset_vo", 12)];
const build = (lane: "recording" | "voiceover") =>
buildAggregatedSections(
@@ -225,7 +210,6 @@ describe("one programme, two lanes", () => {
transcripts as any,
[],
removed,
- [],
);
return { recording: build("recording"), voiceover: build("voiceover") };
}
@@ -258,14 +242,13 @@ describe("one programme, two lanes", () => {
it("removes a word over an inter-clip gap, with nothing to restore", () => {
const gapped = [CLIPS_2[0], { ...CLIPS_2[1], timelineStartSec: 8, timelineEndSec: 14 }];
- const removed = removedRawSpans(gapped, [], []);
+ const removed = removedRawSpans(gapped, []);
const sections = buildAggregatedSections(
voiceoverPlacements([track({ id: "vo_1", startMs: 0, endMs: 14000, durationSec: 14 })]),
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
[secondsTranscript("asset_vo", 14)] as any,
[],
removed,
- [],
);
const w6 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w6"); // raw 6..7
expect(w6?.kept).toBe(false);
@@ -283,8 +266,7 @@ describe("one programme, two lanes", () => {
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
[secondsTranscript("asset_vo", 20)] as any,
[],
- removedRawSpans(CLIPS_2, [], []),
- [],
+ removedRawSpans(CLIPS_2, []),
);
const w15 = sections.flatMap((s) => s.words).find((w) => w.word.id === "w15");
expect(w15?.kept).toBe(true);
@@ -294,14 +276,14 @@ describe("one programme, two lanes", () => {
// The cue used to be resolved into a clip id, which only the recording lane has —
// so this returned null for every moment of every voiceover.
const { voiceover } = lanes([]);
- expect(findCueWordId(voiceover, 4.5, [])).toBe("vo_1:w4");
- expect(findCueWordId(voiceover, 0.5, [])).toBe("vo_1:w0");
+ expect(findCueWordId(voiceover, 4.5)).toBe("vo_1:w4");
+ expect(findCueWordId(voiceover, 0.5)).toBe("vo_1:w0");
});
it("reads a word's raw moment through its own placement", () => {
// A take starting 3s along the ruler, 5s into its file: its source 6 is raw 4.
const placement = { id: "p", assetId: "a", sourceStartSec: 5, timelineStartSec: 3 };
- expect(placementRawSec(placement, 6, [])).toBe(4);
+ expect(placementRawSec(placement, 6)).toBe(4);
});
it("contributes no placement for a looping take", () => {
@@ -315,53 +297,3 @@ describe("one programme, two lanes", () => {
// ─── The cue, after an insertion ─────────────────────────────────────────────
// `findCueWordId` carries its own inverse of the affine map — the fourth copy in the tree.
// It needs no insertion term of its own PROVIDED each placement is affine, which is exactly
-// what walking the take by play pieces buys. Asserted rather than assumed.
-
-describe("the karaoke highlight after a pause", () => {
- const TAKE = track({ id: "vo", startMs: 0, endMs: 6000, offsetMs: 0, durationSec: 6 });
- const WORDS = {
- assetId: "asset_vo",
- language: "en",
- segments: [],
- words: [0, 1, 2, 3, 4, 5].map((i) => ({
- id: `w${i}`,
- segmentId: "s",
- text: `w${i}`,
- startSec: i + 0.1,
- endSec: i + 0.9,
- })),
- };
-
- const sectionsWith = (
- inserts: Array<{ id: string; wordId: string; atSourceSec: number; durationSec: number }>,
- ) =>
- buildAggregatedSections(
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
- voiceoverPlacements([TAKE as any], [], () => inserts),
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
- [WORDS as any],
- [],
- [],
- [],
- );
-
- it("tracks the voice with no insertion", () => {
- expect(findCueWordId(sectionsWith([]), 4.5, [])).toBe("vo:w4");
- });
-
- it("follows the word D later once a pause has pushed it there", () => {
- // A one-second pause at source 3: source 4 is now heard at ruler 5.
- const inserts = [{ id: "i1", wordId: "w3", atSourceSec: 3, durationSec: 1 }];
- const sections = sectionsWith(inserts);
- expect(findCueWordId(sections, 5.5, [])).toBe("vo#1:w4");
- // And it is NOT still answering with the pre-pause mapping.
- expect(findCueWordId(sections, 4.5, [])).not.toBe("vo:w4");
- });
-
- it("highlights nothing while the voice is parked", () => {
- // No word is being said during the pause, so the karaoke goes quiet rather than
- // leaving a word lit that has already been spoken.
- const inserts = [{ id: "i1", wordId: "w3", atSourceSec: 3, durationSec: 1 }];
- expect(findCueWordId(sectionsWith(inserts), 3.5, [])).toBeNull();
- });
-});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index 085435091..1a62d7e4c 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
@@ -63,7 +63,7 @@ describe("buildClipSection", () => {
{ id: "w3", segmentId: "s1", startSec: 2, endSec: 3, text: "friend" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), [], []);
+ const section = buildClipSection(clip, transcript, makeAsset(), []);
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
expect(section.trimRuns).toEqual([]);
});
@@ -83,8 +83,7 @@ describe("buildClipSection", () => {
clip,
transcript,
makeAsset(),
- removedRawSpans([clip], [trim], []),
- [],
+ removedRawSpans([clip], [trim]),
);
expect(section.words.map((cw) => cw.kept)).toEqual([true, false, false, false, true]);
expect(section.words.map((cw) => cw.trimIds)).toEqual([
@@ -118,13 +117,7 @@ describe("buildClipSection", () => {
makeTrim({ id: "trim_b", startSec: 3, endSec: 4 }),
];
- const section = buildClipSection(
- clip,
- transcript,
- makeAsset(),
- removedRawSpans([clip], trims, []),
- [],
- );
+ const section = buildClipSection(clip, transcript, makeAsset(), removedRawSpans([clip], trims));
expect(section.trimRuns).toHaveLength(2);
expect(section.trimRuns[0]).toMatchObject({
trimIds: ["trim_a"],
@@ -164,8 +157,7 @@ describe("buildClipSection", () => {
clips,
[makeTranscript(words())],
[makeAsset()],
- removedRawSpans(clips, [trim], []),
- [],
+ removedRawSpans(clips, [trim]),
);
expect(sections[0].words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -184,8 +176,7 @@ describe("buildClipSection", () => {
clips,
[makeTranscript(words())],
[makeAsset()],
- removedRawSpans(clips, [trim], []),
- [],
+ removedRawSpans(clips, [trim]),
);
expect(sections[0].trimRuns).toHaveLength(1);
expect(sections[1].trimRuns).toHaveLength(1);
@@ -204,8 +195,7 @@ describe("buildClipSection", () => {
clip,
transcript,
makeAsset(),
- removedRawSpans([clip], [trim], []),
- [],
+ removedRawSpans([clip], [trim]),
);
// Trailing gap 2s→3s is a silence — the different-asset trim doesn't
// cover any of the three entries, so all stay kept.
@@ -220,7 +210,7 @@ describe("buildClipSection", () => {
{ id: "w3", segmentId: "s1", startSec: 2, endSec: 3, text: "Um," },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), [], []);
+ const section = buildClipSection(clip, transcript, makeAsset(), []);
// ponytail: the LLM (not the renderer) decides what is a filler. Every
// word renders as plain text in the right pane.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
@@ -229,7 +219,7 @@ describe("buildClipSection", () => {
it("returns an empty words list when the clip has no matching transcript", () => {
const clip = makeClip({ sourceStartSec: 0, sourceEndSec: 5 });
- const section = buildClipSection(clip, null, makeAsset(), [], []);
+ const section = buildClipSection(clip, null, makeAsset(), []);
expect(section.words).toEqual([]);
expect(section.trimRuns).toEqual([]);
@@ -244,7 +234,7 @@ describe("buildClipSection", () => {
{ id: "w_after", segmentId: "s1", startSec: 5, endSec: 6, text: "trim" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), [], []);
+ const section = buildClipSection(clip, transcript, makeAsset(), []);
// Leading (2s→2.5s) and trailing (3.5s→4s) gaps are both silences.
expect(section.words.map((cw) => cw.word.id)).toEqual(["silence_1", "w_mid", "silence_2"]);
});
@@ -258,7 +248,7 @@ describe("silence gaps", () => {
{ id: "w2", segmentId: "s1", startSec: 1.3, endSec: 2, text: "there" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), [], []);
+ const section = buildClipSection(clip, transcript, makeAsset(), []);
const ids = section.words.map((cw) => cw.word.id);
expect(ids).toEqual(["w1", "silence_1", "w2", "silence_2"]);
expect(section.words.filter((cw) => isSilenceWord(cw.word))).toHaveLength(2);
@@ -272,7 +262,7 @@ describe("silence gaps", () => {
{ id: "w2", segmentId: "s1", startSec: 1.1, endSec: 2, text: "there" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), [], []);
+ const section = buildClipSection(clip, transcript, makeAsset(), []);
expect(section.words.map((cw) => cw.word.id)).toEqual(["w1", "w2"]);
});
@@ -288,8 +278,7 @@ describe("silence gaps", () => {
clip,
transcript,
makeAsset(),
- removedRawSpans([clip], [trim], []),
- [],
+ removedRawSpans([clip], [trim]),
);
const silence = section.words.find((cw) => isSilenceWord(cw.word));
expect(silence?.kept).toBe(false);
@@ -330,7 +319,7 @@ describe("buildAggregatedSections", () => {
makeAsset({ id: "asset_2", label: "second.mp4" }),
];
- const sections = buildAggregatedSections(clips, transcripts, assets, [], []);
+ const sections = buildAggregatedSections(clips, transcripts, assets, []);
expect(sections).toHaveLength(2);
expect(sections[0]?.clip.id).toBe("c1");
expect(sections[1]?.clip.id).toBe("c2");
@@ -343,7 +332,7 @@ describe("buildAggregatedSections", () => {
const transcripts = [makeTranscript([])];
const assets = [makeAsset(), makeAsset({ id: "asset_2" })];
- const sections = buildAggregatedSections(clips, transcripts, assets, [], []);
+ const sections = buildAggregatedSections(clips, transcripts, assets, []);
expect(sections).toHaveLength(2);
expect(sections[0]?.transcript).toBeTruthy();
expect(sections[1]?.transcript).toBeNull();
@@ -387,7 +376,7 @@ describe("findCueWordId", () => {
it("returns null when there is no playhead", () => {
const section = makeSection("c1", "asset_1", [["w1", 0, 1]]);
- expect(findCueWordId([section], null, [])).toBeNull();
+ expect(findCueWordId([section], null)).toBeNull();
});
it("returns null when the head is before every section", () => {
@@ -395,7 +384,7 @@ describe("findCueWordId", () => {
timelineStartSec: 10,
timelineEndSec: 110,
});
- expect(findCueWordId([section], 2, [])).toBeNull();
+ expect(findCueWordId([section], 2)).toBeNull();
});
it("returns the word containing the head", () => {
@@ -404,7 +393,7 @@ describe("findCueWordId", () => {
["w2", 1, 2],
["w3", 2, 3],
]);
- expect(findCueWordId([section], 1.5, [])).toBe("c1:w2");
+ expect(findCueWordId([section], 1.5)).toBe("c1:w2");
});
it("returns the previous word when the head is between two words", () => {
@@ -412,7 +401,7 @@ describe("findCueWordId", () => {
["w1", 0, 1],
["w2", 2, 3],
]);
- expect(findCueWordId([section], 1.5, [])).toBe("c1:w1");
+ expect(findCueWordId([section], 1.5)).toBe("c1:w1");
});
it("returns null when the head is before the first word", () => {
@@ -420,7 +409,7 @@ describe("findCueWordId", () => {
["w1", 5, 6],
["w2", 7, 8],
]);
- expect(findCueWordId([section], 0.5, [])).toBeNull();
+ expect(findCueWordId([section], 0.5)).toBeNull();
});
it("returns the last word when the head is past the last word", () => {
@@ -428,7 +417,7 @@ describe("findCueWordId", () => {
["w1", 0, 1],
["w2", 1, 2],
]);
- expect(findCueWordId([section], 99, [])).toBe("c1:w2");
+ expect(findCueWordId([section], 99)).toBe("c1:w2");
});
it("reads the head through the section's own source clock", () => {
@@ -439,8 +428,8 @@ describe("findCueWordId", () => {
timelineStartSec: 20,
timelineEndSec: 30,
});
- expect(findCueWordId([section], 22, [])).toBe("c1:w1");
- expect(findCueWordId([section], 2, [])).toBeNull();
+ expect(findCueWordId([section], 22)).toBe("c1:w1");
+ expect(findCueWordId([section], 2)).toBeNull();
});
// Two clips over the same media project the SAME transcript words twice, so the cue
@@ -469,12 +458,12 @@ describe("findCueWordId", () => {
it("resolves the head against the clip that is playing", () => {
// Source 1.5 in both, but raw 4.5 is only inside c2.
- expect(findCueWordId(sections(), 4.5, [])).toBe("c2:w2");
- expect(findCueWordId(sections(), 1.5, [])).toBe("c1:w2");
+ expect(findCueWordId(sections(), 4.5)).toBe("c2:w2");
+ expect(findCueWordId(sections(), 1.5)).toBe("c1:w2");
});
it("returns an id that cannot match the other clip's copy of the same word", () => {
- const cue = findCueWordId(sections(), 4.5, []);
+ const cue = findCueWordId(sections(), 4.5);
// The whole point: `word.id` is "w2" in BOTH sections, so a bare word id lit up
// both blocks. Exactly one rendered word may claim the cue.
const claiming = sections().flatMap((s) => s.words.filter((cw) => cw.id === cue));
@@ -491,7 +480,7 @@ describe("findCueWordId", () => {
}),
];
// c2 has no words, and borrowing c1's would point at the wrong text.
- expect(findCueWordId(withEmptyC2, 4.5, [])).toBeNull();
+ expect(findCueWordId(withEmptyC2, 4.5)).toBeNull();
});
it("runs an open-ended placement up to the next one", () => {
@@ -508,8 +497,8 @@ describe("findCueWordId", () => {
timelineEndSec: 6,
}),
];
- expect(findCueWordId(open, 2, [])).toBe("c1:w1");
- expect(findCueWordId(open, 3.5, [])).toBe("c2:w1");
+ expect(findCueWordId(open, 2)).toBe("c1:w1");
+ expect(findCueWordId(open, 3.5)).toBe("c2:w1");
});
});
});
@@ -545,7 +534,6 @@ describe("clipWordId", () => {
[transcript],
[makeAsset()],
[],
- [],
);
const rawIds = sections.flatMap((s) => s.words.map((cw) => cw.word.id));
const scopedIds = sections.flatMap((s) => s.words.map((cw) => cw.id));
@@ -554,60 +542,3 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
-
-// ─── The word an insertion is spoken over ───────────────────────────────────
-// The pane mapped source → ruler and back with two hand-written shifts that ignored the
-// media inserted inside the clip. Past an insertion the highlight ran ahead of the voice by
-// exactly the inserted time — it sat on the word AFTER the one being spoken — and the added
-// word itself was never highlighted at all, because the only seconds it is spoken in are the
-// insertion, and no source second exists inside one (issue #560).
-
-describe("the cue word across an insertion", () => {
- // "b" is typed in after "a". It fits in 0.1s of existing silence and buys 2s of media.
- const words = () => [
- { id: "w_a", segmentId: "s1", startSec: 0, endSec: 1, text: "a" },
- { id: "w_b", segmentId: "s1", startSec: 1, endSec: 1.1, text: "b", source: "synth" as const },
- { id: "w_c", segmentId: "s1", startSec: 2, endSec: 3, text: "c" },
- ];
- // The clip carries the insertion, so it runs 0..12 for 10s of recording.
- const clips = () => [makeClip({ sourceStartSec: 0, sourceEndSec: 10, timelineEndSec: 12 })];
- const inserted = [
- {
- id: "i1",
- assetId: "asset_1",
- atSec: 1.1,
- durationSec: 2,
- wordId: "w_b",
- reason: "",
- origin: "user" as const,
- },
- ];
- const sections = () =>
- buildAggregatedSections(
- clips(),
- [makeTranscript(words())],
- [makeAsset()],
- removedRawSpans(clips(), [], inserted),
- inserted,
- );
-
- it("highlights the added word for the whole stretch its insertion occupies", () => {
- // The insertion opens at ruler 1.1 and closes at 3.1.
- for (const at of [1.2, 2, 3.0]) {
- expect(findCueWordId(sections(), at, inserted)).toBe(clipWordId("clip_1", "w_b"));
- }
- });
-
- it("does not run ahead of the voice after the insertion", () => {
- // Source 2..3 is "c", which the insertion has pushed to ruler 4..5.
- expect(findCueWordId(sections(), 4.5, inserted)).toBe(clipWordId("clip_1", "w_c"));
- // Without the shift this same moment resolved to source 4.5 — past "c" entirely.
- expect(findCueWordId(sections(), 0.5, inserted)).toBe(clipWordId("clip_1", "w_a"));
- });
-
- it("still finds a word at the very end of the clip", () => {
- // The extent used to stop at the source length, so the last 2s belonged to no
- // section and the highlight simply went out.
- expect(findCueWordId(sections(), 11.5, inserted)).not.toBeNull();
- });
-});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 27dcbb5cd..309e3c6aa 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -14,18 +14,10 @@
// names a word a filler. The transcript view shows plain text for every
// kept word; the user or the LLM decides what to mark as skipped.
-import { collapseTracksToPills, trackGroupId } from "../document/audioTracks";
-import type {
- AxcutAsset,
- AxcutAudioTrack,
- AxcutClip,
- AxcutInsertRange,
- AxcutTranscript,
- AxcutWord,
-} from "../schema";
-import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
+import { collapseTracksToPills } from "../document/audioTracks";
+import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
-import { type TakeInsert, takeProgramme } from "./take-programme";
+import { takeProgramme } from "./take-programme";
/**
* The unit the aggregation actually runs over: one stretch of ONE asset's source
@@ -63,28 +55,16 @@ export type TranscriptLane = "recording" | "voiceover";
* whether two things coincide; raw time can, which is why kept-or-removed is asked here
* and not in source time (issue #560).
*/
-export function placementRawSec(
- placement: TranscriptPlacement,
- sourceSec: number,
- insertRanges: readonly AxcutInsertRange[],
- edge: "opens" | "closes" = "opens",
-): number {
- return sourceToTimelineSec(placement, sourceSec, insertRanges, edge);
+export function placementRawSec(placement: TranscriptPlacement, sourceSec: number): number {
+ return placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
}
/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
-export function placementRawExtent(
- placement: TranscriptPlacement,
- insertRanges: readonly AxcutInsertRange[],
-): RawSpan | null {
+export function placementRawExtent(placement: TranscriptPlacement): RawSpan | null {
if (placement.sourceEndSec === undefined) return null;
return {
startSec: placement.timelineStartSec,
- // `"closes"` on the end: the media inserted inside this placement is part of its
- // stretch of ruler, so the extent has to reach past the last one. Ending short left
- // the final seconds of every such clip belonging to no section at all, and the
- // highlight simply went out there.
- endSec: placementRawSec(placement, placement.sourceEndSec, insertRanges, "closes"),
+ endSec: placementRawSec(placement, placement.sourceEndSec),
};
}
@@ -234,7 +214,6 @@ export function buildClipSection(
transcript: AxcutTranscript | null,
asset: AxcutAsset | null,
removed: RemovedRawSpan[],
- insertRanges: readonly AxcutInsertRange[],
): ClipSection {
const words = transcript
? withSilenceGaps(
@@ -246,10 +225,7 @@ export function buildClipSection(
const tagged: ClipWord[] = words.map((word) => {
// The word's CENTRE, mirroring the rule the identity filter used, so the recording
// lane's tagging does not shift under this change.
- const covering = removalAt(
- removed,
- placementRawSec(clip, (word.startSec + word.endSec) / 2, insertRanges),
- );
+ const covering = removalAt(removed, placementRawSec(clip, (word.startSec + word.endSec) / 2));
return {
id: clipWordId(clip.id, word.id),
word,
@@ -315,7 +291,6 @@ export function buildAggregatedSections(
transcripts: AxcutTranscript[],
assets: AxcutAsset[],
removed: RemovedRawSpan[],
- insertRanges: readonly AxcutInsertRange[],
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
@@ -325,7 +300,6 @@ export function buildAggregatedSections(
transcriptById.get(clip.assetId) ?? null,
assetById.get(clip.assetId) ?? null,
removed,
- insertRanges,
),
);
}
@@ -353,13 +327,12 @@ export function voiceoverPlacements(
* insertions the walk yields one piece per take, which is what this always produced. */
removed: readonly RemovedRawSpan[] = [],
/** This take's own insertions, by group id. */
- insertsFor: (groupId: string) => readonly TakeInsert[] = () => [],
): TranscriptPlacement[] {
return collapseTracksToPills(audioTracks)
.filter((pill) => pill.kind === "voiceover" && !pill.loop)
.sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id))
.flatMap((pill) =>
- takeProgramme(pill, removed, insertsFor(trackGroupId(pill)))
+ takeProgramme(pill, removed)
.filter((piece) => piece.kind === "play")
.map((piece, i) => ({
// Namespaced by piece so two stretches of one take never collide on a word id.
@@ -378,9 +351,8 @@ export function lanePlacements(
clips: AxcutClip[],
audioTracks: AxcutAudioTrack[],
removed: readonly RemovedRawSpan[] = [],
- insertsFor: (groupId: string) => readonly TakeInsert[] = () => [],
): TranscriptPlacement[] {
- return lane === "voiceover" ? voiceoverPlacements(audioTracks, removed, insertsFor) : clips;
+ return lane === "voiceover" ? voiceoverPlacements(audioTracks, removed) : clips;
}
/**
@@ -405,11 +377,7 @@ export function lanePlacements(
* clip whose media has not been probed) has no extent of its own and runs to the next
* section's head, then to the end of time.
*/
-export function findCueWordId(
- sections: ClipSection[],
- rawSec: number | null,
- insertRanges: readonly AxcutInsertRange[],
-): string | null {
+export function findCueWordId(sections: ClipSection[], rawSec: number | null): string | null {
if (rawSec === null || !Number.isFinite(rawSec)) return null;
// No fallback to a neighbouring section: a placement with no transcript simply has no
// cue word, and borrowing another's would point at the wrong text.
@@ -420,7 +388,7 @@ export function findCueWordId(
let match: ClipSection | null = null;
for (const [i, section] of withWords.entries()) {
if (rawSec < section.clip.timelineStartSec) break;
- const extent = placementRawExtent(section.clip, insertRanges);
+ const extent = placementRawExtent(section.clip);
const endSec =
extent?.endSec ?? withWords[i + 1]?.clip.timelineStartSec ?? Number.POSITIVE_INFINITY;
if (rawSec < endSec) {
@@ -431,14 +399,7 @@ export function findCueWordId(
if (!match) return null;
// Back to the placement's own source clock, which is what the words are stamped in.
- const { sourceSec: t, insideInsert } = timelineToSourceSec(match.clip, rawSec, insertRanges);
- // Inside an insertion, the word being spoken is the one that bought it — those seconds
- // exist for no other reason. There is no source second in there to find it by, which is
- // why the added word could never be highlighted before.
- if (insideInsert) {
- const own = match.words.find((cw) => cw.word.id === insideInsert.wordId);
- if (own) return own.id;
- }
+ const t = match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/camera.test.ts b/src/lib/ai-edition/timeline/camera.test.ts
index e1caa03a5..1f7fea91b 100644
--- a/src/lib/ai-edition/timeline/camera.test.ts
+++ b/src/lib/ai-edition/timeline/camera.test.ts
@@ -56,7 +56,6 @@ describe("resolveActiveCameraTrack", () => {
[assetWithCamera, assetWithoutCamera],
[clipWithCamera, clipWithoutCamera],
2,
- [],
);
expect(track?.sourcePath).toBe("/cam-1.mp4");
});
@@ -66,18 +65,17 @@ describe("resolveActiveCameraTrack", () => {
[assetWithCamera, assetWithoutCamera],
[clipWithCamera, clipWithoutCamera],
7,
- [],
);
expect(track).toBeNull();
});
it("returns null when there are no clips", () => {
- expect(resolveActiveCameraTrack([assetWithCamera], [], 0, [])).toBeNull();
+ expect(resolveActiveCameraTrack([assetWithCamera], [], 0)).toBeNull();
});
it("returns null when the active clip references an unknown asset", () => {
const orphanClip: AxcutClip = { ...clipWithCamera, assetId: "missing" };
- expect(resolveActiveCameraTrack([assetWithCamera], [orphanClip], 2, [])).toBeNull();
+ expect(resolveActiveCameraTrack([assetWithCamera], [orphanClip], 2)).toBeNull();
});
});
diff --git a/src/lib/ai-edition/timeline/camera.ts b/src/lib/ai-edition/timeline/camera.ts
index 0f11ed0ce..4fc576952 100644
--- a/src/lib/ai-edition/timeline/camera.ts
+++ b/src/lib/ai-edition/timeline/camera.ts
@@ -4,18 +4,15 @@
// timeline, and whether the timeline has ANY camera at all — used to gate
// camera-only preview chrome and settings controls.
-import type { AxcutAsset, AxcutCameraTrack, AxcutClip, AxcutInsertRange } from "../schema";
+import type { AxcutAsset, AxcutCameraTrack, AxcutClip } from "../schema";
import { locateVirtualPosition } from "./virtual-preview";
export function resolveActiveCameraTrack(
assets: AxcutAsset[],
clips: AxcutClip[],
currentTimeSec: number,
- /** REQUIRED: a clip carrying insertions is longer than its source window, so which clip
- * a timeline second falls on cannot be answered without them (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): AxcutCameraTrack | null {
- const position = locateVirtualPosition(clips, currentTimeSec, insertRanges);
+ const position = locateVirtualPosition(clips, currentTimeSec);
if (!position) return null;
const activeAsset = assets.find((a) => a.id === position.clip.assetId);
return activeAsset?.cameraTrack ?? null;
diff --git a/src/lib/ai-edition/timeline/cursor-track.ts b/src/lib/ai-edition/timeline/cursor-track.ts
index f190828d6..aec4324a4 100644
--- a/src/lib/ai-edition/timeline/cursor-track.ts
+++ b/src/lib/ai-edition/timeline/cursor-track.ts
@@ -16,7 +16,7 @@
// sample, nothing is summarised, and every pointer-shape change survives the
// reduction because a shape change is an observed event, not a verdict about it.
-import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
import { locateSourcePosition } from "./virtual-preview";
/**
@@ -158,9 +158,6 @@ export interface CursorTrackOptions {
durationSec: number;
clips: AxcutClip[];
trimRanges?: AxcutTrimRange[];
- /** The insertions those clips carry: a clip carrying one is longer than its source
- * window, so a capture timestamp's place on the timeline moves with them (issue #560). */
- insertRanges?: readonly AxcutInsertRange[];
hz?: number;
maxPoints?: number;
/** Movement threshold in frame fractions; see DEFAULT_TRACK_EPSILON. */
@@ -172,7 +169,6 @@ export interface CursorTrackOptions {
export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
const { assetId, samples, durationSec, clips } = options;
const trimRanges = options.trimRanges ?? [];
- const insertRanges = options.insertRanges ?? [];
const maxPoints = options.maxPoints ?? DEFAULT_MAX_TRACK_POINTS;
const ceilingMs = Math.max(0, durationSec) * 1000 || Number.POSITIVE_INFINITY;
@@ -298,7 +294,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
// not told twice.
const shifted = keep.some((s) => {
const atSec = s.timeMs / 1000;
- const position = locateSourcePosition(clips, atSec, assetId, 0.05, undefined, insertRanges);
+ const position = locateSourcePosition(clips, atSec, assetId);
return !position || Math.abs(position.virtualTimeSec - atSec) > 0.005;
});
@@ -307,7 +303,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
// `locateSourcePosition` is the existing source→virtual mapping, exact here
// because trims do NOT compact the document's virtual axis — a trim is a hole
// in playback, not a shortening of the ruler (see timeline/trim-mapping.ts).
- const position = locateSourcePosition(clips, atSec, assetId, 0.05, undefined, insertRanges);
+ const position = locateSourcePosition(clips, atSec, assetId);
const point: CursorTrackPoint = {
atSec: round2(atSec),
cx: round3(s.cx),
diff --git a/src/lib/ai-edition/timeline/insert-mapping.test.ts b/src/lib/ai-edition/timeline/insert-mapping.test.ts
deleted file mode 100644
index 8e75b2d11..000000000
--- a/src/lib/ai-edition/timeline/insert-mapping.test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-// Issue #560. An insertion made from the recording transcript buys the FILM time; one made
-// from a voiceover transcript buys the TAKE silence and leaves the picture alone. The row
-// looks identical either way — only the asset it names says which lane it is on.
-//
-// These also lock the inertness that is true today by ACCIDENT: a voiceover row reaches
-// `rulerInserts` and `resolvePlaybackSegments` and is ignored by both, purely because they
-// match on `clip.assetId`. That accident is the only reason writing one is harmless right
-// now, so it becomes a rule with a test before anything starts writing them.
-
-import { describe, expect, it } from "vitest";
-import { resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutAudioTrack, AxcutClip, AxcutDocument, AxcutInsertRange } from "../schema";
-import { resolveInsertPlacement, takeInserts } from "./insert-mapping";
-import { rulerInserts } from "./inserted-time";
-
-const CLIPS: AxcutClip[] = [
- {
- id: "c1",
- assetId: "rec",
- sourceStartSec: 0,
- sourceEndSec: 6,
- timelineStartSec: 0,
- timelineEndSec: 6,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
- {
- id: "c2",
- assetId: "rec",
- sourceStartSec: 20,
- sourceEndSec: 26,
- timelineStartSec: 6,
- timelineEndSec: 12,
- wordRefs: [],
- origin: "user",
- reason: "",
- },
-];
-
-const TAKE = {
- id: "vo_frag",
- trackId: "vo",
- assetId: "aud",
- kind: "voiceover",
- startMs: 2000,
- endMs: 10_000,
- durationSec: 30,
- offsetMs: 1000,
- gainDb: 0,
- loop: false,
- fadeInMs: 0,
- fadeOutMs: 0,
- muted: false,
- label: "",
- origin: "user",
-} as unknown as AxcutAudioTrack;
-
-function insert(over: Partial & { id: string }): AxcutInsertRange {
- return {
- assetId: "rec",
- atSec: 3,
- durationSec: 0.5,
- wordId: `w_${over.id}`,
- reason: "",
- origin: "user",
- ...over,
- } as AxcutInsertRange;
-}
-
-function doc(inserts: AxcutInsertRange[], over: Partial = {}): AxcutDocument {
- return {
- schemaVersion: 7,
- project: { id: "p", title: "T", createdAt: "", updatedAt: "" },
- assets: [
- {
- id: "rec",
- kind: "video",
- label: "r",
- originalPath: "/r.mp4",
- durationSec: 30,
- cameraTrack: null,
- },
- {
- id: "aud",
- kind: "audio",
- label: "a",
- originalPath: "/a.mp3",
- durationSec: 30,
- cameraTrack: null,
- },
- ],
- transcript: null,
- transcripts: [],
- timeline: {
- clips: CLIPS,
- gaps: [],
- trimRanges: [],
- muteRanges: [],
- speedRanges: [],
- captionRanges: [],
- insertRanges: inserts,
- },
- annotations: [],
- zoomRanges: [],
- audioTracks: [TAKE],
- legacyEditor: null,
- ...over,
- } as unknown as AxcutDocument;
-}
-
-describe("resolveInsertPlacement", () => {
- it("leaves a recording insert to `rulerInserts`, the one place that places one", () => {
- // It used to answer with a raw second of its own, from a plain shift the clip's own
- // insertions made wrong — a second, contradictory answer that nothing read.
- const row = insert({ id: "i1", atSec: 3 });
- expect(resolveInsertPlacement(row, doc([row]))).toBeNull();
- });
-
- it("leaves a voiceover insert UNPROJECTED, naming the take and a source second", () => {
- // Deliberately not a raw moment: where it lands depends on the insertions before it
- // inside the same take and on the cuts under it, and only the take's walk knows.
- const row = insert({ id: "i1", assetId: "aud", atSec: 4 });
- expect(resolveInsertPlacement(row, doc([row]))).toEqual({
- lane: "voiceover",
- trackGroupId: "vo",
- atSourceSec: 4,
- });
- });
-
- it("returns null when nothing carries the moment any more", () => {
- // Past every clip's source window...
- expect(resolveInsertPlacement(insert({ id: "i1", atSec: 40 }), doc([]))).toBeNull();
- // ...outside the take's own window (offset 1s, span 8s → source 1..9)...
- expect(
- resolveInsertPlacement(insert({ id: "i2", assetId: "aud", atSec: 12 }), doc([])),
- ).toBeNull();
- // ...and when the take has been deleted outright.
- expect(
- resolveInsertPlacement(
- insert({ id: "i3", assetId: "aud", atSec: 4 }),
- doc([], { audioTracks: [] }),
- ),
- ).toBeNull();
- });
-
- it("names the take by its GROUP, so a split take resolves to one thing", () => {
- const split = doc([], {
- audioTracks: [
- { ...TAKE, id: "f1", trackId: "vo", startMs: 2000, endMs: 6000, offsetMs: 1000 },
- { ...TAKE, id: "f2", trackId: "vo", startMs: 6000, endMs: 10_000, offsetMs: 5000 },
- ],
- });
- const row = insert({ id: "i1", assetId: "aud", atSec: 2 });
- expect(resolveInsertPlacement(row, split)).toMatchObject({ trackGroupId: "vo" });
- });
-});
-
-describe("takeInserts", () => {
- it("collects one take's insertions in its own source order", () => {
- const rows = [
- insert({ id: "b", assetId: "aud", atSec: 6 }),
- insert({ id: "a", assetId: "aud", atSec: 2 }),
- insert({ id: "film", assetId: "rec", atSec: 3 }),
- ];
- expect(takeInserts(doc(rows), "vo").map((i) => [i.id, i.atSourceSec])).toEqual([
- ["a", 2],
- ["b", 6],
- ]);
- });
-
- it("returns nothing for a take that has none", () => {
- expect(takeInserts(doc([insert({ id: "film" })]), "vo")).toEqual([]);
- });
-});
-
-describe("a voiceover insert is inert on the film, deliberately", () => {
- const row = insert({ id: "i1", assetId: "aud", atSec: 4 });
-
- it("produces no ruler insert, so the film's length does not move", () => {
- expect(rulerInserts([row], CLIPS)).toEqual([]);
- // And the recording's own row still does.
- expect(rulerInserts([insert({ id: "i2", atSec: 3 })], CLIPS)).toHaveLength(1);
- });
-
- it("produces no held segment, so no clip freezes for it", () => {
- const held = (rows: AxcutInsertRange[]) =>
- resolvePlaybackSegments(CLIPS, [], rows).filter((s) => s.heldSec !== undefined);
- expect(held([row])).toEqual([]);
- expect(held([insert({ id: "i2", atSec: 3 })])).toHaveLength(1);
- });
-});
diff --git a/src/lib/ai-edition/timeline/insert-mapping.ts b/src/lib/ai-edition/timeline/insert-mapping.ts
deleted file mode 100644
index 66f2fdf0c..000000000
--- a/src/lib/ai-edition/timeline/insert-mapping.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-// Where an insertion lives, resolved rather than stored (issue #560).
-//
-// An added word buys itself time. On the RECORDING lane that time is film: the clip holds
-// a frame and the ruler grows. On the VOICEOVER lane it is a silence inside the take: the
-// picture is not touched at all, and the narration that follows lands later against the
-// same image.
-//
-// The record says which by naming an asset, and the asset's `kind` says which lane it is
-// on. Nothing stores a container id, and that is deliberate: every candidate is ephemeral.
-// A voiceover fragment id is re-minted by `reanchorAudioTracks` on the first clip drag; a
-// clip id does not survive a split, which in this repo is `duplicateClip` plus two
-// `setClipSourceRange` calls that move `atSec` out of the half that was named. An
-// un-anchored region reaching every placement of its asset is the working state here — see
-// `duplicateClip`'s own comment about trims.
-//
-// The two lanes come back in DIFFERENT shapes on purpose. A recording insert can be given
-// its raw moment immediately, through the clip that plays that source second. A voiceover
-// insert cannot: its ruler position depends on the insertions before it inside the same
-// take AND on the cuts under it, and only the take's own walk can resolve that. Handing
-// back an unprojected result is what stops a caller from inventing a projection that would
-// disagree with the walk.
-
-import { trackGroupId } from "../document/audioTracks";
-import type { AxcutDocument, AxcutInsertRange } from "../schema";
-
-/** A silence inside a take: no picture of its own. */
-export interface VoiceoverInsertPlacement {
- lane: "voiceover";
- /** The user-visible take, not one of its stored fragments. */
- trackGroupId: string;
- /** Deliberately in the take's SOURCE seconds — see the note above. */
- atSourceSec: number;
-}
-
-export type InsertPlacement = VoiceoverInsertPlacement;
-
-/**
- * Which TAKE carries this insertion, or null — a recording's insertion, or a take that has
- * been deleted. Only takes need answering here: an insertion in the film is placed by
- * `rulerInserts`, which is the one definition of where an insertion sits on the timeline.
- *
- * The lane is read from the ASSET, never from the row: `kind: "audio"` is the only thing
- * that distinguishes a take's transcript from the film's, and it is already the
- * discriminator `lanePlacements` uses for the transcript tab.
- */
-export function resolveInsertPlacement(
- insert: AxcutInsertRange,
- document: AxcutDocument,
-): InsertPlacement | null {
- const asset = document.assets.find((a) => a.id === insert.assetId);
- if (asset?.kind !== "audio") return null;
- // The first take drawing on this asset whose source window contains the moment.
- // Inclusive at both edges, matching `rulerInserts`: an insertion sits at the END of the
- // word it follows, which is routinely a window's own boundary.
- for (const track of document.audioTracks ?? []) {
- if (track.kind !== "voiceover" || track.assetId !== insert.assetId) continue;
- const startSec = track.offsetMs / 1000;
- const endSec = startSec + Math.max(0, track.endMs - track.startMs) / 1000;
- if (insert.atSec < startSec || insert.atSec > endSec) continue;
- return { lane: "voiceover", trackGroupId: trackGroupId(track), atSourceSec: insert.atSec };
- }
- return null;
-}
-
-/** The insertions belonging to one take, in the take's own source order. */
-export function takeInserts(
- document: AxcutDocument,
- groupId: string,
-): Array<{ id: string; wordId: string; atSourceSec: number; durationSec: number }> {
- const out: Array<{ id: string; wordId: string; atSourceSec: number; durationSec: number }> = [];
- for (const insert of document.timeline.insertRanges ?? []) {
- const placement = resolveInsertPlacement(insert, document);
- if (placement?.lane !== "voiceover" || placement.trackGroupId !== groupId) continue;
- out.push({
- id: insert.id,
- wordId: insert.wordId,
- atSourceSec: placement.atSourceSec,
- durationSec: insert.durationSec,
- });
- }
- return out.sort((a, b) => a.atSourceSec - b.atSourceSec || a.id.localeCompare(b.id));
-}
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
deleted file mode 100644
index b1037f94c..000000000
--- a/src/lib/ai-edition/timeline/inserted-time.test.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-// The ruler arithmetic behind an added word's pause.
-//
-// The one thing these have to pin: stored raw seconds and the seconds the user scrubs stop
-// being the same number the moment a pause exists, and every reader that confuses the two
-// puts a region, a playhead or a caption in the wrong place. The pair is an inverse
-// everywhere except inside a pause — which is not a gap in the model, it is the pause.
-
-import { describe, expect, it } from "vitest";
-import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
-import {
- insertedWordMarks,
- insertionEnteredBetween,
- type RulerInsert,
- rulerInserts,
- sourceToTimelineSec,
- timelineToSourceSec,
-} from "./inserted-time";
-
-function clipFixture(overrides: Partial & Pick): AxcutClip {
- return {
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user",
- reason: "",
- ...overrides,
- };
-}
-
-function insert(overrides: Partial = {}): AxcutInsertRange {
- return {
- id: "ins_1",
- assetId: "a1",
- atSec: 4,
- durationSec: 0.5,
- wordId: "synth_1",
- reason: "",
- origin: "user",
- ...overrides,
- };
-}
-
-describe("rulerInserts", () => {
- it("projects a pause through the clip that plays its moment", () => {
- // The clip plays source 4–10 starting at ruler 20, so source 6 is ruler 22.
- const clips = [
- clipFixture({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 }),
- ];
- expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([
- { id: "ins_1", wordId: "synth_1", atRawSec: 22, durationSec: 0.5 },
- ]);
- });
-
- // The word is not on the timeline, so its pause has no place on the ruler and adds
- // nothing — the same rule a caption line follows when no clip covers it.
- it("drops a pause no clip plays", () => {
- const clips = [
- clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 }),
- ];
- expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([]);
- });
-
- it("counts a pause sitting exactly on a clip's edge", () => {
- // A pause sits at the END of the word it follows, which is routinely the boundary.
- const clips = [
- clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 }),
- ];
- expect(rulerInserts([insert({ atSec: 4 })], clips)).toHaveLength(1);
- });
-
- it("returns them in ruler order, whatever order they were stored in", () => {
- const clips = [clipFixture({ id: "c1" })];
- const placed = rulerInserts(
- [insert({ id: "b", atSec: 8 }), insert({ id: "a", atSec: 2 })],
- clips,
- );
- expect(placed.map((p) => p.id)).toEqual(["a", "b"]);
- });
-
- it("places a pause only once when two clips could play its moment", () => {
- const clips = [
- clipFixture({ id: "c1" }),
- clipFixture({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
- ];
- expect(rulerInserts([insert()], clips)).toHaveLength(1);
- });
-});
-
-// ─── Where an added word's mark goes ─────────────────────────────────────────
-// Issue #560. Two defects lived in one ternary in V4Timeline: a word WITH a pause was
-// placed on the expanded ruler and one WITHOUT at a fraction of the clip's SOURCE span —
-// two clocks, and the clip box is drawn in neither of them consistently. And both edges
-// were inclusive, so a word whose pause sits on a split boundary painted twice.
-
-function markClip(over: Partial & { id: string }): AxcutClip {
- return {
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 5,
- timelineStartSec: 0,
- timelineEndSec: 5,
- wordRefs: [],
- origin: "user",
- reason: "",
- ...over,
- } as AxcutClip;
-}
-
-const synth = (id: string, startSec: number): AxcutWord =>
- ({ id, segmentId: "s", text: id, startSec, endSec: startSec, source: "synth" }) as AxcutWord;
-
-describe("insertedWordMarks", () => {
- const split = [
- markClip({
- id: "c1",
- sourceStartSec: 0,
- sourceEndSec: 5,
- timelineStartSec: 0,
- timelineEndSec: 5,
- }),
- markClip({
- id: "c2",
- sourceStartSec: 5,
- sourceEndSec: 10,
- timelineStartSec: 5,
- timelineEndSec: 10,
- }),
- ];
-
- it("paints a word on a split boundary exactly once", () => {
- const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_edge", 5)] }], split, []);
- expect(marks).toHaveLength(1);
- expect(marks[0]).toMatchObject({ clipId: "c2", atRawSec: 5 });
- });
-
- it("places every mark in RAW seconds through its own clip", () => {
- const marks = insertedWordMarks(
- [{ assetId: "a1", words: [synth("early", 2), synth("late", 7)] }],
- split,
- [],
- );
- expect(marks.map((m) => [m.clipId, m.atRawSec])).toEqual([
- ["c1", 2],
- ["c2", 7],
- ]);
- });
-
- it("keeps a word at the very end of the last clip", () => {
- // Half-open everywhere but the tail, or the final word of a project vanishes.
- const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_end", 10)] }], split, []);
- expect(marks.map((m) => m.wordId)).toEqual(["w_end"]);
- });
-
- it("ignores words nobody added", () => {
- const spoken = { id: "w1", segmentId: "s", text: "w1", startSec: 2, endSec: 3 } as AxcutWord;
- expect(insertedWordMarks([{ assetId: "a1", words: [spoken] }], split, [])).toEqual([]);
- });
-
- it("ignores a transcript no clip draws on", () => {
- expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split, [])).toEqual(
- [],
- );
- });
-});
-
-// ─── Source ↔ timeline, inside one clip ─────────────────────────────────────
-// The whole consequence of an insertion being MEDIA: the clip is longer than its source
-// window, so a moment past an insertion sits that much further along the timeline. Every
-// place that used to convert between a "raw" and an "expanded" ruler is asking this, of
-// one clip — and getting it wrong put a caption, a playhead or a decoder in the wrong
-// place (issue #560).
-
-describe("source ↔ timeline through a clip that carries insertions", () => {
- // Ten seconds of recording laid at timeline 0, with 0.5s inserted at source 2 and 1s
- // at source 6 — so the clip is 11.5s long and its source window is untouched.
- const clip = clipFixture({
- id: "c1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 11.5,
- });
- const ranges: AxcutInsertRange[] = [
- {
- id: "a",
- assetId: "a1",
- atSec: 2,
- durationSec: 0.5,
- wordId: "w_a",
- reason: "",
- origin: "user",
- },
- {
- id: "b",
- assetId: "a1",
- atSec: 6,
- durationSec: 1,
- wordId: "w_b",
- reason: "",
- origin: "user",
- },
- ];
-
- it("leaves everything before the first insertion where it was", () => {
- expect(sourceToTimelineSec(clip, 0, ranges)).toBeCloseTo(0, 6);
- expect(sourceToTimelineSec(clip, 1.9, ranges)).toBeCloseTo(1.9, 6);
- });
-
- it("counts every insertion before the moment, and only those", () => {
- expect(sourceToTimelineSec(clip, 4, ranges)).toBeCloseTo(4.5, 6);
- expect(sourceToTimelineSec(clip, 10, ranges)).toBeCloseTo(11.5, 6);
- });
-
- it("puts the insertion's own moment where it opens, or where it closes", () => {
- // The choice is real: a position and a span's START go before the inserted media,
- // a span's END goes after it, so a caption running up to an added word covers it.
- expect(sourceToTimelineSec(clip, 2, ranges, "opens")).toBeCloseTo(2, 6);
- expect(sourceToTimelineSec(clip, 2, ranges, "closes")).toBeCloseTo(2.5, 6);
- });
-
- it("comes back to the source moment it started from", () => {
- for (const source of [0, 1.9, 2, 3, 5.5, 6, 9.99]) {
- const back = timelineToSourceSec(clip, sourceToTimelineSec(clip, source, ranges), ranges);
- expect(back.sourceSec).toBeCloseTo(source, 6);
- }
- });
-
- it("has no source moment inside an insertion, and says which one", () => {
- // There is nothing else it could answer: none of those seconds come from the file.
- const inside = timelineToSourceSec(clip, 2.25, ranges);
- expect(inside.sourceSec).toBeCloseTo(2, 6);
- expect(inside.insideInsert?.id).toBe("a");
- expect(timelineToSourceSec(clip, 2.5, ranges).insideInsert).toBeNull();
- });
-
- it("is the plain shift when the clip carries nothing", () => {
- expect(sourceToTimelineSec(clip, 4, [])).toBeCloseTo(4, 6);
- expect(timelineToSourceSec(clip, 4, []).sourceSec).toBeCloseTo(4, 6);
- });
-
- it("ignores insertions belonging to another recording", () => {
- const other = [{ ...ranges[0], assetId: "a2" }];
- expect(sourceToTimelineSec(clip, 4, other)).toBeCloseTo(4, 6);
- });
-});
-
-// ─── Running into an insertion ──────────────────────────────────────────────
-// An added word inserts MEDIA inside the clip — a fixed frame and silence, until there is
-// a generator for it. Playback runs THROUGH that media, and the half-open rule below is
-// what keeps it from running through the same insertion forever.
-
-describe("the insertion a frame runs into", () => {
- const marks: RulerInsert[] = [
- { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
- { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
- ];
-
- it("is found when the frame crosses it", () => {
- expect(insertionEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
- expect(insertionEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
- });
-
- it("is not found again from the moment it occupies", () => {
- // While the insertion plays, the raw playhead stands still at exactly 4. Coming out,
- // the next frames must not re-enter — otherwise the film never gets past it.
- expect(insertionEnteredBetween(4, 4.02, marks)).toBeUndefined();
- expect(insertionEnteredBetween(4, 4.5, marks)).toBeUndefined();
- });
-
- it("takes the earliest of several in one frame, and none outside", () => {
- expect(insertionEnteredBetween(0, 20, marks)?.id).toBe("i1");
- expect(insertionEnteredBetween(5, 8, marks)).toBeUndefined();
- });
-
- it("plays an insertion landing exactly on the frame boundary", () => {
- expect(insertionEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
- });
-});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
deleted file mode 100644
index 8b3e7950e..000000000
--- a/src/lib/ai-edition/timeline/inserted-time.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-// Time the film does not have.
-//
-// An added word needs somewhere to be spoken. Where the transcript has free silence it
-// borrows it; where it does not, the film holds its frame and everything after it moves
-// along the ruler. That created time is stored as an `AxcutInsertRange` — the inverse of a
-// trim, and deliberately the same shape, because a region is what this timeline already
-// carries safely from end to end. (An earlier attempt made CLIPS for it; see the schema's
-// note on `insertRangeSchema` for how that ended.)
-//
-// This module is the arithmetic, and nothing else: pure, no document, no React. It answers
-// two questions.
-//
-// • Where does an insertion land on the RULER? A range is anchored in SOURCE time, so it has
-// to be projected through whichever clip plays that moment — `rulerInserts`.
-// • What does the ruler look like once the insertions are counted? Stored raw seconds and
-// the seconds the user actually scrubs are no longer the same number, and
-// `expandRawSec` / `collapseRawSec` are the one place that difference is resolved.
-//
-// The two are inverses everywhere except INSIDE an insertion, where they cannot be: a stretch
-// of ruler maps to the single source moment being held. `collapseRawSec` returns that
-// moment, which is exactly what a decoder parked on a held frame should be told.
-
-import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
-
-/** An insertion placed on the raw ruler, ready to be counted. */
-export interface RulerInsert {
- id: string;
- wordId: string;
- /** Where the insertion begins, in STORED raw seconds — before any insertion is counted. */
- atRawSec: number;
- durationSec: number;
-}
-
-/**
- * Project each insert onto the ruler through the clip that owns it.
- *
- * A range whose moment no clip plays yields nothing: the insertion exists for a word that is
- * not on the timeline, so there is no ruler position for it and nothing to add. Same rule
- * the captions follow for a line no clip covers.
- *
- * Ordered by ruler position, which is what lets the accumulation below be a single pass.
- */
-/**
- * Which clip owns each insertion.
- *
- * ONE definition, because the geometry and the drawing must not answer this differently.
- * An insertion is anchored to an ASSET and a source moment, not to a clip, so two clips over
- * the same recording could both claim it — and when they did, the film grew twice while the
- * pill was drawn once. It is claimed by the FIRST clip that plays its moment, in timeline
- * order: the insertion is stored once and the word exists once, so it happens once.
- */
-export function assignInsertsToClips(
- clips: readonly AxcutClip[],
- inserts: readonly AxcutInsertRange[],
-): Map {
- const byClip = new Map();
- const claimed = new Set();
- for (const clip of [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec)) {
- const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
- const mine = inserts
- // Inclusive at both edges: an insertion sits at the END of the word it follows,
- // which is routinely a clip's own boundary.
- .filter(
- (insert) =>
- insert.assetId === clip.assetId &&
- !claimed.has(insert.id) &&
- insert.atSec >= clip.sourceStartSec &&
- insert.atSec <= sourceEnd,
- )
- .sort((a, b) => a.atSec - b.atSec);
- for (const insert of mine) claimed.add(insert.id);
- if (mine.length > 0) byClip.set(clip.id, mine);
- }
- return byClip;
-}
-
-export function rulerInserts(
- inserts: readonly AxcutInsertRange[],
- clips: readonly AxcutClip[],
-): RulerInsert[] {
- const byClip = assignInsertsToClips(clips, inserts);
- const placed: RulerInsert[] = [];
- for (const clip of clips) {
- // Each insertion opens after the ones before it in the same clip: the clip's length
- // already carries all of them, so a plain source-shift would stack them all at the
- // first one's position.
- let carriedSec = 0;
- for (const insert of byClip.get(clip.id) ?? []) {
- placed.push({
- id: insert.id,
- wordId: insert.wordId,
- atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + carriedSec,
- durationSec: insert.durationSec,
- });
- carriedSec += insert.durationSec;
- }
- }
- return placed.sort((a, b) => a.atRawSec - b.atRawSec);
-}
-
-/**
- * A clip's own source moment → where it lands on the timeline.
- *
- * Not a plain shift, and this is the whole consequence of an insertion being MEDIA: the
- * clip is longer than its source window by everything inserted inside it, so a moment past
- * an insertion sits that much further along. Every place that used to convert between a
- * "raw" and an "expanded" ruler is really asking this, of one clip.
- *
- * `edge` decides what happens AT an insertion's own moment, which is a real choice and not
- * a rounding detail. `"opens"` puts the moment before the inserted media — right for a
- * position, and for the START of a span, so the span does not swallow the insertion that
- * precedes it. `"closes"` puts it after — right for the END of a span, so a stretch running
- * up to an insertion covers it rather than stopping short and leaving it orphaned.
- */
-export function sourceToTimelineSec(
- /** Only the three fields that locate a clip — so a voiceover placement, which carries
- * the same three, maps through this too (issue #560). */
- clip: Pick,
- sourceSec: number,
- inserts: readonly AxcutInsertRange[],
- edge: "opens" | "closes" = "opens",
-): number {
- let added = 0;
- for (const insert of inserts) {
- if (insert.assetId !== clip.assetId) continue;
- if (insert.atSec <= clip.sourceStartSec) continue;
- if (edge === "opens" ? insert.atSec < sourceSec : insert.atSec <= sourceSec + 1e-6) {
- added += insert.durationSec;
- }
- }
- return clip.timelineStartSec + (sourceSec - clip.sourceStartSec) + added;
-}
-
-/**
- * The inverse: a timeline second → the source moment the clip is showing there.
- *
- * Inside an insertion there is no source moment — that is what makes it an insertion — so
- * it answers with the moment the inserted media follows, and names the insertion. A caller
- * driving a decoder needs both: where to park, and the fact that it should stay parked.
- */
-export function timelineToSourceSec(
- /** The same three fields `sourceToTimelineSec` needs, so a voiceover placement maps
- * through this too. */
- clip: Pick,
- timelineSec: number,
- inserts: readonly AxcutInsertRange[],
-): { sourceSec: number; insideInsert: AxcutInsertRange | null } {
- const mine = inserts
- .filter((insert) => insert.assetId === clip.assetId && insert.atSec > clip.sourceStartSec)
- .sort((a, b) => a.atSec - b.atSec);
- let added = 0;
- for (const insert of mine) {
- const opensAt = clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + added;
- if (timelineSec < opensAt) break;
- if (timelineSec < opensAt + insert.durationSec) {
- return { sourceSec: insert.atSec, insideInsert: insert };
- }
- added += insert.durationSec;
- }
- return {
- sourceSec: clip.sourceStartSec + (timelineSec - clip.timelineStartSec) - added,
- insideInsert: null,
- };
-}
-
-/**
- * The insertion a frame of playback ran into, if it ran into one.
- *
- * Half-open on the LEFT, and that is the whole point: a player parks on the insertion's
- * frame and pins its clock to exactly `atRawSec` for the first frame of it, so `>` is what
- * refuses that same moment on the way in a second time. Closed on the right (with the frame
- * epsilon) so an insertion landing precisely on a frame boundary is played, not skipped.
- */
-export function insertionEnteredBetween(
- prevSec: number,
- nextSec: number,
- inserts: readonly RulerInsert[],
- epsilonSec = 1e-6,
-): RulerInsert | undefined {
- return inserts.find(
- (insert) => insert.atRawSec > prevSec && insert.atRawSec <= nextSec + epsilonSec,
- );
-}
-
-/** An added word, placed on the raw ruler through the clip that carries it. */
-export interface InsertedWordMark {
- clipId: string;
- wordId: string;
- text: string;
- atRawSec: number;
-}
-
-/**
- * Where each added word's mark belongs, one per word.
- *
- * Claimed once, and half-open at a clip's far edge except for the last: an insertion sits at the
- * END of the word it follows, which is routinely a split boundary, and testing both edges
- * inclusively painted the same word in BOTH halves (issue #560).
- *
- * Returns RAW seconds. The caller expands them; it used to mix a raw-then-expanded position
- * for a word with an insertion and a fraction of the clip's SOURCE span for one without, in the
- * same ternary — two clocks, and the clip box is not drawn in the second.
- */
-export function insertedWordMarks(
- transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>,
- clips: readonly AxcutClip[],
- insertRanges: readonly AxcutInsertRange[],
-): InsertedWordMark[] {
- const byAsset = new Map>();
- for (const transcript of transcripts) {
- const added = transcript.words.filter((word) => word.source === "synth");
- if (added.length > 0) byAsset.set(transcript.assetId, added);
- }
- if (byAsset.size === 0) return [];
-
- const marks: InsertedWordMark[] = [];
- const claimed = new Set();
- clips.forEach((clip, index) => {
- const words = byAsset.get(clip.assetId);
- const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
- if (!words || sourceEnd <= clip.sourceStartSec) return;
- const isLast = index === clips.length - 1;
- for (const word of words) {
- if (claimed.has(word.id)) continue;
- if (word.startSec < clip.sourceStartSec) continue;
- if (word.startSec > sourceEnd || (!isLast && word.startSec === sourceEnd)) continue;
- claimed.add(word.id);
- marks.push({
- clipId: clip.id,
- wordId: word.id,
- text: word.text,
- atRawSec: sourceToTimelineSec(clip, word.startSec, insertRanges),
- });
- }
- });
- return marks;
-}
diff --git a/src/lib/ai-edition/timeline/programme-time.test.ts b/src/lib/ai-edition/timeline/programme-time.test.ts
index e302703dd..5d6faa4ef 100644
--- a/src/lib/ai-edition/timeline/programme-time.test.ts
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -7,7 +7,7 @@
import { describe, expect, it } from "vitest";
import { projectRawTimelineSecToPlayback, resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
import { keptRawSpans, removalAt, removedRawSpans, subtractRemoved } from "./programme-time";
function clip(over: Partial & { id: string }): AxcutClip {
@@ -114,7 +114,7 @@ describe("keptRawSpans agrees with playback", () => {
(sum, seg) => sum + ((seg.sourceEndSec ?? seg.sourceStartSec) - seg.sourceStartSec),
0,
);
- const kept = keptRawSpans(clips, trims, []);
+ const kept = keptRawSpans(clips, trims);
expect(total(kept), `seed ${seed} total`).toBeCloseTo(played, 6);
// The sum alone is blind to ORDER, and order is the whole reason this walk was
@@ -125,7 +125,7 @@ describe("keptRawSpans agrees with playback", () => {
let before = 0;
for (const [i, span] of kept.entries()) {
expect(
- projectRawTimelineSecToPlayback(clips, trims, span.startSec, []),
+ projectRawTimelineSecToPlayback(clips, trims, span.startSec),
`seed ${seed} span ${i}`,
).toBeCloseTo(before, 6);
before += span.endSec - span.startSec;
@@ -153,7 +153,7 @@ describe("keptRawSpans agrees with playback", () => {
timelineEndSec: 6,
}),
];
- expect(keptRawSpans(clips, [], []).map((s) => s.startSec)).toEqual([0, 6]);
+ expect(keptRawSpans(clips, []).map((s) => s.startSec)).toEqual([0, 6]);
});
it("leaves the projection identical to what it produced before the lift", () => {
@@ -161,13 +161,13 @@ describe("keptRawSpans agrees with playback", () => {
const trims = [trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 })];
// Raw 2..4 is gone, so everything after it plays 2s earlier; inside the cut the
// playhead lands on the output edge just before it.
- expect(projectRawTimelineSecToPlayback(clips, trims, 1, [])).toBeCloseTo(1, 6);
- expect(projectRawTimelineSecToPlayback(clips, trims, 3, [])).toBeCloseTo(2, 6);
- expect(projectRawTimelineSecToPlayback(clips, trims, 6, [])).toBeCloseTo(4, 6);
- expect(projectRawTimelineSecToPlayback(clips, trims, 20, [])).toBeCloseTo(18, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 1)).toBeCloseTo(1, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 3)).toBeCloseTo(2, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 6)).toBeCloseTo(4, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 20)).toBeCloseTo(18, 6);
// Past the programme the projection is the identity, which is what lets a voiceover
// hang off the end and keep playing.
- expect(projectRawTimelineSecToPlayback(clips, trims, 25, [])).toBeCloseTo(23, 6);
+ expect(projectRawTimelineSecToPlayback(clips, trims, 25)).toBeCloseTo(23, 6);
});
});
@@ -178,8 +178,8 @@ describe("removedRawSpans", () => {
trim({ id: "t1", clipId: "c1", startSec: 2, endSec: 4 }),
trim({ id: "t2", clipId: "c2", startSec: 15, endSec: 16 }),
];
- const kept = [...keptRawSpans(clips, trims, [])].sort((a, b) => a.startSec - b.startSec);
- const removed = removedRawSpans(clips, trims, []);
+ const kept = [...keptRawSpans(clips, trims)].sort((a, b) => a.startSec - b.startSec);
+ const removed = removedRawSpans(clips, trims);
const all = [...kept, ...removed].sort((a, b) => a.startSec - b.startSec);
let cursor = 0;
@@ -201,7 +201,7 @@ describe("removedRawSpans", () => {
timelineEndSec: 23,
}),
];
- const gap = removedRawSpans(clips, [], []).find((s) => s.startSec === 10);
+ const gap = removedRawSpans(clips, []).find((s) => s.startSec === 10);
expect(gap).toMatchObject({ startSec: 10, endSec: 13 });
// No trim took it, so the pane must not offer a restore.
expect(gap?.trimIds).toEqual([]);
@@ -210,7 +210,7 @@ describe("removedRawSpans", () => {
it("removes a trimmed tail of the last clip but never the time past it", () => {
const clips = [twoClips()[0]];
const trims = [trim({ id: "t1", clipId: "c1", startSec: 8, endSec: 10 })];
- const removed = removedRawSpans(clips, trims, []);
+ const removed = removedRawSpans(clips, trims);
expect(removed).toEqual([{ startSec: 8, endSec: 10, trimIds: ["t1"] }]);
// Raw 12 is unfilmed, not removed — the distinction a voiceover overhanging the
// programme depends on.
@@ -225,7 +225,7 @@ describe("removedRawSpans", () => {
// playback walk cuts on overlap, per clip, and this must match it.
const clips = twoClips();
const trims = [trim({ id: "t1", startSec: 5, endSec: 15 })]; // no clipId
- const removed = removedRawSpans(clips, trims, []);
+ const removed = removedRawSpans(clips, trims);
expect(removalAt(removed, 6)).toMatchObject({ trimIds: ["t1"] }); // inside c1
expect(removalAt(removed, 12)).toMatchObject({ trimIds: ["t1"] }); // inside c2
expect(removalAt(removed, 2)).toBeNull();
@@ -240,24 +240,22 @@ describe("removedRawSpans", () => {
];
// `subtractInterval` merges the two into one hole; both ids come with it, so
// restoring from the pane can drop the whole pill.
- expect(removedRawSpans(clips, trims, [])).toEqual([
+ expect(removedRawSpans(clips, trims)).toEqual([
{ startSec: 2, endSec: 7, trimIds: ["t1", "t2"] },
]);
});
it("returns nothing for a document with no clips", () => {
- expect(removedRawSpans([], [trim({ id: "t1" })], [])).toEqual([]);
+ expect(removedRawSpans([], [trim({ id: "t1" })])).toEqual([]);
});
});
describe("subtractRemoved", () => {
it("splits a span that crosses a cut into the pieces that survive", () => {
const clips = twoClips();
- const removed = removedRawSpans(
- clips,
- [trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 5 })],
- [],
- );
+ const removed = removedRawSpans(clips, [
+ trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 5 }),
+ ]);
// A voiceover from raw 1 to raw 8 plays as two pieces, not as one take cut short.
expect(subtractRemoved(1, 8, removed)).toEqual([
{ startSec: 1, endSec: 3 },
@@ -267,141 +265,12 @@ describe("subtractRemoved", () => {
it("yields nothing for a span buried inside a cut, and the whole span when untouched", () => {
const clips = twoClips();
- const removed = removedRawSpans(
- clips,
- [trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 8 })],
- [],
- );
+ const removed = removedRawSpans(clips, [
+ trim({ id: "t1", clipId: "c1", startSec: 3, endSec: 8 }),
+ ]);
expect(subtractRemoved(4, 6, removed)).toEqual([]);
expect(subtractRemoved(10, 14, removed)).toEqual([{ startSec: 10, endSec: 14 }]);
// Past the programme is not removed, so an overhanging take keeps its tail.
expect(subtractRemoved(18, 25, removed)).toEqual([{ startSec: 18, endSec: 25 }]);
});
});
-
-// ─── The insertion the projection has to walk over ──────────────────────────
-// An insertion is media INSIDE a clip, so the clip carrying it is that much longer and the
-// insertion's seconds are timeline seconds like any other. The projection's job is unchanged
-// by that — timeline in, output out — but it has to be TOLD, because it walks each clip's
-// kept SOURCE stretches and those are shorter than the clip.
-
-describe("projectRawTimelineSecToPlayback across an insertion", () => {
- // One second inserted at source 5 of the first clip, so that clip runs 0..11 and the
- // second one starts at 11.
- const inserted: AxcutInsertRange[] = [
- {
- id: "i1",
- assetId: "a1",
- atSec: 5,
- durationSec: 1,
- wordId: "w1",
- reason: "",
- origin: "user",
- },
- ];
- const clips = [
- clip({
- id: "c1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 11,
- }),
- clip({
- id: "c2",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 11,
- timelineEndSec: 21,
- }),
- ];
-
- it("is the identity when nothing is cut — the insertion is already in the film", () => {
- // This is what one clock buys. Under two, the walk had to re-add the insertion here
- // and every reader that forgot to had its audio landing a second early.
- expect(projectRawTimelineSecToPlayback(clips, [], 3, inserted)).toBeCloseTo(3, 6);
- expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted)).toBeCloseTo(8, 6);
- expect(projectRawTimelineSecToPlayback(clips, [], 15, inserted)).toBeCloseTo(15, 6);
- });
-
- it("keeps the insertion's own seconds when a trim takes the film around it", () => {
- // Cutting source 0..2 of the first clip removes two seconds of RECORDING. The second
- // the added word bought is not recording, so it survives.
- const trims = [trim({ id: "t1", clipId: "c1", startSec: 0, endSec: 2 })];
- expect(projectRawTimelineSecToPlayback(clips, trims, 8, inserted)).toBeCloseTo(6, 6);
- });
-
- it("loses an insertion whose own moment a trim removed", () => {
- // The moment it follows is not in the film any more, so neither is it — the same
- // rule `resolvePlaybackSegments` follows.
- const trims = [trim({ id: "t1", clipId: "c1", startSec: 4, endSec: 6 })];
- const out = projectRawTimelineSecToPlayback(clips, trims, 11, inserted);
- // 10s of recording, less the 2s cut, and the insertion gone with it.
- expect(out).toBeCloseTo(8, 6);
- });
-
- it("compresses the film around an insertion, and the insertion with it", () => {
- // A 2x region halves whatever timeline it covers. The insertion is timeline, so it
- // is halved too — the film is one thing, and speed is a property of the film.
- const speed = [{ startMs: 0, endMs: 21_000, speed: 2 }];
- expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted, speed)).toBeCloseTo(4, 6);
- });
-});
-
-// ─── The hole an insertion is not ────────────────────────────────────────────
-// `clipRawExtent` measured a clip by its SOURCE window, so a clip carrying insertions
-// ended short by exactly the inserted time — and `removedRawSpans`, walking clip to clip,
-// reported the difference as a gap nothing had removed. Everything that cuts on removed
-// spans then cut there: a voiceover crossing it went silent for the insertion's length, in
-// the preview and in the exported mix, and its words were struck through in the transcript
-// pane. The user heard the voice drop out exactly where they added a word (issue #560).
-
-describe("an insertion is not a hole in the programme", () => {
- const inserted: AxcutInsertRange[] = [
- {
- id: "i1",
- assetId: "a1",
- atSec: 5,
- durationSec: 1,
- wordId: "w1",
- reason: "",
- origin: "user",
- },
- ];
- const clips = [
- clip({
- id: "c1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 11,
- }),
- clip({
- id: "c2",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 11,
- timelineEndSec: 21,
- }),
- ];
-
- it("reports nothing removed when nothing was trimmed", () => {
- expect(removedRawSpans(clips, [], inserted)).toEqual([]);
- });
-
- it("covers the insertion's own seconds as kept programme", () => {
- const spans = keptRawSpans(clips, [], inserted);
- const covers = (sec: number) => spans.some((s) => sec >= s.startSec && sec < s.endSec);
- // 5.5 is inside the inserted media, 10.5 is the first clip's last second.
- expect(covers(5.5)).toBe(true);
- expect(covers(10.5)).toBe(true);
- });
-
- it("still reports a real gap between two clips", () => {
- const apart = [clips[0], clip({ ...clips[1], timelineStartSec: 13, timelineEndSec: 23 })];
- const removed = removedRawSpans(apart, [], inserted);
- expect(removed).toHaveLength(1);
- expect(removed[0].startSec).toBeCloseTo(11, 6);
- expect(removed[0].endSec).toBeCloseTo(13, 6);
- });
-});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
index d9bfd77a7..60c23f032 100644
--- a/src/lib/ai-edition/timeline/programme-time.ts
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -18,8 +18,7 @@
// Storage does not change: a trim stays source-time anchored to a clip. This is the
// derived READING of those rows, computed on demand and never written back.
-import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
-import { assignInsertsToClips, sourceToTimelineSec } from "./inserted-time";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
import { type Interval, subtractInterval } from "./intervals";
import { trimAppliesToClip } from "./trim-mapping";
@@ -48,39 +47,22 @@ export interface RemovedRawSpan extends RawSpan {
* source length to measure, and falls back to the ruler geometry it was given — matching
* the pass-through branch `resolvePlaybackSegments` takes for the same clips.
*/
-/** A clip's whole stretch of timeline — its source window PLUS the media inserted inside it.
- *
- * Computed, not read off `timelineEndSec`: derived the same way `reflowClipsForInserts`
- * writes it, so a clip whose stored geometry is stale or half-written cannot make this lie.
- * Leaving the insertions out was its own bug — the extent then ended short by exactly the
- * inserted time, and `removedRawSpans` reported a phantom hole at the tail of every clip
- * carrying one, which the audio paths cut as if a trim had taken it. */
-function clipRawExtent(clip: AxcutClip, ownInserts: readonly AxcutInsertRange[]): RawSpan {
+function clipRawExtent(clip: AxcutClip): RawSpan {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
return { startSec: clip.timelineStartSec, endSec: clip.timelineEndSec };
}
- const owed = ownInserts.reduce((sum, insert) => sum + insert.durationSec, 0);
return {
startSec: clip.timelineStartSec,
- endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec) + owed,
+ endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec),
};
}
-/** Source interval → timeline, through the clip that carries it.
- *
- * `"closes"` on the end is what makes an insertion INSIDE a kept stretch part of it: the
- * film plays those seconds, so they belong to the span. An insertion at the stretch's own
- * start belongs to whatever came before — and if a trim took that, it is gone with it,
- * which is right: the moment it follows is not in the film any more. */
-function sourceToRaw(
- clip: AxcutClip,
- interval: Interval,
- insertRanges: readonly AxcutInsertRange[],
-): RawSpan {
+/** Source interval → raw, through the clip that carries it. */
+function sourceToRaw(clip: AxcutClip, interval: Interval): RawSpan {
return {
- startSec: sourceToTimelineSec(clip, interval.startSec, insertRanges, "opens"),
- endSec: sourceToTimelineSec(clip, interval.endSec, insertRanges, "closes"),
+ startSec: clip.timelineStartSec + (interval.startSec - clip.sourceStartSec),
+ endSec: clip.timelineStartSec + (interval.endSec - clip.sourceStartSec),
};
}
@@ -107,27 +89,19 @@ function keptSourceIntervals(clip: AxcutClip, trimRanges: AxcutTrimRange[]): Int
*
* Zero-length spans are dropped, so a caller can trust `endSec > startSec`.
*/
-export function keptRawSpans(
- clips: AxcutClip[],
- trimRanges: AxcutTrimRange[],
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
-): RawSpan[] {
+export function keptRawSpans(clips: AxcutClip[], trimRanges: AxcutTrimRange[]): RawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const owners = assignInsertsToClips(ordered, insertRanges);
const spans: RawSpan[] = [];
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
// Duration not probed yet — the whole raw clip passes through unnarrowed.
- const extent = clipRawExtent(clip, owners.get(clip.id) ?? []);
+ const extent = clipRawExtent(clip);
if (extent.endSec > extent.startSec) spans.push(extent);
continue;
}
for (const iv of keptSourceIntervals(clip, trimRanges)) {
- const span = sourceToRaw(clip, iv, insertRanges);
+ const span = sourceToRaw(clip, iv);
if (span.endSec > span.startSec) spans.push(span);
}
}
@@ -152,20 +126,15 @@ export function keptRawSpans(
export function removedRawSpans(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): RemovedRawSpan[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
if (ordered.length === 0) return [];
- const owners = assignInsertsToClips(ordered, insertRanges);
const removed: RemovedRawSpan[] = [];
let cursor = 0; // raw end of the programme walked so far
for (const clip of ordered) {
- const extent = clipRawExtent(clip, owners.get(clip.id) ?? []);
+ const extent = clipRawExtent(clip);
// The unfilmed stretch before this clip. `max` rather than a bare subtraction so
// two clips overlapping on the ruler contribute no negative gap.
if (extent.startSec > cursor) {
@@ -185,12 +154,12 @@ export function removedRawSpans(
.filter((trim) => trimAppliesToClip(trim, clip))
.map((trim) => ({
id: trim.id,
- ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }, insertRanges),
+ ...sourceToRaw(clip, { startSec: trim.startSec, endSec: trim.endSec }),
}));
let holeStart = extent.startSec;
for (const iv of kept) {
- const span = sourceToRaw(clip, iv, insertRanges);
+ const span = sourceToRaw(clip, iv);
if (span.startSec > holeStart) {
removed.push(taggedHole(holeStart, span.startSec, applicable));
}
diff --git a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
index 4edd43902..5bbe7ac44 100644
--- a/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
+++ b/src/lib/ai-edition/timeline/sharedMediaTrim.test.ts
@@ -74,8 +74,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
next.timeline.clips,
next.transcripts,
next.assets,
- removedRawSpans(next.timeline.clips, next.timeline.trimRanges, []),
- [],
+ removedRawSpans(next.timeline.clips, next.timeline.trimRanges),
);
expect(sections[0].trimRuns).toHaveLength(0);
expect(sections[1].trimRuns).toHaveLength(1);
@@ -85,7 +84,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
]);
// 2. Ruler — one pill, over clip 2 (timeline 11.8 + 8.4 = 20.2 … 22.2).
- const pills = coalescedTrimGroups(next.timeline.trimRanges, next.timeline.clips, []);
+ const pills = coalescedTrimGroups(next.timeline.trimRanges, next.timeline.clips);
expect(pills).toHaveLength(1);
expect(pills[0].start).toBeCloseTo(20.2, 6);
expect(pills[0].end).toBeCloseTo(22.2, 6);
diff --git a/src/lib/ai-edition/timeline/take-programme.test.ts b/src/lib/ai-edition/timeline/take-programme.test.ts
index 7cf3cce0c..def48cc48 100644
--- a/src/lib/ai-edition/timeline/take-programme.test.ts
+++ b/src/lib/ai-edition/timeline/take-programme.test.ts
@@ -7,10 +7,9 @@
// their answers must not move.
import { describe, expect, it } from "vitest";
-import { rawSpanForOutDuration } from "../document/timeline";
import type { AxcutAudioTrack, AxcutClip, AxcutTrimRange } from "../schema";
import { removedRawSpans, subtractRemoved } from "./programme-time";
-import { consumedSourceSec, takePlaybackAt, takeProgramme } from "./take-programme";
+import { takePlaybackAt, takeProgramme } from "./take-programme";
const CLIPS: AxcutClip[] = [
{
@@ -43,16 +42,6 @@ const TAKE = { startMs: 0, endMs: 10_000, offsetMs: 0 } as Pick<
"startMs" | "endMs" | "offsetMs"
>;
-const ins = (atSourceSec: number, durationSec: number, id = "i1") => ({
- id,
- wordId: `w_${id}`,
- atSourceSec,
- durationSec,
-});
-
-const shape = (pieces: ReturnType) =>
- pieces.map((p) => [p.kind, p.rawStartSec, p.rawEndSec, p.sourceStartSec, p.sourceEndSec]);
-
describe("takeProgramme", () => {
it("is exactly subtractRemoved when nothing is inserted", () => {
// The demotion. Every fixture the export and the preview already agree on has to
@@ -64,120 +53,23 @@ describe("takeProgramme", () => {
[trim(8, 12)],
[trim(2, 3), trim(6, 7, "t2")],
]) {
- const removed = removedRawSpans(CLIPS, cuts, []);
- const played = takeProgramme(TAKE, removed, [])
+ const removed = removedRawSpans(CLIPS, cuts);
+ const played = takeProgramme(TAKE, removed)
.filter((p) => p.kind === "play")
.map((p) => [p.rawStartSec, p.rawEndSec]);
const expected = subtractRemoved(0, 10, removed).map((s) => [s.startSec, s.endSec]);
expect(played).toEqual(expected);
}
});
-
- it("parks the voice for an insertion and resumes on the same word", () => {
- const pieces = takeProgramme(TAKE, [], [ins(4, 1)]);
- expect(shape(pieces)).toEqual([
- ["play", 0, 4, 0, 4],
- ["hold", 4, 5, 4, 4],
- ["play", 5, 10, 4, 9],
- ]);
- // The take consumed 9 seconds of its file, not 10: the second the pause took is
- // pushed off the end and lost, which is the accepted cost of the clips deciding
- // the length.
- expect(consumedSourceSec(pieces)).toBeCloseTo(9, 6);
- });
-
- it("resolves the second insertion AFTER the first one's hold, not before it", () => {
- // The case a two-pass design gets wrong: mapped up front, source 6 would be raw 6,
- // which is inside the first hold.
- const pieces = takeProgramme(TAKE, [], [ins(4, 1, "a"), ins(6, 1, "b")]);
- expect(shape(pieces)).toEqual([
- ["play", 0, 4, 0, 4],
- ["hold", 4, 5, 4, 4],
- ["play", 5, 7, 4, 6],
- ["hold", 7, 8, 6, 6],
- ["play", 8, 10, 6, 8],
- ]);
- });
-
- it("makes two insertions at one moment two adjacent holds, with no empty play between", () => {
- const pieces = takeProgramme(TAKE, [], [ins(4, 1, "a"), ins(4, 0.5, "b")]);
- expect(pieces.map((p) => p.kind)).toEqual(["play", "hold", "hold", "play"]);
- expect(pieces.filter((p) => p.kind === "hold").map((p) => p.holdId)).toEqual(["a", "b"]);
- expect(pieces.some((p) => p.rawEndSec === p.rawStartSec)).toBe(false);
- });
-
- it("gives nothing to an insertion a cut swallowed", () => {
- const removed = removedRawSpans(CLIPS, [trim(3, 6)], []);
- const withIt = takeProgramme(TAKE, removed, [ins(4, 1)]);
- const without = takeProgramme(TAKE, removed, []);
- // The moment it holds is not in the film any more, so it buys no time.
- expect(withIt.some((p) => p.kind === "hold")).toBe(false);
- expect(shape(withIt)).toEqual(shape(without));
- });
-
- it("keeps an insertion on a cut's far edge, which is what follows the cut", () => {
- const removed = removedRawSpans(CLIPS, [trim(3, 6)], []);
- const pieces = takeProgramme(TAKE, removed, [ins(6, 1)]);
- expect(pieces.map((p) => p.kind)).toEqual(["play", "removed", "hold", "play"]);
- });
-
- it("drops an insertion at or past the take's last moment", () => {
- expect(takeProgramme(TAKE, [], [ins(10, 1)]).some((p) => p.kind === "hold")).toBe(false);
- expect(takeProgramme(TAKE, [], [ins(30, 1)]).some((p) => p.kind === "hold")).toBe(false);
- });
-
- it("ignores an insertion of no duration", () => {
- expect(takeProgramme(TAKE, [], [ins(4, 0)]).map((p) => p.kind)).toEqual(["play"]);
- });
-
- it("spends a pause on the take's own clock under a speed region", () => {
- // A voice plays at 1x in the mix. Under a 2x region a one-second pause has to eat
- // TWO raw seconds to last one second of programme.
- const speed = [{ startMs: 0, endMs: 20_000, speed: 2 }];
- const pieces = takeProgramme(TAKE, [], [ins(4, 1)], speed);
- const hold = pieces.find((p) => p.kind === "hold");
- expect(hold && hold.rawEndSec - hold.rawStartSec).toBeCloseTo(2, 6);
- });
-});
-
-describe("rawSpanForOutDuration", () => {
- it("is the identity with no regions", () => {
- expect(rawSpanForOutDuration(3, 2)).toBe(2);
- });
-
- it("inverts outputDurationOfRawSpan across a boundary", () => {
- const speed = [{ startMs: 4000, endMs: 8000, speed: 2 }];
- // From raw 3: one output second buys 1 raw second before the region, then the rest
- // at 2x. Two output seconds = 1 + 2 = 3 raw seconds.
- expect(rawSpanForOutDuration(3, 2, speed)).toBeCloseTo(3, 6);
- });
-
- it("returns zero for a non-positive duration", () => {
- expect(rawSpanForOutDuration(0, 0)).toBe(0);
- expect(rawSpanForOutDuration(0, -1)).toBe(0);
- });
});
describe("takePlaybackAt", () => {
- const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)], []), [ins(4, 1)]);
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)]));
it("plays the file where the file plays", () => {
expect(takePlaybackAt(pieces, 2)).toMatchObject({ targetTimeSec: 2, shouldPlay: true });
});
- it("parks on one moment inside the pause rather than tracking a moving target", () => {
- expect(takePlaybackAt(pieces, 4.5)).toMatchObject({
- targetTimeSec: 4,
- shouldPlay: false,
- heldBy: "i1",
- });
- });
-
- it("keeps the clock running through a cut, silently", () => {
- // Raw 7.5 is one second past the pause, so the file is at 6.5 — and muted.
- expect(takePlaybackAt(pieces, 7.5)).toMatchObject({ targetTimeSec: 6.5, shouldPlay: false });
- });
-
it("has nothing to say outside the take", () => {
expect(takePlaybackAt(pieces, 12)).toBeNull();
});
@@ -187,88 +79,3 @@ describe("takePlaybackAt", () => {
// They read the same walk now, but "the same walk" is a claim about wiring. This walks the
// take frame by frame the way the rAF does and asserts the runs of source time it would
// play are the entries the export emits, piece for piece.
-
-describe("preview and export agree over a take with a cut and a pause", () => {
- const removed = removedRawSpans(CLIPS, [trim(7, 8)], []);
- const pieces = takeProgramme(TAKE, removed, [ins(4, 1)]);
-
- it("plays exactly the play pieces, and nothing between them", () => {
- const runs: Array<{ from: number; to: number }> = [];
- // A run breaks on SILENCE, not on a jump in source time. Across a pause the source
- // is deliberately continuous — the voice resumes on the word it stopped on — so a
- // detector watching only the source would merge the two halves and see one run.
- let wasPlaying = false;
- for (let raw = 0; raw < 10; raw += 0.05) {
- const at = takePlaybackAt(pieces, raw);
- if (!at?.shouldPlay) {
- wasPlaying = false;
- continue;
- }
- const last = runs.at(-1);
- if (wasPlaying && last && Math.abs(at.targetTimeSec - last.to) < 0.06) {
- last.to = at.targetTimeSec;
- } else {
- runs.push({ from: at.targetTimeSec, to: at.targetTimeSec });
- }
- wasPlaying = true;
- }
- const entries = pieces
- .filter((p) => p.kind === "play")
- .map((p) => [p.sourceStartSec, p.sourceEndSec]);
- expect(runs).toHaveLength(entries.length);
- runs.forEach((run, i) => {
- expect(run.from).toBeCloseTo(entries[i][0], 1);
- expect(run.to).toBeCloseTo(entries[i][1], 1);
- });
- });
-
- it("never re-seeks while the voice is parked", () => {
- // One value for the whole pause: a target that drifted would re-seek a paused
- // element every frame, and resuming would restart on the wrong word.
- const inside = [4.1, 4.3, 4.5, 4.7, 4.9].map((raw) => takePlaybackAt(pieces, raw));
- expect(inside.every((at) => at?.shouldPlay === false)).toBe(true);
- expect(new Set(inside.map((at) => at?.targetTimeSec)).size).toBe(1);
- });
-
- it("resumes on the second it stopped on", () => {
- const parked = takePlaybackAt(pieces, 4.5)?.targetTimeSec;
- const resumed = takePlaybackAt(pieces, 5.01)?.targetTimeSec;
- expect(resumed).toBeCloseTo(parked ?? -1, 1);
- });
-});
-
-// ─── What the lane has to draw ──────────────────────────────────────────────
-// The notch is positioned from the walk, as a fraction of the PILL's own raw span. These
-// pin the arithmetic the drawing does, so a notch cannot appear where the voice does not
-// actually stop.
-
-describe("the pieces a pill draws", () => {
- const pctOfPill = (pieces: ReturnType, rawSec: number) =>
- ((rawSec - TAKE.startMs / 1000) / (TAKE.endMs / 1000 - TAKE.startMs / 1000)) * 100;
-
- it("cuts one notch, in the middle, at the width of the time it took", () => {
- const pieces = takeProgramme(TAKE, [], [ins(4, 1)]);
- const hold = pieces.find((p) => p.kind === "hold");
- expect(hold).toBeDefined();
- if (!hold) return;
- expect(pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(40, 6);
- expect(pctOfPill(pieces, hold.rawEndSec) - pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(
- 10,
- 6,
- );
- });
-
- it("leaves a take with no insertion in one piece, so the pill draws as it always did", () => {
- expect(takeProgramme(TAKE, [], []).some((p) => p.kind === "hold")).toBe(false);
- });
-
- it("covers the pill end to end, with no overlap and no hole", () => {
- const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)], []), [ins(4, 1)]);
- let cursor = TAKE.startMs / 1000;
- for (const piece of pieces) {
- expect(piece.rawStartSec).toBeCloseTo(cursor, 6);
- cursor = piece.rawEndSec;
- }
- expect(cursor).toBeCloseTo(TAKE.endMs / 1000, 6);
- });
-});
diff --git a/src/lib/ai-edition/timeline/take-programme.ts b/src/lib/ai-edition/timeline/take-programme.ts
index 828055f03..8ea999428 100644
--- a/src/lib/ai-edition/timeline/take-programme.ts
+++ b/src/lib/ai-edition/timeline/take-programme.ts
@@ -24,40 +24,22 @@
// playing on top of itself. `anchorAudioTrackFragments` is untouched and stays correct for
// the music and loop paths that still read it.
-import type { PlaybackSpeedRegion } from "../document/timeline";
-import { rawSpanForOutDuration } from "../document/timeline";
import type { AxcutAudioTrack } from "../schema";
import type { RemovedRawSpan } from "./programme-time";
/** One stretch of a take, in playback order. */
export interface TakePiece {
/**
- * `play` — the file is heard. `hold` — the voice is parked on one source moment while
- * the timeline runs on (an insertion). `removed` — the film lost this stretch, so the
- * take is silent through it, its own clock still running underneath.
+ * `play` — the file is heard. `removed` — the film lost this stretch, so the take is
+ * silent through it, its own clock still running underneath.
*/
- kind: "play" | "hold" | "removed";
- /** Stored RAW ruler seconds. Insertions occupy raw time here because they consume it
- * from the take's own span — they do not create programme time. */
+ kind: "play" | "removed";
+ /** Stored RAW ruler seconds. */
rawStartSec: number;
rawEndSec: number;
- /** The take's own file. Equal on a `hold`: the moment the voice is parked on. */
+ /** The take's own file. */
sourceStartSec: number;
sourceEndSec: number;
- /** The insertion that produced a `hold`. */
- holdId?: string;
- /** The word that insertion exists for. */
- wordId?: string;
-}
-
-/** An insertion inside one take, in the take's own source seconds. */
-export interface TakeInsert {
- id: string;
- wordId: string;
- atSourceSec: number;
- /** OUTPUT seconds. A voice plays at 1x in the mix, so a pause for a spoken word is
- * measured on the take's own clock, not on a raw ruler a speed region compresses. */
- durationSec: number;
}
const EPSILON_SEC = 1e-9;
@@ -76,8 +58,6 @@ const EPSILON_SEC = 1e-9;
export function takeProgramme(
pill: Pick,
removed: readonly RemovedRawSpan[],
- inserts: readonly TakeInsert[],
- speedRegions: PlaybackSpeedRegion[] = [],
): TakePiece[] {
const rawStart = pill.startMs / 1000;
const rawEnd = Math.max(rawStart, pill.endMs / 1000);
@@ -86,9 +66,6 @@ export function takeProgramme(
// Every boundary the walk has to stop at, on the RAW ruler, resolved sequentially:
// an insertion's raw moment depends on the holds before it, so it cannot be mapped in
// one pass up front.
- const pending = [...inserts]
- .filter((ins) => ins.durationSec > 0)
- .sort((a, b) => a.atSourceSec - b.atSourceSec || a.id.localeCompare(b.id));
const cuts = [...removed]
.filter((span) => span.endSec > rawStart && span.startSec < rawEnd)
@@ -97,10 +74,9 @@ export function takeProgramme(
const pieces: TakePiece[] = [];
let raw = rawStart;
let source = sourceStart;
- let nextInsert = 0;
let nextCut = 0;
- const push = (kind: TakePiece["kind"], rawTo: number, sourceTo: number, ins?: TakeInsert) => {
+ const push = (kind: TakePiece["kind"], rawTo: number, sourceTo: number) => {
if (rawTo - raw <= EPSILON_SEC) return;
pieces.push({
kind,
@@ -108,7 +84,6 @@ export function takeProgramme(
rawEndSec: rawTo,
sourceStartSec: source,
sourceEndSec: sourceTo,
- ...(ins ? { holdId: ins.id, wordId: ins.wordId } : {}),
});
raw = rawTo;
source = sourceTo;
@@ -121,43 +96,14 @@ export function takeProgramme(
// so the bound is enforced. Counting passes rather than watching `raw` on purpose: a
// pass that consumes a zero-length insertion makes real progress without moving `raw`,
// and a no-progress test would cut the walk short on a legitimate document.
- const maxPasses = 4 * (removed.length + inserts.length + 2);
+ const maxPasses = 4 * (removed.length + 2);
let passes = 0;
while (raw < rawEnd - EPSILON_SEC) {
if (passes++ > maxPasses) break;
- // Skip cuts and insertions the walk has already passed.
+ // Skip cuts the walk has already passed.
while (nextCut < cuts.length && cuts[nextCut].endSec <= raw + EPSILON_SEC) nextCut++;
- while (nextInsert < pending.length && pending[nextInsert].atSourceSec <= source - EPSILON_SEC) {
- // Its moment is behind the source cursor: a cut swallowed it, or two inserts
- // share a moment and the first already consumed it. Either way it buys nothing.
- nextInsert++;
- }
const cut = cuts[nextCut];
- const insert = pending[nextInsert];
-
- // Sitting exactly on an insertion's moment: hold before anything else, so two
- // inserts at one moment become two adjacent holds rather than one merged stretch.
- if (insert && insert.atSourceSec <= source + EPSILON_SEC) {
- const held = Math.min(
- rawSpanForOutDuration(raw, insert.durationSec, speedRegions),
- rawEnd - raw,
- );
- nextInsert++;
- if (held > EPSILON_SEC) {
- pieces.push({
- kind: "hold",
- rawStartSec: raw,
- rawEndSec: raw + held,
- sourceStartSec: source,
- sourceEndSec: source,
- holdId: insert.id,
- wordId: insert.wordId,
- });
- raw += held;
- }
- continue;
- }
// Inside a cut: silent to the cut's end, both cursors running.
if (cut && cut.startSec <= raw + EPSILON_SEC) {
@@ -169,7 +115,6 @@ export function takeProgramme(
// Otherwise play up to whichever boundary comes first.
let to = rawEnd;
if (cut) to = Math.min(to, cut.startSec);
- if (insert) to = Math.min(to, raw + (insert.atSourceSec - source));
push("play", Math.max(raw, to), source + (Math.max(raw, to) - raw));
}
@@ -200,16 +145,11 @@ export function consumedSourceSec(pieces: readonly TakePiece[]): number {
export function takePlaybackAt(
pieces: readonly TakePiece[],
rawSec: number,
-): { targetTimeSec: number; shouldPlay: boolean; heldBy?: string } | null {
+): { targetTimeSec: number; shouldPlay: boolean } | null {
for (const piece of pieces) {
if (rawSec < piece.rawStartSec) break;
if (rawSec >= piece.rawEndSec) continue;
- if (piece.kind === "hold") {
- // Parked on ONE source moment, not clamped to a moving target: resuming from the
- // post-insert source would restart the narration on the wrong word, and a target
- // that drifts every frame would re-seek a paused element every frame.
- return { targetTimeSec: piece.sourceStartSec, shouldPlay: false, heldBy: piece.holdId };
- }
+
const target = piece.sourceStartSec + (rawSec - piece.rawStartSec);
return { targetTimeSec: target, shouldPlay: piece.kind === "play" };
}
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index 608350a49..ea3f6c61b 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -5,7 +5,7 @@
import { describe, expect, it } from "vitest";
import { resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
import {
anchorRawRegionsToClips,
anchorRegionsWithDerivedMs,
@@ -48,7 +48,7 @@ const region = (id: string, startSec: number, endSec: number, payload?: string):
describe("projectRegionsToSource", () => {
it("passes a region through unchanged (no clipIndex) when there are no segments", () => {
- const out = projectRegionsToSource([region("r", 1.5, 4.25)], [], [], () => "x", []);
+ const out = projectRegionsToSource([region("r", 1.5, 4.25)], [], [], () => "x");
expect(out).toEqual([{ id: "r", startMs: 1500, endMs: 4250 }]);
});
@@ -60,7 +60,7 @@ describe("projectRegionsToSource", () => {
sourceEndSec: 10,
timelineEndSec: 10,
});
- const out = projectRegionsToSource([region("r", 3, 5)], [c], [c], () => "x", []);
+ const out = projectRegionsToSource([region("r", 3, 5)], [c], [c], () => "x");
expect(out).toEqual([{ id: "r", startMs: 3000, endMs: 5000, clipIndex: 0 }]);
});
@@ -75,7 +75,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
- const out = projectRegionsToSource([region("r", 6, 8)], segments, [c], () => "x", []);
+ const out = projectRegionsToSource([region("r", 6, 8)], segments, [c], () => "x");
expect(out).toEqual([{ id: "r", startMs: 6000, endMs: 8000, clipIndex: 1 }]);
});
@@ -96,13 +96,7 @@ describe("projectRegionsToSource", () => {
timelineStartSec: 5,
timelineEndSec: 10,
});
- const out = projectRegionsToSource(
- [region("r", 3, 7, "keep")],
- [c1, c2],
- [c1, c2],
- () => "r2",
- [],
- );
+ const out = projectRegionsToSource([region("r", 3, 7, "keep")], [c1, c2], [c1, c2], () => "r2");
expect(out).toEqual([
{ id: "r", startMs: 103000, endMs: 105000, clipIndex: 0, payload: "keep" },
{ id: "r2", startMs: 200000, endMs: 202000, clipIndex: 1, payload: "keep" },
@@ -120,7 +114,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]);
- const out = projectRegionsToSource([region("r", 3, 8)], segments, [c], () => "r2", []);
+ const out = projectRegionsToSource([region("r", 3, 8)], segments, [c], () => "r2");
expect(out).toEqual([
{ id: "r", startMs: 3000, endMs: 4000, clipIndex: 0 },
{ id: "r2", startMs: 6000, endMs: 8000, clipIndex: 1 },
@@ -155,7 +149,7 @@ describe("projectRegionsToSource", () => {
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 2, 8)]);
// segments: c1[0,2] (0), c1[8,10] (1), c2[0,10] (2).
const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 };
- expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x", [])).toEqual([
+ expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([
{ ...anchored, startMs: 3000, endMs: 5000, clipIndex: 0, underTrim: true },
]);
});
@@ -181,7 +175,7 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 0, 10)]);
const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 };
- expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x", [])).toEqual([]);
+ expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([]);
});
it("keeps an unanchored region a trim removes entirely, mapped through its raw clip", () => {
@@ -197,7 +191,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]);
- expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x", [])).toEqual([
+ expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x")).toEqual([
{ id: "r", startMs: 4500, endMs: 5500, clipIndex: 0, underTrim: true },
]);
});
@@ -214,7 +208,7 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]);
const anchored = { ...region("r", 1, 2), clipId: "c1", sourceStartSec: 1, sourceEndSec: 2 };
- expect(projectRegionsToSource([anchored], segments, [c], () => "x", [])).toEqual([
+ expect(projectRegionsToSource([anchored], segments, [c], () => "x")).toEqual([
{ ...anchored, startMs: 1000, endMs: 2000, clipIndex: 0, underTrim: true },
]);
});
@@ -240,9 +234,9 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 6, 10)]);
const anchored = { ...region("r", 7, 9), clipId: "c1", sourceStartSec: 7, sourceEndSec: 9 };
- const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x", []);
+ const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x");
// raw 8 is inside c1's removed tail; the region covering source [7,9] is that same cut.
- expect(resolveNativePosition(8, segments, [c1, c2], [])?.clipIndex).toBe(projected.clipIndex);
+ expect(resolveNativePosition(8, segments, [c1, c2])?.clipIndex).toBe(projected.clipIndex);
});
// --- anchored path: the anchor is the SSOT, `startMs`/`endMs` are not consulted ---
@@ -264,7 +258,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 6,
sourceEndSec: 8,
};
- const out = projectRegionsToSource([stale], [c], [c], () => "x", []);
+ const out = projectRegionsToSource([stale], [c], [c], () => "x");
expect(out).toEqual([{ ...stale, startMs: 6000, endMs: 8000, clipIndex: 0 }]);
});
@@ -284,7 +278,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 3,
sourceEndSec: 8,
};
- const out = projectRegionsToSource([anchored], segments, [c], () => "r2", []);
+ const out = projectRegionsToSource([anchored], segments, [c], () => "r2");
expect(out).toEqual([
{ ...anchored, id: "r", startMs: 3000, endMs: 4000, clipIndex: 0 },
{ ...anchored, id: "r2", startMs: 6000, endMs: 8000, clipIndex: 1 },
@@ -316,7 +310,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 1,
sourceEndSec: 2,
};
- const out = projectRegionsToSource([anchored], [c1, c2], [c1, c2], () => "x", []);
+ const out = projectRegionsToSource([anchored], [c1, c2], [c1, c2], () => "x");
expect(out).toEqual([{ ...anchored, startMs: 1000, endMs: 2000, clipIndex: 1 }]);
});
@@ -331,7 +325,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const partial = { ...region("r", 3, 5), clipId: "c1" }; // no source span
- const out = projectRegionsToSource([partial], [c], [c], () => "x", []);
+ const out = projectRegionsToSource([partial], [c], [c], () => "x");
expect(out).toEqual([{ ...partial, startMs: 3000, endMs: 5000, clipIndex: 0 }]);
});
});
@@ -357,12 +351,12 @@ describe("resolveNativePosition", () => {
timelineEndSec: 12,
}),
];
- expect(resolveNativePosition(6.5, clips, clips, [])).toMatchObject({
+ expect(resolveNativePosition(6.5, clips, clips)).toMatchObject({
clip: { id: "c2" },
clipIndex: 1,
sourceTimeSec: 22.5,
});
- expect(resolveNativePosition(10, clips, clips, [])).toMatchObject({
+ expect(resolveNativePosition(10, clips, clips)).toMatchObject({
clip: { id: "c3" },
clipIndex: 2,
sourceTimeSec: 42,
@@ -389,12 +383,12 @@ describe("resolveNativePosition", () => {
timelineEndSec: 12,
}),
];
- expect(resolveNativePosition(7.25, clips, clips, [])).toMatchObject({
+ expect(resolveNativePosition(7.25, clips, clips)).toMatchObject({
clip: { assetId: "asset-b" },
clipIndex: 1,
sourceTimeSec: 103.25,
});
- expect(resolveNativePosition(11, clips, clips, [])).toMatchObject({
+ expect(resolveNativePosition(11, clips, clips)).toMatchObject({
clip: { assetId: "asset-c" },
clipIndex: 2,
sourceTimeSec: 14,
@@ -411,11 +405,11 @@ describe("resolveNativePosition", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
// raw 1 → source 1 on seg1; raw 6 → source 6 on seg2 (NOT 8).
- expect(resolveNativePosition(1, segments, [c], [])).toMatchObject({
+ expect(resolveNativePosition(1, segments, [c])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 1,
});
- expect(resolveNativePosition(6, segments, [c], [])).toMatchObject({
+ expect(resolveNativePosition(6, segments, [c])).toMatchObject({
clipIndex: 1,
sourceTimeSec: 6,
});
@@ -436,7 +430,7 @@ describe("resolveNativePosition", () => {
// points at, and would incrust any modifier under the cut on someone else's image (#216).
// The segment it borrows is the one the cut interrupts (seg1), so a modifier under that
// cut — addressed the same way — survives `belongs()`.
- expect(resolveNativePosition(3, segments, [c], [])).toMatchObject({
+ expect(resolveNativePosition(3, segments, [c])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 3,
});
@@ -451,7 +445,7 @@ describe("resolveNativePosition", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]);
- expect(resolveNativePosition(1, segments, [c], [])).toMatchObject({
+ expect(resolveNativePosition(1, segments, [c])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 1,
});
@@ -467,11 +461,11 @@ describe("resolveNativePosition", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
// No raw clip owns raw 99 — nothing to present, so the historical clamp stands.
- expect(resolveNativePosition(99, segments, [c], [])).toMatchObject({ clipIndex: 1 });
+ expect(resolveNativePosition(99, segments, [c])).toMatchObject({ clipIndex: 1 });
});
it("returns null when there are no segments", () => {
- expect(resolveNativePosition(1, [], [], [])).toBeNull();
+ expect(resolveNativePosition(1, [], [])).toBeNull();
});
});
@@ -811,65 +805,3 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
-
-// ─── Placing an unanchored region past an insertion ─────────────────────────
-// A caption cue is built fresh on every derive and carries no clip anchor, so it is placed
-// by intersecting its TIMELINE span with each segment's extent. A clip carrying insertions
-// is longer than its source window, so the segment that resumes after one starts that much
-// further along — and a projection blind to that put every caption after an insertion on
-// the wrong stretch of source. That is what "the subtitles are out of sync" was (#560).
-
-describe("projectRegionsToSource past an insertion", () => {
- const raw = clip({
- id: "c1",
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 11, // 10s of recording + 1s inserted at source 5
- });
- const inserts: AxcutInsertRange[] = [
- {
- id: "i1",
- assetId: "a1",
- atSec: 5,
- durationSec: 1,
- wordId: "w1",
- reason: "",
- origin: "user",
- },
- ];
- // What `resolvePlaybackSegments` produces: the clip split at the insertion, with the
- // inserted media between the halves.
- const segments = [
- clip({ id: "c1_seg1", assetId: "a1", sourceStartSec: 0, sourceEndSec: 5 }),
- clip({ id: "c1_seg2", assetId: "a1", sourceStartSec: 5, sourceEndSec: 10 }),
- ];
-
- it("lands a region on the source it actually names", () => {
- // Timeline 7..9 is one second past the insertion, so it is source 6..8.
- const [out] = projectRegionsToSource(
- [region("cue", 7, 9)],
- segments,
- [raw],
- () => "x",
- inserts,
- );
- expect(out.startMs).toBe(6000);
- expect(out.endMs).toBe(8000);
- expect(out.clipIndex).toBe(1);
- });
-
- it("leaves a region before the insertion where it was", () => {
- const [out] = projectRegionsToSource(
- [region("cue", 1, 3)],
- segments,
- [raw],
- () => "x",
- inserts,
- );
- expect(out.startMs).toBe(1000);
- expect(out.endMs).toBe(3000);
- expect(out.clipIndex).toBe(0);
- });
-});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index e344c2398..b98601a03 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -17,8 +17,7 @@
// against the COMPRESSED segment layout, which slips every region after a trim
// forward by the trimmed duration.
-import type { PlaybackSegment } from "../document/timeline";
-import type { AxcutClip, AxcutInsertRange } from "../schema";
+import type { AxcutClip } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
@@ -96,11 +95,6 @@ export function anchoredToRawSpanSec(
): { startSec: number; endSec: number } | null {
const clip = clips.find((c) => c.id === fragment.clipId);
if (!clip) return null;
- // ponytail: plain shift, wrong by the clip's own insertions for a region anchored past
- // one — the pill is drawn early while `projectRegionsToSource` exports it on the right
- // frames. Route through `sourceToTimelineSec` when zoom/annotation pills need to agree
- // with the effect; it means threading the ranges through `anchorRegionsWithDerivedMs`
- // and its 15 callers, which is its own change.
return {
startSec: clip.timelineStartSec + (fragment.sourceStartSec - clip.sourceStartSec),
endSec: clip.timelineStartSec + (fragment.sourceEndSec - clip.sourceStartSec),
@@ -405,19 +399,11 @@ export function anchorRegionsWithDerivedMs<
* the gap.
*/
export function segmentRawSpanSec(
- segment: PlaybackSegment,
+ segment: AxcutClip,
rawClips: AxcutClip[],
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): { startSec: number; endSec: number } {
- const startSec = getRawVirtualStartTime(segment, rawClips, insertRanges);
- // A held segment's source window is the single frame it shows, so its source length
- // is zero — its RAW span is the pause it carries. Without this the playhead could
- // never be inside it and would step straight over the pause.
- const lenSec =
- segment.heldSec ?? (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ const startSec = getRawVirtualStartTime(segment, rawClips);
+ const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -573,21 +559,14 @@ export function projectRegionsToSource<
T extends { id: string; startMs: number; endMs: number } & RegionClipAnchor,
>(
regions: T[],
- visibleSegments: PlaybackSegment[],
+ visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
makeId: () => string,
- /** The insertions the clips carry. A segment after one starts that much further along
- * the timeline, and an UNANCHORED region — a caption cue, which is built fresh each
- * time and has no clip anchor — is placed by intersecting with exactly that extent.
- * Without them the caption landed on the wrong stretch of source (issue #560).
- *
- * REQUIRED for the same reason as its neighbours: omitting it is silently wrong. */
- insertRanges: readonly AxcutInsertRange[],
): (T & { clipIndex?: number; underTrim?: boolean })[] {
// RAW extents + owning raw clip per visible segment. Both are only consulted by the
// path that needs them (raw fallback / anchor match), but resolving them once keeps
// the per-region loop free of repeated lookups.
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
const segmentRawClipIds = visibleSegments.map((seg) => findRawClipForSegment(seg, rawClips)?.id);
const out: (T & { clipIndex?: number; underTrim?: boolean })[] = [];
for (const region of regions) {
@@ -660,7 +639,7 @@ export function projectRegionsToSource<
export interface NativePosition {
/** The trim-narrowed playback segment (from `visibleSegments`) that is active. */
- clip: PlaybackSegment;
+ clip: AxcutClip;
/** Its index in `visibleSegments`, matching `SceneDescription.clips` / native `clip_index`. */
clipIndex: number;
/** Screen-source seconds the native decoder should present for this segment. */
@@ -696,31 +675,20 @@ const NATIVE_EOF_MARGIN_SEC = 0.033;
*/
export function resolveNativePosition(
rawSec: number,
- visibleSegments: PlaybackSegment[],
+ visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
// Segment whose RAW extent contains the playhead (last segment's end inclusive).
const index = spans.findIndex((s, i) => {
const isLast = i === spans.length - 1;
return rawSec >= s.startSec && (rawSec < s.endSec || (isLast && rawSec <= s.endSec));
});
- if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips, insertRanges);
+ if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips);
const seg = visibleSegments[index];
- // Inside a pause the source clock does not advance: the whole point of the segment
- // is created time over one held frame. Clamping here is what stops the raw-playhead
- // delta — which DOES advance through the pause — from pushing the decoder past the
- // held frame into the content that belongs after it.
- if (seg.heldSec !== undefined) {
- return { clip: seg, clipIndex: index, sourceTimeSec: seg.sourceStartSec };
- }
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
@@ -746,7 +714,6 @@ function positionUnderCut(
rawSec: number,
visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
- insertRanges: readonly AxcutInsertRange[],
): NativePosition {
const rawClip = rawClipAt(rawSec, rawClips);
if (rawClip) {
@@ -771,7 +738,7 @@ function positionUnderCut(
}
}
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
const next = spans.findIndex((s) => s.startSec >= rawSec);
const index = next >= 0 ? next : visibleSegments.length - 1;
const seg = visibleSegments[index];
diff --git a/src/lib/ai-edition/timeline/trim-mapping.test.ts b/src/lib/ai-edition/timeline/trim-mapping.test.ts
index 0e003351e..3ac5cba00 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.test.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.test.ts
@@ -38,7 +38,7 @@ describe("trimToTimelineSpan", () => {
timelineEndSec: 42,
}),
];
- expect(trimToTimelineSpan({ assetId: "a", startSec: 5, endSec: 7 }, clips, [])).toEqual({
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 5, endSec: 7 }, clips)).toEqual({
start: 5,
end: 7,
});
@@ -65,7 +65,7 @@ describe("trimToTimelineSpan", () => {
}),
];
// A trim at source 20..22 lives in c2 → timeline 14 + (20-16) = 18..20.
- expect(trimToTimelineSpan({ assetId: "a", startSec: 20, endSec: 22 }, clips, [])).toEqual({
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 20, endSec: 22 }, clips)).toEqual({
start: 18,
end: 20,
});
@@ -73,8 +73,8 @@ describe("trimToTimelineSpan", () => {
it("returns null when no clip carries the trim's source region", () => {
const clips = [clip({ id: "c1", assetId: "a", sourceStartSec: 0, sourceEndSec: 10 })];
- expect(trimToTimelineSpan({ assetId: "a", startSec: 40, endSec: 42 }, clips, [])).toBeNull();
- expect(trimToTimelineSpan({ assetId: "b", startSec: 2, endSec: 4 }, clips, [])).toBeNull();
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 40, endSec: 42 }, clips)).toBeNull();
+ expect(trimToTimelineSpan({ assetId: "b", startSec: 2, endSec: 4 }, clips)).toBeNull();
});
});
@@ -99,7 +99,7 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Timeline 18..20 falls in c2 (asset b) → source 16 + (18-14)=20 .. 22.
- expect(resolveTimelineSpanToTrim(18, 20, clips, [])).toEqual({
+ expect(resolveTimelineSpanToTrim(18, 20, clips)).toEqual({
assetId: "b",
clipId: "c2",
sourceStartSec: 20,
@@ -127,8 +127,8 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Start in c1 → asset a, start in c2 → asset b.
- expect(resolveTimelineSpanToTrim(2, 4, clips, [])?.assetId).toBe("a");
- expect(resolveTimelineSpanToTrim(20, 22, clips, [])?.assetId).toBe("b");
+ expect(resolveTimelineSpanToTrim(2, 4, clips)?.assetId).toBe("a");
+ expect(resolveTimelineSpanToTrim(20, 22, clips)?.assetId).toBe("b");
});
it("clamps the span to the carrier clip's extent (no straddling)", () => {
@@ -151,7 +151,7 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Span 10..20 starts in c1; end clamps to c1's end (timeline 14 → source 14).
- expect(resolveTimelineSpanToTrim(10, 20, clips, [])).toEqual({
+ expect(resolveTimelineSpanToTrim(10, 20, clips)).toEqual({
assetId: "a",
clipId: "c1",
sourceStartSec: 10,
@@ -178,7 +178,7 @@ describe("resolveTimelineSpanToTrim", () => {
timelineEndSec: 28,
}),
];
- const resolved = resolveTimelineSpanToTrim(18, 21, clips, []);
+ const resolved = resolveTimelineSpanToTrim(18, 21, clips);
expect(resolved).not.toBeNull();
if (!resolved) return;
const back = trimToTimelineSpan(
@@ -188,13 +188,12 @@ describe("resolveTimelineSpanToTrim", () => {
endSec: resolved.sourceEndSec,
},
clips,
- [],
);
expect(back).toEqual({ start: 18, end: 21 });
});
it("returns null with no clips", () => {
- expect(resolveTimelineSpanToTrim(1, 2, [], [])).toBeNull();
+ expect(resolveTimelineSpanToTrim(1, 2, [])).toBeNull();
});
});
@@ -271,9 +270,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 8, endSec: 14 }), // -> timeline 8..14
trim({ id: "t2", assetId: "a", startSec: 16, endSec: 22 }), // -> timeline 14..20
];
- expect(coalescedTrimGroups(trims, clips, [])).toEqual([
- { ids: ["t1", "t2"], start: 8, end: 20 },
- ]);
+ expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1", "t2"], start: 8, end: 20 }]);
});
it("groups two independently-created trims snapped to touching clip boundaries", () => {
@@ -302,9 +299,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 7, endSec: 10 }), // -> timeline 7..10
trim({ id: "t2", assetId: "b", startSec: 0, endSec: 2 }), // -> timeline 10..12
];
- expect(coalescedTrimGroups(trims, clips, [])).toEqual([
- { ids: ["t1", "t2"], start: 7, end: 12 },
- ]);
+ expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1", "t2"], start: 7, end: 12 }]);
});
it("keeps a trim separated by a real gap in its own group", () => {
@@ -322,7 +317,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 2, endSec: 4 }),
trim({ id: "t2", assetId: "a", startSec: 10, endSec: 12 }),
];
- expect(coalescedTrimGroups(trims, clips, [])).toEqual([
+ expect(coalescedTrimGroups(trims, clips)).toEqual([
{ ids: ["t1"], start: 2, end: 4 },
{ ids: ["t2"], start: 10, end: 12 },
]);
@@ -343,7 +338,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "gone", assetId: "b", startSec: 0, endSec: 2 }), // no clip carries asset b
trim({ id: "t1", assetId: "a", startSec: 3, endSec: 5 }),
];
- expect(coalescedTrimGroups(trims, clips, [])).toEqual([{ ids: ["t1"], start: 3, end: 5 }]);
+ expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1"], start: 3, end: 5 }]);
});
});
@@ -375,17 +370,15 @@ describe("two clips sharing one asset over the same source window", () => {
// Without the anchor this returned {3,5} — the first clip — because the loop
// stopped at the first clip whose asset and source window matched.
expect(
- trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, sharedClips(), []),
+ trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, sharedClips()),
).toEqual({ start: 15, end: 17 });
});
it("keeps mapping an un-anchored trim to the first matching clip (pre-v7 behaviour)", () => {
- expect(trimToTimelineSpan({ assetId: "a", startSec: 3, endSec: 5 }, sharedClips(), [])).toEqual(
- {
- start: 3,
- end: 5,
- },
- );
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 3, endSec: 5 }, sharedClips())).toEqual({
+ start: 3,
+ end: 5,
+ });
});
it("draws one pill per clip when each clip carries its own trim", () => {
@@ -394,7 +387,7 @@ describe("two clips sharing one asset over the same source window", () => {
trim({ id: "t2", assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }),
];
// Two pills, 12s apart — not one merged pill, and not two stacked on c1.
- expect(coalescedTrimGroups(trims, sharedClips(), [])).toEqual([
+ expect(coalescedTrimGroups(trims, sharedClips())).toEqual([
{ ids: ["t1"], start: 3, end: 5 },
{ ids: ["t2"], start: 15, end: 17 },
]);
@@ -404,7 +397,7 @@ describe("two clips sharing one asset over the same source window", () => {
// The twin still uses the same asset over the same source range, so an asset-only
// match would resurrect the cut on it.
const trims = [trim({ id: "orphan", assetId: "a", clipId: "c2", startSec: 3, endSec: 5 })];
- expect(coalescedTrimGroups(trims, [sharedClips()[0]], [])).toEqual([]);
+ expect(coalescedTrimGroups(trims, [sharedClips()[0]])).toEqual([]);
});
it("still shows a pill when the anchor clip was re-cut past the trim's start", () => {
@@ -421,7 +414,7 @@ describe("two clips sharing one asset over the same source window", () => {
}),
];
expect(
- trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, clips, []),
+ trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, clips),
).toEqual({ start: 0, end: 1 });
});
});
diff --git a/src/lib/ai-edition/timeline/trim-mapping.ts b/src/lib/ai-edition/timeline/trim-mapping.ts
index 701b575d0..0dee56d7d 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.ts
@@ -14,8 +14,7 @@
// clip) share a coordinate space: without the anchor, "which clip is this cut on?"
// had no answer and each caller invented its own. See `trimAppliesToClip`.
-import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
-import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
import { type CoalescedSpan, ventilateSpanAcrossClips } from "./region-ventilation";
import { coalesceByIdentity, regionIdentityKey } from "./timelineMap";
@@ -69,9 +68,6 @@ export function trimAppliesToClip(
export function trimToTimelineSpan(
trim: TrimAnchor,
clips: AxcutClip[],
- /** REQUIRED: a clip carrying insertions is longer than its source window, so this is not
- * a plain shift (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): { start: number; end: number } | null {
for (const c of clips) {
if (!trimAppliesToClip(trim, c)) continue;
@@ -82,7 +78,7 @@ export function trimToTimelineSpan(
: trim.startSec >= c.sourceStartSec && trim.startSec <= srcEnd;
if (carries) {
const map = (s: number) =>
- sourceToTimelineSec(c, Math.min(Math.max(s, c.sourceStartSec), srcEnd), insertRanges);
+ c.timelineStartSec + (Math.min(Math.max(s, c.sourceStartSec), srcEnd) - c.sourceStartSec);
return { start: map(trim.startSec), end: map(trim.endSec) };
}
}
@@ -144,12 +140,11 @@ export function ventilateTimelineSpanToTrims(
export function coalescedTrimGroups(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
- insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): CoalescedSpan[] {
const spans = trimRanges
.map((t) => {
- const mapped = trimToTimelineSpan(t, clips, insertRanges);
+ const mapped = trimToTimelineSpan(t, clips);
return mapped
? {
id: t.id,
@@ -188,12 +183,10 @@ export function resolveTrimPillIds(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
id: string,
- insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): string[] {
return (
- coalescedTrimGroups(trimRanges, clips, insertRanges, epsilonSec).find((g) => g.ids.includes(id))
- ?.ids ?? [id]
+ coalescedTrimGroups(trimRanges, clips, epsilonSec).find((g) => g.ids.includes(id))?.ids ?? [id]
);
}
@@ -208,14 +201,11 @@ export function dropTrimPillsByIds(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
ids: Iterable,
- insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): AxcutTrimRange[] {
const under = new Set();
for (const id of ids) {
- for (const member of resolveTrimPillIds(trimRanges, clips, id, insertRanges, epsilonSec)) {
- under.add(member);
- }
+ for (const member of resolveTrimPillIds(trimRanges, clips, id, epsilonSec)) under.add(member);
}
if (under.size === 0) return trimRanges;
return trimRanges.filter((t) => !under.has(t.id));
@@ -235,9 +225,6 @@ export function resolveTimelineSpanToTrim(
startSec: number,
endSec: number,
clips: AxcutClip[],
- /** REQUIRED: a clip carrying insertions is longer than its source window, so this is not
- * a plain shift (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): TrimSourceRange | null {
if (clips.length === 0) return null;
const lo = Math.min(startSec, endSec);
@@ -259,7 +246,7 @@ export function resolveTimelineSpanToTrim(
Math.min(lo, carrier.timelineStartSec + srcLen),
);
const tEnd = Math.max(tStart, Math.min(hi, carrier.timelineStartSec + srcLen));
- const toSrc = (t: number) => timelineToSourceSec(carrier, t, insertRanges).sourceSec;
+ const toSrc = (t: number) => carrier.sourceStartSec + (t - carrier.timelineStartSec);
return {
assetId: carrier.assetId,
clipId: carrier.id,
diff --git a/src/lib/ai-edition/timeline/virtual-preview.test.ts b/src/lib/ai-edition/timeline/virtual-preview.test.ts
index 37bac870b..f3dd64888 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.test.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.test.ts
@@ -51,24 +51,24 @@ describe("virtual-preview pure functions", () => {
});
it("locateVirtualPosition maps virtual time to source time", () => {
- const pos = locateVirtualPosition(clips, 12, []);
+ const pos = locateVirtualPosition(clips, 12);
expect(pos).not.toBeNull();
expect(pos?.clipIndex).toBe(1);
expect(pos?.sourceTimeSec).toBe(22);
});
it("locateVirtualPosition returns null for empty clips", () => {
- expect(locateVirtualPosition([], 0, [])).toBeNull();
+ expect(locateVirtualPosition([], 0)).toBeNull();
});
it("locateSourcePosition maps source time back to virtual time", () => {
- const pos = locateSourcePosition(clips, 25, undefined, 0.05, undefined, []);
+ const pos = locateSourcePosition(clips, 25);
expect(pos).not.toBeNull();
expect(pos?.virtualTimeSec).toBe(15);
});
it("locateSourcePosition returns null for source time in a cut", () => {
- expect(locateSourcePosition(clips, 15, undefined, 0.05, undefined, [])).toBeNull();
+ expect(locateSourcePosition(clips, 15)).toBeNull();
});
it("keptWordIdSet flattens wordRefs from all clips", () => {
@@ -106,13 +106,13 @@ describe("virtual-preview pure functions", () => {
reason: "",
},
];
- const pos1 = locateSourcePosition(multiClips, 5, "a1", 0.05, undefined, []);
+ const pos1 = locateSourcePosition(multiClips, 5, "a1");
expect(pos1?.clip.id).toBe("clip_1");
- const pos2 = locateSourcePosition(multiClips, 5, "a2", 0.05, undefined, []);
+ const pos2 = locateSourcePosition(multiClips, 5, "a2");
expect(pos2?.clip.id).toBe("clip_2");
- const posNone = locateSourcePosition(multiClips, 5, "a3", 0.05, undefined, []);
+ const posNone = locateSourcePosition(multiClips, 5, "a3");
expect(posNone).toBeNull();
});
@@ -148,19 +148,19 @@ describe("virtual-preview pure functions", () => {
// the earliest matching clip — this is the bug: playing back the
// second clip's segment would still report position/identity for the
// first.
- const ambiguous = locateSourcePosition(duplicateClips, 5, "a1", 0.05, undefined, []);
+ const ambiguous = locateSourcePosition(duplicateClips, 5, "a1");
expect(ambiguous?.clip.id).toBe("clip_1");
// With the currently-active clip id passed through, it's preferred
// even though clip_1 also matches (assetId, sourceTime).
- const disambiguated = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_2", []);
+ const disambiguated = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_2");
expect(disambiguated?.clip.id).toBe("clip_2");
expect(disambiguated?.virtualTimeSec).toBe(15);
// A preferred clip id that no longer applies (source time moved
// outside its range) falls back to the ambiguous scan rather than
// forcing a stale match.
- const outOfRange = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_3", []);
+ const outOfRange = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_3");
expect(outOfRange?.clip.id).toBe("clip_1");
});
@@ -213,7 +213,6 @@ describe("virtual-preview pure functions", () => {
playingClip.assetId,
0.05,
playingClip.id,
- [],
);
expect(pos?.clip.id).toBe(playing);
expect(pos?.virtualTimeSec).toBeCloseTo(playingClip.timelineStartSec + sourceTimeSec, 6);
@@ -223,8 +222,8 @@ describe("virtual-preview pure functions", () => {
// The scan cannot know which twin is playing — but its answer must at least not
// depend on which twin happens to sit last in the array, which is what
// `index === clips.length - 1` made it do.
- const forward = locateSourcePosition(twins([a1, a2]), 9.96, "a1", 0.05, undefined, []);
- const reversed = locateSourcePosition(twins([a2, a1]), 9.96, "a1", 0.05, undefined, []);
+ const forward = locateSourcePosition(twins([a1, a2]), 9.96, "a1");
+ const reversed = locateSourcePosition(twins([a2, a1]), 9.96, "a1");
expect(forward?.clip.id).toBe("clip_a1");
expect(reversed?.clip.id).toBe("clip_a2");
// i.e. both resolve to the FIRST clip of the asset — the documented behaviour of
@@ -247,17 +246,17 @@ describe("virtual-preview pure functions", () => {
timelineEndSec: 20,
},
];
- expect(locateSourcePosition(split, 10, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a2");
- expect(locateSourcePosition(split, 9.9, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a1");
+ expect(locateSourcePosition(split, 10, "a1")?.clip.id).toBe("clip_a2");
+ expect(locateSourcePosition(split, 9.9, "a1")?.clip.id).toBe("clip_a1");
// …and the very end of the timeline still resolves rather than falling off it.
- expect(locateSourcePosition(split, 20, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a2");
+ expect(locateSourcePosition(split, 20, "a1")?.clip.id).toBe("clip_a2");
});
it("ignores a named clip whose asset is not the one playing", () => {
// A stale id during an asset swap must fall through to the scan rather than
// mapping the time through media that is not on screen.
const clips = twins([a1, c3]);
- const pos = locateSourcePosition(clips, 5, "a1", 0.05, "clip_c3", []);
+ const pos = locateSourcePosition(clips, 5, "a1", 0.05, "clip_c3");
expect(pos?.clip.id).toBe("clip_a1");
});
});
@@ -368,8 +367,8 @@ describe("virtual-preview pure functions", () => {
timelineEndSec: 13.8,
};
- expect(getRawVirtualStartTime(segClip1Part2, rawClips, [])).toBe(6);
- expect(getRawVirtualStartTime(segClip2Part1, rawClips, [])).toBe(13.2);
+ expect(getRawVirtualStartTime(segClip1Part2, rawClips)).toBe(6);
+ expect(getRawVirtualStartTime(segClip2Part1, rawClips)).toBe(13.2);
});
it("findNextKeptSegment finds next kept segment across multi-clip trim boundary", () => {
@@ -422,11 +421,11 @@ describe("virtual-preview pure functions", () => {
];
// At current raw virtual time 2.5s (end of seg 0), next kept segment is seg 1 (clip_2)
- const nextSeg = findNextKeptSegment(playbackClips, rawClips, 2.5, "a1", 2.5, undefined, []);
+ const nextSeg = findNextKeptSegment(playbackClips, rawClips, 2.5, "a1", 2.5);
expect(nextSeg).toBeDefined();
expect(nextSeg?.id).toBe("clip_2");
expect(nextSeg?.assetId).toBe("a2");
- expect(getRawVirtualStartTime(nextSeg!, rawClips, [])).toBe(10.7);
+ expect(getRawVirtualStartTime(nextSeg!, rawClips)).toBe(10.7);
});
describe("findNextKeptSegment never goes backwards", () => {
@@ -474,9 +473,9 @@ describe("virtual-preview pure functions", () => {
// clip_1 starts at source 30, which IS "later in source time", and its raw start
// is 0: answering it sent playback back to the beginning, straight into the same
// cut again, forever.
- const next = findNextKeptSegment(playbackClips, rawClips, 17, "a1", 7, "clip_2", []);
+ const next = findNextKeptSegment(playbackClips, rawClips, 17, "a1", 7, "clip_2");
expect(next).toBeDefined();
- expect(getRawVirtualStartTime(next!, rawClips, [])).toBe(20);
+ expect(getRawVirtualStartTime(next!, rawClips)).toBe(20);
expect(next?.sourceStartSec).toBe(10);
});
@@ -484,7 +483,7 @@ describe("virtual-preview pure functions", () => {
// Same moment, but the raw position has not caught up (still reads 10, the start
// of clip_2). The ruler test alone would answer clip_2's FIRST kept segment —
// the stretch already played. The clip-scoped source test carries it past the cut.
- const next = findNextKeptSegment(playbackClips, rawClips, 10, "a1", 7, "clip_2", []);
+ const next = findNextKeptSegment(playbackClips, rawClips, 10, "a1", 7, "clip_2");
expect(next?.sourceStartSec).toBe(10);
});
});
diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts
index ba27e8cfb..1cdc5d2f6 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.ts
@@ -1,8 +1,7 @@
// Ported from axcut/apps/web/src/lib/virtual-preview.ts — pure time-mapping
// functions shared by the VirtualPreview component and the timeline math.
-import type { AxcutClip, AxcutInsertRange } from "../schema";
-import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
+import type { AxcutClip } from "../schema";
export type VirtualPosition = {
clip: AxcutClip;
@@ -23,10 +22,6 @@ export function clampVirtualTime(clips: AxcutClip[], value: number): number {
export function locateVirtualPosition(
clips: AxcutClip[],
virtualTimeSec: number,
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): VirtualPosition | null {
if (clips.length === 0) return null;
const clamped = clampVirtualTime(clips, virtualTimeSec);
@@ -37,11 +32,7 @@ export function locateVirtualPosition(
const resolvedIndex = clipIndex >= 0 ? clipIndex : clips.length - 1;
const clip = clips[resolvedIndex];
const clipDuration = (clip.sourceEndSec ?? 0) - clip.sourceStartSec;
- // Inside an insertion there is no source moment — none of those seconds came from the
- // file — so this answers with the one the inserted media follows, which is the frame a
- // decoder should be parked on.
- const { sourceSec } = timelineToSourceSec(clip, clamped, insertRanges);
- const clipOffset = Math.max(0, Math.min(clipDuration, sourceSec - clip.sourceStartSec));
+ const clipOffset = Math.max(0, Math.min(clipDuration, clamped - clip.timelineStartSec));
return {
clip,
clipIndex: resolvedIndex,
@@ -81,22 +72,10 @@ export function findRawClipForSegment(
* Maps a kept segment (`AxcutClip` from `resolvePlaybackSegments`) back to its
* exact start position on the raw (untrimmed) document timeline.
*/
-export function getRawVirtualStartTime(
- segment: AxcutClip,
- rawClips: AxcutClip[],
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
-): number {
+export function getRawVirtualStartTime(segment: AxcutClip, rawClips: AxcutClip[]): number {
const rawClip = findRawClipForSegment(segment, rawClips);
if (!rawClip) return segment.timelineStartSec;
- // A HELD segment is the inserted media itself, so it starts where the insertion opens.
- // Every other segment starting at that same source moment is the film RESUMING, so it
- // starts where the insertion closes. Same source second, two different places on the
- // timeline — which is the whole reason an insertion is media and not a marker.
- const edge = (segment as { heldSec?: number }).heldSec !== undefined ? "opens" : "closes";
- return sourceToTimelineSec(rawClip, segment.sourceStartSec, insertRanges, edge);
+ return rawClip.timelineStartSec + (segment.sourceStartSec - rawClip.sourceStartSec);
}
/**
@@ -120,13 +99,12 @@ export function findNextKeptSegment(
playbackClips: AxcutClip[],
rawClips: AxcutClip[],
currentRawTime: number,
- activeSourceId: string | undefined,
- currentSourceTime: number | undefined,
- activeClipId: string | undefined,
- insertRanges: readonly AxcutInsertRange[],
+ activeSourceId?: string,
+ currentSourceTime?: number,
+ activeClipId?: string,
): AxcutClip | undefined {
for (const seg of playbackClips) {
- const segRawStart = getRawVirtualStartTime(seg, rawClips, insertRanges);
+ const segRawStart = getRawVirtualStartTime(seg, rawClips);
if (segRawStart > currentRawTime + 0.001) {
return seg;
}
@@ -148,7 +126,6 @@ function toPositionAt(
clips: AxcutClip[],
clipIndex: number,
sourceTimeSec: number,
- insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition {
const clip = clips[clipIndex];
const sourceOffset = Math.max(
@@ -158,9 +135,7 @@ function toPositionAt(
return {
clip,
clipIndex,
- // Not `timelineStartSec + offset`: a clip carrying insertions is longer than its
- // source window, so a moment past one sits that much further along (issue #560).
- virtualTimeSec: sourceToTimelineSec(clip, clip.sourceStartSec + sourceOffset, insertRanges),
+ virtualTimeSec: clip.timelineStartSec + sourceOffset,
sourceTimeSec,
};
}
@@ -195,8 +170,8 @@ function isWithinClipBounds(
export function locateSourcePosition(
clips: AxcutClip[],
sourceTimeSec: number,
- assetId: string | undefined,
- epsilon: number,
+ assetId?: string,
+ epsilon = 0.05,
// When two clips share the same source asset (and possibly overlapping
// source ranges — a duplicated clip, or simply not trimmed yet), scanning
// by (assetId, sourceTime) alone is ambiguous and always resolves to the
@@ -205,11 +180,7 @@ export function locateSourcePosition(
// which clip they're tracking (VirtualPreview, mid-playback) should pass
// its id here so it's preferred whenever the source time still falls
// inside it, before falling back to the ambiguous asset-wide scan.
- preferredClipId: string | undefined,
- /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
- * so a caller that omits these gets an answer that is plausible and wrong by exactly the
- * inserted time, with nothing to catch it (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
+ preferredClipId?: string,
): VirtualPosition | null {
if (preferredClipId) {
const preferredIndex = clips.findIndex((clip) => clip.id === preferredClipId);
@@ -227,7 +198,7 @@ export function locateSourcePosition(
(!assetId || clips[preferredIndex].assetId === assetId) &&
isWithinClipBounds(clips[preferredIndex], sourceTimeSec, epsilon, "inclusive")
) {
- return toPositionAt(clips, preferredIndex, sourceTimeSec, insertRanges);
+ return toPositionAt(clips, preferredIndex, sourceTimeSec);
}
}
const scan = (closingEdge: ClosingEdge) =>
@@ -248,7 +219,7 @@ export function locateSourcePosition(
const strict = scan("exclusive");
const clipIndex = strict >= 0 ? strict : scan("inclusive");
if (clipIndex < 0) return null;
- return toPositionAt(clips, clipIndex, sourceTimeSec, insertRanges);
+ return toPositionAt(clips, clipIndex, sourceTimeSec);
}
/**
@@ -276,12 +247,8 @@ export function locateKeptSegment(
const ownSegments = activeClipId
? playbackClips.filter((seg) => findRawClipForSegment(seg, rawClips)?.id === activeClipId)
: [];
- // The SEGMENTS are already split at every insertion, so within one of them source → its
- // own start is a plain shift again. `[]` here is the honest answer, not a forgotten
- // argument: there is no insertion inside a segment to account for.
- if (ownSegments.length > 0)
- return locateSourcePosition(ownSegments, sourceTimeSec, assetId, 0.05, undefined, []);
- return locateSourcePosition(playbackClips, sourceTimeSec, assetId, 0.05, undefined, []);
+ if (ownSegments.length > 0) return locateSourcePosition(ownSegments, sourceTimeSec, assetId);
+ return locateSourcePosition(playbackClips, sourceTimeSec, assetId);
}
export function keptWordIdSet(clips: AxcutClip[]): Set {
diff --git a/src/lib/ai-edition/timeline/voiceoverCut.test.ts b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
index 701b403ef..7e121be61 100644
--- a/src/lib/ai-edition/timeline/voiceoverCut.test.ts
+++ b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
@@ -84,7 +84,7 @@ describe("a cut authored from the voiceover lane", () => {
// wrote `clipId: "vo"` here and removed nothing at all.
// biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
const [placement] = voiceoverPlacements([VOICE as any]);
- const rows = cut(placementRawSec(placement, 2, []), placementRawSec(placement, 3, []));
+ const rows = cut(placementRawSec(placement, 2), placementRawSec(placement, 3));
expect(rows).toHaveLength(1);
expect(CLIPS.map((c) => c.id)).toContain(rows[0].clipId);
expect(filmSec([])).toBeCloseTo(12, 6);
@@ -101,7 +101,7 @@ describe("a cut authored from the voiceover lane", () => {
[5, 6],
[20, 21],
]);
- expect(coalescedTrimGroups(rows, CLIPS, [])).toHaveLength(1);
+ expect(coalescedTrimGroups(rows, CLIPS)).toHaveLength(1);
expect(filmSec(rows)).toBeCloseTo(10, 6);
});
@@ -109,7 +109,7 @@ describe("a cut authored from the voiceover lane", () => {
const rows = cut(5, 7);
// Restoring must not leave half the cut behind, with the word still gone and
// nothing on the ruler to click.
- expect(dropTrimPillsByIds(rows, CLIPS, [rows[0].id], [])).toEqual([]);
+ expect(dropTrimPillsByIds(rows, CLIPS, [rows[0].id])).toEqual([]);
});
it("writes nothing where there is no film", () => {
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index b37b71ece..d453936bf 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -274,7 +274,6 @@ function doc(assetIds: string[], clipAssetIds: string[]): AxcutDocument {
})),
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
diff --git a/src/native/contracts.ts b/src/native/contracts.ts
index c2d290387..54c1cc775 100644
--- a/src/native/contracts.ts
+++ b/src/native/contracts.ts
@@ -170,16 +170,6 @@ export interface CompositorClipInput {
* convention). Populated by `buildSceneDescription` and `buildNativeClipList`;
* see the comment on the producer side for the exact rule. */
hasAudio: boolean;
- /** Extra OUTPUT seconds this clip holds its last frame for, silently.
- *
- * A word added to the recording transcript needs somewhere to be spoken, so the film
- * holds a frame and everything after it moves along the ruler (issue #560). The ruler
- * and the preview have honoured that for a while; the EXPORT did not, because a held
- * segment has an empty source window and `walk_composited_timeline` skips those. It is
- * a separate clip carrying this field rather than an adjustment to its predecessor:
- * scene regions are keyed by clip INDEX, so removing an entry from the list would point
- * every region after it at the wrong clip. */
- holdSec: number;
}
/** Bilan d'un export natif (mesure enveloppante §10 : frames, durée, fps). */
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index cda53e559..346c1af3c 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -84,7 +84,6 @@ function makeDoc(
clips,
gaps: [],
trimRanges: [],
- insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -1994,72 +1993,6 @@ describe("buildSceneDescription.captions", () => {
});
// --- imported audio tracks (issue #350) ------------------------------------
-// ─── The pause that exported as nothing ─────────────────────────────────────
-// A held segment has an empty source window, and `walk_composited_timeline` skipped every
-// clip shaped like that — so a word added to the recording lengthened the ruler and the
-// preview and produced no frames at all in the exported file (issue #560). The segment now
-// reaches the compositor as its OWN clip carrying `holdSec`, keeping its index so the scene
-// regions after it still point at the right clip.
-
-describe("buildSceneDescription.holdSec", () => {
- it("sends a pause to the compositor as a clip that holds", () => {
- const doc = makeDoc({
- assets: [makeAsset({ id: "scr", kind: "video", originalPath: "/s.mp4", durationSec: 10 })],
- clips: [
- makeClip({
- id: "c1",
- assetId: "scr",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- }),
- ],
- timeline: {
- insertRanges: [
- {
- id: "i1",
- assetId: "scr",
- atSec: 4,
- durationSec: 1,
- wordId: "w1",
- reason: "",
- origin: "user",
- },
- // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
- ] as any,
- },
- });
- const clips = buildSceneDescription(doc).clips;
- const held = clips.filter((c) => c.holdSec > 0);
- expect(held).toHaveLength(1);
- expect(held[0].holdSec).toBeCloseTo(1, 6);
- // Its own entry, in place — not folded onto its predecessor, which would shift every
- // clip index after it and point the scene's per-clip regions at the wrong clip.
- expect(clips).toHaveLength(3);
- expect(clips[1]).toBe(held[0]);
- // An empty source window: it decodes nothing and exists only for its held frames.
- expect(held[0].sourceEndSec).toBeCloseTo(held[0].sourceStartSec, 6);
- });
-
- it("holds nothing on an ordinary clip", () => {
- const doc = makeDoc({
- assets: [makeAsset({ id: "scr", kind: "video", originalPath: "/s.mp4", durationSec: 10 })],
- clips: [
- makeClip({
- id: "c1",
- assetId: "scr",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- }),
- ],
- });
- expect(buildSceneDescription(doc).clips.every((c) => c.holdSec === 0)).toBe(true);
- });
-});
-
describe("buildSceneDescription.audioTracks", () => {
const audioAsset = makeAsset({
id: "aud",
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 6ede99478..ce22db0ed 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -41,7 +41,6 @@ import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
-import { takeInserts } from "@/lib/ai-edition/timeline/insert-mapping";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { projectRegionsToSource } from "@/lib/ai-edition/timeline/timelineMap";
@@ -520,15 +519,10 @@ function clipAssetIsResolvable(
/**
* Returns `PlaybackSegment[]`, not `AxcutClip[]`: a held segment carries `heldSec`, and
* widening it away here is what kept the pause from ever reaching the compositor. Every
- * caller maps it to `holdSec` on the clip input (issue #560).
*/
export function resolveVisibleClips(document: AxcutDocument): PlaybackSegment[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
- return resolvePlaybackSegments(
- document.timeline.clips,
- document.timeline.trimRanges,
- document.timeline.insertRanges,
- )
+ return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
.sort((a, b) => a.timelineStartSec - b.timelineStartSec)
.filter((clip) => clipAssetIsResolvable(clip, assetById));
}
@@ -573,9 +567,7 @@ export function buildSceneDescription(
// The one removed set, hoisted out of the map: every voiceover asks it the same
// question, and it does not depend on the track.
// Placed once: the projection below counts them, so a track after a pause lands where
- // the ruler says rather than D seconds early.
- const filmInserts = document.timeline.insertRanges ?? [];
- const removed = removedRawSpans(projectedClips, document.timeline.trimRanges, filmInserts);
+ const removed = removedRawSpans(projectedClips, document.timeline.trimRanges);
// The take's pills, keyed by group. A voiceover is walked ONCE per pill and never per
// stored fragment: the document keeps one fragment per clip a take covers, so walking
// them separately would emit overlapping entries and `overlay_track_pcm` sums with `+=`
@@ -595,7 +587,6 @@ export function buildSceneDescription(
projectedClips,
document.timeline.trimRanges,
track.startMs / 1000,
- filmInserts,
rawSpeedRegions,
);
// Length is measured WITHOUT speed, position WITH it — the two do different
@@ -612,13 +603,11 @@ export function buildSceneDescription(
projectedClips,
document.timeline.trimRanges,
track.endMs / 1000,
- filmInserts,
) -
projectRawTimelineSecToPlayback(
projectedClips,
document.timeline.trimRanges,
track.startMs / 1000,
- filmInserts,
);
const spanSec = trimmedSpanSec;
if (spanSec <= 0) return [];
@@ -654,10 +643,7 @@ export function buildSceneDescription(
// file the take covers, which is the honest fallback once the cuts are taken out.
const voWindowSec =
sourceDurationSec > 0 ? Math.max(0, sourceDurationSec - offsetSec) : rawSpanSec;
- // One walk: the cuts take time away, the take's own insertions add it, and the
- // walk resolves them together so a second insertion lands after the first one's
- // hold rather than inside it.
- const kept = takeProgramme(pill, removed, takeInserts(document, groupId), rawSpeedRegions)
+ const kept = takeProgramme(pill, removed)
.filter((piece) => piece.kind === "play")
.map((piece) => ({
...base,
@@ -665,7 +651,6 @@ export function buildSceneDescription(
projectedClips,
document.timeline.trimRanges,
piece.rawStartSec,
- filmInserts,
rawSpeedRegions,
),
trimStartSec: piece.sourceStartSec,
@@ -731,7 +716,6 @@ export function buildSceneDescription(
hasAudio: true,
// A held segment has an empty source window and exists only for the frames it
// holds; every other clip holds nothing.
- holdSec: clip.heldSec ?? 0,
},
];
});
@@ -777,7 +761,6 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("zoom"),
- document.timeline.insertRanges ?? [],
);
// Same raw→source projection as the zoom regions above, for the same reason: annotations are
// authored in RAW document time and the compositor matches each frame's SOURCE time.
@@ -814,7 +797,6 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("ann"),
- document.timeline.insertRanges ?? [],
);
const projectedCameraFullscreenRegions = projectRegionsToSource(
((document.legacyEditor as Record | null)?.cameraFullscreenRegions as
@@ -823,7 +805,6 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("camfull"),
- document.timeline.insertRanges ?? [],
);
// Speed regions carry an extra `speed` field the standard `rangeSchema` does not, so we
// can't read from `document.timeline.speedRanges` today (see SceneDescription.speedRegions
@@ -838,7 +819,6 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("speed"),
- document.timeline.insertRanges ?? [],
);
// Webcam rect, single source of truth between preview & native :
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index 7263d14a0..64e877b63 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -18,7 +18,7 @@
* re-aligns them.
*/
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
-import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
+import type { AxcutClip } from "@/lib/ai-edition/schema";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
getCurrentNativeViewId,
@@ -34,22 +34,13 @@ export function useNativePlaybackSync(
visibleSegments: readonly AxcutClip[],
/** RAW clip layout (`document.timeline.clips`) `currentTimeSec` is expressed against. */
rawClips: readonly AxcutClip[],
- /** The insertions those clips carry — a clip is longer than its source window by them,
- * so a segment's place on the timeline cannot be found without them (issue #560). */
- insertRanges: readonly AxcutInsertRange[],
): void {
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips], insertRanges),
- [visibleSegments, rawClips, currentTimeSec, insertRanges],
+ () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips]),
+ [visibleSegments, rawClips, currentTimeSec],
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
- // A pause holds ONE frame for its whole length. Free-running the decoder through it
- // would play what comes after instead, and the app clock — which does traverse the
- // pause — would then re-seek on the drift and stutter. Pausing the decoder is what
- // makes the pause a pause; the webcam holds with the screen because both derive
- // from the one asset source clock the pause stops advancing.
- const held = activePosition?.clip.heldSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -63,8 +54,8 @@ export function useNativePlaybackSync(
if (!active) {
return;
}
- setNativePlaying(playing && !held);
- }, [active, playing, held]);
+ setNativePlaying(playing);
+ }, [active, playing]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -77,16 +68,6 @@ export function useNativePlaybackSync(
}
const now = performance.now();
- // Inside a pause while playing: the decoder is parked on the held frame (see the
- // transport effect). Refresh the drift refs every run so the check never reads a
- // correctly-frozen source clock as divergence and fights itself with seeks.
- if (playing && held) {
- setNativeTime(sourceTimeSec);
- lastSyncedSourceTimeRef.current = sourceTimeSec;
- lastSyncedWallTimeRef.current = now;
- return;
- }
-
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -114,5 +95,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, held, activeClipId, sourceTimeSec]);
+ }, [active, playing, activeClipId, sourceTimeSec]);
}
From 54ea8f60cf9f97fea9266b2fbcd57a1140b289ce Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 13:03:50 +0200
Subject: [PATCH 092/113] feat(media): generate the media an added word is
spoken over
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 1 of the insertion layer. A word typed into the transcript has no recording behind
it; until there is TTS and frame generation, the stand-in is the last frame the recording
showed, held, over faint noise — but a REAL file, with real frames and a real audio track,
so everything downstream decodes it like any other media instead of special-casing its
absence. That is the whole point of the new architecture.
One ffmpeg pass, no temp file: seek to the moment, keep the single frame there, loop it
for the duration, mux it against a noise source of the same length. `resolveFfmpeg` and
the spawn pattern come from `audioPeaks`. Derived, never authored: the name carries the
word and the duration, so a re-typed word asks for a different file, a stale one is never
asked for again, and a missing one is a regeneration rather than a broken edit.
Two things running it against the live recording taught, both now pinned:
- `libx264` is GPL and is not in the bundled LGPL ffmpeg — the first real run died with
"Unknown encoder". `libopenh264` is the software H.264 encoder every LGPL build
carries on every platform; the hardware ones each need their own hardware, which is
not worth a platform branch for a clip of a few seconds.
- the live project's asset carries `fps: 0` — the probe never filled it in — and a loop
filter with no rate produces nothing. Falls back to 30, marked with its ceiling.
Verified end to end against the real recording: 3.60s out for 3.60s asked, 1920x1080 at
30fps, 48kHz audio. The unit test asserts the ARGUMENTS rather than running ffmpeg —
running it would test ffmpeg, and the arguments are where a mistake actually lives.
---
electron/media/extensionClip.test.ts | 70 ++++++++++++++
electron/media/extensionClip.ts | 135 +++++++++++++++++++++++++++
2 files changed, 205 insertions(+)
create mode 100644 electron/media/extensionClip.test.ts
create mode 100644 electron/media/extensionClip.ts
diff --git a/electron/media/extensionClip.test.ts b/electron/media/extensionClip.test.ts
new file mode 100644
index 000000000..342ff04c8
--- /dev/null
+++ b/electron/media/extensionClip.test.ts
@@ -0,0 +1,70 @@
+// The one thing worth pinning: the command says what we mean. Running ffmpeg in a unit test
+// would test ffmpeg, not us — the arguments are where a mistake actually lives.
+
+import { describe, expect, it } from "vitest";
+import { extensionClipArgs, extensionClipName } from "./extensionClip";
+
+const SPEC = {
+ sourcePath: "C:/rec/take.mp4",
+ atSec: 5.3,
+ durationSec: 3.6,
+ fps: 30,
+ width: 1920,
+ height: 1080,
+};
+
+describe("extensionClipArgs", () => {
+ const args = extensionClipArgs(SPEC, "C:/out/w1_3600.mp4");
+ const at = (flag: string) => args[args.indexOf(flag) + 1];
+
+ it("seeks BEFORE the input, so ffmpeg does not decode up to the moment", () => {
+ expect(args.indexOf("-ss")).toBeLessThan(args.indexOf("-i"));
+ expect(at("-ss")).toBe("5.300");
+ });
+
+ it("holds exactly the frozen frame for the whole duration", () => {
+ const filter = at("-filter_complex");
+ expect(filter).toContain("trim=end_frame=1");
+ expect(filter).toContain("loop=loop=-1:size=1:start=0");
+ expect(filter).toContain("trim=duration=3.600");
+ });
+
+ it("matches the recording's geometry, so the two concatenate downstream", () => {
+ expect(at("-filter_complex")).toContain("fps=30");
+ expect(at("-filter_complex")).toContain("scale=1920:1080");
+ });
+
+ it("carries an audio track rather than none — silence reads as a broken file", () => {
+ expect(args.some((a) => a.startsWith("anoisesrc="))).toBe(true);
+ expect(args).toContain("1:a");
+ });
+
+ it("bounds the output, so a looping filter cannot run away", () => {
+ // `-t` appears twice on purpose: once on the noise input, once on the output.
+ expect(args.filter((a) => a === "-t")).toHaveLength(2);
+ expect(args[args.length - 1]).toBe("C:/out/w1_3600.mp4");
+ });
+});
+
+describe("extensionClipName", () => {
+ it("carries the word and the duration, so a re-typed word asks for a different file", () => {
+ expect(extensionClipName("synth_2", 3.6)).toBe("synth_2_3600.mp4");
+ expect(extensionClipName("synth_2", 3.8)).not.toBe(extensionClipName("synth_2", 3.6));
+ });
+});
+
+describe("the two things reality imposed", () => {
+ it("uses an encoder the bundled LGPL ffmpeg actually has", () => {
+ // `libx264` is GPL and absent: the first real run failed with "Unknown encoder".
+ const args = extensionClipArgs(SPEC, "out.mp4");
+ expect(args).toContain("libopenh264");
+ expect(args).not.toContain("libx264");
+ });
+
+ it("still has a frame rate when the asset does not know its own", () => {
+ // The live project's asset carries `fps: 0` — the probe never filled it in, and a
+ // loop filter with no rate produces nothing.
+ const args = extensionClipArgs({ ...SPEC, fps: 0 }, "out.mp4");
+ expect(args[args.indexOf("-filter_complex") + 1]).toContain("fps=30");
+ });
+});
diff --git a/electron/media/extensionClip.ts b/electron/media/extensionClip.ts
new file mode 100644
index 000000000..5769a1aa7
--- /dev/null
+++ b/electron/media/extensionClip.ts
@@ -0,0 +1,135 @@
+/**
+ * The media an added word is spoken over.
+ *
+ * A word typed into the transcript has no recording behind it. Until there is TTS and frame
+ * generation, the stand-in is the last frame the recording showed, held, over faint noise —
+ * a real file, with real frames and a real audio track, so everything downstream decodes it
+ * like any other media instead of special-casing its absence.
+ *
+ * DERIVED, never authored: the word is the truth, this file is regenerable from it. The name
+ * carries what it was generated from, so a stale one is simply never asked for again, and a
+ * missing one is a regeneration rather than a broken edit.
+ */
+
+import { spawn } from "node:child_process";
+import { access, mkdir } from "node:fs/promises";
+import path from "node:path";
+import { resolveFfmpeg } from "./audioPeaks";
+
+export interface ExtensionClipSpec {
+ /** The recording the frozen frame is taken from. */
+ sourcePath: string;
+ /** Source second to freeze on — the moment the added word follows. */
+ atSec: number;
+ durationSec: number;
+ /** Matched to the recording so the two concatenate without a re-encode downstream.
+ * `0` when the asset was imported before the probe filled it in — see `FALLBACK_FPS`. */
+ fps: number;
+ width: number;
+ height: number;
+}
+
+/** Noise rather than silence: a silent track is indistinguishable from a broken one, and
+ * this stands in for a voice that will be synthesized later. Quiet enough not to startle. */
+const NOISE_AMPLITUDE = 0.02;
+const SAMPLE_RATE = 48_000;
+
+/** The bundled ffmpeg is LGPL, so `libx264` is not in it — `libopenh264` is the software
+ * H.264 encoder every LGPL build carries, on every platform. The hardware encoders
+ * (`h264_nvenc`, `h264_mf`, …) are faster and each needs its own hardware; for a clip of a
+ * few seconds that trade is not worth a platform branch. */
+const VIDEO_ENCODER = "libopenh264";
+
+/** Assets imported before the probe filled `video.fps` carry 0, and the live project has
+ * one. A loop filter needs a rate, so it gets a common one.
+ * ponytail: fixed 30, read the real rate off the source when the probe backfills it. */
+const FALLBACK_FPS = 30;
+
+/**
+ * The ffmpeg arguments, as a pure function so the command can be asserted without running it.
+ *
+ * One pass, no temp file: seek to the moment, keep the single frame there, loop it for the
+ * duration, and mux it against a noise source of the same length.
+ */
+export function extensionClipArgs(spec: ExtensionClipSpec, outPath: string): string[] {
+ const dur = spec.durationSec.toFixed(3);
+ const fps = spec.fps > 0 ? spec.fps : FALLBACK_FPS;
+ return [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ // Before `-i`, so ffmpeg seeks rather than decoding up to the moment.
+ "-ss",
+ spec.atSec.toFixed(3),
+ "-i",
+ spec.sourcePath,
+ "-f",
+ "lavfi",
+ "-t",
+ dur,
+ "-i",
+ `anoisesrc=c=pink:a=${NOISE_AMPLITUDE}:r=${SAMPLE_RATE}`,
+ "-filter_complex",
+ `[0:v]trim=end_frame=1,loop=loop=-1:size=1:start=0,fps=${fps},trim=duration=${dur},setpts=PTS-STARTPTS,scale=${spec.width}:${spec.height}[v]`,
+ "-map",
+ "[v]",
+ "-map",
+ "1:a",
+ "-c:v",
+ VIDEO_ENCODER,
+ "-pix_fmt",
+ "yuv420p",
+ "-c:a",
+ "aac",
+ "-t",
+ dur,
+ outPath,
+ ];
+}
+
+/** Deterministic, and carries what it was generated from: a word whose text changed asks for
+ * a different name, so the old file is never mistaken for the new one. */
+export function extensionClipName(wordId: string, durationSec: number): string {
+ return `${wordId}_${Math.round(durationSec * 1000)}.mp4`;
+}
+
+/**
+ * Generate the file if it is not already there, and return its path.
+ *
+ * Idempotent by name: the same word and duration reuse the file rather than re-encoding.
+ */
+export async function ensureExtensionClip(
+ spec: ExtensionClipSpec,
+ wordId: string,
+ outDir: string,
+): Promise {
+ const outPath = path.join(outDir, extensionClipName(wordId, spec.durationSec));
+ try {
+ await access(outPath);
+ return outPath;
+ } catch {
+ // Not there yet — generate it.
+ }
+ const ffmpeg = resolveFfmpeg();
+ if (!ffmpeg) throw new Error("no bundled ffmpeg to generate the extension clip with");
+ await mkdir(outDir, { recursive: true });
+ await run(ffmpeg, extensionClipArgs(spec, outPath));
+ return outPath;
+}
+
+function run(bin: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
+ let stderr = "";
+ child.stderr?.on("data", (chunk) => {
+ stderr += String(chunk);
+ });
+ child.on("error", reject);
+ child.on("close", (code) =>
+ code === 0
+ ? resolve()
+ : reject(new Error(`ffmpeg exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`)),
+ );
+ });
+}
From 612e1c68ddf3d1a22dd433417d4e1c016241c6eb Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 13:25:10 +0200
Subject: [PATCH 093/113] =?UTF-8?q?feat(timeline):=20the=20insertion=20lay?=
=?UTF-8?q?er=20=E2=80=94=20a=20clip=20reads=20a=20list=20of=20parts,=20no?=
=?UTF-8?q?t=20a=20file?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 2. `clipParts(clip, words)` is the ONE module that knows an extension exists:
[ recording 0→4 ] [ extension "really" 0.4s ] [ recording 4→10 ]
The extension is appended to the clip's LIST, never spliced into the media's own axis, and
that is the decision everything rests on: no stored source coordinate moves. The second
recording part resumes at source 4, exactly where the first stopped — a word, a trim, a
zoom keeps the second it was authored at, for ever, and adding or removing an extension
cannot corrupt an anchor. Pinned by its own assertion, not left as a claim.
Nothing new is stored. The word is the truth; its duration comes from its text, its file
is derived from the pair, its part is derived from the clip's window. That whole chain
reads in one direction, which is what `insertRanges` never managed — it was a stored fact
beside the word that something had to keep true, and three separate reconcilers grew
around it.
Above this module a part is just media with a source window and a place on the timeline,
so the plain arithmetic every reader used before insertions works again. That is the point
of the layer: the 30 signatures and 61 special cases do not come back.
Mutation-checked: dropping the split, the advance, or the source cursor each fails two or
three assertions.
---
electron/media/extensionClip.ts | 5 +-
.../ai-edition/timeline/clip-parts.test.ts | 107 ++++++++++++++++
src/lib/ai-edition/timeline/clip-parts.ts | 120 ++++++++++++++++++
3 files changed, 231 insertions(+), 1 deletion(-)
create mode 100644 src/lib/ai-edition/timeline/clip-parts.test.ts
create mode 100644 src/lib/ai-edition/timeline/clip-parts.ts
diff --git a/electron/media/extensionClip.ts b/electron/media/extensionClip.ts
index 5769a1aa7..09dc4f503 100644
--- a/electron/media/extensionClip.ts
+++ b/electron/media/extensionClip.ts
@@ -89,7 +89,10 @@ export function extensionClipArgs(spec: ExtensionClipSpec, outPath: string): str
}
/** Deterministic, and carries what it was generated from: a word whose text changed asks for
- * a different name, so the old file is never mistaken for the new one. */
+ * a different name, so the old file is never mistaken for the new one.
+ *
+ * The duration comes from `extensionDurationSec` — the ONE rule for how long an added word
+ * takes — so the file a part asks for and the file this writes are named by the same fact. */
export function extensionClipName(wordId: string, durationSec: number): string {
return `${wordId}_${Math.round(durationSec * 1000)}.mp4`;
}
diff --git a/src/lib/ai-edition/timeline/clip-parts.test.ts b/src/lib/ai-edition/timeline/clip-parts.test.ts
new file mode 100644
index 000000000..92d5fa955
--- /dev/null
+++ b/src/lib/ai-edition/timeline/clip-parts.test.ts
@@ -0,0 +1,107 @@
+// The one property everything above this module depends on: the parts are contiguous, they
+// start where the clip starts, and no stored source coordinate moved to achieve it.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutClip, AxcutWord } from "../schema";
+import { clipParts, extensionDurationSec, partsLengthSec } from "./clip-parts";
+
+const CLIP: AxcutClip = {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+};
+
+const word = (over: Partial & { id: string; startSec: number }): AxcutWord => ({
+ segmentId: "s1",
+ endSec: over.startSec,
+ text: "hello",
+ ...over,
+});
+
+const added = (id: string, at: number, text: string): AxcutWord =>
+ word({ id, startSec: at, text, source: "synth" });
+
+describe("clipParts", () => {
+ it("is one recording part when nothing was added", () => {
+ expect(clipParts(CLIP, [word({ id: "w1", startSec: 1 })])).toEqual([
+ {
+ kind: "recording",
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ },
+ ]);
+ });
+
+ it("splits the recording where the word was added, extension between the halves", () => {
+ const parts = clipParts(CLIP, [added("s1", 4, "really")]);
+ expect(parts.map((p) => p.kind)).toEqual(["recording", "extension", "recording"]);
+ expect(parts[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 });
+ // "really" is 6 chars at 15/s.
+ expect(parts[1]).toMatchObject({ kind: "extension", wordId: "s1", timelineStartSec: 4 });
+ expect(parts[1].timelineEndSec).toBeCloseTo(4.4, 6);
+ // The SOURCE window of the second half is untouched — it resumes where it left off.
+ expect(parts[2]).toMatchObject({ sourceStartSec: 4, sourceEndSec: 10 });
+ expect(parts[2].timelineStartSec).toBeCloseTo(4.4, 6);
+ });
+
+ it("leaves every stored source coordinate exactly where it was", () => {
+ // The whole reason the extension is appended to the LIST and not spliced into the
+ // media's axis: adding one cannot move an anchor anyone else stored.
+ const parts = clipParts(CLIP, [added("s1", 4, "really"), added("s2", 7, "quite")]);
+ const recorded = parts.filter((p) => p.kind === "recording");
+ expect(recorded.map((p) => [p.sourceStartSec, p.sourceEndSec])).toEqual([
+ [0, 4],
+ [4, 7],
+ [7, 10],
+ ]);
+ });
+
+ it("is contiguous, and starts where the clip starts", () => {
+ const parts = clipParts({ ...CLIP, timelineStartSec: 12, timelineEndSec: 22 }, [
+ added("s1", 4, "really"),
+ added("s2", 7, "quite"),
+ ]);
+ expect(parts[0].timelineStartSec).toBe(12);
+ for (const [i, part] of parts.slice(0, -1).entries()) {
+ expect(part.timelineEndSec).toBeCloseTo(parts[i + 1].timelineStartSec, 9);
+ }
+ });
+
+ it("grows the clip by exactly what was added, and by nothing else", () => {
+ const bare = partsLengthSec(clipParts(CLIP, []));
+ const grown = partsLengthSec(clipParts(CLIP, [added("s1", 4, "really")]));
+ expect(bare).toBeCloseTo(10, 6);
+ expect(grown - bare).toBeCloseTo(extensionDurationSec("really"), 6);
+ });
+
+ it("ignores a word another clip of the same recording plays", () => {
+ const late = { ...CLIP, sourceStartSec: 6, sourceEndSec: 10, timelineEndSec: 4 };
+ expect(clipParts(late, [added("s1", 4, "really")]).map((p) => p.kind)).toEqual(["recording"]);
+ });
+
+ it("ignores an empty word, which buys nothing", () => {
+ expect(clipParts(CLIP, [added("s1", 4, " ")]).map((p) => p.kind)).toEqual(["recording"]);
+ });
+});
+
+describe("extensionDurationSec", () => {
+ it("is the text's own length at the assumed rate", () => {
+ expect(extensionDurationSec("really")).toBeCloseTo(6 / 15, 6);
+ });
+
+ it("never returns a span too short to be a part", () => {
+ expect(extensionDurationSec("a")).toBe(0.15);
+ });
+
+ it("is nothing at all for nothing at all", () => {
+ expect(extensionDurationSec(" ")).toBe(0);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
new file mode 100644
index 000000000..c86305f5f
--- /dev/null
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -0,0 +1,120 @@
+// The insertion layer: media -> parts -> clip.
+//
+// A clip does not read a file, it reads a LIST of parts:
+//
+// [ recording 0→5.3 ] [ extension 0.4s ] [ recording 5.3→20.9 ]
+//
+// The extension does NOT slide into the media's own axis — it is appended to the clip's
+// list. That is the decision the whole design rests on, and its consequence is the point:
+// no stored source coordinate ever moves. A word, a trim, a zoom keeps the second it was
+// authored at, for ever. Adding or removing an extension cannot corrupt an anchor.
+//
+// This is the ONLY module that knows an extension exists. Above it, a part is just media
+// with a source window and a place on the timeline, and the plain arithmetic every reader
+// used before insertions works again.
+
+import type { AxcutClip, AxcutWord } from "../schema";
+
+/** How fast a synthesized voice will be assumed to speak, in characters per second.
+ *
+ * A stand-in for measuring the real thing: there is no TTS yet, so nothing can say how
+ * long the sentence actually takes. It is deliberately one number rather than a model —
+ * ponytail: fixed rate, ask the synthesizer for the real duration once there is one. */
+const CHARS_PER_SEC = 15;
+
+/** Below this the extension is not worth a part of its own: the clip would gain a few
+ * frames nobody asked for and every reader would carry a degenerate span. */
+export const MIN_EXTENSION_SEC = 0.15;
+
+/** How long the media for an added word has to be. */
+export function extensionDurationSec(text: string): number {
+ const chars = text.trim().length;
+ if (chars === 0) return 0;
+ return Math.max(MIN_EXTENSION_SEC, chars / CHARS_PER_SEC);
+}
+
+/** True for a word the user typed in, which no one said and nothing in the media carries. */
+export function isAddedWord(word: AxcutWord): boolean {
+ return word.source === "synth";
+}
+
+export type ClipPart =
+ | {
+ kind: "recording";
+ timelineStartSec: number;
+ timelineEndSec: number;
+ /** The window of the clip's own asset this part plays. */
+ sourceStartSec: number;
+ sourceEndSec: number;
+ }
+ | {
+ kind: "extension";
+ timelineStartSec: number;
+ timelineEndSec: number;
+ /** The word this media exists for. Its file is derived from the pair. */
+ wordId: string;
+ text: string;
+ };
+
+/**
+ * One clip, as the ordered list of media it actually plays.
+ *
+ * Added words split the clip's source window where they sit — at the END of the word they
+ * follow — and their extension goes between the halves. A word outside the window belongs
+ * to another clip of the same recording and is not this clip's business.
+ *
+ * `words` is the transcript of the clip's OWN asset; passing another's yields the clip
+ * unsplit, which is the right answer rather than an error.
+ */
+export function clipParts(clip: AxcutClip, words: readonly AxcutWord[]): ClipPart[] {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ const parts: ClipPart[] = [];
+ let timeline = clip.timelineStartSec;
+ let from = clip.sourceStartSec;
+
+ const added = words
+ .filter(
+ (word) =>
+ isAddedWord(word) &&
+ word.startSec > clip.sourceStartSec &&
+ word.startSec <= sourceEnd &&
+ extensionDurationSec(word.text) > 0,
+ )
+ .sort((a, b) => a.startSec - b.startSec);
+
+ const pushRecording = (to: number) => {
+ if (to - from <= 1e-6) return;
+ parts.push({
+ kind: "recording",
+ timelineStartSec: timeline,
+ timelineEndSec: timeline + (to - from),
+ sourceStartSec: from,
+ sourceEndSec: to,
+ });
+ timeline += to - from;
+ from = to;
+ };
+
+ for (const word of added) {
+ pushRecording(Math.min(word.startSec, sourceEnd));
+ const durationSec = extensionDurationSec(word.text);
+ parts.push({
+ kind: "extension",
+ timelineStartSec: timeline,
+ timelineEndSec: timeline + durationSec,
+ wordId: word.id,
+ text: word.text,
+ });
+ timeline += durationSec;
+ }
+ pushRecording(sourceEnd);
+
+ return parts;
+}
+
+/** What the clip's timeline length has to be, given its parts. The stored `timelineEndSec`
+ * is this — the writer that adds a word is what keeps them equal. */
+export function partsLengthSec(parts: readonly ClipPart[]): number {
+ if (parts.length === 0) return 0;
+ return parts[parts.length - 1].timelineEndSec - parts[0].timelineStartSec;
+}
From c9b261ff8fea6e672292dd04c10d170046a2ee0b Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 13:34:13 +0200
Subject: [PATCH 094/113] feat(timeline): the one funnel reads parts, so an
extension is a segment like any other
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 3. `resolvePlaybackSegments` iterates a clip's PARTS instead of its source window, so
an added word becomes a segment of its own between the two halves of the recording — and
the halves keep the source seconds they always had.
`extensionWordId` replaces `heldSec` on the derived segment, and the difference is the
whole architecture: it names which MEDIA to play, not what behaviour to perform. A reader
resolves it to a file the way it resolves any other; it does not carry a special case for
a stretch with nothing to decode. That is why the 30 signatures and 61 special cases have
no reason to come back.
A trim still cuts the recording and can never cut an extension — not by a guard, but
because a trim is anchored in the RECORDING's seconds and an extension has none. Asserted
rather than assumed.
The transcripts reach the funnel through the props that already carry the trims, and
`transcripts` defaults to empty: a caller that passes none gets exactly the behaviour it
had before extensions existed, which is what the untouched 2573 tests are checking.
Mutation-checked: bypassing the layer fails three of the five new assertions.
---
src/components/ai-edition/NewEditorShell.tsx | 1 +
src/components/ai-edition/Preview.tsx | 4 +
src/components/ai-edition/PreviewCanvas.tsx | 3 +
src/components/ai-edition/VirtualPreview.tsx | 8 +-
src/lib/ai-edition/document/timeline.test.ts | 77 ++++++++++++++++++++
src/lib/ai-edition/document/timeline.ts | 72 +++++++++++++-----
src/native/sceneDescription.ts | 6 +-
7 files changed, 150 insertions(+), 21 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 2d24ed32e..e69fe96de 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -1513,6 +1513,7 @@ export function NewEditorShell() {
speedRegions={tl.speedRegions}
cameraFullscreenRegions={tl.cameraFullscreenRegions}
trimRanges={tl.trimRanges}
+ transcripts={document?.transcripts ?? []}
selectedZoomRegionId={tl.selection?.kind === "zoom" ? tl.selection.id : null}
onZoomFocusChange={tl.updateZoomFocusLive}
onZoomFocusCommit={() => void tl.commitZoomFocus()}
diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx
index cd6868e75..3a1db9756 100644
--- a/src/components/ai-edition/Preview.tsx
+++ b/src/components/ai-edition/Preview.tsx
@@ -5,6 +5,7 @@ import type {
AxcutAnnotationRegion,
AxcutAudioTrack,
AxcutClip,
+ AxcutTranscript,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -33,6 +34,7 @@ interface PreviewProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ transcripts?: AxcutTranscript[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -66,6 +68,7 @@ export function Preview({
speedRegions,
cameraFullscreenRegions,
trimRanges,
+ transcripts,
selectedZoomRegionId,
onZoomFocusChange,
onZoomFocusCommit,
@@ -194,6 +197,7 @@ export function Preview({
speedRegions={speedRegions}
cameraFullscreenRegions={cameraFullscreenRegions}
trimRanges={trimRanges}
+ transcripts={transcripts}
selectedZoomRegionId={selectedZoomRegionId}
onZoomFocusChange={onZoomFocusChange}
onZoomFocusCommit={onZoomFocusCommit}
diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 150012b95..55e9ada15 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -36,6 +36,7 @@ import type {
AxcutAnnotationRegion,
AxcutAudioTrack,
AxcutClip,
+ AxcutTranscript,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -75,6 +76,8 @@ interface PreviewCanvasProps {
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ /** Relayed to `VirtualPreview` so a clip carrying added words plays their extensions. */
+ transcripts?: AxcutTranscript[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index feb174ff8..cf388eb2e 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -16,6 +16,7 @@ import {
import type {
AxcutAudioTrack,
AxcutClip,
+ AxcutTranscript,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -212,6 +213,8 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
+ /** So a clip carrying added words plays their extensions — see `clipParts`. */
+ transcripts?: AxcutTranscript[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -258,6 +261,7 @@ export function VirtualPreview({
zoomRegions = [],
speedRegions = [],
trimRanges = [],
+ transcripts = [],
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -589,8 +593,8 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- () => resolvePlaybackSegments(clips, trimRanges),
- [clips, trimRanges],
+ () => resolvePlaybackSegments(clips, trimRanges, transcripts),
+ [clips, trimRanges, transcripts],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index 9cbdbc5ec..9abbbb2c8 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -1740,3 +1740,80 @@ describe("projectRawTimelineSecToPlayback with speed regions", () => {
// ─── The pause an added word bought ──────────────────────────────
// Created time only exists once playback honours it. These pin the one thing the record
// is for: the stream really does stay on the held frame, and the film really is longer.
+
+// ─── Segments across an extension ───────────────────────────────────────────
+// The whole point of the layer, seen from the one funnel every consumer goes through: an
+// added word becomes a SEGMENT of its own, naming media rather than a behaviour, and the
+// recording on either side keeps the source window it always had.
+
+describe("resolvePlaybackSegments across an added word", () => {
+ const clip = makeClip({
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ });
+ const transcripts = [
+ {
+ assetId: "a1",
+ language: "en",
+ segments: [],
+ words: [
+ { id: "w1", segmentId: "s", startSec: 1, endSec: 2, text: "said" },
+ {
+ id: "synth_1",
+ segmentId: "s",
+ startSec: 4,
+ endSec: 4,
+ text: "really",
+ source: "synth" as const,
+ },
+ ],
+ },
+ ];
+
+ it("puts the extension between the halves, as its own segment", () => {
+ const segs = resolvePlaybackSegments([clip], [], transcripts);
+ expect(segs.map((s) => s.extensionWordId ?? null)).toEqual([null, "synth_1", null]);
+ // The recording resumes at the source second it stopped on — nothing moved.
+ expect(segs[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 4 });
+ expect(segs[2]).toMatchObject({ sourceStartSec: 4, sourceEndSec: 10 });
+ });
+
+ it("gives the extension its own media window, starting at zero", () => {
+ const [, ext] = resolvePlaybackSegments([clip], [], transcripts);
+ expect(ext.sourceStartSec).toBe(0);
+ expect(ext.sourceEndSec).toBeCloseTo(6 / 15, 6);
+ expect(ext.timelineEndSec - ext.timelineStartSec).toBeCloseTo(6 / 15, 6);
+ });
+
+ it("lays the programme out end to end, with no gap and no overlap", () => {
+ const segs = resolvePlaybackSegments([clip], [], transcripts);
+ expect(segs[0].timelineStartSec).toBe(0);
+ for (const [i, seg] of segs.slice(0, -1).entries()) {
+ expect(seg.timelineEndSec).toBeCloseTo(segs[i + 1].timelineStartSec, 9);
+ }
+ });
+
+ it("still cuts the recording, and never the extension", () => {
+ // A trim is anchored in the RECORDING's seconds; an extension has none, so no trim
+ // can name it. Cutting source 5..6 shortens the half after the added word only.
+ const trims = [makeTrim({ id: "t1", clipId: "c1", startSec: 5, endSec: 6 })];
+ const segs = resolvePlaybackSegments([clip], trims, transcripts);
+ expect(segs.filter((s) => s.extensionWordId).length).toBe(1);
+ const recorded = segs.filter((s) => !s.extensionWordId);
+ expect(recorded.map((s) => [s.sourceStartSec, s.sourceEndSec])).toEqual([
+ [0, 4],
+ [4, 5],
+ [6, 10],
+ ]);
+ });
+
+ it("behaves exactly as before when no word was added", () => {
+ const plain = resolvePlaybackSegments([clip], []);
+ expect(plain).toHaveLength(1);
+ expect(plain[0]).toMatchObject({ id: "c1", sourceStartSec: 0, sourceEndSec: 10 });
+ });
+});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index ae26d7fb7..a1598db4b 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -6,15 +6,19 @@
import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "../schema";
/**
- * What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
- * one thing a stored clip can never carry — `heldSec`, the pause an added word created.
+ * What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film.
*
- * A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
- * The field lives only on this derived shape, never on `clipSchema`, so nothing can write
- * one to disk — which is the whole difference from the attempt that made clips for it.
+ * `extensionWordId` names the ONE case where the media is not the clip's own asset — the
+ * extension an added word is spoken over, whose file is derived from the word. It says which
+ * MEDIA to play, not what behaviour to perform, and that is the whole difference from the
+ * `heldSec` this replaces: a reader resolves it to a file like any other, rather than
+ * carrying a special case for a stretch with nothing to decode.
+ *
+ * Derived-only, never on `clipSchema`, so nothing can write one to disk.
*/
-export type PlaybackSegment = AxcutClip;
+export type PlaybackSegment = AxcutClip & { extensionWordId?: string };
+import { clipParts } from "../timeline/clip-parts";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -160,8 +164,12 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
+ /** The transcripts, so a clip carrying added words splits into its parts. Omitted, a
+ * clip is one recording part and this behaves exactly as it did before extensions. */
+ transcripts: readonly AxcutTranscript[] = [],
): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ const wordsByAsset = new Map(transcripts.map((t) => [t.assetId, t.words]));
const result: PlaybackSegment[] = [];
let timelineCursor = 0;
for (const clip of ordered) {
@@ -177,18 +185,39 @@ export function resolvePlaybackSegments(
timelineCursor += dur;
continue;
}
- let kept: Interval[] = [{ startSec: clip.sourceStartSec, endSec: sourceEnd }];
- for (const trim of trimRanges) {
- if (!trimAppliesToClip(trim, clip)) continue;
- kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
- }
- const pieces = kept.map((iv) => ({ startSec: iv.startSec, endSec: iv.endSec }));
- pieces.forEach((piece, i) => {
- const dur = piece.endSec - piece.startSec;
- if (dur > 0) {
- result.push({
+ // The insertion layer, and the only place this function meets it. Below here a part
+ // is media with a source window, and the trim arithmetic is the same it always was.
+ const parts = clipParts(clip, wordsByAsset.get(clip.assetId) ?? []);
+ // Named once the clip's own pieces are known: a clip keeps its id only when it yields
+ // exactly ONE piece of recording and nothing else. Naming from the PART count instead
+ // missed the split a trim makes inside a single part.
+ const clipSegments: PlaybackSegment[] = [];
+ for (const part of parts) {
+ if (part.kind === "extension") {
+ // No trim can name it: a trim is anchored in the RECORDING's seconds, and an
+ // extension has none. Its own media starts at zero and runs its full length.
+ clipSegments.push({
+ ...clip,
+ id: `${clip.id}__ext_${part.wordId}`,
+ extensionWordId: part.wordId,
+ sourceStartSec: 0,
+ sourceEndSec: part.timelineEndSec - part.timelineStartSec,
+ timelineStartSec: timelineCursor,
+ timelineEndSec: timelineCursor + (part.timelineEndSec - part.timelineStartSec),
+ });
+ timelineCursor += part.timelineEndSec - part.timelineStartSec;
+ continue;
+ }
+ let kept: Interval[] = [{ startSec: part.sourceStartSec, endSec: part.sourceEndSec }];
+ for (const trim of trimRanges) {
+ if (!trimAppliesToClip(trim, clip)) continue;
+ kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
+ }
+ for (const piece of kept) {
+ const dur = piece.endSec - piece.startSec;
+ if (dur <= 0) continue;
+ clipSegments.push({
...clip,
- id: pieces.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
sourceStartSec: piece.startSec,
sourceEndSec: piece.endSec,
timelineStartSec: timelineCursor,
@@ -196,7 +225,14 @@ export function resolvePlaybackSegments(
});
timelineCursor += dur;
}
- });
+ }
+ const recorded = clipSegments.filter((seg) => seg.extensionWordId === undefined);
+ let n = 0;
+ for (const seg of clipSegments) {
+ if (seg.extensionWordId !== undefined) continue;
+ seg.id = recorded.length === 1 ? clip.id : `${clip.id}_seg${++n}`;
+ }
+ result.push(...clipSegments);
}
return result;
}
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index ce22db0ed..6c7981d0f 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -522,7 +522,11 @@ function clipAssetIsResolvable(
*/
export function resolveVisibleClips(document: AxcutDocument): PlaybackSegment[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
- return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
+ return resolvePlaybackSegments(
+ document.timeline.clips,
+ document.timeline.trimRanges,
+ document.transcripts,
+ )
.sort((a, b) => a.timelineStartSec - b.timelineStartSec)
.filter((clip) => clipAssetIsResolvable(clip, assetById));
}
From 09fa94bf0de8e46b9f8b946b854ed2dde2c8355d Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 13:46:03 +0200
Subject: [PATCH 095/113] feat(document): the clip grows with the word, at the
one funnel every write goes through
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 4a. `timelineEndSec` is stored and the ruler reads it, so a clip left short draws a
film that ends before the programme does — the desync the previous attempt spent its life
chasing. `withClipsSizedToParts` recomputes it from the parts, and `withTranscript` — the
single door every transcript write already went through — calls it.
A recompute, not a reconciliation, and the distinction is the whole reason this is three
lines instead of three modules: the parts are derived from the words, so there is no
second stored fact that can drift from the first. Idempotent, and a no-op for a document
with no added words, which is what the 2576 untouched tests are checking.
Pinned: the clip lengthens by exactly the media the word needs and takes not one frame of
the recording with it, and removing the word gives the length back exactly.
Mutation-checked: dropping the resize fails the first assertion.
---
src/lib/ai-edition/document/timeline.ts | 31 ++++++-
.../ai-edition/document/transcript.test.ts | 86 ++++++++++++++++++-
src/lib/ai-edition/document/transcript.ts | 7 +-
3 files changed, 119 insertions(+), 5 deletions(-)
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index a1598db4b..3d724c425 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -18,7 +18,7 @@ import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "
*/
export type PlaybackSegment = AxcutClip & { extensionWordId?: string };
-import { clipParts } from "../timeline/clip-parts";
+import { clipParts, partsLengthSec } from "../timeline/clip-parts";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -133,6 +133,35 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
+/**
+ * Clip lengths, recomputed from the parts they now have.
+ *
+ * `timelineEndSec` is STORED, so something has to keep it true once a word can add media
+ * inside a clip — and the ruler reads it, so a clip left short draws a film that ends before
+ * the programme does. This is that something, and it is a recompute, not a reconciliation:
+ * the parts are derived from the words, so there is no second stored fact to drift from.
+ *
+ * Idempotent, and a no-op for a document with no added words.
+ */
+export function withClipsSizedToParts(document: AxcutDocument): AxcutDocument {
+ const wordsByAsset = new Map(document.transcripts.map((t) => [t.assetId, t.words]));
+ const clips = document.timeline.clips.map((clip) => {
+ const parts = clipParts(clip, wordsByAsset.get(clip.assetId) ?? []);
+ // No parts means the duration has not been probed yet; the prober owns the length.
+ if (parts.length === 0) return clip;
+ return { ...clip, timelineEndSec: clip.timelineStartSec + partsLengthSec(parts) };
+ });
+ const resequenced = resequenceClips(clips);
+ const unchanged = resequenced.every(
+ (clip, i) =>
+ Math.abs(clip.timelineStartSec - document.timeline.clips[i].timelineStartSec) < 1e-9 &&
+ Math.abs(clip.timelineEndSec - document.timeline.clips[i].timelineEndSec) < 1e-9,
+ );
+ return unchanged
+ ? document
+ : { ...document, timeline: { ...document.timeline, clips: resequenced } };
+}
+
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
return clips.map((c) => {
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index 1bc1f5451..b8c40f301 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
-import { type AxcutTranscript, createEmptyDocument } from "../schema";
-import { carryOverWordEdits, setDocumentWordText, setWordText, withTranscript } from "./transcript";
+import { type AxcutDocument, type AxcutTranscript, createEmptyDocument } from "../schema";
+import {
+ carryOverWordEdits,
+ insertDocumentWord,
+ removeDocumentWords,
+ setDocumentWordText,
+ setWordText,
+ withTranscript,
+} from "./transcript";
function fixture(language = "en"): AxcutTranscript {
return {
@@ -483,3 +490,78 @@ describe("carryOverWordEdits", () => {
expect(carryOverWordEdits(null, next).transcript).toBe(next);
});
});
+
+// ─── The clip grows with the word ───────────────────────────────────────────
+// `timelineEndSec` is stored and the ruler reads it, so a clip left short draws a film that
+// ends before the programme does — the desync the previous attempt spent its life chasing.
+// Recomputed from the parts, at the one funnel every transcript write goes through.
+
+describe("adding a word sizes the clip it lands in", () => {
+ function docWithClip(): AxcutDocument {
+ return {
+ assets: [{ id: "a1", kind: "video" }],
+ project: { primaryAssetId: "a1" },
+ transcripts: [
+ {
+ assetId: "a1",
+ language: "en",
+ segments: [
+ {
+ id: "s1",
+ kind: "speech",
+ startSec: 0,
+ endSec: 6,
+ text: "hello there",
+ wordIds: ["w1", "w2"],
+ },
+ ],
+ words: [
+ { id: "w1", segmentId: "s1", startSec: 0, endSec: 1, text: "hello" },
+ { id: "w2", segmentId: "s1", startSec: 1, endSec: 2, text: "there" },
+ ],
+ },
+ ],
+ timeline: {
+ clips: [
+ {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ trimRanges: [],
+ },
+ } as unknown as AxcutDocument;
+ }
+
+ it("lengthens the clip by exactly the media the word needs", () => {
+ const after = insertDocumentWord(docWithClip(), "a1", "w2", "after", "really");
+ const [clip] = after.timeline.clips;
+ // "really" is 6 chars at 15/s.
+ expect(clip.timelineEndSec - clip.timelineStartSec).toBeCloseTo(10 + 6 / 15, 6);
+ // And takes not one frame of the recording with it.
+ expect(clip.sourceStartSec).toBe(0);
+ expect(clip.sourceEndSec).toBe(10);
+ });
+
+ it("gives the length back when the word goes", () => {
+ const before = docWithClip();
+ const added = insertDocumentWord(before, "a1", "w2", "after", "really");
+ const gone = removeDocumentWords(added, "a1", [
+ added.transcripts[0].words.find((w) => w.source === "synth")?.id ?? "",
+ ]);
+ expect(gone.timeline.clips).toEqual(before.timeline.clips);
+ });
+
+ it("leaves a document with no added words untouched", () => {
+ const before = docWithClip();
+ const after = setDocumentWordText(before, "a1", "w1", "HELLO");
+ expect(after.timeline.clips).toEqual(before.timeline.clips);
+ });
+});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index dec291474..19b44dd9d 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,4 +1,5 @@
import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
+import { withClipsSizedToParts } from "./timeline";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -121,12 +122,14 @@ export function withTranscript(
...document.transcripts.filter((t) => t.assetId !== transcript.assetId),
transcript,
];
- return {
+ // Sized here, at the ONE funnel every transcript write goes through, so adding or
+ // removing a word cannot leave a clip claiming a length its parts no longer have.
+ return withClipsSizedToParts({
...document,
transcript:
document.project.primaryAssetId === transcript.assetId ? transcript : document.transcript,
transcripts,
- };
+ });
}
/**
From c67fa22a63d4ec29a0fd18c13cdeb92a969c97a7 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 13:51:35 +0200
Subject: [PATCH 096/113] feat(media): an extension resolves to a file, and the
save generates it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 4b. `extensionWordId` becomes a path, and the path becomes a file.
`extensionClipPath` is pure string work over the asset path and the word, so the renderer
and the main process arrive at the same name without asking each other — the renderer names
the file it expects, the process that can spawn ffmpeg writes the file it named. One rule,
both sides, nothing stored. It lives beside the recording it was cut from, in a hidden
sibling folder, because it is derived: deleting it costs a regeneration and nothing else.
The scene builder gives an extension segment that path and leaves everything else about the
entry alone — same webcam pairing, same audio expectation — which is the point of making it
a file rather than a behaviour.
Generation happens on SAVE, the only moment the main process sees the document, and AFTER
the write: a derived file is not worth delaying the user's edit reaching disk, and a failure
to produce one is logged and swallowed rather than failing the save. The segment renders
black until the next save regenerates it — the edit is never lost to a missing derived file.
The Windows-path test builds its backslash with `String.fromCharCode(92)` rather than
escaping it: the escape is what kept getting lost between the tool and the file, and a test
that quietly passes a carriage return is worse than no test.
---
electron/ai-edition/document-service.ts | 4 ++
electron/media/extensionClip.test.ts | 25 +++++++--
electron/media/extensionClip.ts | 62 +++++++++++++++++++----
src/lib/ai-edition/timeline/clip-parts.ts | 19 +++++++
src/native/sceneDescription.ts | 20 ++++++++
5 files changed, 115 insertions(+), 15 deletions(-)
diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts
index 9c9b958b9..2fca7962c 100644
--- a/electron/ai-edition/document-service.ts
+++ b/electron/ai-edition/document-service.ts
@@ -21,6 +21,7 @@ import {
documentSchema,
migrateRawDocumentToCurrent,
} from "../../src/lib/ai-edition/schema";
+import { ensureDocumentExtensions } from "../media/extensionClip";
import { relinkProjectMedia } from "../media/projectMediaRelinker";
const PROJECT_FILE_EXTENSION = ".openscreen";
@@ -294,6 +295,9 @@ export class DocumentService {
project: { ...parsed.project, updatedAt: new Date().toISOString() },
};
await this.writeProject(stamped);
+ // After the write, never before: a derived file is not worth delaying the user's edit
+ // reaching disk, and a failure to generate one must not fail the save.
+ await ensureDocumentExtensions(stamped);
return stamped;
}
diff --git a/electron/media/extensionClip.test.ts b/electron/media/extensionClip.test.ts
index 342ff04c8..d7199550a 100644
--- a/electron/media/extensionClip.test.ts
+++ b/electron/media/extensionClip.test.ts
@@ -2,7 +2,8 @@
// would test ffmpeg, not us — the arguments are where a mistake actually lives.
import { describe, expect, it } from "vitest";
-import { extensionClipArgs, extensionClipName } from "./extensionClip";
+import { extensionClipPath } from "../../src/lib/ai-edition/timeline/clip-parts";
+import { extensionClipArgs } from "./extensionClip";
const SPEC = {
sourcePath: "C:/rec/take.mp4",
@@ -46,10 +47,26 @@ describe("extensionClipArgs", () => {
});
});
-describe("extensionClipName", () => {
+/** One backslash, built rather than escaped: the escape is what this test keeps losing. */
+const BS = String.fromCharCode(92);
+
+describe("extensionClipPath", () => {
+ it("sits beside the recording it was cut from, in a hidden folder", () => {
+ expect(extensionClipPath("C:/rec/take.mp4", "synth_2", 3.6)).toBe(
+ "C:/rec/.openscreen-extensions/synth_2_3600.mp4",
+ );
+ });
+
it("carries the word and the duration, so a re-typed word asks for a different file", () => {
- expect(extensionClipName("synth_2", 3.6)).toBe("synth_2_3600.mp4");
- expect(extensionClipName("synth_2", 3.8)).not.toBe(extensionClipName("synth_2", 3.6));
+ expect(extensionClipPath("C:/rec/take.mp4", "synth_2", 3.8)).not.toBe(
+ extensionClipPath("C:/rec/take.mp4", "synth_2", 3.6),
+ );
+ });
+
+ it("is the same rule on a Windows path, so both processes name one file", () => {
+ expect(extensionClipPath(`C:${BS}rec${BS}take.mp4`, "w1", 1)).toBe(
+ `C:${BS}rec${BS}.openscreen-extensions${BS}w1_1000.mp4`,
+ );
});
});
diff --git a/electron/media/extensionClip.ts b/electron/media/extensionClip.ts
index 09dc4f503..4b874a66a 100644
--- a/electron/media/extensionClip.ts
+++ b/electron/media/extensionClip.ts
@@ -14,6 +14,12 @@
import { spawn } from "node:child_process";
import { access, mkdir } from "node:fs/promises";
import path from "node:path";
+import type { AxcutWord } from "../../src/lib/ai-edition/schema";
+import {
+ extensionClipPath,
+ extensionDurationSec,
+ isAddedWord,
+} from "../../src/lib/ai-edition/timeline/clip-parts";
import { resolveFfmpeg } from "./audioPeaks";
export interface ExtensionClipSpec {
@@ -88,26 +94,60 @@ export function extensionClipArgs(spec: ExtensionClipSpec, outPath: string): str
];
}
-/** Deterministic, and carries what it was generated from: a word whose text changed asks for
- * a different name, so the old file is never mistaken for the new one.
+/**
+ * Every extension the document's words call for, generated if it is not already there.
*
- * The duration comes from `extensionDurationSec` — the ONE rule for how long an added word
- * takes — so the file a part asks for and the file this writes are named by the same fact. */
-export function extensionClipName(wordId: string, durationSec: number): string {
- return `${wordId}_${Math.round(durationSec * 1000)}.mp4`;
+ * Called on SAVE, which is the only moment the main process — the one that can spawn ffmpeg
+ * — sees the document. Idempotent by name, so a save that adds nothing costs one `stat` per
+ * added word. A failure is logged and swallowed: the edit is not lost because a derived file
+ * could not be written, and the segment renders black until the next save regenerates it.
+ */
+export async function ensureDocumentExtensions(document: {
+ assets: ReadonlyArray<{
+ id: string;
+ originalPath?: string;
+ video?: { width: number; height: number; fps: number };
+ }>;
+ transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>;
+}): Promise {
+ for (const transcript of document.transcripts) {
+ const asset = document.assets.find((a) => a.id === transcript.assetId);
+ if (!asset?.originalPath) continue;
+ for (const word of transcript.words) {
+ if (!isAddedWord(word)) continue;
+ const durationSec = extensionDurationSec(word.text);
+ if (durationSec <= 0) continue;
+ const outPath = extensionClipPath(asset.originalPath, word.id, durationSec);
+ try {
+ await ensureExtensionClip(
+ {
+ sourcePath: asset.originalPath,
+ atSec: word.startSec,
+ durationSec,
+ fps: asset.video?.fps ?? 0,
+ width: asset.video?.width ?? 0,
+ height: asset.video?.height ?? 0,
+ },
+ outPath,
+ );
+ } catch (error) {
+ console.error(`[extension] ${word.id}: ${(error as Error).message}`);
+ }
+ }
+ }
}
/**
* Generate the file if it is not already there, and return its path.
*
- * Idempotent by name: the same word and duration reuse the file rather than re-encoding.
+ * Idempotent: the same word and duration name the same file, which is reused rather than
+ * re-encoded. The path is decided by `extensionClipPath`, so the renderer names the file it
+ * expects and this writes the file it named — one rule, both sides.
*/
export async function ensureExtensionClip(
spec: ExtensionClipSpec,
- wordId: string,
- outDir: string,
+ outPath: string,
): Promise {
- const outPath = path.join(outDir, extensionClipName(wordId, spec.durationSec));
try {
await access(outPath);
return outPath;
@@ -116,7 +156,7 @@ export async function ensureExtensionClip(
}
const ffmpeg = resolveFfmpeg();
if (!ffmpeg) throw new Error("no bundled ffmpeg to generate the extension clip with");
- await mkdir(outDir, { recursive: true });
+ await mkdir(path.dirname(outPath), { recursive: true });
await run(ffmpeg, extensionClipArgs(spec, outPath));
return outPath;
}
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
index c86305f5f..c45abe372 100644
--- a/src/lib/ai-edition/timeline/clip-parts.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -112,6 +112,25 @@ export function clipParts(clip: AxcutClip, words: readonly AxcutWord[]): ClipPar
return parts;
}
+/** Where the generated media for an added word lives.
+ *
+ * Beside the recording it was cut from, in a hidden sibling folder, and derived by pure
+ * string work from the asset path and the word — so the renderer and the main process
+ * arrive at the same path without asking each other. That is what keeps the file DERIVED:
+ * no one stores it, anyone can name it, and the process that can spawn ffmpeg is the only
+ * one that has to create it.
+ *
+ * The name carries the duration, so a re-typed word asks for a different file and a stale
+ * one is simply never named again. */
+export function extensionClipPath(assetPath: string, wordId: string, durationSec: number): string {
+ const sep = assetPath.includes("\\") ? "\\" : "/";
+ const dir = assetPath.slice(0, Math.max(0, assetPath.lastIndexOf(sep)));
+ return `${dir}${sep}${EXTENSIONS_DIR}${sep}${wordId}_${Math.round(durationSec * 1000)}.mp4`;
+}
+
+/** Hidden, because it is derived: deleting it costs nothing but a regeneration. */
+export const EXTENSIONS_DIR = ".openscreen-extensions";
+
/** What the clip's timeline length has to be, given its parts. The stored `timelineEndSec`
* is this — the writer that adds a word is what keeps them equal. */
export function partsLengthSec(parts: readonly ClipPart[]): number {
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 6c7981d0f..1832851be 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -40,6 +40,7 @@ import {
import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
+import { extensionClipPath } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
@@ -702,6 +703,25 @@ export function buildSceneDescription(
const clips: CompositorClipInput[] = visibleClips.flatMap((clip) => {
const asset = assetById.get(clip.assetId);
if (!asset?.originalPath) return [];
+ // An extension plays GENERATED media, not the recording's. Everything else about the
+ // entry is the clip's — same webcam pairing, same audio expectation — because that is
+ // the point of making it a file rather than a behaviour.
+ if (clip.extensionWordId) {
+ return [
+ {
+ screenPath: extensionClipPath(
+ asset.originalPath,
+ clip.extensionWordId,
+ clip.sourceEndSec ?? 0,
+ ),
+ webcamPath: "",
+ sourceStartSec: 0,
+ sourceEndSec: clip.sourceEndSec ?? 0,
+ webcamOffsetSec: 0,
+ hasAudio: true,
+ },
+ ];
+ }
const camera = assetCameraSource(asset);
// ponytail: `asset.audio` exists in the schema but the probe pipeline never
// populates it, so there is no per-asset "is there a track?" signal to read
From 9a12a2778e26820362f36a0bb890caa6b47f7a1a Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 14:14:45 +0200
Subject: [PATCH 097/113] feat(preview): the DOM preview plays an extension,
because it is handed a clip
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The player already knows how to play several clips over several files and swap at the
boundary. An extension is exactly that — a different file, played for a stretch — so it is
handed one rather than taught a new case. `clipsWithExtensions` splices each one in as a
clip on its own media, and the source-swap, the clock and the boundary advance apply to it
untouched.
`ext:` is the id its media answers to. Prefixed rather than opaque: it can never
collide with a real asset, and it says what it is in a log line. The shell adds one video
source per added word, beside the recordings, pointing at the path `extensionClipPath`
names — the same path the save writes.
The one place this could double up says so: `resolvePlaybackSegments` is given `[]`
transcripts inside the preview, because `playerClips` has already spliced the extensions in
and splicing them twice would play each one twice.
A file the save has not written yet simply fails to load, and the player reports it the way
it reports any unreadable source. The edit stands; the picture catches up on the next save.
That is what making it a file rather than a behaviour buys — there is no state to get stuck
in, only a source that is there or is not.
---
src/components/ai-edition/NewEditorShell.tsx | 55 +++++++++++++++----
src/components/ai-edition/VirtualPreview.tsx | 16 ++++--
.../ai-edition/timeline/clip-parts.test.ts | 39 ++++++++++++-
src/lib/ai-edition/timeline/clip-parts.ts | 44 +++++++++++++++
4 files changed, 137 insertions(+), 17 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index e69fe96de..61744066c 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -32,6 +32,12 @@ import {
import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
+import {
+ extensionAssetId,
+ extensionClipPath,
+ extensionDurationSec,
+ isAddedWord,
+} from "@/lib/ai-edition/timeline/clip-parts";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import {
@@ -355,18 +361,43 @@ export function NewEditorShell() {
const videoSources = useMemo(() => {
if (!document) return [];
- return document.assets.map((asset) => ({
- id: asset.id,
- filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath,
- // Real Electron assets are filesystem paths and go through toFileUrl.
- // In the browser preview an asset can already point at an http(s)/
- // blob/data URL served by Vite; toFileUrl would mangle those into a
- // broken file:// URL, so pass web URLs through untouched.
- src: /^(https?|blob|data):/.test(asset.originalPath)
- ? asset.originalPath
- : toFileUrl(asset.originalPath),
- label: asset.label,
- }));
+ // One source per added word, beside the recordings. The player keys sources by asset
+ // id and an extension answers to `ext:`, so it swaps to the generated file at
+ // the boundary exactly as it swaps between two recordings — no new case.
+ //
+ // A file the save has not written yet simply fails to load, and the player reports it
+ // the way it reports any unreadable source: the edit stands, the picture catches up.
+ const extensions = document.transcripts.flatMap((transcript) => {
+ const asset = document.assets.find((a) => a.id === transcript.assetId);
+ if (!asset?.originalPath) return [];
+ return transcript.words.filter(isAddedWord).flatMap((word) => {
+ const durationSec = extensionDurationSec(word.text);
+ if (durationSec <= 0) return [];
+ const filePath = extensionClipPath(asset.originalPath, word.id, durationSec);
+ return [
+ {
+ id: extensionAssetId(word.id),
+ filePath,
+ src: toFileUrl(filePath),
+ label: word.text,
+ },
+ ];
+ });
+ });
+ return document.assets
+ .map((asset) => ({
+ id: asset.id,
+ filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath,
+ // Real Electron assets are filesystem paths and go through toFileUrl.
+ // In the browser preview an asset can already point at an http(s)/
+ // blob/data URL served by Vite; toFileUrl would mangle those into a
+ // broken file:// URL, so pass web URLs through untouched.
+ src: /^(https?|blob|data):/.test(asset.originalPath)
+ ? asset.originalPath
+ : toFileUrl(asset.originalPath),
+ label: asset.label,
+ }))
+ .concat(extensions);
}, [document]);
const handleLoadedMetadata = useCallback(
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index cf388eb2e..84875bf35 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -22,6 +22,7 @@ import type {
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
+import { clipsWithExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
@@ -551,8 +552,13 @@ export function VirtualPreview({
// stuck at 0% and the drag range at `max=1`. The refs let the rAF
// always see the latest values without re-creating on every clip
// mutation.
- const clipsRef = useRef(clips);
- clipsRef.current = clips;
+ // The clips this player sees: each extension spliced in as a clip of its own, so the
+ // source-swap and the clock it already has apply to it without a new case. Everything
+ // below reads THIS list — `clips` from the props is the document's, and the difference
+ // is exactly the media an added word is spoken over.
+ const playerClips = useMemo(() => clipsWithExtensions(clips, transcripts), [clips, transcripts]);
+ const clipsRef = useRef(playerClips);
+ clipsRef.current = playerClips;
// Same reason as `clipsRef`: the rAF projects the playhead and each imported
// audio track's head raw→output every frame (see the audio-track loop), and
// must see the live trims, not the set captured when the loop was created.
@@ -593,8 +599,10 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- () => resolvePlaybackSegments(clips, trimRanges, transcripts),
- [clips, trimRanges, transcripts],
+ // `[]` transcripts, not a forgotten argument: `playerClips` has already spliced the
+ // extensions in, and splicing them twice would play each one twice.
+ () => resolvePlaybackSegments(playerClips, trimRanges, []),
+ [playerClips, trimRanges],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
diff --git a/src/lib/ai-edition/timeline/clip-parts.test.ts b/src/lib/ai-edition/timeline/clip-parts.test.ts
index 92d5fa955..71bde2a9c 100644
--- a/src/lib/ai-edition/timeline/clip-parts.test.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.test.ts
@@ -3,7 +3,13 @@
import { describe, expect, it } from "vitest";
import type { AxcutClip, AxcutWord } from "../schema";
-import { clipParts, extensionDurationSec, partsLengthSec } from "./clip-parts";
+import {
+ clipParts,
+ clipsWithExtensions,
+ extensionAssetId,
+ extensionDurationSec,
+ partsLengthSec,
+} from "./clip-parts";
const CLIP: AxcutClip = {
id: "c1",
@@ -105,3 +111,34 @@ describe("extensionDurationSec", () => {
expect(extensionDurationSec(" ")).toBe(0);
});
});
+
+// ─── What a player sees ─────────────────────────────────────────────────────
+// The DOM preview already plays several clips over several files and swaps at the boundary.
+// An extension is exactly that, so it is handed a clip rather than taught a new case.
+
+describe("clipsWithExtensions", () => {
+ const transcripts = [{ assetId: "a1", words: [added("synth_1", 4, "really")] }];
+
+ it("splices the extension in as a clip of its own, on its own media", () => {
+ const out = clipsWithExtensions([CLIP], transcripts);
+ expect(out.map((c) => c.assetId)).toEqual(["a1", "ext:synth_1", "a1"]);
+ expect(out[1]).toMatchObject({ sourceStartSec: 0 });
+ expect(out[1].sourceEndSec).toBeCloseTo(6 / 15, 6);
+ });
+
+ it("lays them end to end, so the player's clock never sees a gap", () => {
+ const out = clipsWithExtensions([CLIP], transcripts);
+ expect(out[0].timelineStartSec).toBe(0);
+ for (const [i, clip] of out.slice(0, -1).entries()) {
+ expect(clip.timelineEndSec).toBeCloseTo(out[i + 1].timelineStartSec, 9);
+ }
+ });
+
+ it("returns the clips unchanged when no word was added", () => {
+ expect(clipsWithExtensions([CLIP], [{ assetId: "a1", words: [] }])).toEqual([CLIP]);
+ });
+
+ it("gives the extension an id no real asset can collide with", () => {
+ expect(extensionAssetId("synth_1")).toBe("ext:synth_1");
+ });
+});
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
index c45abe372..fcd3bc29b 100644
--- a/src/lib/ai-edition/timeline/clip-parts.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -128,6 +128,50 @@ export function extensionClipPath(assetPath: string, wordId: string, durationSec
return `${dir}${sep}${EXTENSIONS_DIR}${sep}${wordId}_${Math.round(durationSec * 1000)}.mp4`;
}
+/** The id an extension's media answers to, so a player that keys sources by asset finds it
+ * without knowing what an extension is. Prefixed rather than opaque: it can never collide
+ * with a real asset id, and it says what it is in a log line. */
+export function extensionAssetId(wordId: string): string {
+ return `ext:${wordId}`;
+}
+
+/**
+ * The clips a PLAYER should see: each extension spliced in as a clip of its own.
+ *
+ * The DOM preview already knows how to play several clips over several files and swap at the
+ * boundary. An extension is exactly that — a different file, played for a stretch — so it is
+ * handed one rather than taught a new case. `resolvePlaybackSegments` does the same thing
+ * one layer down, with the trims applied; this is the untrimmed list the player's own clock
+ * maps against.
+ */
+export function clipsWithExtensions(
+ clips: readonly AxcutClip[],
+ transcripts: ReadonlyArray<{ assetId: string; words: readonly AxcutWord[] }>,
+): AxcutClip[] {
+ const wordsByAsset = new Map(transcripts.map((t) => [t.assetId, t.words]));
+ return clips.flatMap((clip) =>
+ clipParts(clip, wordsByAsset.get(clip.assetId) ?? []).map((part) =>
+ part.kind === "extension"
+ ? {
+ ...clip,
+ id: `${clip.id}__ext_${part.wordId}`,
+ assetId: extensionAssetId(part.wordId),
+ sourceStartSec: 0,
+ sourceEndSec: part.timelineEndSec - part.timelineStartSec,
+ timelineStartSec: part.timelineStartSec,
+ timelineEndSec: part.timelineEndSec,
+ }
+ : {
+ ...clip,
+ sourceStartSec: part.sourceStartSec,
+ sourceEndSec: part.sourceEndSec,
+ timelineStartSec: part.timelineStartSec,
+ timelineEndSec: part.timelineEndSec,
+ },
+ ),
+ );
+}
+
/** Hidden, because it is derived: deleting it costs nothing but a regeneration. */
export const EXTENSIONS_DIR = ".openscreen-extensions";
From 762201679da1b19db35c6319e47d6b8d9544dc98 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 14:36:41 +0200
Subject: [PATCH 098/113] fix(transcript): the conversion source second ->
ruler second lives in one place
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three readers each wrote `timelineStartSec + (sec - sourceStartSec)` for themselves. That
subtraction is right until a clip carries an extension, and wrong for every second after
one — by the whole length inserted before it. So the captions after an insertion showed
early, and the pane highlighted the word after the inserted one instead of the inserted
one. Same arithmetic, three copies, one bug counted three times.
`partsRawSec` is now the only place it is written, and its inverse `partsSourceSec` the
only place it is undone. Both read the parts, which is where the interruption already
lives, so neither has a notion of an extension of its own.
Two answers the subtraction could not give:
- A second sitting exactly where an insertion split maps to the moment AFTER the extension.
That is where the recorded media of that second actually plays.
- Inside an extension the source clock is parked at the split. None of the recording runs
there, and the second before the split is the last one that did.
The added word never asks either question. It has no source second — through source time it
is indistinguishable from the recorded word that resumes beside it, which is exactly how the
pane kept skipping over it. `extensionAt` names its moment from its own part, by id.
`TranscriptPlacement` carries its parts now. One placement per clip, not one per part: a
clip does not become three clips because a word was typed into it, and the pane still draws
one header. Absent parts, every reader behaves as it did before extensions existed, which is
what keeps the voiceover lane untouched.
Known and not fixed here: `removedRawSpans` still measures trims linearly, so a trim in a
clip that also carries an insertion tags words kept-or-removed slightly off. It moves no
caption and no highlight — the recording lane never reads it for position.
---
src/components/ai-edition/RightPanes.tsx | 6 +-
src/lib/ai-edition/captions/captions.test.ts | 40 +++++++++++++
src/lib/ai-edition/captions/cues.ts | 16 +++--
.../timeline/aggregated-transcript.ts | 45 +++++++++++++-
.../ai-edition/timeline/clip-parts.test.ts | 60 +++++++++++++++++++
src/lib/ai-edition/timeline/clip-parts.ts | 59 ++++++++++++++++++
6 files changed, 217 insertions(+), 9 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 79f9f2e00..7bbbac76a 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -72,6 +72,7 @@ import {
isSilenceWord,
placementRawExtent,
placementRawSec,
+ recordingPlacements,
type TranscriptLane,
type TrimRun,
voiceoverPlacements,
@@ -896,7 +897,10 @@ export function TranscriptPane({
},
[setCaptionSettings],
);
- const placements = activeLane === "voiceover" ? voiceover : clips;
+ // The clips WITH their parts, so a word after an insertion resolves to the moment it is
+ // actually spoken rather than to its distance from the clip's start.
+ const recording = useMemo(() => recordingPlacements(clips, transcripts), [clips, transcripts]);
+ const placements = activeLane === "voiceover" ? voiceover : recording;
const sections = useMemo(
() => buildAggregatedSections(placements, transcripts, assets, removed),
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index ce3d4a961..b313cd8f7 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,3 +650,43 @@ describe("translated caption layout", () => {
);
});
});
+
+// The whole point of the insertion layer, seen from the far end: a word typed into the
+// middle of a take lengthens the film, and every caption after it has to move with the
+// audio it belongs to. This is the check that failed on screen before it failed here.
+describe("captions over an inserted word", () => {
+ const withInsertion = () => {
+ const t = transcript();
+ return doc({
+ transcripts: [
+ {
+ ...t,
+ words: [
+ ...t.words.slice(0, 3),
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 2,
+ endSec: 2,
+ text: "wait",
+ source: "synth",
+ },
+ ...t.words.slice(3),
+ ],
+ },
+ ],
+ });
+ };
+
+ it("moves every cue after the insertion by exactly the extension's length", () => {
+ const cues = deriveCaptionCues(withInsertion(), ON, {});
+ // "wait" is 4 chars at 15/s.
+ expect(cues[cues.length - 1].endMs).toBeCloseTo(6000 + (4 / 15) * 1000, 0);
+ });
+
+ it("leaves the cues before it exactly where they were", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withInsertion(), ON, {});
+ expect(after[0].startMs).toBe(before[0].startMs);
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 4d7395063..49d8fa315 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -23,7 +23,11 @@ import {
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
import type { AxcutDocument, AxcutTranscript } from "../schema";
-import { lanePlacements, type TranscriptPlacement } from "../timeline/aggregated-transcript";
+import {
+ lanePlacements,
+ placementRawSec,
+ type TranscriptPlacement,
+} from "../timeline/aggregated-transcript";
import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
@@ -171,10 +175,11 @@ export function sourceSpanToTimelineSpans(
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
- out.push({
- startSec: clip.timelineStartSec + (s - clip.sourceStartSec),
- endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
- });
+ // Through `placementRawSec`, never the subtraction it used to write here: a clip
+ // carrying an added word plays its media in pieces, and the seconds after the
+ // insertion sit further along the ruler than their distance from the clip's start.
+ // Writing the short version here is what put every caption after an insertion early.
+ out.push({ startSec: placementRawSec(clip, s), endSec: placementRawSec(clip, e) });
}
// Onto the ruler the viewer actually sees. Expanding BOTH ends does the whole job:
return out;
@@ -201,6 +206,7 @@ export function deriveCaptionCues(
// document written before it — or hand-built, never through the schema — has none.
document.audioTracks ?? [],
removedRawSpans(document.timeline.clips, document.timeline.trimRanges),
+ document.transcripts,
);
if (placements.length === 0) return [];
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 309e3c6aa..eeebae973 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -16,6 +16,7 @@
import { collapseTracksToPills } from "../document/audioTracks";
import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
+import { type ClipPart, clipParts, extensionAt, partsRawSec, partsSourceSec } from "./clip-parts";
import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
import { takeProgramme } from "./take-programme";
@@ -43,6 +44,11 @@ export interface TranscriptPlacement {
/** Where the window lands on the RAW ruler. Source time is per asset, so this is
* the only thing that turns a word back into a moment the playhead can seek to. */
timelineStartSec: number;
+ /** The placement's parts, when it has any: a clip carrying added words plays its own
+ * media in pieces with generated media between them, so a source second no longer sits
+ * a fixed distance from the head. Absent, the placement is one uninterrupted stretch
+ * and every reader below behaves exactly as it did before extensions existed. */
+ parts?: readonly ClipPart[];
}
/** Which lane's speech the transcript tab is reading. */
@@ -56,7 +62,9 @@ export type TranscriptLane = "recording" | "voiceover";
* and not in source time (issue #560).
*/
export function placementRawSec(placement: TranscriptPlacement, sourceSec: number): number {
- return placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
+ return placement.parts
+ ? partsRawSec(placement.parts, sourceSec)
+ : placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
}
/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
@@ -345,14 +353,36 @@ export function voiceoverPlacements(
);
}
+/**
+ * The recording lane's placements: the clips, each carrying its own parts.
+ *
+ * One placement per clip, not one per part. The pane draws a header per placement, and a
+ * clip does not become three clips because the user typed a word into it — the split
+ * belongs to the mapping, which is where `parts` puts it.
+ */
+export function recordingPlacements(
+ clips: AxcutClip[],
+ transcripts: readonly AxcutTranscript[],
+): TranscriptPlacement[] {
+ const wordsByAsset = new Map(transcripts.map((t) => [t.assetId, t.words]));
+ return clips.map((clip) => ({
+ ...clip,
+ parts: clipParts(clip, wordsByAsset.get(clip.assetId) ?? []),
+ }));
+}
+
/** The placements a lane contributes, in timeline order. */
export function lanePlacements(
lane: TranscriptLane,
clips: AxcutClip[],
audioTracks: AxcutAudioTrack[],
removed: readonly RemovedRawSpan[] = [],
+ /** Needed only by the recording lane, to know where its clips are interrupted. */
+ transcripts: readonly AxcutTranscript[] = [],
): TranscriptPlacement[] {
- return lane === "voiceover" ? voiceoverPlacements(audioTracks, removed) : clips;
+ return lane === "voiceover"
+ ? voiceoverPlacements(audioTracks, removed)
+ : recordingPlacements(clips, transcripts);
}
/**
@@ -398,8 +428,17 @@ export function findCueWordId(sections: ClipSection[], rawSec: number | null): s
}
if (!match) return null;
+ // An added word is spoken over its extension, which occupies ruler time and no source
+ // time at all. Asked first, and by id: through source time it is indistinguishable from
+ // the recorded word that resumes at the same second — which is why the pane used to skip
+ // straight past every inserted word to the one after it.
+ const spoken = match.clip.parts ? extensionAt(match.clip.parts, rawSec) : null;
+ if (spoken) return clipWordId(match.clip.id, spoken);
+
// Back to the placement's own source clock, which is what the words are stamped in.
- const t = match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
+ const t = match.clip.parts
+ ? partsSourceSec(match.clip.parts, rawSec)
+ : match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/clip-parts.test.ts b/src/lib/ai-edition/timeline/clip-parts.test.ts
index 71bde2a9c..d26495167 100644
--- a/src/lib/ai-edition/timeline/clip-parts.test.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.test.ts
@@ -7,8 +7,11 @@ import {
clipParts,
clipsWithExtensions,
extensionAssetId,
+ extensionAt,
extensionDurationSec,
partsLengthSec,
+ partsRawSec,
+ partsSourceSec,
} from "./clip-parts";
const CLIP: AxcutClip = {
@@ -142,3 +145,60 @@ describe("clipsWithExtensions", () => {
expect(extensionAssetId("synth_1")).toBe("ext:synth_1");
});
});
+
+// ─── The one conversion ─────────────────────────────────────────────────────
+// Every reader used to write `timelineStartSec + (sec - sourceStartSec)` for itself. That
+// subtraction is right until a clip carries an extension and wrong for every second after
+// it, which is one bug per reader — so it lives here now, once.
+
+describe("partsRawSec", () => {
+ const parts = clipParts(CLIP, [added("s1", 4, "really")]);
+ const ext = extensionDurationSec("really");
+
+ it("leaves the seconds before the insertion where they were", () => {
+ expect(partsRawSec(parts, 2)).toBeCloseTo(2, 6);
+ });
+
+ it("pushes the seconds after it along by exactly what was inserted", () => {
+ expect(partsRawSec(parts, 6)).toBeCloseTo(6 + ext, 6);
+ expect(partsRawSec(parts, 10)).toBeCloseTo(10 + ext, 6);
+ });
+
+ it("puts the split second AFTER the extension, where its media actually plays", () => {
+ expect(partsRawSec(parts, 4)).toBeCloseTo(4 + ext, 6);
+ });
+
+ it("is the plain subtraction when nothing was added", () => {
+ const plain = clipParts({ ...CLIP, timelineStartSec: 12, timelineEndSec: 22 }, []);
+ expect(partsRawSec(plain, 6)).toBeCloseTo(18, 6);
+ });
+});
+
+describe("partsSourceSec", () => {
+ const parts = clipParts(CLIP, [added("s1", 4, "really")]);
+ const ext = extensionDurationSec("really");
+
+ it("undoes partsRawSec for a second the recording actually plays", () => {
+ for (const sec of [0, 2, 4, 6, 10]) {
+ expect(partsSourceSec(parts, partsRawSec(parts, sec))).toBeCloseTo(sec, 6);
+ }
+ });
+
+ it("parks at the split while the extension plays — no recording runs there", () => {
+ expect(partsSourceSec(parts, 4 + ext / 2)).toBeCloseTo(4, 6);
+ });
+});
+
+describe("extensionAt", () => {
+ const parts = clipParts(CLIP, [added("s1", 4, "really")]);
+ const ext = extensionDurationSec("really");
+
+ it("names the word being spoken over its own media", () => {
+ expect(extensionAt(parts, 4 + ext / 2)).toBe("s1");
+ });
+
+ it("is nothing on either side of it, so the recorded words keep the highlight", () => {
+ expect(extensionAt(parts, 3.9)).toBeNull();
+ expect(extensionAt(parts, 4 + ext + 0.01)).toBeNull();
+ });
+});
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
index fcd3bc29b..b9aef8375 100644
--- a/src/lib/ai-edition/timeline/clip-parts.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -181,3 +181,62 @@ export function partsLengthSec(parts: readonly ClipPart[]): number {
if (parts.length === 0) return 0;
return parts[parts.length - 1].timelineEndSec - parts[0].timelineStartSec;
}
+
+/** The parts that carry source time. An extension has none, which is the whole point. */
+function recordings(parts: readonly ClipPart[]): Array> {
+ return parts.filter((p): p is Extract => p.kind === "recording");
+}
+
+/**
+ * A source second of the clip's media, as a second on the ruler. THE conversion.
+ *
+ * Every reader that writes `timelineStartSec + (sec - sourceStartSec)` is wrong the moment
+ * the clip carries an extension: the seconds after one are pushed along by it, and the
+ * reader lands everything early by the total inserted before it. That is one bug with one
+ * home, not one per reader.
+ *
+ * A second sitting exactly where an insertion split maps to the moment AFTER the extension,
+ * because that is where the recorded media of that second actually plays. The added word
+ * itself never asks this question — it has no source second at all, and `extensionAt`
+ * names its moment directly.
+ */
+export function partsRawSec(parts: readonly ClipPart[], sourceSec: number): number {
+ const played = recordings(parts);
+ if (played.length === 0) return parts[0]?.timelineStartSec ?? sourceSec;
+ let part = played[0];
+ for (const candidate of played) {
+ if (candidate.sourceStartSec <= sourceSec) part = candidate;
+ }
+ return part.timelineStartSec + (sourceSec - part.sourceStartSec);
+}
+
+/**
+ * The inverse: a second on the ruler, as a second of the clip's media.
+ *
+ * Inside an extension the source clock is PARKED at the second the insertion split. That is
+ * the honest answer — none of the recording plays there, and the split is the last second
+ * that did.
+ */
+export function partsSourceSec(parts: readonly ClipPart[], rawSec: number): number {
+ const played = recordings(parts);
+ if (played.length === 0) return rawSec;
+ let part = played[0];
+ for (const candidate of played) {
+ if (candidate.timelineStartSec <= rawSec) part = candidate;
+ }
+ return Math.min(part.sourceEndSec, part.sourceStartSec + (rawSec - part.timelineStartSec));
+}
+
+/**
+ * The added word being spoken at this moment on the ruler, if any.
+ *
+ * Asked of the parts directly rather than resolved through source time, where an extension
+ * and the media that follows it share one second and nothing could tell them apart.
+ */
+export function extensionAt(parts: readonly ClipPart[], rawSec: number): string | null {
+ for (const part of parts) {
+ if (part.kind !== "extension") continue;
+ if (rawSec >= part.timelineStartSec && rawSec < part.timelineEndSec) return part.wordId;
+ }
+ return null;
+}
From 63f16b7183d841a83e257938e1603a281573333e Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 15:07:07 +0200
Subject: [PATCH 099/113] fix(insertions): an extension is a clip, because that
is the only shape the app maps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every mapping in this codebase rests on one property: a clip is an UNINTERRUPTED shift
between its source seconds and the ruler. `timelineMap`, the native `setActiveClip`, the
exporter, the DOM player and the caption path all assume it. A clip interrupted by generated
media breaks that assumption in all of them at once — which is why teaching them each about
extensions never converged. It was eight special cases, and it was still one bug.
What that cost, concretely: the native decoder was pointed at the recording for the whole
clip, and the ruler seconds the insertion added mapped past the end of the media. The
compositor did the only thing it can there — held the last frame. Hence generated content at
the END of the clip instead of at the insertion.
So the interruption is resolved once, in `withExtensions`, into the shape they already
handle: a clip on an asset with a file. Below that line there are no extensions, only clips
that happen to play a generated file, and every one of those mappings is correct again
without being touched. `PlaybackSegment.extensionWordId` and the scene's extension branch
are gone with it — the resolver is back to what it was before insertions existed.
Derived, never stored: one direction, nothing to reconcile. The stored clip keeps its own
identity and its own source window, and the words remain the only truth about what was added.
A trim still names the clip it was authored on, so both halves of a split answer to that
name — `baseClipId`.
The generated media is a MIRE over audible noise, and the recording is not read at all.
A held frame is indistinguishable on screen from a decoder stuck at the end of a clip, which
is exactly the bug it hid for three rounds; a test pattern says "this is generated, and it is
playing HERE" at a glance. Swap it for synthesized frames the day there are any.
Two more the same insertion caused:
- The ruler's amber mark had `const width = 0` hard-coded and was placed by the clip's SOURCE
span, which stops matching the box on screen the moment a word is added. It now spans the
extension it stands for.
- An added word was grouped into the caption line beside it and inherited that line's span,
which is what glued the inserted subtitle to the one before. It is its own line now, over
its own media — kept a point in source time on purpose, since its length comes from the
extension and not from any word timing.
---
electron/media/extensionClip.test.ts | 73 ++++-----
electron/media/extensionClip.ts | 57 ++++---
src/cli/CliExportRunner.tsx | 4 +-
src/components/ai-edition/ExportDialog.tsx | 4 +-
.../ai-edition/NativeCompositorOverlay.tsx | 30 +++-
src/components/ai-edition/NewEditorShell.tsx | 71 +++-----
src/components/ai-edition/VirtualPreview.tsx | 20 +--
src/components/ai-edition/v4/V4Timeline.tsx | 89 +++++-----
src/lib/ai-edition/captions/captions.test.ts | 9 ++
src/lib/ai-edition/captions/cues.ts | 57 ++++++-
src/lib/ai-edition/document/timeline.test.ts | 81 ----------
src/lib/ai-edition/document/timeline.ts | 80 +++------
.../ai-edition/timeline/clip-parts.test.ts | 91 ++++++++---
src/lib/ai-edition/timeline/clip-parts.ts | 153 ++++++++++++++----
src/lib/ai-edition/timeline/trim-mapping.ts | 6 +-
src/native/sceneDescription.ts | 41 ++---
16 files changed, 457 insertions(+), 409 deletions(-)
diff --git a/electron/media/extensionClip.test.ts b/electron/media/extensionClip.test.ts
index d7199550a..d2f7df681 100644
--- a/electron/media/extensionClip.test.ts
+++ b/electron/media/extensionClip.test.ts
@@ -5,45 +5,48 @@ import { describe, expect, it } from "vitest";
import { extensionClipPath } from "../../src/lib/ai-edition/timeline/clip-parts";
import { extensionClipArgs } from "./extensionClip";
-const SPEC = {
- sourcePath: "C:/rec/take.mp4",
- atSec: 5.3,
- durationSec: 3.6,
- fps: 30,
- width: 1920,
- height: 1080,
-};
+const SPEC = { durationSec: 3.6, fps: 30, width: 1920, height: 1080 };
describe("extensionClipArgs", () => {
const args = extensionClipArgs(SPEC, "C:/out/w1_3600.mp4");
- const at = (flag: string) => args[args.indexOf(flag) + 1];
+ const filter = (prefix: string) => args.find((a) => a.startsWith(prefix)) ?? "";
- it("seeks BEFORE the input, so ffmpeg does not decode up to the moment", () => {
- expect(args.indexOf("-ss")).toBeLessThan(args.indexOf("-i"));
- expect(at("-ss")).toBe("5.300");
+ it("draws a test pattern, so generated media is unmistakable on screen", () => {
+ // A held frame from the recording looked exactly like a decoder stuck at the end of
+ // a clip — which is the bug it hid for three rounds.
+ expect(filter("testsrc2=")).toContain("size=1920x1080");
+ expect(filter("testsrc2=")).toContain("rate=30");
});
- it("holds exactly the frozen frame for the whole duration", () => {
- const filter = at("-filter_complex");
- expect(filter).toContain("trim=end_frame=1");
- expect(filter).toContain("loop=loop=-1:size=1:start=0");
- expect(filter).toContain("trim=duration=3.600");
+ it("carries an audible noise track rather than silence", () => {
+ expect(filter("anoisesrc=")).toContain("a=0.2");
+ expect(args).toContain("1:a");
});
- it("matches the recording's geometry, so the two concatenate downstream", () => {
- expect(at("-filter_complex")).toContain("fps=30");
- expect(at("-filter_complex")).toContain("scale=1920:1080");
+ it("reads the recording not at all — nothing to seek, nothing to decode", () => {
+ expect(args.filter((a) => a === "-i")).toHaveLength(2);
+ expect(args).not.toContain("-ss");
+ expect(args.some((a) => a.endsWith(".mp4") && a !== "C:/out/w1_3600.mp4")).toBe(false);
});
- it("carries an audio track rather than none — silence reads as a broken file", () => {
- expect(args.some((a) => a.startsWith("anoisesrc="))).toBe(true);
- expect(args).toContain("1:a");
+ it("runs for exactly the duration asked for, on both streams and the output", () => {
+ expect(filter("testsrc2=")).toContain("duration=3.600");
+ expect(filter("anoisesrc=")).toContain("d=3.600");
+ expect(args[args.indexOf("-t") + 1]).toBe("3.600");
+ expect(args[args.length - 1]).toBe("C:/out/w1_3600.mp4");
});
- it("bounds the output, so a looping filter cannot run away", () => {
- // `-t` appears twice on purpose: once on the noise input, once on the output.
- expect(args.filter((a) => a === "-t")).toHaveLength(2);
- expect(args[args.length - 1]).toBe("C:/out/w1_3600.mp4");
+ it("uses an encoder the bundled LGPL ffmpeg actually has", () => {
+ // `libx264` is GPL and absent: the first real run failed with "Unknown encoder".
+ expect(args).toContain("libopenh264");
+ expect(args).not.toContain("libx264");
+ });
+
+ it("still has a geometry when the asset does not know its own", () => {
+ // The live project's asset carries `fps: 0` — the probe never filled it in.
+ const blind = extensionClipArgs({ ...SPEC, fps: 0, width: 0, height: 0 }, "out.mp4");
+ expect(blind.find((a) => a.startsWith("testsrc2="))).toContain("size=1920x1080");
+ expect(blind.find((a) => a.startsWith("testsrc2="))).toContain("rate=30");
});
});
@@ -69,19 +72,3 @@ describe("extensionClipPath", () => {
);
});
});
-
-describe("the two things reality imposed", () => {
- it("uses an encoder the bundled LGPL ffmpeg actually has", () => {
- // `libx264` is GPL and absent: the first real run failed with "Unknown encoder".
- const args = extensionClipArgs(SPEC, "out.mp4");
- expect(args).toContain("libopenh264");
- expect(args).not.toContain("libx264");
- });
-
- it("still has a frame rate when the asset does not know its own", () => {
- // The live project's asset carries `fps: 0` — the probe never filled it in, and a
- // loop filter with no rate produces nothing.
- const args = extensionClipArgs({ ...SPEC, fps: 0 }, "out.mp4");
- expect(args[args.indexOf("-filter_complex") + 1]).toContain("fps=30");
- });
-});
diff --git a/electron/media/extensionClip.ts b/electron/media/extensionClip.ts
index 4b874a66a..5022fc5b7 100644
--- a/electron/media/extensionClip.ts
+++ b/electron/media/extensionClip.ts
@@ -2,9 +2,14 @@
* The media an added word is spoken over.
*
* A word typed into the transcript has no recording behind it. Until there is TTS and frame
- * generation, the stand-in is the last frame the recording showed, held, over faint noise —
- * a real file, with real frames and a real audio track, so everything downstream decodes it
- * like any other media instead of special-casing its absence.
+ * generation the stand-in is a TEST PATTERN over noise — a real file, with real frames and a
+ * real audio track, so everything downstream decodes it like any other media instead of
+ * special-casing its absence.
+ *
+ * ponytail: a mire, on purpose, and not the recording's last frame held. A held frame is
+ * indistinguishable on screen from a decoder stuck at the end of a clip, which is exactly
+ * the bug it hid. The mire says "this is generated media, and it is playing HERE" at a
+ * glance. Swap it for synthesized frames the day there are any.
*
* DERIVED, never authored: the word is the truth, this file is regenerable from it. The name
* carries what it was generated from, so a stale one is simply never asked for again, and a
@@ -23,63 +28,57 @@ import {
import { resolveFfmpeg } from "./audioPeaks";
export interface ExtensionClipSpec {
- /** The recording the frozen frame is taken from. */
- sourcePath: string;
- /** Source second to freeze on — the moment the added word follows. */
- atSec: number;
durationSec: number;
/** Matched to the recording so the two concatenate without a re-encode downstream.
- * `0` when the asset was imported before the probe filled it in — see `FALLBACK_FPS`. */
+ * `0` when the asset was imported before the probe filled it in — see the fallbacks. */
fps: number;
width: number;
height: number;
}
/** Noise rather than silence: a silent track is indistinguishable from a broken one, and
- * this stands in for a voice that will be synthesized later. Quiet enough not to startle. */
-const NOISE_AMPLITUDE = 0.02;
+ * this stands in for a voice that will be synthesized later. Loud enough to be unmistakable
+ * while the generated stretch is the thing being debugged. */
+const NOISE_AMPLITUDE = 0.2;
const SAMPLE_RATE = 48_000;
/** The bundled ffmpeg is LGPL, so `libx264` is not in it — `libopenh264` is the software
- * H.264 encoder every LGPL build carries, on every platform. The hardware encoders
- * (`h264_nvenc`, `h264_mf`, …) are faster and each needs its own hardware; for a clip of a
- * few seconds that trade is not worth a platform branch. */
+ * H.264 encoder every LGPL build carries, on every platform. */
const VIDEO_ENCODER = "libopenh264";
-/** Assets imported before the probe filled `video.fps` carry 0, and the live project has
- * one. A loop filter needs a rate, so it gets a common one.
- * ponytail: fixed 30, read the real rate off the source when the probe backfills it. */
+/** Assets imported before the probe filled `video` carry zeroes, and the live project does.
+ * ponytail: fixed, read the real geometry off the source when the probe backfills it. */
const FALLBACK_FPS = 30;
+const FALLBACK_WIDTH = 1920;
+const FALLBACK_HEIGHT = 1080;
/**
* The ffmpeg arguments, as a pure function so the command can be asserted without running it.
*
- * One pass, no temp file: seek to the moment, keep the single frame there, loop it for the
- * duration, and mux it against a noise source of the same length.
+ * Two synthetic inputs and nothing else: the recording is not read at all, which is what
+ * makes this fast, independent of what the source codec is, and impossible to confuse with
+ * the recording once it is on screen.
*/
export function extensionClipArgs(spec: ExtensionClipSpec, outPath: string): string[] {
const dur = spec.durationSec.toFixed(3);
const fps = spec.fps > 0 ? spec.fps : FALLBACK_FPS;
+ const width = spec.width > 0 ? spec.width : FALLBACK_WIDTH;
+ const height = spec.height > 0 ? spec.height : FALLBACK_HEIGHT;
return [
"-y",
"-hide_banner",
"-loglevel",
"error",
- // Before `-i`, so ffmpeg seeks rather than decoding up to the moment.
- "-ss",
- spec.atSec.toFixed(3),
+ "-f",
+ "lavfi",
"-i",
- spec.sourcePath,
+ `testsrc2=size=${width}x${height}:rate=${fps}:duration=${dur}`,
"-f",
"lavfi",
- "-t",
- dur,
"-i",
- `anoisesrc=c=pink:a=${NOISE_AMPLITUDE}:r=${SAMPLE_RATE}`,
- "-filter_complex",
- `[0:v]trim=end_frame=1,loop=loop=-1:size=1:start=0,fps=${fps},trim=duration=${dur},setpts=PTS-STARTPTS,scale=${spec.width}:${spec.height}[v]`,
+ `anoisesrc=c=pink:a=${NOISE_AMPLITUDE}:r=${SAMPLE_RATE}:d=${dur}`,
"-map",
- "[v]",
+ "0:v",
"-map",
"1:a",
"-c:v",
@@ -121,8 +120,6 @@ export async function ensureDocumentExtensions(document: {
try {
await ensureExtensionClip(
{
- sourcePath: asset.originalPath,
- atSec: word.startSec,
durationSec,
fps: asset.video?.fps ?? 0,
width: asset.video?.width ?? 0,
diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx
index 9a7f1b590..6c29b9975 100644
--- a/src/cli/CliExportRunner.tsx
+++ b/src/cli/CliExportRunner.tsx
@@ -24,6 +24,7 @@ import { applyProbedDuration } from "@/lib/ai-edition/document/timeline";
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
+import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { DEFAULT_ZOOM_DEPTH, ZOOM_DEPTH_SCALES } from "@/lib/ai-edition/timeline/zoom-scale";
import { buildAutoZoomSuggestions } from "@/lib/ai-edition/timeline/zoom-suggestions";
@@ -78,7 +79,8 @@ function replaceExtension(filePath: string, newExtension: string): string {
/** Mirrors ExportDialog.buildNativeClipList: trim-narrowed visible clips mapped
* onto the native multiclip contract. Kept in lock-step with
* buildSceneDescription so export and scene agree on the clip stream. */
-function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[] {
+function buildNativeClipList(rawDocument: AxcutDocument): CompositorClipInput[] {
+ const axcutDocument = withExtensions(rawDocument);
const assetById = new Map(axcutDocument.assets.map((asset) => [asset.id, asset]));
return resolveVisibleClips(axcutDocument).flatMap((clip) => {
const asset = assetById.get(clip.assetId);
diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx
index 5dff50acf..97da5286a 100644
--- a/src/components/ai-edition/ExportDialog.tsx
+++ b/src/components/ai-edition/ExportDialog.tsx
@@ -21,6 +21,7 @@ import {
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
+import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import {
type ExportFormat,
@@ -80,7 +81,8 @@ function revealExportedFile(filePath: string): void {
* `NativeCompositorOverlay`, so export/preview/scene all see the exact same clip stream),
* each with its asset's screen file + camera file (falls back to the screen when a clip has
* no camera — the no-webcam layout is a later step) and its source trim. */
-function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
+function buildNativeClipList(rawDocument: AxcutDocument): CompositorClipInput[] {
+ const document = withExtensions(rawDocument);
const assetById = new Map(document.assets.map((a) => [a.id, a]));
return resolveVisibleClips(document).flatMap((clip) => {
const asset = assetById.get(clip.assetId);
diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx
index e4af64102..5bfa32c50 100644
--- a/src/components/ai-edition/NativeCompositorOverlay.tsx
+++ b/src/components/ai-edition/NativeCompositorOverlay.tsx
@@ -4,6 +4,7 @@ import { noteUiProbeClipSwitch } from "@/lib/ai-edition/perf/uiFrameProbe";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
+import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
pushAllNativeParams,
@@ -69,13 +70,18 @@ export function NativeCompositorOverlay() {
// Sans ça (ancien `resolveNativePlaybackPosition(nativeClips, currentTimeSec)`), un playhead
// RAW lu contre des clips compactés désignait le mauvais clip après un trim → mauvaise caméra
// + décalage écran/cam.
+ // The document the NATIVE side plays: an extension is a clip on its own asset, with its
+ // own file. Everything below — the compacted segments, the raw layout they are placed
+ // against, the asset the decoder is pointed at — has to be read from the same one, or
+ // they disagree about what the film contains.
+ const playedDocument = useMemo(() => (document ? withExtensions(document) : null), [document]);
const nativeClips = useMemo(() => {
- if (!document) return [];
- return resolveVisibleClips(document);
- }, [document]);
+ if (!playedDocument) return [];
+ return resolveVisibleClips(playedDocument);
+ }, [playedDocument]);
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
- [nativeClips, currentTimeSec, document],
+ () => resolveNativePosition(currentTimeSec, nativeClips, playedDocument?.timeline.clips ?? []),
+ [nativeClips, currentTimeSec, playedDocument],
);
const activeClip = activePosition?.clip ?? null;
@@ -191,7 +197,7 @@ export function NativeCompositorOverlay() {
useEffect(() => {
if (
viewId === null ||
- !document ||
+ !playedDocument ||
!activeClipId ||
!activeClip ||
activeClipIndex === null ||
@@ -202,7 +208,7 @@ export function NativeCompositorOverlay() {
if (previousActiveClipIdRef.current === activeClipId) {
return;
}
- const asset = document.assets.find((candidate) => candidate.id === activeClip.assetId);
+ const asset = playedDocument?.assets.find((candidate) => candidate.id === activeClip.assetId);
if (!asset?.originalPath) {
return;
}
@@ -248,7 +254,15 @@ export function NativeCompositorOverlay() {
previousActiveClipIdRef.current = null;
}
});
- }, [viewId, document, activeClipId, activeClip, activeClipIndex, activeSourceTimeSec, playing]);
+ }, [
+ viewId,
+ playedDocument,
+ activeClipId,
+ activeClip,
+ activeClipIndex,
+ activeSourceTimeSec,
+ playing,
+ ]);
if (!ready) {
return null;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 61744066c..5622e9990 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -32,12 +32,7 @@ import {
import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
-import {
- extensionAssetId,
- extensionClipPath,
- extensionDurationSec,
- isAddedWord,
-} from "@/lib/ai-edition/timeline/clip-parts";
+import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import {
@@ -236,6 +231,10 @@ export function NewEditorShell() {
document?.assets.find((a) => a.id === document.project.primaryAssetId)?.originalPath ?? null;
void primaryAssetPath;
const clips: AxcutClip[] = document?.timeline.clips ?? [];
+ // The document as the PLAYER sees it: an extension is a clip on its own asset. The ruler
+ // deliberately keeps `clips` above — a clip does not become three on screen because a
+ // word was typed into it.
+ const playedDocument = useMemo(() => (document ? withExtensions(document) : null), [document]);
const visibleClips = useMemo(() => (document ? resolveVisibleClips(document) : []), [document]);
const hasProject = Boolean(document);
const hasAsset = projectId !== null && (document?.assets.length ?? 0) > 0;
@@ -360,45 +359,24 @@ export function NewEditorShell() {
}, [promptUnsaved, saveDocument]);
const videoSources = useMemo(() => {
- if (!document) return [];
- // One source per added word, beside the recordings. The player keys sources by asset
- // id and an extension answers to `ext:`, so it swaps to the generated file at
- // the boundary exactly as it swaps between two recordings — no new case.
- //
- // A file the save has not written yet simply fails to load, and the player reports it
- // the way it reports any unreadable source: the edit stands, the picture catches up.
- const extensions = document.transcripts.flatMap((transcript) => {
- const asset = document.assets.find((a) => a.id === transcript.assetId);
- if (!asset?.originalPath) return [];
- return transcript.words.filter(isAddedWord).flatMap((word) => {
- const durationSec = extensionDurationSec(word.text);
- if (durationSec <= 0) return [];
- const filePath = extensionClipPath(asset.originalPath, word.id, durationSec);
- return [
- {
- id: extensionAssetId(word.id),
- filePath,
- src: toFileUrl(filePath),
- label: word.text,
- },
- ];
- });
- });
- return document.assets
- .map((asset) => ({
- id: asset.id,
- filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath,
- // Real Electron assets are filesystem paths and go through toFileUrl.
- // In the browser preview an asset can already point at an http(s)/
- // blob/data URL served by Vite; toFileUrl would mangle those into a
- // broken file:// URL, so pass web URLs through untouched.
- src: /^(https?|blob|data):/.test(asset.originalPath)
- ? asset.originalPath
- : toFileUrl(asset.originalPath),
- label: asset.label,
- }))
- .concat(extensions);
- }, [document]);
+ if (!playedDocument) return [];
+ // Every asset, extensions included — they are assets by the time they get here, each
+ // with a real path. A file the save has not written yet simply fails to load, and the
+ // player reports it the way it reports any unreadable source: the edit stands, the
+ // picture catches up on the next save.
+ return playedDocument.assets.map((asset) => ({
+ id: asset.id,
+ filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath,
+ // Real Electron assets are filesystem paths and go through toFileUrl.
+ // In the browser preview an asset can already point at an http(s)/
+ // blob/data URL served by Vite; toFileUrl would mangle those into a
+ // broken file:// URL, so pass web URLs through untouched.
+ src: /^(https?|blob|data):/.test(asset.originalPath)
+ ? asset.originalPath
+ : toFileUrl(asset.originalPath),
+ label: asset.label,
+ }));
+ }, [playedDocument]);
const handleLoadedMetadata = useCallback(
(durationSec: number, assetId: string) => {
@@ -1539,12 +1517,11 @@ export function NewEditorShell() {
// itself keeps playing — that is what the user is narrating to.
audioTracks={voiceoverRecording ? NO_AUDIO_TRACKS : tl.audioTracks}
audioSources={videoSources}
- clips={clips}
+ clips={playedDocument?.timeline.clips ?? clips}
zoomRegions={tl.zoomRegions}
speedRegions={tl.speedRegions}
cameraFullscreenRegions={tl.cameraFullscreenRegions}
trimRanges={tl.trimRanges}
- transcripts={document?.transcripts ?? []}
selectedZoomRegionId={tl.selection?.kind === "zoom" ? tl.selection.id : null}
onZoomFocusChange={tl.updateZoomFocusLive}
onZoomFocusCommit={() => void tl.commitZoomFocus()}
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 84875bf35..feb174ff8 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -16,13 +16,11 @@ import {
import type {
AxcutAudioTrack,
AxcutClip,
- AxcutTranscript,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
-import { clipsWithExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
@@ -214,8 +212,6 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
- /** So a clip carrying added words plays their extensions — see `clipParts`. */
- transcripts?: AxcutTranscript[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -262,7 +258,6 @@ export function VirtualPreview({
zoomRegions = [],
speedRegions = [],
trimRanges = [],
- transcripts = [],
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -552,13 +547,8 @@ export function VirtualPreview({
// stuck at 0% and the drag range at `max=1`. The refs let the rAF
// always see the latest values without re-creating on every clip
// mutation.
- // The clips this player sees: each extension spliced in as a clip of its own, so the
- // source-swap and the clock it already has apply to it without a new case. Everything
- // below reads THIS list — `clips` from the props is the document's, and the difference
- // is exactly the media an added word is spoken over.
- const playerClips = useMemo(() => clipsWithExtensions(clips, transcripts), [clips, transcripts]);
- const clipsRef = useRef(playerClips);
- clipsRef.current = playerClips;
+ const clipsRef = useRef(clips);
+ clipsRef.current = clips;
// Same reason as `clipsRef`: the rAF projects the playhead and each imported
// audio track's head raw→output every frame (see the audio-track loop), and
// must see the live trims, not the set captured when the loop was created.
@@ -599,10 +589,8 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- // `[]` transcripts, not a forgotten argument: `playerClips` has already spliced the
- // extensions in, and splicing them twice would play each one twice.
- () => resolvePlaybackSegments(playerClips, trimRanges, []),
- [playerClips, trimRanges],
+ () => resolvePlaybackSegments(clips, trimRanges),
+ [clips, trimRanges],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 81b79d80b..8d87a48b3 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -49,6 +49,7 @@ import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
+import { clipParts } from "@/lib/ai-edition/timeline/clip-parts";
import { formatSec } from "@/lib/ai-edition/timeline/format";
import {
newRegionDurationSec,
@@ -712,21 +713,29 @@ export function V4Timeline({
// clip because each mark is positioned inside its clip's own box — it then travels with
// the clip through a reorder for free, with no ruler arithmetic of its own.
const insertedWordsByClip = useMemo(() => {
- const byAsset = new Map();
- for (const transcript of tl.transcripts) {
- const added = transcript.words.filter((word) => word.source === "synth");
- if (added.length > 0) byAsset.set(transcript.assetId, added);
- }
- if (byAsset.size === 0) return new Map>();
- const out = new Map>();
+ type Mark = { word: AxcutWord; atPct: number; widthPct: number; atSec: number };
+ const byAsset = new Map(tl.transcripts.map((t) => [t.assetId, t.words]));
+ const out = new Map();
for (const clip of clips) {
const words = byAsset.get(clip.assetId);
- const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
- const span = sourceEnd - clip.sourceStartSec;
+ // The clip's TIMELINE span, which is what the box on screen measures. Using the
+ // source span put the mark at the wrong percentage AND gave it no width, because
+ // the two spans stop being equal the moment a word is added.
+ const span = clip.timelineEndSec - clip.timelineStartSec;
if (!words || span <= 0) continue;
- const marks = words
- .filter((word) => word.startSec >= clip.sourceStartSec && word.startSec <= sourceEnd)
- .map((word) => ({ word, atPct: ((word.startSec - clip.sourceStartSec) / span) * 100 }));
+ const byId = new Map(words.map((w) => [w.id, w]));
+ const marks = clipParts(clip, words).flatMap((part): Mark[] => {
+ const word = part.kind === "extension" ? byId.get(part.wordId) : undefined;
+ if (!word) return [];
+ return [
+ {
+ word,
+ atPct: ((part.timelineStartSec - clip.timelineStartSec) / span) * 100,
+ widthPct: ((part.timelineEndSec - part.timelineStartSec) / span) * 100,
+ atSec: part.timelineStartSec,
+ },
+ ];
+ });
if (marks.length > 0) out.set(clip.id, marks);
}
return out;
@@ -2259,33 +2268,35 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
- {(insertedWordsByClip.get(c.id) ?? []).map(({ word, atPct }) => {
- const left = atPct;
- const width = 0;
- return (
- 0
- ? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
- : { left: `${left}%` }
- }
- title={t("toolbar.addedWord", { word: word.text })}
- aria-label={t("toolbar.addedWord", { word: word.text })}
- onPointerDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- // Jump to the moment the added text sits on. The clip box
- // underneath would otherwise take this as a selection.
- e.stopPropagation();
- setCurrentTime(c.timelineStartSec + (word.startSec - c.sourceStartSec));
- }}
- />
- );
- })}
+ {(insertedWordsByClip.get(c.id) ?? []).map(
+ ({ word, atPct, widthPct, atSec }) => {
+ const left = atPct;
+ const width = widthPct;
+ return (
+ 0
+ ? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
+ : { left: `${left}%` }
+ }
+ title={t("toolbar.addedWord", { word: word.text })}
+ aria-label={t("toolbar.addedWord", { word: word.text })}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => {
+ // Jump to the moment the added text sits on. The clip box
+ // underneath would otherwise take this as a selection.
+ e.stopPropagation();
+ setCurrentTime(atSec);
+ }}
+ />
+ );
+ },
+ )}
{selected ? (
{
expect(cues[cues.length - 1].endMs).toBeCloseTo(6000 + (4 / 15) * 1000, 0);
});
+ it("gives the inserted word a line of its own, over the media it inserted", () => {
+ // Grouped with the recorded words beside it, it inherited their span — which is what
+ // left the inserted subtitle glued to the line before it.
+ const wait = deriveCaptionCues(withInsertion(), ON, {}).find((c) => c.text === "wait");
+ expect(wait).toBeDefined();
+ expect(wait?.startMs).toBe(2000);
+ expect(wait?.endMs).toBeCloseTo(2000 + (4 / 15) * 1000, 0);
+ });
+
it("leaves the cues before it exactly where they were", () => {
const before = deriveCaptionCues(doc(), ON, {});
const after = deriveCaptionCues(withInsertion(), ON, {});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index 49d8fa315..a8ac19be4 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -28,6 +28,7 @@ import {
placementRawSec,
type TranscriptPlacement,
} from "../timeline/aggregated-transcript";
+import { extensionSpanAtSource, isAddedWord } from "../timeline/clip-parts";
import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
@@ -50,10 +51,42 @@ export interface CaptionCue {
* the subtitle. Well clear of the annotation z-range, which counts up from 1. */
export const CAPTION_Z_INDEX_BASE = 100_000;
-function toCaptionSegments(transcript: AxcutTranscript): CaptionSegment[] {
+/** A caption word that carries whether it was ADDED, which decides where a line breaks. */
+type StreamWord = CaptionSegment & { added?: true };
+
+function toCaptionSegments(transcript: AxcutTranscript): StreamWord[] {
return transcript.words
.filter((word) => word.text.trim().length > 0)
- .map((word) => ({ startSec: word.startSec, endSec: word.endSec, text: word.text }));
+ .map((word) => ({
+ startSec: word.startSec,
+ endSec: word.endSec,
+ text: word.text,
+ ...(isAddedWord(word) ? { added: true as const } : {}),
+ }));
+}
+
+/**
+ * The stream cut into runs, each grouped into lines on its own, with every added word a run
+ * of one.
+ *
+ * An added word is spoken over its OWN media for its own stretch. Grouped with the recorded
+ * words beside it, its line inherits their span — which is what glued the inserted subtitle
+ * to the line before it and left it on screen at the wrong moment.
+ */
+function runsAroundAddedWords(stream: StreamWord[]): StreamWord[][] {
+ const runs: StreamWord[][] = [];
+ let run: StreamWord[] = [];
+ for (const word of stream) {
+ if (word.added) {
+ if (run.length > 0) runs.push(run);
+ runs.push([word]);
+ run = [];
+ continue;
+ }
+ run.push(word);
+ }
+ if (run.length > 0) runs.push(run);
+ return runs;
}
function segmentWordsAsCaptionSegments(
@@ -119,10 +152,17 @@ export function captionLinesForAsset(
: translatedWordStream(transcript, translations, settings.language);
if (stream.length === 0) return [];
- return polish(groupTimedCaptionWordsIntoLines(stream, minWords, maxWords));
+ return runsAroundAddedWords(stream).flatMap((run) =>
+ // An added word is already a line, and must stay the POINT in source time that it is:
+ // the polish pass gives a line a minimum span, and a span is exactly what this has
+ // none of. Its real length comes from the extension it is spoken over, downstream.
+ run.length === 1 && run[0].added
+ ? [{ startSec: run[0].startSec, endSec: run[0].endSec, text: run[0].text }]
+ : polish(groupTimedCaptionWordsIntoLines(run, minWords, maxWords)),
+ );
}
-function originalWordStream(transcript: AxcutTranscript): CaptionSegment[] {
+function originalWordStream(transcript: AxcutTranscript): StreamWord[] {
const words = toCaptionSegments(transcript);
if (words.length > 0) return words;
// A transcript with segments but no words (hand-authored / imported) still
@@ -174,7 +214,14 @@ export function sourceSpanToTimelineSpans(
const clipSourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
- if (e <= s) continue;
+ if (e <= s) {
+ // An added word occupies no source time at all — its line is a POINT here. Its
+ // moment is the extension inserted there, which is the one thing that knows how
+ // long it lasts.
+ const span = clip.parts ? extensionSpanAtSource(clip.parts, s) : null;
+ if (span) out.push(span);
+ continue;
+ }
// Through `placementRawSec`, never the subtraction it used to write here: a clip
// carrying an added word plays its media in pieces, and the seconds after the
// insertion sit further along the ruler than their distance from the clip's start.
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index 9abbbb2c8..06f83943d 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -1736,84 +1736,3 @@ describe("projectRawTimelineSecToPlayback with speed regions", () => {
expect(projectRawTimelineSecToPlayback([clip], [], 4, speed)).toBeCloseTo(4, 6);
});
});
-
-// ─── The pause an added word bought ──────────────────────────────
-// Created time only exists once playback honours it. These pin the one thing the record
-// is for: the stream really does stay on the held frame, and the film really is longer.
-
-// ─── Segments across an extension ───────────────────────────────────────────
-// The whole point of the layer, seen from the one funnel every consumer goes through: an
-// added word becomes a SEGMENT of its own, naming media rather than a behaviour, and the
-// recording on either side keeps the source window it always had.
-
-describe("resolvePlaybackSegments across an added word", () => {
- const clip = makeClip({
- id: "c1",
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- });
- const transcripts = [
- {
- assetId: "a1",
- language: "en",
- segments: [],
- words: [
- { id: "w1", segmentId: "s", startSec: 1, endSec: 2, text: "said" },
- {
- id: "synth_1",
- segmentId: "s",
- startSec: 4,
- endSec: 4,
- text: "really",
- source: "synth" as const,
- },
- ],
- },
- ];
-
- it("puts the extension between the halves, as its own segment", () => {
- const segs = resolvePlaybackSegments([clip], [], transcripts);
- expect(segs.map((s) => s.extensionWordId ?? null)).toEqual([null, "synth_1", null]);
- // The recording resumes at the source second it stopped on — nothing moved.
- expect(segs[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 4 });
- expect(segs[2]).toMatchObject({ sourceStartSec: 4, sourceEndSec: 10 });
- });
-
- it("gives the extension its own media window, starting at zero", () => {
- const [, ext] = resolvePlaybackSegments([clip], [], transcripts);
- expect(ext.sourceStartSec).toBe(0);
- expect(ext.sourceEndSec).toBeCloseTo(6 / 15, 6);
- expect(ext.timelineEndSec - ext.timelineStartSec).toBeCloseTo(6 / 15, 6);
- });
-
- it("lays the programme out end to end, with no gap and no overlap", () => {
- const segs = resolvePlaybackSegments([clip], [], transcripts);
- expect(segs[0].timelineStartSec).toBe(0);
- for (const [i, seg] of segs.slice(0, -1).entries()) {
- expect(seg.timelineEndSec).toBeCloseTo(segs[i + 1].timelineStartSec, 9);
- }
- });
-
- it("still cuts the recording, and never the extension", () => {
- // A trim is anchored in the RECORDING's seconds; an extension has none, so no trim
- // can name it. Cutting source 5..6 shortens the half after the added word only.
- const trims = [makeTrim({ id: "t1", clipId: "c1", startSec: 5, endSec: 6 })];
- const segs = resolvePlaybackSegments([clip], trims, transcripts);
- expect(segs.filter((s) => s.extensionWordId).length).toBe(1);
- const recorded = segs.filter((s) => !s.extensionWordId);
- expect(recorded.map((s) => [s.sourceStartSec, s.sourceEndSec])).toEqual([
- [0, 4],
- [4, 5],
- [6, 10],
- ]);
- });
-
- it("behaves exactly as before when no word was added", () => {
- const plain = resolvePlaybackSegments([clip], []);
- expect(plain).toHaveLength(1);
- expect(plain[0]).toMatchObject({ id: "c1", sourceStartSec: 0, sourceEndSec: 10 });
- });
-});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 3d724c425..9fa9330f4 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -8,15 +8,11 @@ import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "
/**
* What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film.
*
- * `extensionWordId` names the ONE case where the media is not the clip's own asset — the
- * extension an added word is spoken over, whose file is derived from the word. It says which
- * MEDIA to play, not what behaviour to perform, and that is the whole difference from the
- * `heldSec` this replaces: a reader resolves it to a file like any other, rather than
- * carrying a special case for a stretch with nothing to decode.
- *
- * Derived-only, never on `clipSchema`, so nothing can write one to disk.
+ * A plain clip, deliberately. An extension is one too by the time it gets here — `withExtensions`
+ * resolved it into a clip on its own asset upstream — so nothing below this line carries a
+ * notion of inserted media, and the trim arithmetic is the same it was before insertions existed.
*/
-export type PlaybackSegment = AxcutClip & { extensionWordId?: string };
+export type PlaybackSegment = AxcutClip;
import { clipParts, partsLengthSec } from "../timeline/clip-parts";
import { type Interval, subtractInterval } from "../timeline/intervals";
@@ -193,12 +189,8 @@ export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
- /** The transcripts, so a clip carrying added words splits into its parts. Omitted, a
- * clip is one recording part and this behaves exactly as it did before extensions. */
- transcripts: readonly AxcutTranscript[] = [],
): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const wordsByAsset = new Map(transcripts.map((t) => [t.assetId, t.words]));
const result: PlaybackSegment[] = [];
let timelineCursor = 0;
for (const clip of ordered) {
@@ -214,54 +206,24 @@ export function resolvePlaybackSegments(
timelineCursor += dur;
continue;
}
- // The insertion layer, and the only place this function meets it. Below here a part
- // is media with a source window, and the trim arithmetic is the same it always was.
- const parts = clipParts(clip, wordsByAsset.get(clip.assetId) ?? []);
- // Named once the clip's own pieces are known: a clip keeps its id only when it yields
- // exactly ONE piece of recording and nothing else. Naming from the PART count instead
- // missed the split a trim makes inside a single part.
- const clipSegments: PlaybackSegment[] = [];
- for (const part of parts) {
- if (part.kind === "extension") {
- // No trim can name it: a trim is anchored in the RECORDING's seconds, and an
- // extension has none. Its own media starts at zero and runs its full length.
- clipSegments.push({
- ...clip,
- id: `${clip.id}__ext_${part.wordId}`,
- extensionWordId: part.wordId,
- sourceStartSec: 0,
- sourceEndSec: part.timelineEndSec - part.timelineStartSec,
- timelineStartSec: timelineCursor,
- timelineEndSec: timelineCursor + (part.timelineEndSec - part.timelineStartSec),
- });
- timelineCursor += part.timelineEndSec - part.timelineStartSec;
- continue;
- }
- let kept: Interval[] = [{ startSec: part.sourceStartSec, endSec: part.sourceEndSec }];
- for (const trim of trimRanges) {
- if (!trimAppliesToClip(trim, clip)) continue;
- kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
- }
- for (const piece of kept) {
- const dur = piece.endSec - piece.startSec;
- if (dur <= 0) continue;
- clipSegments.push({
- ...clip,
- sourceStartSec: piece.startSec,
- sourceEndSec: piece.endSec,
- timelineStartSec: timelineCursor,
- timelineEndSec: timelineCursor + dur,
- });
- timelineCursor += dur;
- }
- }
- const recorded = clipSegments.filter((seg) => seg.extensionWordId === undefined);
- let n = 0;
- for (const seg of clipSegments) {
- if (seg.extensionWordId !== undefined) continue;
- seg.id = recorded.length === 1 ? clip.id : `${clip.id}_seg${++n}`;
+ let kept: Interval[] = [{ startSec: clip.sourceStartSec, endSec: sourceEnd }];
+ for (const trim of trimRanges) {
+ if (!trimAppliesToClip(trim, clip)) continue;
+ kept = subtractInterval(kept, { startSec: trim.startSec, endSec: trim.endSec });
}
- result.push(...clipSegments);
+ const pieces = kept.filter((piece) => piece.endSec > piece.startSec);
+ pieces.forEach((piece, i) => {
+ const dur = piece.endSec - piece.startSec;
+ result.push({
+ ...clip,
+ id: pieces.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
+ sourceStartSec: piece.startSec,
+ sourceEndSec: piece.endSec,
+ timelineStartSec: timelineCursor,
+ timelineEndSec: timelineCursor + dur,
+ });
+ timelineCursor += dur;
+ });
}
return result;
}
diff --git a/src/lib/ai-edition/timeline/clip-parts.test.ts b/src/lib/ai-edition/timeline/clip-parts.test.ts
index d26495167..3ac97d4ce 100644
--- a/src/lib/ai-edition/timeline/clip-parts.test.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.test.ts
@@ -2,16 +2,18 @@
// start where the clip starts, and no stored source coordinate moved to achieve it.
import { describe, expect, it } from "vitest";
-import type { AxcutClip, AxcutWord } from "../schema";
+import { resolvePlaybackSegments } from "../document/timeline";
+import type { AxcutClip, AxcutDocument, AxcutWord } from "../schema";
import {
+ baseClipId,
clipParts,
- clipsWithExtensions,
- extensionAssetId,
extensionAt,
+ extensionClipPath,
extensionDurationSec,
partsLengthSec,
partsRawSec,
partsSourceSec,
+ withExtensions,
} from "./clip-parts";
const CLIP: AxcutClip = {
@@ -115,34 +117,85 @@ describe("extensionDurationSec", () => {
});
});
-// ─── What a player sees ─────────────────────────────────────────────────────
-// The DOM preview already plays several clips over several files and swaps at the boundary.
-// An extension is exactly that, so it is handed a clip rather than taught a new case.
+// ─── What everything that RENDERS sees ──────────────────────────────────────
+// Every mapping downstream — the ruler, the native decoder swap, the exporter, the DOM
+// player — is built on "a clip is an uninterrupted shift from source to ruler". So the
+// interruption is resolved into the shape they already handle, and below this line an
+// extension is simply a clip that plays a generated file.
-describe("clipsWithExtensions", () => {
- const transcripts = [{ assetId: "a1", words: [added("synth_1", 4, "really")] }];
+const DOC = (words: AxcutWord[], clips: AxcutClip[] = [CLIP]): AxcutDocument =>
+ ({
+ assets: [
+ {
+ id: "a1",
+ kind: "video",
+ label: "take",
+ originalPath: "C:/rec/take.mp4",
+ video: { width: 1920, height: 1080, fps: 30 },
+ cameraTrack: null,
+ },
+ ],
+ transcripts: [{ assetId: "a1", words }],
+ timeline: { clips, trimRanges: [] },
+ }) as unknown as AxcutDocument;
+
+describe("withExtensions", () => {
+ const doc = DOC([added("synth_1", 4, "really")]);
+
+ it("makes the extension a clip on an asset of its own", () => {
+ const out = withExtensions(doc);
+ expect(out.timeline.clips.map((c) => c.assetId)).toEqual(["a1", "ext:synth_1", "a1"]);
+ expect(out.timeline.clips[1]).toMatchObject({ sourceStartSec: 0 });
+ expect(out.timeline.clips[1].sourceEndSec).toBeCloseTo(6 / 15, 6);
+ });
- it("splices the extension in as a clip of its own, on its own media", () => {
- const out = clipsWithExtensions([CLIP], transcripts);
- expect(out.map((c) => c.assetId)).toEqual(["a1", "ext:synth_1", "a1"]);
- expect(out[1]).toMatchObject({ sourceStartSec: 0 });
- expect(out[1].sourceEndSec).toBeCloseTo(6 / 15, 6);
+ it("gives that asset the file the generator writes, so the decoder can open it", () => {
+ const asset = withExtensions(doc).assets.find((a) => a.id === "ext:synth_1");
+ expect(asset?.originalPath).toBe(
+ extensionClipPath("C:/rec/take.mp4", "synth_1", extensionDurationSec("really")),
+ );
+ // The recording's geometry: the generated file was made to match it.
+ expect(asset?.video).toMatchObject({ width: 1920, height: 1080 });
});
- it("lays them end to end, so the player's clock never sees a gap", () => {
- const out = clipsWithExtensions([CLIP], transcripts);
+ it("lays them end to end, so no mapping downstream sees a gap", () => {
+ const out = withExtensions(doc).timeline.clips;
expect(out[0].timelineStartSec).toBe(0);
for (const [i, clip] of out.slice(0, -1).entries()) {
expect(clip.timelineEndSec).toBeCloseTo(out[i + 1].timelineStartSec, 9);
}
});
- it("returns the clips unchanged when no word was added", () => {
- expect(clipsWithExtensions([CLIP], [{ assetId: "a1", words: [] }])).toEqual([CLIP]);
+ it("is the same document when no word was added", () => {
+ const plain = DOC([]);
+ expect(withExtensions(plain)).toBe(plain);
+ });
+
+ it("is idempotent — deriving twice must not split the halves again", () => {
+ const once = withExtensions(doc);
+ expect(withExtensions(once)).toBe(once);
});
- it("gives the extension an id no real asset can collide with", () => {
- expect(extensionAssetId("synth_1")).toBe("ext:synth_1");
+ it("keeps every piece answering to the name a trim knows the clip by", () => {
+ const ids = withExtensions(doc).timeline.clips.map((c) => baseClipId(c.id));
+ expect(ids).toEqual(["c1", "c1", "c1"]);
+ });
+
+ it("still cuts the recording, and never the extension", () => {
+ // A trim is anchored in the RECORDING's seconds; an extension has none. Cutting
+ // source 5..6 shortens the half AFTER the added word and nothing else.
+ const clips = withExtensions(doc).timeline.clips;
+ const trims = [
+ { id: "t1", clipId: "c1", assetId: "a1", startSec: 5, endSec: 6 },
+ ] as unknown as Parameters[1];
+ const segs = resolvePlaybackSegments(clips, trims);
+ const recorded = segs.filter((s) => !s.assetId.startsWith("ext:"));
+ expect(recorded.map((s) => [s.sourceStartSec, s.sourceEndSec])).toEqual([
+ [0, 4],
+ [4, 5],
+ [6, 10],
+ ]);
+ expect(segs.filter((s) => s.assetId.startsWith("ext:"))).toHaveLength(1);
});
});
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
index b9aef8375..c7617dad3 100644
--- a/src/lib/ai-edition/timeline/clip-parts.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -13,7 +13,7 @@
// with a source window and a place on the timeline, and the plain arithmetic every reader
// used before insertions works again.
-import type { AxcutClip, AxcutWord } from "../schema";
+import type { AxcutAsset, AxcutClip, AxcutDocument, AxcutWord } from "../schema";
/** How fast a synthesized voice will be assumed to speak, in characters per second.
*
@@ -128,48 +128,111 @@ export function extensionClipPath(assetPath: string, wordId: string, durationSec
return `${dir}${sep}${EXTENSIONS_DIR}${sep}${wordId}_${Math.round(durationSec * 1000)}.mp4`;
}
-/** The id an extension's media answers to, so a player that keys sources by asset finds it
- * without knowing what an extension is. Prefixed rather than opaque: it can never collide
- * with a real asset id, and it says what it is in a log line. */
+/** Marks every id this module derives. Never produced by the writers, so `withExtensions`
+ * can recognise a document it has already derived — and a log line says what it is. */
+export const EXTENSION_ID_PREFIX = "ext:";
+
+/** The id an extension's media answers to. */
export function extensionAssetId(wordId: string): string {
- return `ext:${wordId}`;
+ return `${EXTENSION_ID_PREFIX}${wordId}`;
+}
+
+/** Separates a derived piece from the clip it was cut out of. Double, so it cannot collide
+ * with the single underscores every generated clip id already carries. */
+const PIECE = "__";
+
+/** The stored clip a derived piece came from. A trim names a clip by id, and both halves of
+ * a split clip are still that clip — so both must answer to its name. */
+export function baseClipId(clipId: string): string {
+ const at = clipId.indexOf(PIECE);
+ return at < 0 ? clipId : clipId.slice(0, at);
}
/**
- * The clips a PLAYER should see: each extension spliced in as a clip of its own.
+ * The document as everything that RENDERS it should see it: an extension is a clip, on an
+ * asset, with a file.
+ *
+ * This is the whole insertion layer, and it exists because of one property every mapping in
+ * this codebase is built on — a clip is an UNINTERRUPTED shift between its source seconds
+ * and the ruler. `timelineMap`, the native `setActiveClip`, the exporter and the DOM player
+ * all assume it. A clip interrupted by generated media breaks that assumption in every one
+ * of them at once, which is why teaching them each about extensions never converged: the
+ * fix was eight special cases, and it was still one bug.
+ *
+ * So the interruption is resolved HERE, once, into the shape they already handle. Below this
+ * line there are no extensions — only clips, some of which happen to play a generated file.
*
- * The DOM preview already knows how to play several clips over several files and swap at the
- * boundary. An extension is exactly that — a different file, played for a stretch — so it is
- * handed one rather than taught a new case. `resolvePlaybackSegments` does the same thing
- * one layer down, with the trims applied; this is the untrimmed list the player's own clock
- * maps against.
+ * Derived, never stored: one direction, nothing to reconcile. The stored clip keeps its own
+ * single identity and its own source window, and the words remain the only truth about what
+ * was added.
*/
-export function clipsWithExtensions(
- clips: readonly AxcutClip[],
- transcripts: ReadonlyArray<{ assetId: string; words: readonly AxcutWord[] }>,
-): AxcutClip[] {
- const wordsByAsset = new Map(transcripts.map((t) => [t.assetId, t.words]));
- return clips.flatMap((clip) =>
- clipParts(clip, wordsByAsset.get(clip.assetId) ?? []).map((part) =>
- part.kind === "extension"
- ? {
- ...clip,
- id: `${clip.id}__ext_${part.wordId}`,
- assetId: extensionAssetId(part.wordId),
- sourceStartSec: 0,
- sourceEndSec: part.timelineEndSec - part.timelineStartSec,
- timelineStartSec: part.timelineStartSec,
- timelineEndSec: part.timelineEndSec,
- }
- : {
+export function withExtensions(document: AxcutDocument): AxcutDocument {
+ // Already derived — deriving again would split the halves a second time, because the
+ // added word sits exactly on the first half's end.
+ if (document.assets.some((a) => a.id.startsWith(EXTENSION_ID_PREFIX))) return document;
+
+ const wordsByAsset = new Map(document.transcripts.map((t) => [t.assetId, t.words]));
+ const assetById = new Map(document.assets.map((a) => [a.id, a]));
+ const assets = [...document.assets];
+
+ const clips = document.timeline.clips.flatMap((clip): AxcutClip[] => {
+ const parts = clipParts(clip, wordsByAsset.get(clip.assetId) ?? []);
+ if (parts.length < 2) return [clip];
+ const source = assetById.get(clip.assetId);
+ let piece = 0;
+ return parts.flatMap((part): AxcutClip[] => {
+ if (part.kind === "recording") {
+ piece += 1;
+ return [
+ {
...clip,
+ // The first piece keeps the clip's own name so anything holding it still
+ // finds something; `baseClipId` is what makes the rest answer to it too.
+ id: piece === 1 ? clip.id : `${clip.id}${PIECE}r${piece}`,
sourceStartSec: part.sourceStartSec,
sourceEndSec: part.sourceEndSec,
timelineStartSec: part.timelineStartSec,
timelineEndSec: part.timelineEndSec,
},
- ),
- );
+ ];
+ }
+ // Nothing to name the file after, so nothing to play: the clip simply stays short
+ // of what the word asked for, rather than pointing at a path that cannot exist.
+ if (!source?.originalPath) return [];
+ const durationSec = part.timelineEndSec - part.timelineStartSec;
+ const id = extensionAssetId(part.wordId);
+ if (!assetById.has(id)) {
+ const asset: AxcutAsset = {
+ id,
+ kind: "video",
+ label: part.text.trim().slice(0, 40) || part.wordId,
+ originalPath: extensionClipPath(source.originalPath, part.wordId, durationSec),
+ durationSec,
+ // The recording's geometry, because the generated file was made to match it.
+ video: source.video,
+ cameraTrack: null,
+ };
+ assetById.set(id, asset);
+ assets.push(asset);
+ }
+ return [
+ {
+ ...clip,
+ id: `${clip.id}${PIECE}ext_${part.wordId}`,
+ assetId: id,
+ // Authored against the recording's framing, which this is not.
+ cropRegion: undefined,
+ sourceStartSec: 0,
+ sourceEndSec: durationSec,
+ timelineStartSec: part.timelineStartSec,
+ timelineEndSec: part.timelineEndSec,
+ },
+ ];
+ });
+ });
+
+ if (assets.length === document.assets.length) return document;
+ return { ...document, assets, timeline: { ...document.timeline, clips } };
}
/** Hidden, because it is derived: deleting it costs nothing but a regeneration. */
@@ -240,3 +303,31 @@ export function extensionAt(parts: readonly ClipPart[], rawSec: number): string
}
return null;
}
+
+/**
+ * The ruler span of the extension inserted at this source second, if there is one.
+ *
+ * The answer to a question source time cannot express: an added word occupies no source
+ * seconds, so anything measuring it there measures zero. Its span lives on its part.
+ */
+export function extensionSpanAtSource(
+ parts: readonly ClipPart[],
+ sourceSec: number,
+): { startSec: number; endSec: number } | null {
+ const played = recordings(parts);
+ for (const [i, part] of parts.entries()) {
+ if (part.kind !== "extension") continue;
+ // The recording part that ENDS where this extension begins is the one whose last
+ // source second the word was typed after.
+ const before =
+ parts
+ .slice(0, i)
+ .filter((p) => p.kind === "recording")
+ .at(-1) ?? played[0];
+ const at = before ? before.sourceEndSec : sourceSec;
+ if (Math.abs(at - sourceSec) < 1e-6) {
+ return { startSec: part.timelineStartSec, endSec: part.timelineEndSec };
+ }
+ }
+ return null;
+}
diff --git a/src/lib/ai-edition/timeline/trim-mapping.ts b/src/lib/ai-edition/timeline/trim-mapping.ts
index 0dee56d7d..62a79b4ce 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.ts
@@ -15,6 +15,7 @@
// had no answer and each caller invented its own. See `trimAppliesToClip`.
import type { AxcutClip, AxcutTrimRange } from "../schema";
+import { baseClipId } from "./clip-parts";
import { type CoalescedSpan, ventilateSpanAcrossClips } from "./region-ventilation";
import { coalesceByIdentity, regionIdentityKey } from "./timelineMap";
@@ -46,7 +47,10 @@ export function trimAppliesToClip(
trim: TrimAnchor,
clip: Pick,
): boolean {
- if (trim.clipId !== undefined) return trim.clipId === clip.id;
+ // `baseClipId`, not the id itself: an insertion splits a clip into pieces that are all
+ // still the clip the trim was authored on, and matching the piece would have left the
+ // cut applied to the first half only.
+ if (trim.clipId !== undefined) return trim.clipId === baseClipId(clip.id);
return trim.assetId === clip.assetId;
}
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 1832851be..ef6743476 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -40,7 +40,7 @@ import {
import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
-import { extensionClipPath } from "@/lib/ai-edition/timeline/clip-parts";
+import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
@@ -521,22 +521,24 @@ function clipAssetIsResolvable(
* Returns `PlaybackSegment[]`, not `AxcutClip[]`: a held segment carries `heldSec`, and
* widening it away here is what kept the pause from ever reaching the compositor. Every
*/
-export function resolveVisibleClips(document: AxcutDocument): PlaybackSegment[] {
+export function resolveVisibleClips(rawDocument: AxcutDocument): PlaybackSegment[] {
+ // An extension is a clip on its own asset from here down. Idempotent, so a caller that
+ // already derived the document pays nothing.
+ const document = withExtensions(rawDocument);
const assetById = new Map(document.assets.map((a) => [a.id, a]));
- return resolvePlaybackSegments(
- document.timeline.clips,
- document.timeline.trimRanges,
- document.transcripts,
- )
+ return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
.sort((a, b) => a.timelineStartSec - b.timelineStartSec)
.filter((clip) => clipAssetIsResolvable(clip, assetById));
}
/** Serialize a document into a {@link SceneDescription}. Pure — no per-frame math. */
export function buildSceneDescription(
- document: AxcutDocument,
+ rawDocument: AxcutDocument,
webcamSourceSize: { width: number; height: number } | null = null,
): SceneDescription {
+ // Once, at the top: every list below — the assets, the clips, the raw layout the regions
+ // are projected through — has to agree about what the film contains.
+ const document = withExtensions(rawDocument);
const settings = getEditorSettings(document);
const assetById = new Map(document.assets.map((a) => [a.id, a]));
@@ -703,25 +705,6 @@ export function buildSceneDescription(
const clips: CompositorClipInput[] = visibleClips.flatMap((clip) => {
const asset = assetById.get(clip.assetId);
if (!asset?.originalPath) return [];
- // An extension plays GENERATED media, not the recording's. Everything else about the
- // entry is the clip's — same webcam pairing, same audio expectation — because that is
- // the point of making it a file rather than a behaviour.
- if (clip.extensionWordId) {
- return [
- {
- screenPath: extensionClipPath(
- asset.originalPath,
- clip.extensionWordId,
- clip.sourceEndSec ?? 0,
- ),
- webcamPath: "",
- sourceStartSec: 0,
- sourceEndSec: clip.sourceEndSec ?? 0,
- webcamOffsetSec: 0,
- hasAudio: true,
- },
- ];
- }
const camera = assetCameraSource(asset);
// ponytail: `asset.audio` exists in the schema but the probe pipeline never
// populates it, so there is no per-asset "is there a track?" signal to read
@@ -809,7 +792,9 @@ export function buildSceneDescription(
const captionAspect = outputDims.height > 0 ? outputDims.width / outputDims.height : 16 / 9;
const captionSettings = getCaptionSettings(document, captionAspect);
const captionRegions = captionCuesToTextRegions(
- deriveCaptionCues(document, captionSettings, getCaptionTranslations(document)),
+ // `rawDocument`: the caption path resolves the insertion itself, through the clip's
+ // parts, and handing it the derived clips would ask the same question twice.
+ deriveCaptionCues(rawDocument, captionSettings, getCaptionTranslations(document)),
captionSettings,
captionAspect,
);
From e2c1147e9b199a58627437859e4a81374cf4494b Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 16:04:08 +0200
Subject: [PATCH 100/113] feat(insertions): an insertion is a clip, stored as
one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A word typed into the transcript now cuts its clip in two and puts a generated clip between
the halves. Not derived at read time, as it was an hour ago — stored, because a derived view
means two answers to "what does the film contain" and the readers were free to pick either.
The generated clip is a clip like any other. It can be moved, cropped, edited and deleted
from the timeline, and none of that needed a line of code: it has an asset with a real file,
a source window, and a transcript holding its one word at 0→duration. So the pane, the
captions, the cue highlight, the native decoder swap and the exporter all read it through the
paths they already had.
What that deletes: `withExtensions`, `clipParts`, `partsRawSec`, `partsSourceSec`,
`extensionAt`, `extensionSpanAtSource`, `withClipsSizedToParts`, `parts` on
`TranscriptPlacement`, `recordingPlacements`, the caption line-splitting, the amber mark and
its arithmetic, the insert/remove word pair in `transcript.ts`, and the re-insertion half of
`carryOverWordEdits` — re-transcribing a recording no longer has to put insertions back,
because it never touches them.
The delicate part is the inverse, and it is the part I would not have written unprompted:
deleting the generated clip has to put the halves back together. It lives in `removeClip`,
the single mutator for taking a clip away, so both delete paths get it. The guard is
structural — same media, source ranges that meet, same crop — and refuses when the user has
since made the halves two clips he means to keep. Both directions are mutation-tested: forcing
the join breaks the crop case, refusing it breaks the round trip.
ponytail: nothing else in the app can produce two contiguous clips of one media, so this can
only ever undo an insertion. Give the cut a marker the day a razor tool lands.
`baseClipId` is gone with it. Two clips sharing a name prefix was a hidden coupling that
would have outlived the move it was meant to survive. The split re-anchors its rows instead:
each one is copied onto both halves and `rederiveRegionMs` — which already clamps a region to
its clip's window and drops what has nothing left — decides which survive. A zoom drawn across
the moment a word was typed into survives on both sides, which is what it meant.
The generated media is amber on the ruler.
---
electron/media/extensionClip.ts | 58 ++--
src/cli/CliExportRunner.tsx | 4 +-
src/components/ai-edition/ExportDialog.tsx | 4 +-
.../ai-edition/NativeCompositorOverlay.tsx | 30 +-
src/components/ai-edition/NewEditorShell.tsx | 21 +-
src/components/ai-edition/RightPanes.tsx | 6 +-
.../ai-edition/v4/EditorShellV4.module.css | 45 +--
src/components/ai-edition/v4/V4Timeline.tsx | 72 +---
src/lib/ai-edition/captions/captions.test.ts | 65 ++--
src/lib/ai-edition/captions/cues.ts | 58 +---
src/lib/ai-edition/document/insertion.test.ts | 183 ++++++++++
src/lib/ai-edition/document/insertion.ts | 292 ++++++++++++++++
src/lib/ai-edition/document/timeline.ts | 99 ++++--
.../ai-edition/document/transcript.test.ts | 36 +-
src/lib/ai-edition/document/transcript.ts | 231 ++-----------
.../timeline/aggregated-transcript.ts | 45 +--
.../ai-edition/timeline/clip-parts.test.ts | 251 ++------------
src/lib/ai-edition/timeline/clip-parts.ts | 318 ++----------------
src/lib/ai-edition/timeline/trim-mapping.ts | 6 +-
src/native/sceneDescription.ts | 15 +-
20 files changed, 713 insertions(+), 1126 deletions(-)
create mode 100644 src/lib/ai-edition/document/insertion.test.ts
create mode 100644 src/lib/ai-edition/document/insertion.ts
diff --git a/electron/media/extensionClip.ts b/electron/media/extensionClip.ts
index 5022fc5b7..d7641c25f 100644
--- a/electron/media/extensionClip.ts
+++ b/electron/media/extensionClip.ts
@@ -19,12 +19,7 @@
import { spawn } from "node:child_process";
import { access, mkdir } from "node:fs/promises";
import path from "node:path";
-import type { AxcutWord } from "../../src/lib/ai-edition/schema";
-import {
- extensionClipPath,
- extensionDurationSec,
- isAddedWord,
-} from "../../src/lib/ai-edition/timeline/clip-parts";
+import { isGeneratedAssetId } from "../../src/lib/ai-edition/document/insertion";
import { resolveFfmpeg } from "./audioPeaks";
export interface ExtensionClipSpec {
@@ -94,42 +89,39 @@ export function extensionClipArgs(spec: ExtensionClipSpec, outPath: string): str
}
/**
- * Every extension the document's words call for, generated if it is not already there.
+ * Every insertion's media, generated if it is not already there.
*
- * Called on SAVE, which is the only moment the main process — the one that can spawn ffmpeg
- * — sees the document. Idempotent by name, so a save that adds nothing costs one `stat` per
- * added word. A failure is logged and swallowed: the edit is not lost because a derived file
- * could not be written, and the segment renders black until the next save regenerates it.
+ * Read off the ASSETS, not the words: an insertion is a clip on an asset that already knows
+ * its own path, its own length and its own geometry. There is nothing to derive here and
+ * nothing to agree with the renderer about beyond the path it stored.
+ *
+ * Called on SAVE, the only moment the main process — the one that can spawn ffmpeg — sees
+ * the document. Idempotent by name, so a save that adds nothing costs one `stat` per
+ * insertion. A failure is logged and swallowed: an edit is not lost because a derived file
+ * could not be written, and the clip renders black until the next save regenerates it.
*/
export async function ensureDocumentExtensions(document: {
assets: ReadonlyArray<{
id: string;
originalPath?: string;
+ durationSec?: number;
video?: { width: number; height: number; fps: number };
}>;
- transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>;
}): Promise {
- for (const transcript of document.transcripts) {
- const asset = document.assets.find((a) => a.id === transcript.assetId);
- if (!asset?.originalPath) continue;
- for (const word of transcript.words) {
- if (!isAddedWord(word)) continue;
- const durationSec = extensionDurationSec(word.text);
- if (durationSec <= 0) continue;
- const outPath = extensionClipPath(asset.originalPath, word.id, durationSec);
- try {
- await ensureExtensionClip(
- {
- durationSec,
- fps: asset.video?.fps ?? 0,
- width: asset.video?.width ?? 0,
- height: asset.video?.height ?? 0,
- },
- outPath,
- );
- } catch (error) {
- console.error(`[extension] ${word.id}: ${(error as Error).message}`);
- }
+ for (const asset of document.assets) {
+ if (!isGeneratedAssetId(asset.id) || !asset.originalPath || !asset.durationSec) continue;
+ try {
+ await ensureExtensionClip(
+ {
+ durationSec: asset.durationSec,
+ fps: asset.video?.fps ?? 0,
+ width: asset.video?.width ?? 0,
+ height: asset.video?.height ?? 0,
+ },
+ asset.originalPath,
+ );
+ } catch (error) {
+ console.error(`[insertion] ${asset.id}: ${(error as Error).message}`);
}
}
}
diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx
index 6c29b9975..9a7f1b590 100644
--- a/src/cli/CliExportRunner.tsx
+++ b/src/cli/CliExportRunner.tsx
@@ -24,7 +24,6 @@ import { applyProbedDuration } from "@/lib/ai-edition/document/timeline";
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
-import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { DEFAULT_ZOOM_DEPTH, ZOOM_DEPTH_SCALES } from "@/lib/ai-edition/timeline/zoom-scale";
import { buildAutoZoomSuggestions } from "@/lib/ai-edition/timeline/zoom-suggestions";
@@ -79,8 +78,7 @@ function replaceExtension(filePath: string, newExtension: string): string {
/** Mirrors ExportDialog.buildNativeClipList: trim-narrowed visible clips mapped
* onto the native multiclip contract. Kept in lock-step with
* buildSceneDescription so export and scene agree on the clip stream. */
-function buildNativeClipList(rawDocument: AxcutDocument): CompositorClipInput[] {
- const axcutDocument = withExtensions(rawDocument);
+function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[] {
const assetById = new Map(axcutDocument.assets.map((asset) => [asset.id, asset]));
return resolveVisibleClips(axcutDocument).flatMap((clip) => {
const asset = assetById.get(clip.assetId);
diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx
index 97da5286a..5dff50acf 100644
--- a/src/components/ai-edition/ExportDialog.tsx
+++ b/src/components/ai-edition/ExportDialog.tsx
@@ -21,7 +21,6 @@ import {
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
-import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import {
type ExportFormat,
@@ -81,8 +80,7 @@ function revealExportedFile(filePath: string): void {
* `NativeCompositorOverlay`, so export/preview/scene all see the exact same clip stream),
* each with its asset's screen file + camera file (falls back to the screen when a clip has
* no camera — the no-webcam layout is a later step) and its source trim. */
-function buildNativeClipList(rawDocument: AxcutDocument): CompositorClipInput[] {
- const document = withExtensions(rawDocument);
+function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
return resolveVisibleClips(document).flatMap((clip) => {
const asset = assetById.get(clip.assetId);
diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx
index 5bfa32c50..e4af64102 100644
--- a/src/components/ai-edition/NativeCompositorOverlay.tsx
+++ b/src/components/ai-edition/NativeCompositorOverlay.tsx
@@ -4,7 +4,6 @@ import { noteUiProbeClipSwitch } from "@/lib/ai-edition/perf/uiFrameProbe";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
-import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
pushAllNativeParams,
@@ -70,18 +69,13 @@ export function NativeCompositorOverlay() {
// Sans ça (ancien `resolveNativePlaybackPosition(nativeClips, currentTimeSec)`), un playhead
// RAW lu contre des clips compactés désignait le mauvais clip après un trim → mauvaise caméra
// + décalage écran/cam.
- // The document the NATIVE side plays: an extension is a clip on its own asset, with its
- // own file. Everything below — the compacted segments, the raw layout they are placed
- // against, the asset the decoder is pointed at — has to be read from the same one, or
- // they disagree about what the film contains.
- const playedDocument = useMemo(() => (document ? withExtensions(document) : null), [document]);
const nativeClips = useMemo(() => {
- if (!playedDocument) return [];
- return resolveVisibleClips(playedDocument);
- }, [playedDocument]);
+ if (!document) return [];
+ return resolveVisibleClips(document);
+ }, [document]);
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, nativeClips, playedDocument?.timeline.clips ?? []),
- [nativeClips, currentTimeSec, playedDocument],
+ () => resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
+ [nativeClips, currentTimeSec, document],
);
const activeClip = activePosition?.clip ?? null;
@@ -197,7 +191,7 @@ export function NativeCompositorOverlay() {
useEffect(() => {
if (
viewId === null ||
- !playedDocument ||
+ !document ||
!activeClipId ||
!activeClip ||
activeClipIndex === null ||
@@ -208,7 +202,7 @@ export function NativeCompositorOverlay() {
if (previousActiveClipIdRef.current === activeClipId) {
return;
}
- const asset = playedDocument?.assets.find((candidate) => candidate.id === activeClip.assetId);
+ const asset = document.assets.find((candidate) => candidate.id === activeClip.assetId);
if (!asset?.originalPath) {
return;
}
@@ -254,15 +248,7 @@ export function NativeCompositorOverlay() {
previousActiveClipIdRef.current = null;
}
});
- }, [
- viewId,
- playedDocument,
- activeClipId,
- activeClip,
- activeClipIndex,
- activeSourceTimeSec,
- playing,
- ]);
+ }, [viewId, document, activeClipId, activeClip, activeClipIndex, activeSourceTimeSec, playing]);
if (!ready) {
return null;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 5622e9990..416ea8022 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -32,7 +32,6 @@ import {
import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
-import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import {
@@ -231,10 +230,6 @@ export function NewEditorShell() {
document?.assets.find((a) => a.id === document.project.primaryAssetId)?.originalPath ?? null;
void primaryAssetPath;
const clips: AxcutClip[] = document?.timeline.clips ?? [];
- // The document as the PLAYER sees it: an extension is a clip on its own asset. The ruler
- // deliberately keeps `clips` above — a clip does not become three on screen because a
- // word was typed into it.
- const playedDocument = useMemo(() => (document ? withExtensions(document) : null), [document]);
const visibleClips = useMemo(() => (document ? resolveVisibleClips(document) : []), [document]);
const hasProject = Boolean(document);
const hasAsset = projectId !== null && (document?.assets.length ?? 0) > 0;
@@ -359,12 +354,12 @@ export function NewEditorShell() {
}, [promptUnsaved, saveDocument]);
const videoSources = useMemo(() => {
- if (!playedDocument) return [];
- // Every asset, extensions included — they are assets by the time they get here, each
- // with a real path. A file the save has not written yet simply fails to load, and the
- // player reports it the way it reports any unreadable source: the edit stands, the
- // picture catches up on the next save.
- return playedDocument.assets.map((asset) => ({
+ if (!document) return [];
+ // Every asset, insertions included — an insertion is a clip on an asset with a real
+ // path. A file the save has not written yet simply fails to load, and the player
+ // reports it the way it reports any unreadable source: the edit stands, the picture
+ // catches up on the next save.
+ return document.assets.map((asset) => ({
id: asset.id,
filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath,
// Real Electron assets are filesystem paths and go through toFileUrl.
@@ -376,7 +371,7 @@ export function NewEditorShell() {
: toFileUrl(asset.originalPath),
label: asset.label,
}));
- }, [playedDocument]);
+ }, [document]);
const handleLoadedMetadata = useCallback(
(durationSec: number, assetId: string) => {
@@ -1517,7 +1512,7 @@ export function NewEditorShell() {
// itself keeps playing — that is what the user is narrating to.
audioTracks={voiceoverRecording ? NO_AUDIO_TRACKS : tl.audioTracks}
audioSources={videoSources}
- clips={playedDocument?.timeline.clips ?? clips}
+ clips={clips}
zoomRegions={tl.zoomRegions}
speedRegions={tl.speedRegions}
cameraFullscreenRegions={tl.cameraFullscreenRegions}
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 7bbbac76a..79f9f2e00 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -72,7 +72,6 @@ import {
isSilenceWord,
placementRawExtent,
placementRawSec,
- recordingPlacements,
type TranscriptLane,
type TrimRun,
voiceoverPlacements,
@@ -897,10 +896,7 @@ export function TranscriptPane({
},
[setCaptionSettings],
);
- // The clips WITH their parts, so a word after an insertion resolves to the moment it is
- // actually spoken rather than to its distance from the clip's start.
- const recording = useMemo(() => recordingPlacements(clips, transcripts), [clips, transcripts]);
- const placements = activeLane === "voiceover" ? voiceover : recording;
+ const placements = activeLane === "voiceover" ? voiceover : clips;
const sections = useMemo(
() => buildAggregatedSections(placements, transcripts, assets, removed),
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 15af76b3e..422ff0b54 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1680,6 +1680,13 @@
.tlClipSel {
border-color: var(--accent);
}
+/* A generated clip: the media behind an inserted word, which nobody shot. Amber on the box
+ itself rather than a badge inside it, so it stays legible at the zoom levels where a clip
+ is a few pixels wide and there is no room to draw anything in it. */
+.tlClipGenerated {
+ border-color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 18%, var(--surface-1));
+}
/* The clip being carried during a reorder drag — follows the pointer 1:1
(no transition lag) while its siblings slide out of the way with the
base .tlClip transform transition above. */
@@ -1775,44 +1782,6 @@
over the waveform, at the moment the word sits on, wide enough to hit and no wider
— the clip underneath still has to be draggable everywhere else. Amber is the
colour the transcript pane gives the same word, so the two read as one thing. */
-.tlClipInsert {
- position: absolute;
- top: 0;
- bottom: 0;
- z-index: 2;
- /* A pause of zero still needs somewhere to be clicked; a real one is sized inline. */
- min-width: 9px;
- margin-left: -4px;
- padding: 0;
- border: 0;
- background: transparent;
- cursor: pointer;
-}
-/* The mark fills its button rather than sitting at a fixed 3px inside it. The button has
- carried the pause's real width in its inline style for a while; `width: 3px` here meant a
- word that bought two seconds and one that bought nothing drew the same tick, and the wide
- case was a multi-second invisible column that swallowed clip drags (issue #560).
- `min-width` is the floor that keeps a word which borrowed existing silence — a pause of
- zero, so a zero-width button — visible and clickable. */
-.tlClipInsert::before {
- content: "";
- position: absolute;
- left: 0;
- right: 0;
- top: 4px;
- bottom: 4px;
- min-width: 3px;
- border-radius: 2px;
- background: var(--warn);
- box-shadow: 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent);
- transition: box-shadow var(--motion-fast) var(--ease);
-}
-.tlClipInsert:hover::before,
-.tlClipInsert:focus-visible::before {
- box-shadow:
- 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent),
- 0 0 0 4px var(--warn-soft);
-}
.tlDropHint {
position: absolute;
inset: 0;
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index 8d87a48b3..ba08eb74f 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -40,8 +40,9 @@ import {
slipAudioOffsetMs,
} from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
+import { isGeneratedAssetId } from "@/lib/ai-edition/document/insertion";
import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe";
-import type { AxcutAudioTrack, AxcutClip, AxcutWord } from "@/lib/ai-edition/schema";
+import type { AxcutAudioTrack, AxcutClip } from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionStore";
@@ -49,7 +50,6 @@ import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
-import { clipParts } from "@/lib/ai-edition/timeline/clip-parts";
import { formatSec } from "@/lib/ai-edition/timeline/format";
import {
newRegionDurationSec,
@@ -707,39 +707,6 @@ export function V4Timeline({
label: `${(p.member.customScale ?? ZOOM_DEPTH_SCALES[p.member.depth]).toFixed(2)}×`,
sourceIds: p.ids,
}));
- // Where the user has ADDED words. Derived from the transcript on every render and
- // stored nowhere: the word carries `source: "synth"` and its own source time, so a mark
- // built from it cannot drift from the amber word the transcript pane shows. Grouped by
- // clip because each mark is positioned inside its clip's own box — it then travels with
- // the clip through a reorder for free, with no ruler arithmetic of its own.
- const insertedWordsByClip = useMemo(() => {
- type Mark = { word: AxcutWord; atPct: number; widthPct: number; atSec: number };
- const byAsset = new Map(tl.transcripts.map((t) => [t.assetId, t.words]));
- const out = new Map();
- for (const clip of clips) {
- const words = byAsset.get(clip.assetId);
- // The clip's TIMELINE span, which is what the box on screen measures. Using the
- // source span put the mark at the wrong percentage AND gave it no width, because
- // the two spans stop being equal the moment a word is added.
- const span = clip.timelineEndSec - clip.timelineStartSec;
- if (!words || span <= 0) continue;
- const byId = new Map(words.map((w) => [w.id, w]));
- const marks = clipParts(clip, words).flatMap((part): Mark[] => {
- const word = part.kind === "extension" ? byId.get(part.wordId) : undefined;
- if (!word) return [];
- return [
- {
- word,
- atPct: ((part.timelineStartSec - clip.timelineStartSec) / span) * 100,
- widthPct: ((part.timelineEndSec - part.timelineStartSec) / span) * 100,
- atSec: part.timelineStartSec,
- },
- ];
- });
- if (marks.length > 0) out.set(clip.id, marks);
- }
- return out;
- }, [tl.transcripts, clips]);
// trims: content-free (no per-instance text/settings), so touching rows —
// inevitable once a trim is ventilated across a clip boundary — are
@@ -2216,7 +2183,11 @@ export function V4Timeline({
a.id === c.assetId)?.label ?? c.assetId}
- {(insertedWordsByClip.get(c.id) ?? []).map(
- ({ word, atPct, widthPct, atSec }) => {
- const left = atPct;
- const width = widthPct;
- return (
- 0
- ? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
- : { left: `${left}%` }
- }
- title={t("toolbar.addedWord", { word: word.text })}
- aria-label={t("toolbar.addedWord", { word: word.text })}
- onPointerDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- // Jump to the moment the added text sits on. The clip box
- // underneath would otherwise take this as a selection.
- e.stopPropagation();
- setCurrentTime(atSec);
- }}
- />
- );
- },
- )}
{selected ? (
= {}): AxcutDocument {
updatedAt: "2026-01-01T00:00:00.000Z",
primaryAssetId: "asset-1",
},
- assets: [],
+ assets: [
+ {
+ id: "asset-1",
+ kind: "video",
+ label: "take",
+ originalPath: "C:/rec/take.mp4",
+ video: { width: 1920, height: 1080, fps: 30 },
+ cameraTrack: null,
+ },
+ ],
transcript: null,
transcripts: [transcript()],
timeline: {
@@ -651,51 +661,28 @@ describe("translated caption layout", () => {
});
});
-// The whole point of the insertion layer, seen from the far end: a word typed into the
-// middle of a take lengthens the film, and every caption after it has to move with the
-// audio it belongs to. This is the check that failed on screen before it failed here.
+// An insertion is a clip on its own media, so the cues it produces come out of the ordinary
+// per-asset path: the recording's own lines shift along the ruler, and the inserted text
+// gets a line of its own over the clip that plays it. Both used to be wrong at once.
describe("captions over an inserted word", () => {
- const withInsertion = () => {
- const t = transcript();
- return doc({
- transcripts: [
- {
- ...t,
- words: [
- ...t.words.slice(0, 3),
- {
- id: "synth_1",
- segmentId: "seg_1",
- startSec: 2,
- endSec: 2,
- text: "wait",
- source: "synth",
- },
- ...t.words.slice(3),
- ],
- },
- ],
- });
- };
+ const inserted = () => insertGeneratedClip(doc(), "asset-1", "w3", "after", "wait");
+ // "wait" is 4 chars at 15/s.
+ const GEN_MS = (4 / 15) * 1000;
- it("moves every cue after the insertion by exactly the extension's length", () => {
- const cues = deriveCaptionCues(withInsertion(), ON, {});
- // "wait" is 4 chars at 15/s.
- expect(cues[cues.length - 1].endMs).toBeCloseTo(6000 + (4 / 15) * 1000, 0);
+ it("moves every cue after the insertion by exactly the clip's length", () => {
+ const cues = deriveCaptionCues(inserted(), ON, {});
+ expect(cues[cues.length - 1].endMs).toBeCloseTo(6000 + GEN_MS, 0);
});
- it("gives the inserted word a line of its own, over the media it inserted", () => {
- // Grouped with the recorded words beside it, it inherited their span — which is what
- // left the inserted subtitle glued to the line before it.
- const wait = deriveCaptionCues(withInsertion(), ON, {}).find((c) => c.text === "wait");
- expect(wait).toBeDefined();
+ it("gives the inserted word a line of its own, over the clip that plays it", () => {
+ const wait = deriveCaptionCues(inserted(), ON, {}).find((c) => c.text === "wait");
expect(wait?.startMs).toBe(2000);
- expect(wait?.endMs).toBeCloseTo(2000 + (4 / 15) * 1000, 0);
+ expect(wait?.endMs).toBeCloseTo(2000 + GEN_MS, 0);
});
it("leaves the cues before it exactly where they were", () => {
- const before = deriveCaptionCues(doc(), ON, {});
- const after = deriveCaptionCues(withInsertion(), ON, {});
- expect(after[0].startMs).toBe(before[0].startMs);
+ expect(deriveCaptionCues(inserted(), ON, {})[0].startMs).toBe(
+ deriveCaptionCues(doc(), ON, {})[0].startMs,
+ );
});
});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index a8ac19be4..ed881ab72 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -28,7 +28,6 @@ import {
placementRawSec,
type TranscriptPlacement,
} from "../timeline/aggregated-transcript";
-import { extensionSpanAtSource, isAddedWord } from "../timeline/clip-parts";
import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
@@ -51,42 +50,10 @@ export interface CaptionCue {
* the subtitle. Well clear of the annotation z-range, which counts up from 1. */
export const CAPTION_Z_INDEX_BASE = 100_000;
-/** A caption word that carries whether it was ADDED, which decides where a line breaks. */
-type StreamWord = CaptionSegment & { added?: true };
-
-function toCaptionSegments(transcript: AxcutTranscript): StreamWord[] {
+function toCaptionSegments(transcript: AxcutTranscript): CaptionSegment[] {
return transcript.words
.filter((word) => word.text.trim().length > 0)
- .map((word) => ({
- startSec: word.startSec,
- endSec: word.endSec,
- text: word.text,
- ...(isAddedWord(word) ? { added: true as const } : {}),
- }));
-}
-
-/**
- * The stream cut into runs, each grouped into lines on its own, with every added word a run
- * of one.
- *
- * An added word is spoken over its OWN media for its own stretch. Grouped with the recorded
- * words beside it, its line inherits their span — which is what glued the inserted subtitle
- * to the line before it and left it on screen at the wrong moment.
- */
-function runsAroundAddedWords(stream: StreamWord[]): StreamWord[][] {
- const runs: StreamWord[][] = [];
- let run: StreamWord[] = [];
- for (const word of stream) {
- if (word.added) {
- if (run.length > 0) runs.push(run);
- runs.push([word]);
- run = [];
- continue;
- }
- run.push(word);
- }
- if (run.length > 0) runs.push(run);
- return runs;
+ .map((word) => ({ startSec: word.startSec, endSec: word.endSec, text: word.text }));
}
function segmentWordsAsCaptionSegments(
@@ -152,17 +119,10 @@ export function captionLinesForAsset(
: translatedWordStream(transcript, translations, settings.language);
if (stream.length === 0) return [];
- return runsAroundAddedWords(stream).flatMap((run) =>
- // An added word is already a line, and must stay the POINT in source time that it is:
- // the polish pass gives a line a minimum span, and a span is exactly what this has
- // none of. Its real length comes from the extension it is spoken over, downstream.
- run.length === 1 && run[0].added
- ? [{ startSec: run[0].startSec, endSec: run[0].endSec, text: run[0].text }]
- : polish(groupTimedCaptionWordsIntoLines(run, minWords, maxWords)),
- );
+ return polish(groupTimedCaptionWordsIntoLines(stream, minWords, maxWords));
}
-function originalWordStream(transcript: AxcutTranscript): StreamWord[] {
+function originalWordStream(transcript: AxcutTranscript): CaptionSegment[] {
const words = toCaptionSegments(transcript);
if (words.length > 0) return words;
// A transcript with segments but no words (hand-authored / imported) still
@@ -214,14 +174,7 @@ export function sourceSpanToTimelineSpans(
const clipSourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
const s = Math.max(startSec, clip.sourceStartSec);
const e = Math.min(endSec, clipSourceEnd);
- if (e <= s) {
- // An added word occupies no source time at all — its line is a POINT here. Its
- // moment is the extension inserted there, which is the one thing that knows how
- // long it lasts.
- const span = clip.parts ? extensionSpanAtSource(clip.parts, s) : null;
- if (span) out.push(span);
- continue;
- }
+ if (e <= s) continue;
// Through `placementRawSec`, never the subtraction it used to write here: a clip
// carrying an added word plays its media in pieces, and the seconds after the
// insertion sit further along the ruler than their distance from the clip's start.
@@ -253,7 +206,6 @@ export function deriveCaptionCues(
// document written before it — or hand-built, never through the schema — has none.
document.audioTracks ?? [],
removedRawSpans(document.timeline.clips, document.timeline.trimRanges),
- document.transcripts,
);
if (placements.length === 0) return [];
diff --git a/src/lib/ai-edition/document/insertion.test.ts b/src/lib/ai-edition/document/insertion.test.ts
new file mode 100644
index 000000000..8a0aae028
--- /dev/null
+++ b/src/lib/ai-edition/document/insertion.test.ts
@@ -0,0 +1,183 @@
+// An insertion is a clip. These pin what that buys and what it costs.
+//
+// The one genuinely delicate part is the inverse: taking the generated clip away has to put
+// the halves back together, and must NOT do it when the user has since made them two clips
+// he means to keep.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutDocument } from "../schema";
+import { insertGeneratedClip, removeGeneratedClips, retextGeneratedClip } from "./insertion";
+import { resolvePlaybackSegments } from "./timeline";
+
+const doc = (over: Partial = {}): AxcutDocument =>
+ ({
+ schemaVersion: 5,
+ project: { id: "p1", title: "t", createdAt: "", updatedAt: "", primaryAssetId: "a1" },
+ assets: [
+ {
+ id: "a1",
+ kind: "video",
+ label: "take",
+ originalPath: "C:/rec/take.mp4",
+ video: { width: 1920, height: 1080, fps: 30 },
+ cameraTrack: null,
+ },
+ ],
+ transcript: null,
+ transcripts: [
+ {
+ assetId: "a1",
+ language: "en",
+ segments: [
+ { id: "s1", kind: "speech", startSec: 0, endSec: 6, text: "a b", wordIds: ["w1", "w2"] },
+ ],
+ words: [
+ { id: "w1", segmentId: "s1", startSec: 1, endSec: 4, text: "hello" },
+ { id: "w2", segmentId: "s1", startSec: 5, endSec: 6, text: "world" },
+ ],
+ },
+ ],
+ timeline: {
+ clips: [
+ {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks: [],
+ legacyEditor: null,
+ ...over,
+ }) as unknown as AxcutDocument;
+
+/** Insert "hi" after `w1`, which ends at source second 4. */
+const withInsertion = (base = doc()) => insertGeneratedClip(base, "a1", "w1", "after", "hi");
+// "hi" is 2 chars at 15/s, under the floor.
+const GEN_SEC = 0.15;
+
+describe("insertGeneratedClip", () => {
+ it("cuts the clip in two and puts the generated clip between the halves", () => {
+ const clips = withInsertion().timeline.clips;
+ expect(clips.map((c) => c.assetId)).toEqual(["a1", "ext:synth_1", "a1"]);
+ expect(clips[1].timelineEndSec - clips[1].timelineStartSec).toBeCloseTo(GEN_SEC, 6);
+ });
+
+ it("leaves both halves on the source seconds they always had", () => {
+ const clips = withInsertion().timeline.clips;
+ expect([clips[0].sourceStartSec, clips[0].sourceEndSec]).toEqual([0, 4]);
+ expect([clips[2].sourceStartSec, clips[2].sourceEndSec]).toEqual([4, 10]);
+ });
+
+ it("lays them end to end, so the film grew by exactly the insertion", () => {
+ const clips = withInsertion().timeline.clips;
+ expect(clips[0].timelineStartSec).toBe(0);
+ for (const [i, clip] of clips.slice(0, -1).entries()) {
+ expect(clip.timelineEndSec).toBeCloseTo(clips[i + 1].timelineStartSec, 9);
+ }
+ expect(clips[2].timelineEndSec).toBeCloseTo(10 + GEN_SEC, 6);
+ });
+
+ it("gives the generated clip its own media, and its own transcript to be read from", () => {
+ const next = withInsertion();
+ const asset = next.assets.find((a) => a.id === "ext:synth_1");
+ expect(asset?.originalPath).toBe("C:/rec/.openscreen-extensions/synth_1_150.mp4");
+ const transcript = next.transcripts.find((t) => t.assetId === "ext:synth_1");
+ expect(transcript?.words).toEqual([
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 0,
+ endSec: GEN_SEC,
+ text: "hi",
+ source: "synth",
+ },
+ ]);
+ });
+
+ it("keeps a trim authored across the cut cutting on both sides of it", () => {
+ // Anchored to the clip that no longer exists as one. Copied onto both halves, each
+ // subtracting its own overlap — so the film loses the same 3..5 it did before.
+ const base = doc();
+ base.timeline.trimRanges = [
+ { id: "t1", clipId: "c1", assetId: "a1", startSec: 3, endSec: 5 },
+ ] as unknown as AxcutDocument["timeline"]["trimRanges"];
+ const next = withInsertion(base);
+ const kept = resolvePlaybackSegments(next.timeline.clips, next.timeline.trimRanges)
+ .filter((s) => s.assetId === "a1")
+ .map((s) => [s.sourceStartSec, s.sourceEndSec]);
+ expect(kept).toEqual([
+ [0, 3],
+ [5, 10],
+ ]);
+ });
+});
+
+describe("removeGeneratedClips", () => {
+ it("puts the clip back exactly as it was", () => {
+ const back = removeGeneratedClips(withInsertion(), ["synth_1"]);
+ expect(back.timeline.clips).toHaveLength(1);
+ expect(back.timeline.clips[0]).toMatchObject({
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ });
+ });
+
+ it("takes the media it was the only user of with it", () => {
+ const back = removeGeneratedClips(withInsertion(), ["synth_1"]);
+ expect(back.assets.map((a) => a.id)).toEqual(["a1"]);
+ expect(back.transcripts.map((t) => t.assetId)).toEqual(["a1"]);
+ });
+
+ it("does NOT rejoin halves the user has since made different clips", () => {
+ // A crop on one half is an edit the user made deliberately. Rejoining would throw it
+ // away to make the inverse look tidy, which is the one thing this must never do.
+ const next = withInsertion();
+ const cropped = {
+ ...next,
+ timeline: {
+ ...next.timeline,
+ clips: next.timeline.clips.map((c, i) =>
+ i === 2 ? { ...c, cropRegion: { x: 0.1, y: 0.1, width: 0.5, height: 0.5 } } : c,
+ ),
+ },
+ };
+ const back = removeGeneratedClips(cropped, ["synth_1"]);
+ expect(back.timeline.clips).toHaveLength(2);
+ expect(back.timeline.clips[1].timelineStartSec).toBeCloseTo(4, 6);
+ });
+
+ it("is a no-op for a word that has no clip", () => {
+ const base = doc();
+ expect(removeGeneratedClips(base, ["synth_9"])).toBe(base);
+ });
+});
+
+describe("retextGeneratedClip", () => {
+ it("resizes the clip to the new text and renames the file it plays", () => {
+ const next = retextGeneratedClip(withInsertion(), "synth_1", "a much longer sentence");
+ const seconds = "a much longer sentence".length / 15;
+ const clip = next.timeline.clips.find((c) => c.id === "ext:synth_1");
+ expect(clip?.sourceEndSec).toBeCloseTo(seconds, 6);
+ expect(clip?.timelineEndSec ?? 0 - (clip?.timelineStartSec ?? 0)).toBeGreaterThan(0);
+ expect(next.assets.find((a) => a.id === "ext:synth_1")?.originalPath).toBe(
+ `C:/rec/.openscreen-extensions/synth_1_${Math.round(seconds * 1000)}.mp4`,
+ );
+ });
+});
diff --git a/src/lib/ai-edition/document/insertion.ts b/src/lib/ai-edition/document/insertion.ts
new file mode 100644
index 000000000..8a25c2375
--- /dev/null
+++ b/src/lib/ai-edition/document/insertion.ts
@@ -0,0 +1,292 @@
+// An insertion IS a clip.
+//
+// A word typed into the transcript cuts its clip in two and puts a generated clip between
+// the halves:
+//
+// [ recording 0→5.3 ] [ generated 0→0.4 ] [ recording 5.3→20.9 ]
+//
+// Stored exactly as it reads. Nothing downstream carries a notion of an insertion: every
+// mapping in this codebase rests on "a clip is an uninterrupted shift between its source
+// seconds and the ruler", and three clips satisfy that where one interrupted clip satisfied
+// none of them. The generated clip is then a clip like any other — it can be moved, cropped,
+// edited and deleted from the timeline, with no code of its own for any of it.
+//
+// It owns its word: the asset is `ext:`, the file is named by the pair, and the
+// transcript holds that one word at 0→duration. So the pane, the captions, the cue highlight
+// and the exporter all read it through the paths they already had.
+//
+// Deleting is the exact inverse, and it is not spelled out here: `removeClip` is the single
+// mutator for taking a clip away, and it rejoins contiguous survivors. Both delete paths —
+// the transcript pane and the timeline — therefore put the clip back together for free.
+
+import type { AxcutAsset, AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
+import {
+ EXTENSION_ID_PREFIX,
+ extensionAssetId,
+ extensionClipPath,
+ extensionDurationSec,
+} from "../timeline/clip-parts";
+import { createId } from "./ids";
+import { rederiveRegionMs, removeClip, resequenceClips } from "./timeline";
+
+/** Where a new word goes relative to the word the caret was resting on. */
+export type InsertSide = "before" | "after";
+
+/** True for the asset — and the clip, which shares its id — of a generated insertion. */
+export function isGeneratedAssetId(id: string): boolean {
+ return id.startsWith(EXTENSION_ID_PREFIX);
+}
+
+const EPS = 1e-6;
+
+/**
+ * The moment on the RULER the caret is asking for.
+ *
+ * Ruler seconds, not source seconds, so one path covers both anchors: a recorded word
+ * resolves through its clip's own shift, and a word already inserted resolves to the edge of
+ * the generated clip it lives on. Inserting beside an insertion then needs no case at all —
+ * nothing is cut and the new clip lands between two existing ones.
+ */
+function anchorRulerSec(
+ document: AxcutDocument,
+ assetId: string,
+ anchorWordId: string,
+ side: InsertSide,
+): number | null {
+ const word = document.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find((w) => w.id === anchorWordId);
+ if (!word) return null;
+ const at = side === "after" ? word.endSec : word.startSec;
+ const clip = [...document.timeline.clips]
+ .filter((c) => c.assetId === assetId)
+ .sort((a, b) => a.timelineStartSec - b.timelineStartSec)
+ .find((c) => at >= c.sourceStartSec - EPS && at <= (c.sourceEndSec ?? c.sourceStartSec) + EPS);
+ return clip ? clip.timelineStartSec + (at - clip.sourceStartSec) : null;
+}
+
+/** `synth_N`, numbered past every generated asset the document already carries. */
+function nextGeneratedWordId(document: AxcutDocument): string {
+ let highest = 0;
+ for (const asset of document.assets) {
+ const match = /^ext:synth_(\d+)$/.exec(asset.id);
+ if (match) highest = Math.max(highest, Number(match[1]));
+ }
+ return `synth_${highest + 1}`;
+}
+
+/** The recording the generated files are written beside. One rule, so the renderer and the
+ * main process arrive at the same folder without asking each other. */
+function hostAsset(document: AxcutDocument): AxcutAsset | null {
+ const primary = document.assets.find((a) => a.id === document.project.primaryAssetId);
+ if (primary?.originalPath && !isGeneratedAssetId(primary.id)) return primary;
+ return document.assets.find((a) => a.originalPath && !isGeneratedAssetId(a.id)) ?? null;
+}
+
+function generatedAsset(
+ host: AxcutAsset,
+ wordId: string,
+ durationSec: number,
+ text: string,
+): AxcutAsset {
+ return {
+ id: extensionAssetId(wordId),
+ kind: "video",
+ label: text.slice(0, 40),
+ originalPath: extensionClipPath(host.originalPath, wordId, durationSec),
+ durationSec,
+ // The recording's geometry: the generated file is made to match it.
+ video: host.video,
+ cameraTrack: null,
+ };
+}
+
+function generatedTranscript(
+ wordId: string,
+ durationSec: number,
+ text: string,
+ language: string,
+): AxcutTranscript {
+ return {
+ assetId: extensionAssetId(wordId),
+ language,
+ segments: [
+ { id: "seg_1", kind: "speech", startSec: 0, endSec: durationSec, text, wordIds: [wordId] },
+ ],
+ words: [
+ { id: wordId, segmentId: "seg_1", startSec: 0, endSec: durationSec, text, source: "synth" },
+ ],
+ };
+}
+
+/**
+ * Every row anchored to the clip that was just cut, copied onto BOTH halves.
+ *
+ * Not "decide which half each row belongs to" — that is interval arithmetic this file has no
+ * business owning. One copy per half, and `rederiveRegionMs` clamps each to its own clip's
+ * source window and drops what has nothing left. A row wholly on one side survives once; one
+ * straddling the cut survives on both, which is what a zoom drawn across the moment a word
+ * was typed into actually means.
+ */
+function fanOutAnchors(document: AxcutDocument, from: string, to: string): AxcutDocument {
+ // `?? []` for the reason every other collection walk here has one: these keys are
+ // additive, so a document written before one of them — or hand-built, never through the
+ // schema — simply has none, and the schema defaults it back to an empty array anyway.
+ const both = (rows: readonly T[] | undefined): T[] =>
+ (rows ?? []).flatMap((row) =>
+ row.clipId === from ? [row, { ...row, id: createId("frag"), clipId: to }] : [row],
+ );
+ return {
+ ...document,
+ timeline: { ...document.timeline, trimRanges: both(document.timeline.trimRanges) },
+ zoomRanges: both(document.zoomRanges),
+ annotations: both(document.annotations),
+ audioTracks: both(document.audioTracks),
+ };
+}
+
+/**
+ * Insert a word nobody said, as a clip of its own.
+ *
+ * The clip under the caret is cut at that moment and the generated clip goes between the
+ * halves; everything after slides along by its length. Dropped on a clip's edge nothing is
+ * cut — the new clip simply takes its place in the order.
+ */
+export function insertGeneratedClip(
+ document: AxcutDocument,
+ assetId: string,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutDocument {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return document;
+ const host = hostAsset(document);
+ if (!host) {
+ throw new Error("Cannot insert a word: the project has no recording to generate beside");
+ }
+ const atRuler = anchorRulerSec(document, assetId, anchorWordId, side);
+ if (atRuler === null) {
+ throw new Error(`Cannot insert beside word "${anchorWordId}": no clip plays that moment`);
+ }
+
+ const wordId = nextGeneratedWordId(document);
+ const durationSec = extensionDurationSec(trimmed);
+ const asset = generatedAsset(host, wordId, durationSec, trimmed);
+ const generated: AxcutClip = {
+ id: asset.id,
+ assetId: asset.id,
+ sourceStartSec: 0,
+ sourceEndSec: durationSec,
+ timelineStartSec: atRuler,
+ timelineEndSec: atRuler + durationSec,
+ wordRefs: [],
+ origin: "user",
+ reason: "inserted word",
+ };
+
+ const ordered = [...document.timeline.clips].sort(
+ (a, b) => a.timelineStartSec - b.timelineStartSec,
+ );
+ const clips: AxcutClip[] = [];
+ let split: { from: string; to: string } | null = null;
+ let placed = false;
+ for (const clip of ordered) {
+ const cutsHere = atRuler > clip.timelineStartSec + EPS && atRuler < clip.timelineEndSec - EPS;
+ if (!placed && cutsHere) {
+ const cut = clip.sourceStartSec + (atRuler - clip.timelineStartSec);
+ const right = {
+ ...clip,
+ id: createId("clip"),
+ sourceStartSec: cut,
+ timelineStartSec: atRuler,
+ };
+ clips.push({ ...clip, sourceEndSec: cut, timelineEndSec: atRuler }, generated, right);
+ split = { from: clip.id, to: right.id };
+ placed = true;
+ continue;
+ }
+ if (!placed && clip.timelineStartSec >= atRuler - EPS) {
+ clips.push(generated);
+ placed = true;
+ }
+ clips.push(clip);
+ }
+ if (!placed) clips.push(generated);
+
+ const next: AxcutDocument = {
+ ...document,
+ assets: [...document.assets, asset],
+ transcripts: [
+ ...document.transcripts,
+ generatedTranscript(
+ wordId,
+ durationSec,
+ trimmed,
+ document.transcripts.find((t) => t.assetId === assetId)?.language ?? "en",
+ ),
+ ],
+ timeline: { ...document.timeline, clips: resequenceClips(clips) },
+ };
+ const anchored = split ? fanOutAnchors(next, split.from, split.to) : next;
+ return rederiveRegionMs(anchored, anchored.timeline.clips);
+}
+
+/**
+ * Delete inserted words.
+ *
+ * `removeClip` does the whole of it: the gap closes, the halves rejoin when they are still
+ * one continuous piece of media, and the rows anchored to the half that goes away follow.
+ * What is left here is the media the clip was the only user of.
+ */
+export function removeGeneratedClips(
+ document: AxcutDocument,
+ wordIds: readonly string[],
+): AxcutDocument {
+ return wordIds.reduce((next, wordId) => {
+ const id = extensionAssetId(wordId);
+ if (!next.timeline.clips.some((c) => c.id === id)) return next;
+ const after = removeClip(next, id);
+ return {
+ ...after,
+ assets: after.assets.filter((a) => a.id !== id),
+ transcripts: after.transcripts.filter((t) => t.assetId !== id),
+ };
+ }, document);
+}
+
+/**
+ * Rewrite an inserted word's text.
+ *
+ * Its length IS its text, so this resizes the clip and renames the file it plays. The old
+ * file is simply never asked for again; the new one is generated on the next save.
+ */
+export function retextGeneratedClip(
+ document: AxcutDocument,
+ wordId: string,
+ text: string,
+): AxcutDocument {
+ const trimmed = text.trim();
+ const id = extensionAssetId(wordId);
+ const host = hostAsset(document);
+ if (trimmed.length === 0 || !host || !document.assets.some((a) => a.id === id)) return document;
+
+ const durationSec = extensionDurationSec(trimmed);
+ const language = document.transcripts.find((t) => t.assetId === id)?.language ?? "en";
+ const next: AxcutDocument = {
+ ...document,
+ assets: document.assets.map((a) =>
+ a.id === id ? generatedAsset(host, wordId, durationSec, trimmed) : a,
+ ),
+ transcripts: document.transcripts.map((t) =>
+ t.assetId === id ? generatedTranscript(wordId, durationSec, trimmed, language) : t,
+ ),
+ timeline: {
+ ...document.timeline,
+ clips: resequenceClips(
+ document.timeline.clips.map((c) => (c.id === id ? { ...c, sourceEndSec: durationSec } : c)),
+ ),
+ },
+ };
+ return rederiveRegionMs(next, next.timeline.clips);
+}
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 9fa9330f4..e1ca19a5a 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -14,7 +14,6 @@ import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "
*/
export type PlaybackSegment = AxcutClip;
-import { clipParts, partsLengthSec } from "../timeline/clip-parts";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -129,34 +128,6 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
-/**
- * Clip lengths, recomputed from the parts they now have.
- *
- * `timelineEndSec` is STORED, so something has to keep it true once a word can add media
- * inside a clip — and the ruler reads it, so a clip left short draws a film that ends before
- * the programme does. This is that something, and it is a recompute, not a reconciliation:
- * the parts are derived from the words, so there is no second stored fact to drift from.
- *
- * Idempotent, and a no-op for a document with no added words.
- */
-export function withClipsSizedToParts(document: AxcutDocument): AxcutDocument {
- const wordsByAsset = new Map(document.transcripts.map((t) => [t.assetId, t.words]));
- const clips = document.timeline.clips.map((clip) => {
- const parts = clipParts(clip, wordsByAsset.get(clip.assetId) ?? []);
- // No parts means the duration has not been probed yet; the prober owns the length.
- if (parts.length === 0) return clip;
- return { ...clip, timelineEndSec: clip.timelineStartSec + partsLengthSec(parts) };
- });
- const resequenced = resequenceClips(clips);
- const unchanged = resequenced.every(
- (clip, i) =>
- Math.abs(clip.timelineStartSec - document.timeline.clips[i].timelineStartSec) < 1e-9 &&
- Math.abs(clip.timelineEndSec - document.timeline.clips[i].timelineEndSec) < 1e-9,
- );
- return unchanged
- ? document
- : { ...document, timeline: { ...document.timeline, clips: resequenced } };
-}
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
@@ -1084,13 +1055,18 @@ export function removeClip(document: AxcutDocument, clipId: string): AxcutDocume
const oldClips = document.timeline.clips;
const arr = oldClips.filter((c) => c.id !== clipId);
if (arr.length === oldClips.length) return document;
- const newClips = resequenceClips(arr);
+ const { clips: joined, reanchor } = joinContiguous(arr);
+ const newClips = resequenceClips(joined);
const next: AxcutDocument = {
...document,
timeline: {
...document.timeline,
clips: newClips,
- trimRanges: document.timeline.trimRanges.filter((t) => t.clipId !== clipId),
+ trimRanges: document.timeline.trimRanges
+ .filter((t) => t.clipId !== clipId)
+ .map((t) =>
+ t.clipId && reanchor.has(t.clipId) ? { ...t, clipId: reanchor.get(t.clipId) } : t,
+ ),
},
};
// The asymmetry with the trim filter three lines up is deliberate, and the obvious
@@ -1123,7 +1099,66 @@ export function removeClip(document: AxcutDocument, clipId: string): AxcutDocume
regions.filter((region) => !(hasCompleteClipAnchor(region) && region.clipId === clipId)),
);
}
- return rederiveRegionMs(next, newClips);
+ const reanchored =
+ reanchor.size === 0
+ ? next
+ : mapAllRegionCollections(next, (regions) =>
+ regions.map((region) =>
+ hasCompleteClipAnchor(region) && reanchor.has(region.clipId)
+ ? { ...region, clipId: reanchor.get(region.clipId) as string }
+ : region,
+ ),
+ );
+ return rederiveRegionMs(reanchored, newClips);
+}
+
+/**
+ * Two clips that are one continuous piece of media are one clip.
+ *
+ * The inverse of the cut an insertion makes: take the generated clip away and the halves it
+ * separated go back to being what they were. Asked only when a clip is REMOVED, which is
+ * the only moment adjacency can appear — a pass over the whole timeline would join clips
+ * the user meant to keep apart.
+ *
+ * Deliberately blind to WHY the two halves are contiguous: no marker, no parent id, nothing
+ * to keep in sync. Same media, source ranges that meet, same crop — then they play as one
+ * clip already, and drawing them as two is the only difference.
+ *
+ * ponytail: today nothing else can produce two contiguous clips of one media, so this can
+ * only ever undo an insertion. Give the cut a marker the day a razor tool lands and a split
+ * with no gap becomes something the user asked for.
+ */
+function joinContiguous(clips: AxcutClip[]): {
+ clips: AxcutClip[];
+ /** Ids that went away, and the clip that now carries their content. */
+ reanchor: Map;
+} {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ const out: AxcutClip[] = [];
+ const reanchor = new Map();
+ for (const clip of ordered) {
+ const previous = out[out.length - 1];
+ if (previous && joinable(previous, clip)) {
+ out[out.length - 1] = {
+ ...previous,
+ sourceEndSec: clip.sourceEndSec,
+ timelineEndSec: previous.timelineEndSec + (clip.timelineEndSec - clip.timelineStartSec),
+ };
+ reanchor.set(clip.id, previous.id);
+ continue;
+ }
+ out.push(clip);
+ }
+ return { clips: out, reanchor };
+}
+
+function joinable(left: AxcutClip, right: AxcutClip): boolean {
+ return (
+ left.assetId === right.assetId &&
+ left.sourceEndSec !== undefined &&
+ Math.abs(left.sourceEndSec - right.sourceStartSec) < 1e-6 &&
+ JSON.stringify(left.cropRegion ?? null) === JSON.stringify(right.cropRegion ?? null)
+ );
}
export function restoreFullTimeline(document: AxcutDocument): AxcutDocument {
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
index b8c40f301..79e1397aa 100644
--- a/src/lib/ai-edition/document/transcript.test.ts
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -1,13 +1,6 @@
import { describe, expect, it } from "vitest";
import { type AxcutDocument, type AxcutTranscript, createEmptyDocument } from "../schema";
-import {
- carryOverWordEdits,
- insertDocumentWord,
- removeDocumentWords,
- setDocumentWordText,
- setWordText,
- withTranscript,
-} from "./transcript";
+import { carryOverWordEdits, setDocumentWordText, setWordText, withTranscript } from "./transcript";
function fixture(language = "en"): AxcutTranscript {
return {
@@ -492,11 +485,11 @@ describe("carryOverWordEdits", () => {
});
// ─── The clip grows with the word ───────────────────────────────────────────
-// `timelineEndSec` is stored and the ruler reads it, so a clip left short draws a film that
-// ends before the programme does — the desync the previous attempt spent its life chasing.
-// Recomputed from the parts, at the one funnel every transcript write goes through.
+// An insertion is a clip, so its arithmetic lives in `insertion.test.ts`. What is left here
+// is the promise the plain transcript writes still make: they touch the words and nothing
+// else on the timeline.
-describe("adding a word sizes the clip it lands in", () => {
+describe("correcting a word leaves the timeline alone", () => {
function docWithClip(): AxcutDocument {
return {
assets: [{ id: "a1", kind: "video" }],
@@ -540,25 +533,6 @@ describe("adding a word sizes the clip it lands in", () => {
} as unknown as AxcutDocument;
}
- it("lengthens the clip by exactly the media the word needs", () => {
- const after = insertDocumentWord(docWithClip(), "a1", "w2", "after", "really");
- const [clip] = after.timeline.clips;
- // "really" is 6 chars at 15/s.
- expect(clip.timelineEndSec - clip.timelineStartSec).toBeCloseTo(10 + 6 / 15, 6);
- // And takes not one frame of the recording with it.
- expect(clip.sourceStartSec).toBe(0);
- expect(clip.sourceEndSec).toBe(10);
- });
-
- it("gives the length back when the word goes", () => {
- const before = docWithClip();
- const added = insertDocumentWord(before, "a1", "w2", "after", "really");
- const gone = removeDocumentWords(added, "a1", [
- added.transcripts[0].words.find((w) => w.source === "synth")?.id ?? "",
- ]);
- expect(gone.timeline.clips).toEqual(before.timeline.clips);
- });
-
it("leaves a document with no added words untouched", () => {
const before = docWithClip();
const after = setDocumentWordText(before, "a1", "w1", "HELLO");
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 19b44dd9d..6262803f6 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -1,5 +1,11 @@
import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
-import { withClipsSizedToParts } from "./timeline";
+import {
+ type InsertSide,
+ insertGeneratedClip,
+ isGeneratedAssetId,
+ removeGeneratedClips,
+ retextGeneratedClip,
+} from "./insertion";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -122,14 +128,12 @@ export function withTranscript(
...document.transcripts.filter((t) => t.assetId !== transcript.assetId),
transcript,
];
- // Sized here, at the ONE funnel every transcript write goes through, so adding or
- // removing a word cannot leave a clip claiming a length its parts no longer have.
- return withClipsSizedToParts({
+ return {
...document,
transcript:
document.project.primaryAssetId === transcript.assetId ? transcript : document.transcript,
transcripts,
- });
+ };
}
/**
@@ -143,6 +147,9 @@ export function setDocumentWordText(
wordId: string,
text: string,
): AxcutDocument {
+ // An inserted word's length IS its text, so rewriting it resizes the clip it plays on
+ // and renames the file. Nothing a plain transcript write can express.
+ if (isGeneratedAssetId(assetId)) return retextGeneratedClip(document, wordId, text);
const transcript = document.transcripts.find((t) => t.assetId === assetId);
if (!transcript) {
throw new Error(`Cannot edit a word of asset "${assetId}": it has no transcript`);
@@ -150,162 +157,10 @@ export function setDocumentWordText(
return withTranscript(document, setWordText(transcript, wordId, text));
}
-/** Where a new word goes relative to the word the caret was resting on. */
-export type InsertSide = "before" | "after";
-
-/**
- * How long an inserted word needs to be readable on screen. Subtitle practice is roughly
- * fifteen characters a second, with a floor so a one-letter word is not a single frame.
- * It is only ever a REQUEST — `insertWord` gives the word whatever silence is actually
- * free, and no more.
- */
-function readingSeconds(text: string): number {
- return Math.max(0.4, text.trim().length / 15);
-}
-
-/** `synth_N`, numbered past every id already in the transcript.
- *
- * The prefix buys uniqueness, not meaning: a transcription run regenerates `word_N` from
- * 1, so a synthesized word holding one of those ids would be overwritten by the next run.
- * What the word IS lives in `source`, which is what every reader checks. */
-function nextSynthWordId(transcript: AxcutTranscript): string {
- let highest = 0;
- for (const word of transcript.words) {
- const match = /^synth_(\d+)$/.exec(word.id);
- if (match) highest = Math.max(highest, Number(match[1]));
- }
- return `synth_${highest + 1}`;
-}
-
-/**
- * Insert a word that no one said.
- *
- * It carries no audio, so it takes the SILENCE it is dropped into and nothing else: from
- * the word it follows up to what its text needs to be read, and never past the word that
- * comes next. Dropped between two words that run straight into each other it has no
- * duration at all and simply rides their caption line — which is where it reads correctly
- * anyway, since there is no pause on screen to fill.
- *
- * That is the whole of what an inserted word can do today: it reaches the captions and
- * stops there. When a voice can be synthesized for it, `source: "synth"` is what marks the
- * words that need speaking, and the span computed here is the slot that audio has to fit.
- */
-export function insertWord(
- transcript: AxcutTranscript,
- anchorWordId: string,
- side: InsertSide,
- text: string,
-): AxcutTranscript {
- const trimmed = text.trim();
- if (trimmed.length === 0) {
- throw new Error("Cannot insert an empty word");
- }
- const anchorIndex = transcript.words.findIndex((word) => word.id === anchorWordId);
- if (anchorIndex < 0) {
- throw new Error(`Cannot insert next to missing transcript word "${anchorWordId}"`);
- }
- const anchor = transcript.words[anchorIndex];
- const segment = transcript.segments.find((seg) => seg.id === anchor.segmentId);
- if (!segment) {
- throw new Error(
- `Transcript word "${anchorWordId}" references missing segment "${anchor.segmentId}"`,
- );
- }
- const anchorSlot = segment.wordIds.indexOf(anchorWordId);
- if (anchorSlot < 0) {
- throw new Error(`Segment "${segment.id}" does not reference anchor word "${anchorWordId}"`);
- }
-
- const wanted = readingSeconds(trimmed);
- let startSec: number;
- let endSec: number;
- if (side === "after") {
- startSec = anchor.endSec;
- // The next word IN TIME, which is not necessarily the next one in the array — the
- // array is insertion order, and only time decides what the new word may overlap.
- const nextStart = transcript.words
- .filter((word) => word.startSec >= startSec && word.id !== anchorWordId)
- .reduce(
- (soonest, word) => (soonest === null ? word.startSec : Math.min(soonest, word.startSec)),
- null,
- );
- endSec = nextStart === null ? startSec + wanted : Math.min(startSec + wanted, nextStart);
- } else {
- endSec = anchor.startSec;
- const previousEnd = transcript.words
- .filter((word) => word.endSec <= endSec && word.id !== anchorWordId)
- .reduce(
- (latest, word) => (latest === null ? word.endSec : Math.max(latest, word.endSec)),
- null,
- );
- const floor = previousEnd === null ? 0 : previousEnd;
- startSec = Math.max(floor, endSec - wanted);
- }
-
- const inserted: AxcutWord = {
- id: nextSynthWordId(transcript),
- segmentId: segment.id,
- startSec,
- endSec: Math.max(startSec, endSec),
- text: trimmed,
- source: "synth",
- };
+export type { InsertSide } from "./insertion";
- // Position in `words` matters as well as the timings: a zero-length insert shares its
- // start with the word it sits against, and the reading order of that tie is the array
- // order (see `withSilenceGaps`).
- const at = side === "after" ? anchorIndex + 1 : anchorIndex;
- const words = [...transcript.words.slice(0, at), inserted, ...transcript.words.slice(at)];
- const slot = side === "after" ? anchorSlot + 1 : anchorSlot;
- const wordIds = [...segment.wordIds.slice(0, slot), inserted.id, ...segment.wordIds.slice(slot)];
- const byId = new Map(words.map((word) => [word.id, word]));
- const segmentText = joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? ""));
-
- return {
- ...transcript,
- words,
- segments: transcript.segments.map((seg) =>
- seg.id === segment.id ? { ...seg, wordIds, text: segmentText } : seg,
- ),
- };
-}
-
-/**
- * Delete an inserted word.
- *
- * Only a synthesized one: a transcribed word is the label on a piece of audio, and the
- * operation for making that go away is a trim, which removes the sound with it. Deleting
- * the label alone would leave the film saying a word the transcript denies.
- */
-export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTranscript {
- const target = transcript.words.find((word) => word.id === wordId);
- if (!target) {
- throw new Error(`Cannot remove missing transcript word "${wordId}"`);
- }
- if (target.source !== "synth") {
- throw new Error(
- `Refusing to remove transcribed word "${wordId}": cut it with a trim, or blank its text`,
- );
- }
- const words = transcript.words.filter((word) => word.id !== wordId);
- const byId = new Map(words.map((word) => [word.id, word]));
- return {
- ...transcript,
- words,
- segments: transcript.segments.map((segment) => {
- if (!segment.wordIds.includes(wordId)) return segment;
- const wordIds = segment.wordIds.filter((id) => id !== wordId);
- return {
- ...segment,
- wordIds,
- text: joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? "")),
- };
- }),
- };
-}
-
-/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
- * reason {@link setDocumentWordText} does. */
+/** Add a word nobody said. It becomes a CLIP — see `insertion.ts`, which is the whole of
+ * what an insertion is. */
export function insertDocumentWord(
document: AxcutDocument,
assetId: string,
@@ -313,29 +168,20 @@ export function insertDocumentWord(
side: InsertSide,
text: string,
): AxcutDocument {
- const transcript = document.transcripts.find((t) => t.assetId === assetId);
- if (!transcript) {
- throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
- }
- return withTranscript(document, insertWord(transcript, anchorWordId, side, text));
+ return insertGeneratedClip(document, assetId, anchorWordId, side, text);
}
-/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
- * over several inserted words has to be ONE write, or undoing it takes as many presses as
- * there were words. */
+/** Delete inserted words, taking the whole set at once: a Backspace over several of them
+ * has to be ONE write, or undoing it takes as many presses as there were words.
+ *
+ * `assetId` is not read. Each inserted word names its own clip and its own asset, so the
+ * set can span several of them and the caller does not have to group by section. */
export function removeDocumentWords(
document: AxcutDocument,
- assetId: string,
+ _assetId: string,
wordIds: readonly string[],
): AxcutDocument {
- const transcript = document.transcripts.find((t) => t.assetId === assetId);
- if (!transcript) {
- throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
- }
- return withTranscript(
- document,
- wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
- );
+ return removeGeneratedClips(document, wordIds);
}
/** What {@link carryOverWordEdits} managed to save from the previous transcript. */
@@ -367,12 +213,11 @@ export function carryOverWordEdits(
const edits = (previous?.words ?? []).filter(
(word) => word.source === "user" && word.originalText !== undefined,
);
- const inserts = (previous?.words ?? [])
- .filter((word) => word.source === "synth")
- .sort((a, b) => a.startSec - b.startSec);
- if (edits.length === 0 && inserts.length === 0) {
- return { transcript: next, carried: 0, dropped: 0 };
- }
+ // Insertions are not carried, and no longer need to be: each one is a CLIP of its own,
+ // on its own asset, so re-transcribing this recording does not touch it. It stays exactly
+ // where it sits on the ruler — which is more than the time-matching this replaces ever
+ // managed.
+ if (edits.length === 0) return { transcript: next, carried: 0, dropped: 0 };
// Candidates are read from `next` throughout, never from the transcript being
// built up: a word already rewritten by an earlier correction no longer carries
@@ -395,23 +240,5 @@ export function carryOverWordEdits(
carried += 1;
}
- // An inserted word has no original text to recognise, so time is what places it: the
- // audio did not change between runs, only how it was heard. Each one goes back after
- // whatever the new transcript now ends last before it — including a word re-inserted a
- // moment ago, which is what keeps two inserts at the same spot in their old order.
- for (const insert of inserts) {
- const before = transcript.words
- .filter((word) => word.endSec <= insert.startSec)
- .reduce(
- (latest, word) => (latest === null || word.endSec >= latest.endSec ? word : latest),
- null,
- );
- const head = transcript.words[0];
- const target = before ?? head ?? null;
- if (!target) continue;
- transcript = insertWord(transcript, target.id, before ? "after" : "before", insert.text);
- carried += 1;
- }
-
- return { transcript, carried, dropped: edits.length + inserts.length - carried };
+ return { transcript, carried, dropped: edits.length - carried };
}
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index eeebae973..309e3c6aa 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -16,7 +16,6 @@
import { collapseTracksToPills } from "../document/audioTracks";
import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
-import { type ClipPart, clipParts, extensionAt, partsRawSec, partsSourceSec } from "./clip-parts";
import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
import { takeProgramme } from "./take-programme";
@@ -44,11 +43,6 @@ export interface TranscriptPlacement {
/** Where the window lands on the RAW ruler. Source time is per asset, so this is
* the only thing that turns a word back into a moment the playhead can seek to. */
timelineStartSec: number;
- /** The placement's parts, when it has any: a clip carrying added words plays its own
- * media in pieces with generated media between them, so a source second no longer sits
- * a fixed distance from the head. Absent, the placement is one uninterrupted stretch
- * and every reader below behaves exactly as it did before extensions existed. */
- parts?: readonly ClipPart[];
}
/** Which lane's speech the transcript tab is reading. */
@@ -62,9 +56,7 @@ export type TranscriptLane = "recording" | "voiceover";
* and not in source time (issue #560).
*/
export function placementRawSec(placement: TranscriptPlacement, sourceSec: number): number {
- return placement.parts
- ? partsRawSec(placement.parts, sourceSec)
- : placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
+ return placement.timelineStartSec + (sourceSec - placement.sourceStartSec);
}
/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
@@ -353,36 +345,14 @@ export function voiceoverPlacements(
);
}
-/**
- * The recording lane's placements: the clips, each carrying its own parts.
- *
- * One placement per clip, not one per part. The pane draws a header per placement, and a
- * clip does not become three clips because the user typed a word into it — the split
- * belongs to the mapping, which is where `parts` puts it.
- */
-export function recordingPlacements(
- clips: AxcutClip[],
- transcripts: readonly AxcutTranscript[],
-): TranscriptPlacement[] {
- const wordsByAsset = new Map(transcripts.map((t) => [t.assetId, t.words]));
- return clips.map((clip) => ({
- ...clip,
- parts: clipParts(clip, wordsByAsset.get(clip.assetId) ?? []),
- }));
-}
-
/** The placements a lane contributes, in timeline order. */
export function lanePlacements(
lane: TranscriptLane,
clips: AxcutClip[],
audioTracks: AxcutAudioTrack[],
removed: readonly RemovedRawSpan[] = [],
- /** Needed only by the recording lane, to know where its clips are interrupted. */
- transcripts: readonly AxcutTranscript[] = [],
): TranscriptPlacement[] {
- return lane === "voiceover"
- ? voiceoverPlacements(audioTracks, removed)
- : recordingPlacements(clips, transcripts);
+ return lane === "voiceover" ? voiceoverPlacements(audioTracks, removed) : clips;
}
/**
@@ -428,17 +398,8 @@ export function findCueWordId(sections: ClipSection[], rawSec: number | null): s
}
if (!match) return null;
- // An added word is spoken over its extension, which occupies ruler time and no source
- // time at all. Asked first, and by id: through source time it is indistinguishable from
- // the recorded word that resumes at the same second — which is why the pane used to skip
- // straight past every inserted word to the one after it.
- const spoken = match.clip.parts ? extensionAt(match.clip.parts, rawSec) : null;
- if (spoken) return clipWordId(match.clip.id, spoken);
-
// Back to the placement's own source clock, which is what the words are stamped in.
- const t = match.clip.parts
- ? partsSourceSec(match.clip.parts, rawSec)
- : match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
+ const t = match.clip.sourceStartSec + (rawSec - match.clip.timelineStartSec);
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/clip-parts.test.ts b/src/lib/ai-edition/timeline/clip-parts.test.ts
index 3ac97d4ce..447ac3dec 100644
--- a/src/lib/ai-edition/timeline/clip-parts.test.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.test.ts
@@ -1,114 +1,15 @@
-// The one property everything above this module depends on: the parts are contiguous, they
-// start where the clip starts, and no stored source coordinate moved to achieve it.
+// The rules the writer and the generator both have to agree on. Everything else an
+// insertion needs is in `document/insertion.ts` — it is a clip, and clips are already tested.
import { describe, expect, it } from "vitest";
-import { resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutDocument, AxcutWord } from "../schema";
-import {
- baseClipId,
- clipParts,
- extensionAt,
- extensionClipPath,
- extensionDurationSec,
- partsLengthSec,
- partsRawSec,
- partsSourceSec,
- withExtensions,
-} from "./clip-parts";
-
-const CLIP: AxcutClip = {
- id: "c1",
- assetId: "a1",
- sourceStartSec: 0,
- sourceEndSec: 10,
- timelineStartSec: 0,
- timelineEndSec: 10,
- wordRefs: [],
- origin: "user",
- reason: "",
-};
-
-const word = (over: Partial & { id: string; startSec: number }): AxcutWord => ({
- segmentId: "s1",
- endSec: over.startSec,
- text: "hello",
- ...over,
-});
-
-const added = (id: string, at: number, text: string): AxcutWord =>
- word({ id, startSec: at, text, source: "synth" });
-
-describe("clipParts", () => {
- it("is one recording part when nothing was added", () => {
- expect(clipParts(CLIP, [word({ id: "w1", startSec: 1 })])).toEqual([
- {
- kind: "recording",
- timelineStartSec: 0,
- timelineEndSec: 10,
- sourceStartSec: 0,
- sourceEndSec: 10,
- },
- ]);
- });
-
- it("splits the recording where the word was added, extension between the halves", () => {
- const parts = clipParts(CLIP, [added("s1", 4, "really")]);
- expect(parts.map((p) => p.kind)).toEqual(["recording", "extension", "recording"]);
- expect(parts[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 });
- // "really" is 6 chars at 15/s.
- expect(parts[1]).toMatchObject({ kind: "extension", wordId: "s1", timelineStartSec: 4 });
- expect(parts[1].timelineEndSec).toBeCloseTo(4.4, 6);
- // The SOURCE window of the second half is untouched — it resumes where it left off.
- expect(parts[2]).toMatchObject({ sourceStartSec: 4, sourceEndSec: 10 });
- expect(parts[2].timelineStartSec).toBeCloseTo(4.4, 6);
- });
-
- it("leaves every stored source coordinate exactly where it was", () => {
- // The whole reason the extension is appended to the LIST and not spliced into the
- // media's axis: adding one cannot move an anchor anyone else stored.
- const parts = clipParts(CLIP, [added("s1", 4, "really"), added("s2", 7, "quite")]);
- const recorded = parts.filter((p) => p.kind === "recording");
- expect(recorded.map((p) => [p.sourceStartSec, p.sourceEndSec])).toEqual([
- [0, 4],
- [4, 7],
- [7, 10],
- ]);
- });
-
- it("is contiguous, and starts where the clip starts", () => {
- const parts = clipParts({ ...CLIP, timelineStartSec: 12, timelineEndSec: 22 }, [
- added("s1", 4, "really"),
- added("s2", 7, "quite"),
- ]);
- expect(parts[0].timelineStartSec).toBe(12);
- for (const [i, part] of parts.slice(0, -1).entries()) {
- expect(part.timelineEndSec).toBeCloseTo(parts[i + 1].timelineStartSec, 9);
- }
- });
-
- it("grows the clip by exactly what was added, and by nothing else", () => {
- const bare = partsLengthSec(clipParts(CLIP, []));
- const grown = partsLengthSec(clipParts(CLIP, [added("s1", 4, "really")]));
- expect(bare).toBeCloseTo(10, 6);
- expect(grown - bare).toBeCloseTo(extensionDurationSec("really"), 6);
- });
-
- it("ignores a word another clip of the same recording plays", () => {
- const late = { ...CLIP, sourceStartSec: 6, sourceEndSec: 10, timelineEndSec: 4 };
- expect(clipParts(late, [added("s1", 4, "really")]).map((p) => p.kind)).toEqual(["recording"]);
- });
-
- it("ignores an empty word, which buys nothing", () => {
- expect(clipParts(CLIP, [added("s1", 4, " ")]).map((p) => p.kind)).toEqual(["recording"]);
- });
-});
+import { extensionAssetId, extensionClipPath, extensionDurationSec } from "./clip-parts";
describe("extensionDurationSec", () => {
it("is the text's own length at the assumed rate", () => {
expect(extensionDurationSec("really")).toBeCloseTo(6 / 15, 6);
});
- it("never returns a span too short to be a part", () => {
+ it("never returns a span too short to be a clip", () => {
expect(extensionDurationSec("a")).toBe(0.15);
});
@@ -117,141 +18,31 @@ describe("extensionDurationSec", () => {
});
});
-// ─── What everything that RENDERS sees ──────────────────────────────────────
-// Every mapping downstream — the ruler, the native decoder swap, the exporter, the DOM
-// player — is built on "a clip is an uninterrupted shift from source to ruler". So the
-// interruption is resolved into the shape they already handle, and below this line an
-// extension is simply a clip that plays a generated file.
-
-const DOC = (words: AxcutWord[], clips: AxcutClip[] = [CLIP]): AxcutDocument =>
- ({
- assets: [
- {
- id: "a1",
- kind: "video",
- label: "take",
- originalPath: "C:/rec/take.mp4",
- video: { width: 1920, height: 1080, fps: 30 },
- cameraTrack: null,
- },
- ],
- transcripts: [{ assetId: "a1", words }],
- timeline: { clips, trimRanges: [] },
- }) as unknown as AxcutDocument;
+/** One backslash, built rather than escaped: the escape is what this test keeps losing. */
+const BS = String.fromCharCode(92);
-describe("withExtensions", () => {
- const doc = DOC([added("synth_1", 4, "really")]);
-
- it("makes the extension a clip on an asset of its own", () => {
- const out = withExtensions(doc);
- expect(out.timeline.clips.map((c) => c.assetId)).toEqual(["a1", "ext:synth_1", "a1"]);
- expect(out.timeline.clips[1]).toMatchObject({ sourceStartSec: 0 });
- expect(out.timeline.clips[1].sourceEndSec).toBeCloseTo(6 / 15, 6);
- });
-
- it("gives that asset the file the generator writes, so the decoder can open it", () => {
- const asset = withExtensions(doc).assets.find((a) => a.id === "ext:synth_1");
- expect(asset?.originalPath).toBe(
- extensionClipPath("C:/rec/take.mp4", "synth_1", extensionDurationSec("really")),
+describe("extensionClipPath", () => {
+ it("sits beside the recording it belongs to, in a hidden folder", () => {
+ expect(extensionClipPath("C:/rec/take.mp4", "synth_2", 3.6)).toBe(
+ "C:/rec/.openscreen-extensions/synth_2_3600.mp4",
);
- // The recording's geometry: the generated file was made to match it.
- expect(asset?.video).toMatchObject({ width: 1920, height: 1080 });
});
- it("lays them end to end, so no mapping downstream sees a gap", () => {
- const out = withExtensions(doc).timeline.clips;
- expect(out[0].timelineStartSec).toBe(0);
- for (const [i, clip] of out.slice(0, -1).entries()) {
- expect(clip.timelineEndSec).toBeCloseTo(out[i + 1].timelineStartSec, 9);
- }
- });
-
- it("is the same document when no word was added", () => {
- const plain = DOC([]);
- expect(withExtensions(plain)).toBe(plain);
- });
-
- it("is idempotent — deriving twice must not split the halves again", () => {
- const once = withExtensions(doc);
- expect(withExtensions(once)).toBe(once);
- });
-
- it("keeps every piece answering to the name a trim knows the clip by", () => {
- const ids = withExtensions(doc).timeline.clips.map((c) => baseClipId(c.id));
- expect(ids).toEqual(["c1", "c1", "c1"]);
- });
-
- it("still cuts the recording, and never the extension", () => {
- // A trim is anchored in the RECORDING's seconds; an extension has none. Cutting
- // source 5..6 shortens the half AFTER the added word and nothing else.
- const clips = withExtensions(doc).timeline.clips;
- const trims = [
- { id: "t1", clipId: "c1", assetId: "a1", startSec: 5, endSec: 6 },
- ] as unknown as Parameters[1];
- const segs = resolvePlaybackSegments(clips, trims);
- const recorded = segs.filter((s) => !s.assetId.startsWith("ext:"));
- expect(recorded.map((s) => [s.sourceStartSec, s.sourceEndSec])).toEqual([
- [0, 4],
- [4, 5],
- [6, 10],
- ]);
- expect(segs.filter((s) => s.assetId.startsWith("ext:"))).toHaveLength(1);
- });
-});
-
-// ─── The one conversion ─────────────────────────────────────────────────────
-// Every reader used to write `timelineStartSec + (sec - sourceStartSec)` for itself. That
-// subtraction is right until a clip carries an extension and wrong for every second after
-// it, which is one bug per reader — so it lives here now, once.
-
-describe("partsRawSec", () => {
- const parts = clipParts(CLIP, [added("s1", 4, "really")]);
- const ext = extensionDurationSec("really");
-
- it("leaves the seconds before the insertion where they were", () => {
- expect(partsRawSec(parts, 2)).toBeCloseTo(2, 6);
- });
-
- it("pushes the seconds after it along by exactly what was inserted", () => {
- expect(partsRawSec(parts, 6)).toBeCloseTo(6 + ext, 6);
- expect(partsRawSec(parts, 10)).toBeCloseTo(10 + ext, 6);
- });
-
- it("puts the split second AFTER the extension, where its media actually plays", () => {
- expect(partsRawSec(parts, 4)).toBeCloseTo(4 + ext, 6);
- });
-
- it("is the plain subtraction when nothing was added", () => {
- const plain = clipParts({ ...CLIP, timelineStartSec: 12, timelineEndSec: 22 }, []);
- expect(partsRawSec(plain, 6)).toBeCloseTo(18, 6);
- });
-});
-
-describe("partsSourceSec", () => {
- const parts = clipParts(CLIP, [added("s1", 4, "really")]);
- const ext = extensionDurationSec("really");
-
- it("undoes partsRawSec for a second the recording actually plays", () => {
- for (const sec of [0, 2, 4, 6, 10]) {
- expect(partsSourceSec(parts, partsRawSec(parts, sec))).toBeCloseTo(sec, 6);
- }
+ it("carries the duration, so a re-typed word asks for a different file", () => {
+ expect(extensionClipPath("C:/rec/take.mp4", "synth_2", 3.8)).not.toBe(
+ extensionClipPath("C:/rec/take.mp4", "synth_2", 3.6),
+ );
});
- it("parks at the split while the extension plays — no recording runs there", () => {
- expect(partsSourceSec(parts, 4 + ext / 2)).toBeCloseTo(4, 6);
+ it("is the same rule on a Windows path, so both processes name one file", () => {
+ expect(extensionClipPath(`C:${BS}rec${BS}take.mp4`, "w1", 1)).toBe(
+ `C:${BS}rec${BS}.openscreen-extensions${BS}w1_1000.mp4`,
+ );
});
});
-describe("extensionAt", () => {
- const parts = clipParts(CLIP, [added("s1", 4, "really")]);
- const ext = extensionDurationSec("really");
-
- it("names the word being spoken over its own media", () => {
- expect(extensionAt(parts, 4 + ext / 2)).toBe("s1");
- });
-
- it("is nothing on either side of it, so the recorded words keep the highlight", () => {
- expect(extensionAt(parts, 3.9)).toBeNull();
- expect(extensionAt(parts, 4 + ext + 0.01)).toBeNull();
+describe("extensionAssetId", () => {
+ it("cannot collide with a real asset id, and says what it is in a log line", () => {
+ expect(extensionAssetId("synth_1")).toBe("ext:synth_1");
});
});
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
index c7617dad3..470ede0e3 100644
--- a/src/lib/ai-edition/timeline/clip-parts.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -1,19 +1,12 @@
-// The insertion layer: media -> parts -> clip.
+// What an insertion is made of: a duration, a name, and a file.
//
-// A clip does not read a file, it reads a LIST of parts:
-//
-// [ recording 0→5.3 ] [ extension 0.4s ] [ recording 5.3→20.9 ]
-//
-// The extension does NOT slide into the media's own axis — it is appended to the clip's
-// list. That is the decision the whole design rests on, and its consequence is the point:
-// no stored source coordinate ever moves. A word, a trim, a zoom keeps the second it was
-// authored at, for ever. Adding or removing an extension cannot corrupt an anchor.
-//
-// This is the ONLY module that knows an extension exists. Above it, a part is just media
-// with a source window and a place on the timeline, and the plain arithmetic every reader
-// used before insertions works again.
+// An insertion IS a clip (see `document/insertion.ts`), so there is nothing here that maps
+// between coordinate systems and nothing downstream that knows an insertion exists. What
+// remains is the handful of rules the writer and the generator both have to agree on — how
+// long an added word takes, what its asset is called, and where its file lives — expressed
+// once so the renderer names the file the main process writes.
-import type { AxcutAsset, AxcutClip, AxcutDocument, AxcutWord } from "../schema";
+import type { AxcutWord } from "../schema";
/** How fast a synthesized voice will be assumed to speak, in characters per second.
*
@@ -22,8 +15,7 @@ import type { AxcutAsset, AxcutClip, AxcutDocument, AxcutWord } from "../schema"
* ponytail: fixed rate, ask the synthesizer for the real duration once there is one. */
const CHARS_PER_SEC = 15;
-/** Below this the extension is not worth a part of its own: the clip would gain a few
- * frames nobody asked for and every reader would carry a degenerate span. */
+/** Below this the clip would be a few frames nobody asked for. */
export const MIN_EXTENSION_SEC = 0.15;
/** How long the media for an added word has to be. */
@@ -38,296 +30,26 @@ export function isAddedWord(word: AxcutWord): boolean {
return word.source === "synth";
}
-export type ClipPart =
- | {
- kind: "recording";
- timelineStartSec: number;
- timelineEndSec: number;
- /** The window of the clip's own asset this part plays. */
- sourceStartSec: number;
- sourceEndSec: number;
- }
- | {
- kind: "extension";
- timelineStartSec: number;
- timelineEndSec: number;
- /** The word this media exists for. Its file is derived from the pair. */
- wordId: string;
- text: string;
- };
-
-/**
- * One clip, as the ordered list of media it actually plays.
- *
- * Added words split the clip's source window where they sit — at the END of the word they
- * follow — and their extension goes between the halves. A word outside the window belongs
- * to another clip of the same recording and is not this clip's business.
- *
- * `words` is the transcript of the clip's OWN asset; passing another's yields the clip
- * unsplit, which is the right answer rather than an error.
- */
-export function clipParts(clip: AxcutClip, words: readonly AxcutWord[]): ClipPart[] {
- const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
- const parts: ClipPart[] = [];
- let timeline = clip.timelineStartSec;
- let from = clip.sourceStartSec;
-
- const added = words
- .filter(
- (word) =>
- isAddedWord(word) &&
- word.startSec > clip.sourceStartSec &&
- word.startSec <= sourceEnd &&
- extensionDurationSec(word.text) > 0,
- )
- .sort((a, b) => a.startSec - b.startSec);
-
- const pushRecording = (to: number) => {
- if (to - from <= 1e-6) return;
- parts.push({
- kind: "recording",
- timelineStartSec: timeline,
- timelineEndSec: timeline + (to - from),
- sourceStartSec: from,
- sourceEndSec: to,
- });
- timeline += to - from;
- from = to;
- };
-
- for (const word of added) {
- pushRecording(Math.min(word.startSec, sourceEnd));
- const durationSec = extensionDurationSec(word.text);
- parts.push({
- kind: "extension",
- timelineStartSec: timeline,
- timelineEndSec: timeline + durationSec,
- wordId: word.id,
- text: word.text,
- });
- timeline += durationSec;
- }
- pushRecording(sourceEnd);
-
- return parts;
-}
-
-/** Where the generated media for an added word lives.
- *
- * Beside the recording it was cut from, in a hidden sibling folder, and derived by pure
- * string work from the asset path and the word — so the renderer and the main process
- * arrive at the same path without asking each other. That is what keeps the file DERIVED:
- * no one stores it, anyone can name it, and the process that can spawn ffmpeg is the only
- * one that has to create it.
- *
- * The name carries the duration, so a re-typed word asks for a different file and a stale
- * one is simply never named again. */
-export function extensionClipPath(assetPath: string, wordId: string, durationSec: number): string {
- const sep = assetPath.includes("\\") ? "\\" : "/";
- const dir = assetPath.slice(0, Math.max(0, assetPath.lastIndexOf(sep)));
- return `${dir}${sep}${EXTENSIONS_DIR}${sep}${wordId}_${Math.round(durationSec * 1000)}.mp4`;
-}
-
-/** Marks every id this module derives. Never produced by the writers, so `withExtensions`
- * can recognise a document it has already derived — and a log line says what it is. */
+/** Marks every id an insertion owns — its asset and its clip share it. Never produced by
+ * anything else, so a reader can tell generated media from a recording at a glance. */
export const EXTENSION_ID_PREFIX = "ext:";
-/** The id an extension's media answers to. */
+/** The id an insertion's media answers to. */
export function extensionAssetId(wordId: string): string {
return `${EXTENSION_ID_PREFIX}${wordId}`;
}
-/** Separates a derived piece from the clip it was cut out of. Double, so it cannot collide
- * with the single underscores every generated clip id already carries. */
-const PIECE = "__";
-
-/** The stored clip a derived piece came from. A trim names a clip by id, and both halves of
- * a split clip are still that clip — so both must answer to its name. */
-export function baseClipId(clipId: string): string {
- const at = clipId.indexOf(PIECE);
- return at < 0 ? clipId : clipId.slice(0, at);
-}
-
-/**
- * The document as everything that RENDERS it should see it: an extension is a clip, on an
- * asset, with a file.
- *
- * This is the whole insertion layer, and it exists because of one property every mapping in
- * this codebase is built on — a clip is an UNINTERRUPTED shift between its source seconds
- * and the ruler. `timelineMap`, the native `setActiveClip`, the exporter and the DOM player
- * all assume it. A clip interrupted by generated media breaks that assumption in every one
- * of them at once, which is why teaching them each about extensions never converged: the
- * fix was eight special cases, and it was still one bug.
- *
- * So the interruption is resolved HERE, once, into the shape they already handle. Below this
- * line there are no extensions — only clips, some of which happen to play a generated file.
- *
- * Derived, never stored: one direction, nothing to reconcile. The stored clip keeps its own
- * single identity and its own source window, and the words remain the only truth about what
- * was added.
- */
-export function withExtensions(document: AxcutDocument): AxcutDocument {
- // Already derived — deriving again would split the halves a second time, because the
- // added word sits exactly on the first half's end.
- if (document.assets.some((a) => a.id.startsWith(EXTENSION_ID_PREFIX))) return document;
-
- const wordsByAsset = new Map(document.transcripts.map((t) => [t.assetId, t.words]));
- const assetById = new Map(document.assets.map((a) => [a.id, a]));
- const assets = [...document.assets];
-
- const clips = document.timeline.clips.flatMap((clip): AxcutClip[] => {
- const parts = clipParts(clip, wordsByAsset.get(clip.assetId) ?? []);
- if (parts.length < 2) return [clip];
- const source = assetById.get(clip.assetId);
- let piece = 0;
- return parts.flatMap((part): AxcutClip[] => {
- if (part.kind === "recording") {
- piece += 1;
- return [
- {
- ...clip,
- // The first piece keeps the clip's own name so anything holding it still
- // finds something; `baseClipId` is what makes the rest answer to it too.
- id: piece === 1 ? clip.id : `${clip.id}${PIECE}r${piece}`,
- sourceStartSec: part.sourceStartSec,
- sourceEndSec: part.sourceEndSec,
- timelineStartSec: part.timelineStartSec,
- timelineEndSec: part.timelineEndSec,
- },
- ];
- }
- // Nothing to name the file after, so nothing to play: the clip simply stays short
- // of what the word asked for, rather than pointing at a path that cannot exist.
- if (!source?.originalPath) return [];
- const durationSec = part.timelineEndSec - part.timelineStartSec;
- const id = extensionAssetId(part.wordId);
- if (!assetById.has(id)) {
- const asset: AxcutAsset = {
- id,
- kind: "video",
- label: part.text.trim().slice(0, 40) || part.wordId,
- originalPath: extensionClipPath(source.originalPath, part.wordId, durationSec),
- durationSec,
- // The recording's geometry, because the generated file was made to match it.
- video: source.video,
- cameraTrack: null,
- };
- assetById.set(id, asset);
- assets.push(asset);
- }
- return [
- {
- ...clip,
- id: `${clip.id}${PIECE}ext_${part.wordId}`,
- assetId: id,
- // Authored against the recording's framing, which this is not.
- cropRegion: undefined,
- sourceStartSec: 0,
- sourceEndSec: durationSec,
- timelineStartSec: part.timelineStartSec,
- timelineEndSec: part.timelineEndSec,
- },
- ];
- });
- });
-
- if (assets.length === document.assets.length) return document;
- return { ...document, assets, timeline: { ...document.timeline, clips } };
-}
-
/** Hidden, because it is derived: deleting it costs nothing but a regeneration. */
export const EXTENSIONS_DIR = ".openscreen-extensions";
-/** What the clip's timeline length has to be, given its parts. The stored `timelineEndSec`
- * is this — the writer that adds a word is what keeps them equal. */
-export function partsLengthSec(parts: readonly ClipPart[]): number {
- if (parts.length === 0) return 0;
- return parts[parts.length - 1].timelineEndSec - parts[0].timelineStartSec;
-}
-
-/** The parts that carry source time. An extension has none, which is the whole point. */
-function recordings(parts: readonly ClipPart[]): Array> {
- return parts.filter((p): p is Extract => p.kind === "recording");
-}
-
-/**
- * A source second of the clip's media, as a second on the ruler. THE conversion.
- *
- * Every reader that writes `timelineStartSec + (sec - sourceStartSec)` is wrong the moment
- * the clip carries an extension: the seconds after one are pushed along by it, and the
- * reader lands everything early by the total inserted before it. That is one bug with one
- * home, not one per reader.
- *
- * A second sitting exactly where an insertion split maps to the moment AFTER the extension,
- * because that is where the recorded media of that second actually plays. The added word
- * itself never asks this question — it has no source second at all, and `extensionAt`
- * names its moment directly.
- */
-export function partsRawSec(parts: readonly ClipPart[], sourceSec: number): number {
- const played = recordings(parts);
- if (played.length === 0) return parts[0]?.timelineStartSec ?? sourceSec;
- let part = played[0];
- for (const candidate of played) {
- if (candidate.sourceStartSec <= sourceSec) part = candidate;
- }
- return part.timelineStartSec + (sourceSec - part.sourceStartSec);
-}
-
-/**
- * The inverse: a second on the ruler, as a second of the clip's media.
- *
- * Inside an extension the source clock is PARKED at the second the insertion split. That is
- * the honest answer — none of the recording plays there, and the split is the last second
- * that did.
- */
-export function partsSourceSec(parts: readonly ClipPart[], rawSec: number): number {
- const played = recordings(parts);
- if (played.length === 0) return rawSec;
- let part = played[0];
- for (const candidate of played) {
- if (candidate.timelineStartSec <= rawSec) part = candidate;
- }
- return Math.min(part.sourceEndSec, part.sourceStartSec + (rawSec - part.timelineStartSec));
-}
-
-/**
- * The added word being spoken at this moment on the ruler, if any.
- *
- * Asked of the parts directly rather than resolved through source time, where an extension
- * and the media that follows it share one second and nothing could tell them apart.
- */
-export function extensionAt(parts: readonly ClipPart[], rawSec: number): string | null {
- for (const part of parts) {
- if (part.kind !== "extension") continue;
- if (rawSec >= part.timelineStartSec && rawSec < part.timelineEndSec) return part.wordId;
- }
- return null;
-}
-
-/**
- * The ruler span of the extension inserted at this source second, if there is one.
+/** Where the generated media for an added word lives.
*
- * The answer to a question source time cannot express: an added word occupies no source
- * seconds, so anything measuring it there measures zero. Its span lives on its part.
- */
-export function extensionSpanAtSource(
- parts: readonly ClipPart[],
- sourceSec: number,
-): { startSec: number; endSec: number } | null {
- const played = recordings(parts);
- for (const [i, part] of parts.entries()) {
- if (part.kind !== "extension") continue;
- // The recording part that ENDS where this extension begins is the one whose last
- // source second the word was typed after.
- const before =
- parts
- .slice(0, i)
- .filter((p) => p.kind === "recording")
- .at(-1) ?? played[0];
- const at = before ? before.sourceEndSec : sourceSec;
- if (Math.abs(at - sourceSec) < 1e-6) {
- return { startSec: part.timelineStartSec, endSec: part.timelineEndSec };
- }
- }
- return null;
+ * Beside the recording, in a hidden sibling folder, and derived by pure string work from
+ * the asset path and the word — so the renderer and the main process arrive at the same
+ * path without asking each other. The name carries the duration, so a re-typed word asks
+ * for a different file and a stale one is simply never named again. */
+export function extensionClipPath(assetPath: string, wordId: string, durationSec: number): string {
+ const sep = assetPath.includes("\\") ? "\\" : "/";
+ const dir = assetPath.slice(0, Math.max(0, assetPath.lastIndexOf(sep)));
+ return `${dir}${sep}${EXTENSIONS_DIR}${sep}${wordId}_${Math.round(durationSec * 1000)}.mp4`;
}
diff --git a/src/lib/ai-edition/timeline/trim-mapping.ts b/src/lib/ai-edition/timeline/trim-mapping.ts
index 62a79b4ce..0dee56d7d 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.ts
@@ -15,7 +15,6 @@
// had no answer and each caller invented its own. See `trimAppliesToClip`.
import type { AxcutClip, AxcutTrimRange } from "../schema";
-import { baseClipId } from "./clip-parts";
import { type CoalescedSpan, ventilateSpanAcrossClips } from "./region-ventilation";
import { coalesceByIdentity, regionIdentityKey } from "./timelineMap";
@@ -47,10 +46,7 @@ export function trimAppliesToClip(
trim: TrimAnchor,
clip: Pick,
): boolean {
- // `baseClipId`, not the id itself: an insertion splits a clip into pieces that are all
- // still the clip the trim was authored on, and matching the piece would have left the
- // cut applied to the first half only.
- if (trim.clipId !== undefined) return trim.clipId === baseClipId(clip.id);
+ if (trim.clipId !== undefined) return trim.clipId === clip.id;
return trim.assetId === clip.assetId;
}
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index ef6743476..ce22db0ed 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -40,7 +40,6 @@ import {
import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
-import { withExtensions } from "@/lib/ai-edition/timeline/clip-parts";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
@@ -521,10 +520,7 @@ function clipAssetIsResolvable(
* Returns `PlaybackSegment[]`, not `AxcutClip[]`: a held segment carries `heldSec`, and
* widening it away here is what kept the pause from ever reaching the compositor. Every
*/
-export function resolveVisibleClips(rawDocument: AxcutDocument): PlaybackSegment[] {
- // An extension is a clip on its own asset from here down. Idempotent, so a caller that
- // already derived the document pays nothing.
- const document = withExtensions(rawDocument);
+export function resolveVisibleClips(document: AxcutDocument): PlaybackSegment[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
.sort((a, b) => a.timelineStartSec - b.timelineStartSec)
@@ -533,12 +529,9 @@ export function resolveVisibleClips(rawDocument: AxcutDocument): PlaybackSegment
/** Serialize a document into a {@link SceneDescription}. Pure — no per-frame math. */
export function buildSceneDescription(
- rawDocument: AxcutDocument,
+ document: AxcutDocument,
webcamSourceSize: { width: number; height: number } | null = null,
): SceneDescription {
- // Once, at the top: every list below — the assets, the clips, the raw layout the regions
- // are projected through — has to agree about what the film contains.
- const document = withExtensions(rawDocument);
const settings = getEditorSettings(document);
const assetById = new Map(document.assets.map((a) => [a.id, a]));
@@ -792,9 +785,7 @@ export function buildSceneDescription(
const captionAspect = outputDims.height > 0 ? outputDims.width / outputDims.height : 16 / 9;
const captionSettings = getCaptionSettings(document, captionAspect);
const captionRegions = captionCuesToTextRegions(
- // `rawDocument`: the caption path resolves the insertion itself, through the clip's
- // parts, and handing it the derived clips would ask the same question twice.
- deriveCaptionCues(rawDocument, captionSettings, getCaptionTranslations(document)),
+ deriveCaptionCues(document, captionSettings, getCaptionTranslations(document)),
captionSettings,
captionAspect,
);
From 10a2bb935e994ea28df864f0f026e10a62514231 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 16:27:10 +0200
Subject: [PATCH 101/113] refactor(timeline): the seam a clip leaves closes
only under generated media
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tried it as the general invariant first — two adjacent clips that are one continuous piece
of media are one clip, everywhere, so `insertion.ts` never learns how its cut goes back
together. The premise it rests on is that two identical clips are laid side by side
precisely when they are NOT joined in time. This codebase disagrees, twice, and its own
tests said so:
- `duplicateClip` copies a clip that sits before a contiguous neighbour. The copy is then
contiguous with it, and swallowing the neighbour is not what "duplicate" means.
- `replaceTimeline` cuts a recording into consecutive clips on purpose, so each piece can
carry its own zoom. Joining them on the next unrelated delete collapses structure the user
asked for.
So the rule is narrowed on both axes, and both halves of that narrowness are paid for: only
at the SEAM the departing clip was filling, and only when that clip was GENERATED media.
What is left is exactly the inverse of what an insertion did, without an insertion having
marked anything — generated media leaves no seam behind it. Deleting the clip and dragging
it elsewhere both heal the cut, and `insertion.ts` still knows nothing about either.
No marker on the cut, and no `baseClipId`: a parent id would have to survive every move that
makes it wrong, and the departing clip already says everything needed at the moment it
matters.
The three mutators that hand-rolled `resequenceClips` + `rederiveRegionMs` now share one
pass. The join sits beside it as the variant a removal or a move uses.
One real bug found by writing the move test: the first version sorted by `timelineStartSec`
before joining, and at that point the positions are still the pre-move ones — every reorder
was silently sorted back where it came from. Adjacency is ARRAY adjacency.
---
src/lib/ai-edition/document/insertion.test.ts | 58 ++++-
src/lib/ai-edition/document/insertion.ts | 7 +-
src/lib/ai-edition/document/timeline.ts | 227 ++++++++++--------
src/lib/ai-edition/store/useTimeline.ts | 9 +-
src/lib/ai-edition/timeline/clip-parts.ts | 5 +
5 files changed, 197 insertions(+), 109 deletions(-)
diff --git a/src/lib/ai-edition/document/insertion.test.ts b/src/lib/ai-edition/document/insertion.test.ts
index 8a0aae028..69e14ca63 100644
--- a/src/lib/ai-edition/document/insertion.test.ts
+++ b/src/lib/ai-edition/document/insertion.test.ts
@@ -7,7 +7,7 @@
import { describe, expect, it } from "vitest";
import type { AxcutDocument } from "../schema";
import { insertGeneratedClip, removeGeneratedClips, retextGeneratedClip } from "./insertion";
-import { resolvePlaybackSegments } from "./timeline";
+import { moveClip, removeClip, resolvePlaybackSegments } from "./timeline";
const doc = (over: Partial = {}): AxcutDocument =>
({
@@ -163,12 +163,68 @@ describe("removeGeneratedClips", () => {
expect(back.timeline.clips[1].timelineStartSec).toBeCloseTo(4, 6);
});
+ it("rejoins the recording when the generated clip is dragged away instead", () => {
+ // Nothing in `insertion.ts` knows this happens. The clip list holds the invariant, so
+ // moving the insertion out of the middle heals the cut exactly as deleting it does.
+ const next = withInsertion();
+ const moved = moveClip(next, "ext:synth_1", 2, "user", "");
+ expect(moved.timeline.clips.map((c) => c.assetId)).toEqual(["a1", "ext:synth_1"]);
+ expect(moved.timeline.clips[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 10 });
+ });
+
it("is a no-op for a word that has no clip", () => {
const base = doc();
expect(removeGeneratedClips(base, ["synth_9"])).toBe(base);
});
});
+describe("the seam only closes under generated media", () => {
+ it("leaves two halves of a recording apart when an ORDINARY clip between them goes", () => {
+ // Cutting a recording into consecutive clips is something this app does on purpose —
+ // `replaceTimeline` builds exactly that so each piece can carry its own zoom. Deleting
+ // a B-roll clip laid between two of them must not collapse the two into one.
+ const base = doc({
+ assets: [
+ ...doc().assets,
+ {
+ id: "a2",
+ kind: "video",
+ label: "broll",
+ originalPath: "C:/rec/broll.mp4",
+ cameraTrack: null,
+ },
+ ],
+ } as never);
+ base.timeline.clips = [
+ { ...base.timeline.clips[0], id: "left", sourceEndSec: 4, timelineEndSec: 4 },
+ {
+ ...base.timeline.clips[0],
+ id: "broll",
+ assetId: "a2",
+ sourceStartSec: 0,
+ sourceEndSec: 2,
+ timelineStartSec: 4,
+ timelineEndSec: 6,
+ },
+ {
+ ...base.timeline.clips[0],
+ id: "right",
+ sourceStartSec: 4,
+ sourceEndSec: 10,
+ timelineStartSec: 6,
+ timelineEndSec: 12,
+ },
+ ];
+ const after = removeClip(base, "broll");
+ expect(after.timeline.clips.map((c) => c.id)).toEqual(["left", "right"]);
+ });
+
+ it("keeps a word that has no clip a no-op", () => {
+ const base = doc();
+ expect(removeGeneratedClips(base, ["synth_9"])).toBe(base);
+ });
+});
+
describe("retextGeneratedClip", () => {
it("resizes the clip to the new text and renames the file it plays", () => {
const next = retextGeneratedClip(withInsertion(), "synth_1", "a much longer sentence");
diff --git a/src/lib/ai-edition/document/insertion.ts b/src/lib/ai-edition/document/insertion.ts
index 8a25c2375..49c2ffc66 100644
--- a/src/lib/ai-edition/document/insertion.ts
+++ b/src/lib/ai-edition/document/insertion.ts
@@ -21,10 +21,10 @@
import type { AxcutAsset, AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
import {
- EXTENSION_ID_PREFIX,
extensionAssetId,
extensionClipPath,
extensionDurationSec,
+ isGeneratedAssetId,
} from "../timeline/clip-parts";
import { createId } from "./ids";
import { rederiveRegionMs, removeClip, resequenceClips } from "./timeline";
@@ -32,10 +32,7 @@ import { rederiveRegionMs, removeClip, resequenceClips } from "./timeline";
/** Where a new word goes relative to the word the caret was resting on. */
export type InsertSide = "before" | "after";
-/** True for the asset — and the clip, which shares its id — of a generated insertion. */
-export function isGeneratedAssetId(id: string): boolean {
- return id.startsWith(EXTENSION_ID_PREFIX);
-}
+export { isGeneratedAssetId };
const EPS = 1e-6;
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index e1ca19a5a..169a9cdb6 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -14,6 +14,7 @@ import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "
*/
export type PlaybackSegment = AxcutClip;
+import { isGeneratedAssetId } from "../timeline/clip-parts";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -887,15 +888,9 @@ export function moveClip(
const remaining = document.timeline.clips.filter((c) => c.id !== clipId);
const bounded = Math.max(0, Math.min(insertIndex, remaining.length));
const reordered = [...remaining.slice(0, bounded), movingClip, ...remaining.slice(bounded)];
- const newClips = resequenceClips(reordered);
- const next: AxcutDocument = {
- ...document,
- timeline: {
- ...document.timeline,
- clips: newClips,
- },
- };
- return rederiveRegionMs(next, newClips);
+ // The seam is where it USED to be, shifted when it moved forward past its own hole.
+ const seam = bounded <= index ? index + 1 : index;
+ return withClipsJoined(document, reordered, movingClip, seam);
}
// ponytail: duplicate a clip (preserves the original). Used for "split this
@@ -929,19 +924,19 @@ export function duplicateClip(
};
const oldClips = document.timeline.clips;
const next = [...oldClips.slice(0, index + 1), copy, ...oldClips.slice(index + 1)];
- const newClips = resequenceClips(next);
const copiedTrims = document.timeline.trimRanges
.filter((t) => t.clipId === original.id)
.map((t) => ({ ...t, id: createId("trim"), clipId: copy.id }));
- const updatedDoc: AxcutDocument = {
- ...document,
- timeline: {
- ...document.timeline,
- clips: newClips,
- trimRanges: [...document.timeline.trimRanges, ...copiedTrims],
+ return withClipsChanged(
+ {
+ ...document,
+ timeline: {
+ ...document.timeline,
+ trimRanges: [...document.timeline.trimRanges, ...copiedTrims],
+ },
},
- };
- return rederiveRegionMs(updatedDoc, newClips);
+ next,
+ );
}
/**
@@ -971,12 +966,7 @@ export function setClipSourceRange(
? { ...c, sourceStartSec: lo, sourceEndSec: hi, timelineStartSec: 0, timelineEndSec: 0 }
: c,
);
- const newClips = resequenceClips(arr);
- const next: AxcutDocument = {
- ...document,
- timeline: { ...document.timeline, clips: newClips },
- };
- return rederiveRegionMs(next, newClips);
+ return withClipsChanged(document, arr);
}
/**
@@ -1041,6 +1031,114 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri
}
}
+/**
+ * The document, with its clip list changed to this one.
+ *
+ * The pass every clip mutation ends with, and the only place the clip list's invariant is
+ * enforced: TWO ADJACENT CLIPS THAT ARE ONE CONTINUOUS PIECE OF MEDIA ARE ONE CLIP. Same
+ * asset, source ranges that meet, same crop — then they play as one clip already and drawing
+ * them as two says nothing. Two identical clips are laid side by side precisely when they are
+ * NOT joined in time; joined, there is nothing to tell them apart.
+ *
+ * It is an invariant rather than a step in a few operations because everything that can
+ * create adjacency then gets it for free, and nothing has to remember to ask: deleting the
+ * clip between two halves, dragging one of them away, extending a clip until it meets its
+ * neighbour. It is also what lets an insertion be completely agnostic about being undone —
+ * `insertion.ts` cuts a clip and never learns how the pieces go back together.
+ *
+ * The cost, stated so it is a decision: two clips a user joined by hand fuse, and the only
+ * way back is to move them apart again before anything else re-lays the list. Rare, and
+ * cheaper than a marker on every cut that would have to survive every move.
+ *
+ * ponytail: no razor tool exists, so today this can only ever undo an insertion. Give the cut
+ * a marker the day a deliberate split with no gap becomes something a user can make.
+ */
+export function withClipsChanged(document: AxcutDocument, clips: AxcutClip[]): AxcutDocument {
+ const laid = resequenceClips(clips);
+ const next: AxcutDocument = {
+ ...document,
+ timeline: { ...document.timeline, clips: laid },
+ };
+ // `rederiveRegionMs` bails on an empty clip list — a guard against a transient wipe
+ // dropping every region — so there is nothing to refresh against.
+ return laid.length === 0 ? next : rederiveRegionMs(next, laid);
+}
+
+/**
+ * The same, for a clip that LEFT its place — deleted, or dragged elsewhere. The seam it was
+ * filling closes when it was GENERATED media and the two sides are one continuous piece of
+ * recording again.
+ *
+ * Narrow on purpose, and both halves of that narrowness were paid for:
+ *
+ * - Only at the seam. A pass over the whole list joins pairs that had nothing to do with
+ * the edit — `duplicateClip` copies a clip that sits before a contiguous neighbour, and
+ * the copy would swallow it.
+ * - Only for generated media. Two contiguous clips of one recording are a state this app
+ * builds ON PURPOSE: `replaceTimeline` cuts a recording into consecutive clips so each
+ * can carry its own zoom. Joining them on the next unrelated delete would collapse
+ * structure the user asked for.
+ *
+ * What is left is exactly the inverse of what an insertion did, without an insertion having
+ * marked anything: generated media leaves no seam behind it. `insertion.ts` cuts a clip and
+ * never learns how the pieces go back together.
+ */
+export function withClipsJoined(
+ document: AxcutDocument,
+ clips: AxcutClip[],
+ departed: AxcutClip,
+ seam: number,
+): AxcutDocument {
+ const left = clips[seam - 1];
+ const right = clips[seam];
+ if (!isGeneratedAssetId(departed.assetId) || !left || !right || !joinable(left, right)) {
+ return withClipsChanged(document, clips);
+ }
+ const merged: AxcutClip = {
+ ...left,
+ sourceEndSec: right.sourceEndSec,
+ timelineEndSec: left.timelineEndSec + (right.timelineEndSec - right.timelineStartSec),
+ wordRefs: [...left.wordRefs, ...right.wordRefs],
+ };
+ const joined = [...clips.slice(0, seam - 1), merged, ...clips.slice(seam + 1)];
+ return withClipsChanged(reanchorRows(document, new Map([[right.id, left.id]])), joined);
+}
+
+/** Same media, source ranges that meet, same framing. Crop is the only property a clip
+ * carries that two halves could legitimately disagree on, so it is the whole guard. */
+function joinable(left: AxcutClip, right: AxcutClip): boolean {
+ return (
+ left.assetId === right.assetId &&
+ left.sourceEndSec !== undefined &&
+ Math.abs(left.sourceEndSec - right.sourceStartSec) < 1e-6 &&
+ JSON.stringify(left.cropRegion ?? null) === JSON.stringify(right.cropRegion ?? null)
+ );
+}
+
+/** Move every row anchored to an absorbed clip onto the one that swallowed it. A trim, a
+ * zoom, an annotation and an audio take all name a clip the same way, and an id that no
+ * longer exists has to stop being named. */
+function reanchorRows(document: AxcutDocument, absorbed: Map): AxcutDocument {
+ const moved = mapAllRegionCollections(document, (regions) =>
+ regions.map((region) =>
+ hasCompleteClipAnchor(region) && absorbed.has(region.clipId)
+ ? { ...region, clipId: absorbed.get(region.clipId) as string }
+ : region,
+ ),
+ );
+ return {
+ ...moved,
+ timeline: {
+ ...moved.timeline,
+ trimRanges: moved.timeline.trimRanges.map((trim) =>
+ trim.clipId && absorbed.has(trim.clipId)
+ ? { ...trim, clipId: absorbed.get(trim.clipId) }
+ : trim,
+ ),
+ },
+ };
+}
+
/**
* The single mutator for "delete a clip". Removing a clip closes the gap: the survivors are
* re-laid back-to-back (`resequenceClips`) and every anchored pill's derived ms is refreshed
@@ -1053,20 +1151,14 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri
*/
export function removeClip(document: AxcutDocument, clipId: string): AxcutDocument {
const oldClips = document.timeline.clips;
+ const removedAt = oldClips.findIndex((c) => c.id === clipId);
+ if (removedAt < 0) return document;
const arr = oldClips.filter((c) => c.id !== clipId);
- if (arr.length === oldClips.length) return document;
- const { clips: joined, reanchor } = joinContiguous(arr);
- const newClips = resequenceClips(joined);
const next: AxcutDocument = {
...document,
timeline: {
...document.timeline,
- clips: newClips,
- trimRanges: document.timeline.trimRanges
- .filter((t) => t.clipId !== clipId)
- .map((t) =>
- t.clipId && reanchor.has(t.clipId) ? { ...t, clipId: reanchor.get(t.clipId) } : t,
- ),
+ trimRanges: document.timeline.trimRanges.filter((t) => t.clipId !== clipId),
},
};
// The asymmetry with the trim filter three lines up is deliberate, and the obvious
@@ -1094,71 +1186,14 @@ export function removeClip(document: AxcutDocument, clipId: string): AxcutDocume
// twice per delete. `rederiveRegionMs` bails on an empty clip list (a guard against
// a transient wipe deleting everything), which is why the empty case is handled
// here rather than left to it.
- if (newClips.length === 0) {
- return mapAllRegionCollections(next, (regions) =>
- regions.filter((region) => !(hasCompleteClipAnchor(region) && region.clipId === clipId)),
+ if (arr.length === 0) {
+ return mapAllRegionCollections(
+ { ...next, timeline: { ...next.timeline, clips: [] } },
+ (regions) =>
+ regions.filter((region) => !(hasCompleteClipAnchor(region) && region.clipId === clipId)),
);
}
- const reanchored =
- reanchor.size === 0
- ? next
- : mapAllRegionCollections(next, (regions) =>
- regions.map((region) =>
- hasCompleteClipAnchor(region) && reanchor.has(region.clipId)
- ? { ...region, clipId: reanchor.get(region.clipId) as string }
- : region,
- ),
- );
- return rederiveRegionMs(reanchored, newClips);
-}
-
-/**
- * Two clips that are one continuous piece of media are one clip.
- *
- * The inverse of the cut an insertion makes: take the generated clip away and the halves it
- * separated go back to being what they were. Asked only when a clip is REMOVED, which is
- * the only moment adjacency can appear — a pass over the whole timeline would join clips
- * the user meant to keep apart.
- *
- * Deliberately blind to WHY the two halves are contiguous: no marker, no parent id, nothing
- * to keep in sync. Same media, source ranges that meet, same crop — then they play as one
- * clip already, and drawing them as two is the only difference.
- *
- * ponytail: today nothing else can produce two contiguous clips of one media, so this can
- * only ever undo an insertion. Give the cut a marker the day a razor tool lands and a split
- * with no gap becomes something the user asked for.
- */
-function joinContiguous(clips: AxcutClip[]): {
- clips: AxcutClip[];
- /** Ids that went away, and the clip that now carries their content. */
- reanchor: Map;
-} {
- const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const out: AxcutClip[] = [];
- const reanchor = new Map();
- for (const clip of ordered) {
- const previous = out[out.length - 1];
- if (previous && joinable(previous, clip)) {
- out[out.length - 1] = {
- ...previous,
- sourceEndSec: clip.sourceEndSec,
- timelineEndSec: previous.timelineEndSec + (clip.timelineEndSec - clip.timelineStartSec),
- };
- reanchor.set(clip.id, previous.id);
- continue;
- }
- out.push(clip);
- }
- return { clips: out, reanchor };
-}
-
-function joinable(left: AxcutClip, right: AxcutClip): boolean {
- return (
- left.assetId === right.assetId &&
- left.sourceEndSec !== undefined &&
- Math.abs(left.sourceEndSec - right.sourceStartSec) < 1e-6 &&
- JSON.stringify(left.cropRegion ?? null) === JSON.stringify(right.cropRegion ?? null)
- );
+ return withClipsJoined(next, arr, oldClips[removedAt], removedAt);
}
export function restoreFullTimeline(document: AxcutDocument): AxcutDocument {
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 06a4e6b90..40e2408fd 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -21,11 +21,11 @@ import {
moveClip as moveClipInDocument,
PLACEHOLDER_DURATION_SEC,
type RegionKind,
- rederiveRegionMs,
removeClip as removeClipInDocument,
removeRegion as removeRegionInDocument,
resequenceClips,
setClipSourceRange,
+ withClipsChanged,
} from "../document/timeline";
import type { AxcutAudioTrack, AxcutClipCropRegion, AxcutDocument } from "../schema";
import { hasAnyClipWithCamera } from "../timeline/camera";
@@ -1165,12 +1165,7 @@ export function useTimeline() {
const arr = [...oldClips];
const at = Math.max(0, Math.min(arr.length, index));
arr.splice(at, 0, newClip);
- const newClips = resequenceClips(arr);
- const next: AxcutDocument = {
- ...currentDoc,
- timeline: { ...currentDoc.timeline, clips: newClips },
- };
- const finalDoc = rederiveRegionMs(next, newClips);
+ const finalDoc = withClipsChanged(currentDoc, arr);
if (!(await saveDocument(finalDoc, { history: true }))) return;
setClipSelection(newClip.id);
diff --git a/src/lib/ai-edition/timeline/clip-parts.ts b/src/lib/ai-edition/timeline/clip-parts.ts
index 470ede0e3..acc33bfab 100644
--- a/src/lib/ai-edition/timeline/clip-parts.ts
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -34,6 +34,11 @@ export function isAddedWord(word: AxcutWord): boolean {
* anything else, so a reader can tell generated media from a recording at a glance. */
export const EXTENSION_ID_PREFIX = "ext:";
+/** True for the asset — and the clip, which shares its id — of a generated insertion. */
+export function isGeneratedAssetId(id: string): boolean {
+ return id.startsWith(EXTENSION_ID_PREFIX);
+}
+
/** The id an insertion's media answers to. */
export function extensionAssetId(wordId: string): string {
return `${EXTENSION_ID_PREFIX}${wordId}`;
From cd6a706384d50f286e45633cedf25ccfc368b62b Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 16:48:20 +0200
Subject: [PATCH 102/113] refactor(timeline): two clips whose media continues
across the join are one clip
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rule, general again, on every clip change. I narrowed it last commit on the strength of
a counter-example that does not hold, and the reasoning is worth writing down because it is
what makes the general form safe.
The counter-example was `duplicateClip`: a copy inserted after its original ends in the media
where the original ends, so it can meet the clip that follows. It only meets it if the
original met it — and if the original met it, they were already one clip. The fixture that
made it fail started from two clips of one recording whose media timecodes met, which is
exactly the state the rule says cannot exist. Given the invariant holds, no mutation can
break it in a way that loses anything; the only states it can surprise are ones where the
two clips were indistinguishable to begin with.
The second reason I gave was wrong outright: `replaceTimeline` does not cut a recording into
consecutive clips so each can carry a zoom. Zooms do not create clips. It rebuilds the clip
list from kept intervals — auto-import, `drop_range`, `restore_full_timeline` — and none of
those produce clips whose media continues across the join. I extrapolated a use case from a
test fixture.
So the guard is the two conditions and nothing else: same media, same crop, and the left
clip's media timecode ENDS where the right one's BEGINS. Media timecodes, never ruler ones —
clips are always laid back to back here, so every neighbouring pair touches on the ruler and
that says nothing at all.
`insertion.ts` is now completely agnostic about being undone. It cuts a clip and never learns
how the pieces go back together; delete, drag away, undo all heal through the same rule.
Three fixtures encoded the forbidden state and are corrected to say what they meant — the
same ruler layout with media timecodes that do not meet. Every asserted millisecond is
unchanged, because the anchors are relative.
The accepted cost is pinned as its own test rather than left in a comment: an ordinary clip
deleted from between two halves joins them too. Nothing is lost when it does.
---
src/lib/ai-edition/document/insertion.test.ts | 39 ++++--
src/lib/ai-edition/document/timeline.test.ts | 30 +++--
src/lib/ai-edition/document/timeline.ts | 119 ++++++++----------
src/lib/ai-edition/store/useTimeline.test.ts | 7 +-
4 files changed, 108 insertions(+), 87 deletions(-)
diff --git a/src/lib/ai-edition/document/insertion.test.ts b/src/lib/ai-edition/document/insertion.test.ts
index 69e14ca63..a65ea2b27 100644
--- a/src/lib/ai-edition/document/insertion.test.ts
+++ b/src/lib/ai-edition/document/insertion.test.ts
@@ -178,11 +178,12 @@ describe("removeGeneratedClips", () => {
});
});
-describe("the seam only closes under generated media", () => {
- it("leaves two halves of a recording apart when an ORDINARY clip between them goes", () => {
- // Cutting a recording into consecutive clips is something this app does on purpose —
- // `replaceTimeline` builds exactly that so each piece can carry its own zoom. Deleting
- // a B-roll clip laid between two of them must not collapse the two into one.
+describe("the join is blind to what made the clips contiguous", () => {
+ it("also heals two halves when an ORDINARY clip between them goes", () => {
+ // The accepted cost of the rule, on the record. Two clips of one recording whose media
+ // timecodes meet are one clip whatever put a third between them, so deleting that third
+ // joins them. Nothing is lost: they were indistinguishable — same media, same framing,
+ // one continuing where the other stops — and the film plays identically either way.
const base = doc({
assets: [
...doc().assets,
@@ -216,12 +217,34 @@ describe("the seam only closes under generated media", () => {
},
];
const after = removeClip(base, "broll");
- expect(after.timeline.clips.map((c) => c.id)).toEqual(["left", "right"]);
+ expect(after.timeline.clips.map((c) => c.id)).toEqual(["left"]);
+ expect(after.timeline.clips[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 10 });
});
- it("keeps a word that has no clip a no-op", () => {
+ it("leaves them alone when the media does not continue across the join", () => {
const base = doc();
- expect(removeGeneratedClips(base, ["synth_9"])).toBe(base);
+ base.timeline.clips = [
+ { ...base.timeline.clips[0], id: "left", sourceEndSec: 4, timelineEndSec: 4 },
+ {
+ ...base.timeline.clips[0],
+ id: "broll",
+ assetId: "a1",
+ sourceStartSec: 20,
+ sourceEndSec: 22,
+ timelineStartSec: 4,
+ timelineEndSec: 6,
+ },
+ {
+ ...base.timeline.clips[0],
+ id: "right",
+ sourceStartSec: 6,
+ sourceEndSec: 10,
+ timelineStartSec: 6,
+ timelineEndSec: 10,
+ },
+ ];
+ const after = removeClip(base, "broll");
+ expect(after.timeline.clips.map((c) => c.id)).toEqual(["left", "right"]);
});
});
diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts
index 06f83943d..1e5517394 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -387,7 +387,11 @@ describe("timeline pure functions", () => {
/** Three clips, and a zoom straddling the boundary between the last two —
* stored as TWO fragments, which is the case where a reorder can pull the
- * halves of one pill apart. */
+ * halves of one pill apart.
+ *
+ * Their media timecodes deliberately do NOT meet: three clips of one recording that
+ * continue into each other are one clip (`withClipsChanged`), so a fixture built that
+ * way would collapse on the first structural edit and test nothing. */
function straddled(): AxcutDocument {
return makeDoc({
timeline: {
@@ -395,15 +399,15 @@ describe("timeline pure functions", () => {
makeClip({ id: "clip_1", sourceStartSec: 0, sourceEndSec: 20, timelineEndSec: 20 }),
makeClip({
id: "clip_2",
- sourceStartSec: 20,
- sourceEndSec: 40,
+ sourceStartSec: 25,
+ sourceEndSec: 45,
timelineStartSec: 20,
timelineEndSec: 40,
}),
makeClip({
id: "clip_3",
- sourceStartSec: 40,
- sourceEndSec: 60,
+ sourceStartSec: 50,
+ sourceEndSec: 70,
timelineStartSec: 40,
timelineEndSec: 60,
}),
@@ -422,8 +426,8 @@ describe("timeline pure functions", () => {
depth: 3,
focus: { cx: 0.5, cy: 0.5 },
clipId: "clip_2",
- sourceStartSec: 35,
- sourceEndSec: 40,
+ sourceStartSec: 40,
+ sourceEndSec: 45,
},
{
id: "zoom_b",
@@ -432,8 +436,8 @@ describe("timeline pure functions", () => {
depth: 3,
focus: { cx: 0.5, cy: 0.5 },
clipId: "clip_3",
- sourceStartSec: 40,
- sourceEndSec: 45,
+ sourceStartSec: 50,
+ sourceEndSec: 55,
},
] as unknown as AxcutDocument["zoomRanges"],
});
@@ -509,12 +513,12 @@ describe("timeline pure functions", () => {
});
it("holds after a setClipRange that narrows a fragment's window", () => {
- const narrowed = setClipSourceRange(straddled(), "clip_2", 20, 37);
+ const narrowed = setClipSourceRange(straddled(), "clip_2", 25, 42);
assertAnchorsAgree(narrowed);
- // zoom_a covered 35–40 of a window that now ends at 37: clamped, kept.
+ // zoom_a covered 40–45 of a window that now ends at 42: clamped, kept.
expect(narrowed.zoomRanges.find((z) => z.id === "zoom_a")).toMatchObject({
- sourceStartSec: 35,
- sourceEndSec: 37,
+ sourceStartSec: 40,
+ sourceEndSec: 42,
});
});
});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 169a9cdb6..3bfe91fbc 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -14,7 +14,6 @@ import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "
*/
export type PlaybackSegment = AxcutClip;
-import { isGeneratedAssetId } from "../timeline/clip-parts";
import { type Interval, subtractInterval } from "../timeline/intervals";
import { keptRawSpans } from "../timeline/programme-time";
import {
@@ -888,9 +887,7 @@ export function moveClip(
const remaining = document.timeline.clips.filter((c) => c.id !== clipId);
const bounded = Math.max(0, Math.min(insertIndex, remaining.length));
const reordered = [...remaining.slice(0, bounded), movingClip, ...remaining.slice(bounded)];
- // The seam is where it USED to be, shifted when it moved forward past its own hole.
- const seam = bounded <= index ? index + 1 : index;
- return withClipsJoined(document, reordered, movingClip, seam);
+ return withClipsChanged(document, reordered);
}
// ponytail: duplicate a clip (preserves the original). Used for "split this
@@ -1034,78 +1031,73 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri
/**
* The document, with its clip list changed to this one.
*
- * The pass every clip mutation ends with, and the only place the clip list's invariant is
- * enforced: TWO ADJACENT CLIPS THAT ARE ONE CONTINUOUS PIECE OF MEDIA ARE ONE CLIP. Same
- * asset, source ranges that meet, same crop — then they play as one clip already and drawing
- * them as two says nothing. Two identical clips are laid side by side precisely when they are
- * NOT joined in time; joined, there is nothing to tell them apart.
+ * The pass every clip mutation ends with, and where the clip list's one invariant lives:
+ * TWO ADJACENT CLIPS THAT ARE THE SAME MEDIA, THE SAME CROP, AND WHOSE MEDIA TIMECODES MEET
+ * ARE ONE CLIP.
*
- * It is an invariant rather than a step in a few operations because everything that can
- * create adjacency then gets it for free, and nothing has to remember to ask: deleting the
- * clip between two halves, dragging one of them away, extending a clip until it meets its
- * neighbour. It is also what lets an insertion be completely agnostic about being undone —
- * `insertion.ts` cuts a clip and never learns how the pieces go back together.
+ * Media timecodes, not ruler ones. Clips are always laid back to back here, so every
+ * neighbouring pair touches on the ruler and that says nothing; what carries information is
+ * whether the left clip ENDS in the media where the right one BEGINS. Two clips of one
+ * recording are laid side by side precisely when they do NOT — a piece dropped between them,
+ * a different stretch, a different framing. When they do, and nothing distinguishes them,
+ * they already play as one clip and drawing them as two says nothing at all.
*
- * The cost, stated so it is a decision: two clips a user joined by hand fuse, and the only
- * way back is to move them apart again before anything else re-lays the list. Rare, and
- * cheaper than a marker on every cut that would have to survive every move.
+ * Why it is safe to apply everywhere rather than at the one edit that needs it: given the
+ * invariant holds, no mutation can make it false in a way that loses anything.
+ * `duplicateClip` is the case that looks dangerous — a copy inserted after its original ends
+ * in the media where the original does, so it could meet the next clip. It cannot: the
+ * original was already adjacent to that clip and did not meet it, or they would be one clip
+ * already. The only states this can surprise are ones that already violated it, which is to
+ * say ones where the two clips were indistinguishable to begin with.
*
- * ponytail: no razor tool exists, so today this can only ever undo an insertion. Give the cut
- * a marker the day a deliberate split with no gap becomes something a user can make.
+ * The cost, stated so it is a decision: two such clips a user joined by hand fuse, and the
+ * way back is to move them apart. Cheaper than a marker on every cut, which would have to
+ * survive every move that makes it wrong.
*/
export function withClipsChanged(document: AxcutDocument, clips: AxcutClip[]): AxcutDocument {
- const laid = resequenceClips(clips);
+ const { clips: joined, absorbed } = joinContiguous(clips);
+ const laid = resequenceClips(joined);
+ const reanchored = absorbed.size === 0 ? document : reanchorRows(document, absorbed);
const next: AxcutDocument = {
- ...document,
- timeline: { ...document.timeline, clips: laid },
+ ...reanchored,
+ timeline: { ...reanchored.timeline, clips: laid },
};
// `rederiveRegionMs` bails on an empty clip list — a guard against a transient wipe
// dropping every region — so there is nothing to refresh against.
return laid.length === 0 ? next : rederiveRegionMs(next, laid);
}
-/**
- * The same, for a clip that LEFT its place — deleted, or dragged elsewhere. The seam it was
- * filling closes when it was GENERATED media and the two sides are one continuous piece of
- * recording again.
- *
- * Narrow on purpose, and both halves of that narrowness were paid for:
- *
- * - Only at the seam. A pass over the whole list joins pairs that had nothing to do with
- * the edit — `duplicateClip` copies a clip that sits before a contiguous neighbour, and
- * the copy would swallow it.
- * - Only for generated media. Two contiguous clips of one recording are a state this app
- * builds ON PURPOSE: `replaceTimeline` cuts a recording into consecutive clips so each
- * can carry its own zoom. Joining them on the next unrelated delete would collapse
- * structure the user asked for.
- *
- * What is left is exactly the inverse of what an insertion did, without an insertion having
- * marked anything: generated media leaves no seam behind it. `insertion.ts` cuts a clip and
- * never learns how the pieces go back together.
- */
-export function withClipsJoined(
- document: AxcutDocument,
- clips: AxcutClip[],
- departed: AxcutClip,
- seam: number,
-): AxcutDocument {
- const left = clips[seam - 1];
- const right = clips[seam];
- if (!isGeneratedAssetId(departed.assetId) || !left || !right || !joinable(left, right)) {
- return withClipsChanged(document, clips);
+/** Fold every run of same-media, same-crop, media-contiguous clips into one, reporting the
+ * ids that went away and the clip that now carries their content. */
+function joinContiguous(clips: AxcutClip[]): {
+ clips: AxcutClip[];
+ absorbed: Map;
+} {
+ // ARRAY order, not `timelineStartSec`: the list IS the order, and at this point the
+ // positions are still the pre-edit ones. Sorting by them re-sorted a reorder back to
+ // where it came from.
+ const out: AxcutClip[] = [];
+ const absorbed = new Map();
+ for (const clip of clips) {
+ const previous = out[out.length - 1];
+ if (previous && joinable(previous, clip)) {
+ out[out.length - 1] = {
+ ...previous,
+ sourceEndSec: clip.sourceEndSec,
+ timelineEndSec: previous.timelineEndSec + (clip.timelineEndSec - clip.timelineStartSec),
+ wordRefs: [...previous.wordRefs, ...clip.wordRefs],
+ };
+ absorbed.set(clip.id, previous.id);
+ continue;
+ }
+ out.push(clip);
}
- const merged: AxcutClip = {
- ...left,
- sourceEndSec: right.sourceEndSec,
- timelineEndSec: left.timelineEndSec + (right.timelineEndSec - right.timelineStartSec),
- wordRefs: [...left.wordRefs, ...right.wordRefs],
- };
- const joined = [...clips.slice(0, seam - 1), merged, ...clips.slice(seam + 1)];
- return withClipsChanged(reanchorRows(document, new Map([[right.id, left.id]])), joined);
+ return { clips: out, absorbed };
}
-/** Same media, source ranges that meet, same framing. Crop is the only property a clip
- * carries that two halves could legitimately disagree on, so it is the whole guard. */
+/** Same media, media timecodes that meet, same framing. Crop is the only property a clip
+ * carries that two otherwise-identical neighbours could legitimately disagree on, so it is
+ * the whole of the guard. */
function joinable(left: AxcutClip, right: AxcutClip): boolean {
return (
left.assetId === right.assetId &&
@@ -1151,9 +1143,8 @@ function reanchorRows(document: AxcutDocument, absorbed: Map): A
*/
export function removeClip(document: AxcutDocument, clipId: string): AxcutDocument {
const oldClips = document.timeline.clips;
- const removedAt = oldClips.findIndex((c) => c.id === clipId);
- if (removedAt < 0) return document;
const arr = oldClips.filter((c) => c.id !== clipId);
+ if (arr.length === oldClips.length) return document;
const next: AxcutDocument = {
...document,
timeline: {
@@ -1193,7 +1184,7 @@ export function removeClip(document: AxcutDocument, clipId: string): AxcutDocume
regions.filter((region) => !(hasCompleteClipAnchor(region) && region.clipId === clipId)),
);
}
- return withClipsJoined(next, arr, oldClips[removedAt], removedAt);
+ return withClipsChanged(next, arr);
}
export function restoreFullTimeline(document: AxcutDocument): AxcutDocument {
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index 7b77e9c46..2bc430311 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -191,8 +191,11 @@ describe("useTimeline.moveClip / duplicateClip (delegates to document/timeline.t
{
id: "clip_b",
assetId: "asset_1",
- sourceStartSec: 10,
- sourceEndSec: 20,
+ // Does not continue where clip_a stops, on purpose: two clips of one recording
+ // whose media timecodes meet are one clip, so a fixture like that would
+ // collapse under any structural edit.
+ sourceStartSec: 15,
+ sourceEndSec: 25,
timelineStartSec: 10,
timelineEndSec: 20,
wordRefs: [],
From 0b7152a54e93cf1faef6978024d511e973beff9e Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:05:07 +0200
Subject: [PATCH 103/113] fix(insertions): editing an insertion's text resizes
the clip it plays
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An insertion's length IS its text, so a correction has to reach the clip and the file. It
reached neither: `retextGeneratedClip` widened the source window and left `timelineEndSec`
alone, and `resequenceClips` takes a clip's length from its ruler extent whenever that extent
is non-zero — so the window grew, the length did not, and the clip went on playing the old
duration while the film never got longer.
Zeroing the ruler extent is how `setClipSourceRange` already asks for "take the length from
the source window". Same idiom, same funnel.
Pinned through `setDocumentWordText`, which is the function the transcript pane actually
calls, rather than through the operation underneath it — the bug was in what the pane's edit
produced, and a test on the inner function would have passed while the app stayed broken.
Also: a clip too narrow to hold its own controls now lets them out. An insertion of a few
tenths of a second on a half-minute timeline is a handful of pixels wide, and no arrangement
fits a button inside that; while it is SELECTED the pencil and the bin step outside the box
and float over what follows. Selection-only, so nothing is littered, and no layout above or
below has to make room. Not visually verified — worth a look.
---
.../ai-edition/v4/EditorShellV4.module.css | 32 +++++++++++++++++
src/components/ai-edition/v4/V4Timeline.tsx | 11 +++++-
src/lib/ai-edition/document/insertion.test.ts | 34 +++++++++++++++++++
src/lib/ai-edition/document/insertion.ts | 10 +++++-
4 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 422ff0b54..c69b4277a 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1683,6 +1683,38 @@
/* A generated clip: the media behind an inserted word, which nobody shot. Amber on the box
itself rather than a badge inside it, so it stays legible at the zoom levels where a clip
is a few pixels wide and there is no room to draw anything in it. */
+/* A clip a few pixels wide — an insertion of a few tenths of a second at a wide zoom is
+ exactly that. Nothing fits inside it, so while it is SELECTED its controls step out to the
+ right and float over whatever follows. Selection-only, so the timeline is never littered,
+ and no layout above or below has to make room for them. */
+.tlClipNarrow .tlClipLabel {
+ display: none;
+}
+.tlClipSel.tlClipNarrow {
+ overflow: visible;
+ z-index: 6;
+}
+/* Selected: the pencil comes back, beside the box rather than in it, and without the name —
+ which is what needed the room in the first place. */
+.tlClipSel.tlClipNarrow .tlClipLabel {
+ display: inline-flex;
+ left: calc(100% + 6px);
+ top: 50%;
+ transform: translateY(-50%);
+ max-width: none;
+ padding: 3px 6px;
+ cursor: pointer;
+}
+.tlClipSel.tlClipNarrow .tlClipName {
+ display: none;
+}
+/* Clear of the pencil chip beside it. */
+.tlClipDelete[data-narrow] {
+ right: auto;
+ left: calc(100% + 43px);
+ top: 50%;
+ transform: translateY(-50%);
+}
.tlClipGenerated {
border-color: var(--warn);
background: color-mix(in srgb, var(--warn) 18%, var(--surface-1));
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index ba08eb74f..a4d121409 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -155,6 +155,9 @@ const AUDIO_LANE_PAD_PX = 3;
/** Visual separation between two clip cards. Taken off each clip's own width
* (see .tlClip) rather than inserted between them, so it cannot displace the
* clips that follow — which is what a flex `gap` did, once per junction. */
+/** Below this a clip cannot show a label and a delete button inside itself. */
+const NARROW_CLIP_PX = 120;
+
const CLIP_GUTTER_PX = 6;
/**
* Shortest region a resize may leave behind — the storage grid itself (regions
@@ -2179,11 +2182,16 @@ export function V4Timeline({
else if (target < from && i >= target && i < from)
clipTransform = `translateX(${shiftPx}px)`;
}
+ // Too narrow to hold its own controls. An insertion of a few tenths
+ // of a second on a half-minute timeline is a handful of pixels, and
+ // there is no arrangement that fits a button inside that — so while
+ // it is selected the controls step outside the box instead.
+ const narrow = boxLen * pxPerSec < NARROW_CLIP_PX;
return (
{
diff --git a/src/lib/ai-edition/document/insertion.test.ts b/src/lib/ai-edition/document/insertion.test.ts
index a65ea2b27..39e430230 100644
--- a/src/lib/ai-edition/document/insertion.test.ts
+++ b/src/lib/ai-edition/document/insertion.test.ts
@@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
import type { AxcutDocument } from "../schema";
import { insertGeneratedClip, removeGeneratedClips, retextGeneratedClip } from "./insertion";
import { moveClip, removeClip, resolvePlaybackSegments } from "./timeline";
+import { setDocumentWordText } from "./transcript";
const doc = (over: Partial = {}): AxcutDocument =>
({
@@ -248,6 +249,39 @@ describe("the join is blind to what made the clips contiguous", () => {
});
});
+describe("editing an insertion's text, through the path the pane actually calls", () => {
+ it("regrows the clip and the film with it", () => {
+ // The pane hands `setDocumentWordText` the SECTION's asset, which for an insertion is
+ // its own `ext:` one. Its length is its text, so the edit has to reach the clip and the
+ // file — a correction that left the duration alone would play the old media.
+ const before = withInsertion();
+ const after = setDocumentWordText(before, "ext:synth_1", "synth_1", "a much longer line");
+ const seconds = "a much longer line".length / 15;
+ const clip = after.timeline.clips.find((c) => c.id === "ext:synth_1");
+ expect(clip?.sourceEndSec).toBeCloseTo(seconds, 6);
+ expect(clip?.timelineEndSec ?? 0).toBeCloseTo((clip?.timelineStartSec ?? 0) + seconds, 6);
+ // The whole film is longer by the difference, so the ruler agrees with the media.
+ const end = (cs: typeof after.timeline.clips) => cs[cs.length - 1].timelineEndSec;
+ expect(end(after.timeline.clips) - end(before.timeline.clips)).toBeCloseTo(
+ seconds - GEN_SEC,
+ 6,
+ );
+ });
+
+ it("renames the file, so the save generates the media the new text needs", () => {
+ const after = setDocumentWordText(withInsertion(), "ext:synth_1", "synth_1", "longer");
+ expect(after.assets.find((a) => a.id === "ext:synth_1")?.originalPath).toBe(
+ `C:/rec/.openscreen-extensions/synth_1_${Math.round((6 / 15) * 1000)}.mp4`,
+ );
+ });
+
+ it("leaves a recorded word's correction alone — it changes no media", () => {
+ const before = doc();
+ const after = setDocumentWordText(before, "a1", "w1", "HELLO");
+ expect(after.timeline.clips).toEqual(before.timeline.clips);
+ });
+});
+
describe("retextGeneratedClip", () => {
it("resizes the clip to the new text and renames the file it plays", () => {
const next = retextGeneratedClip(withInsertion(), "synth_1", "a much longer sentence");
diff --git a/src/lib/ai-edition/document/insertion.ts b/src/lib/ai-edition/document/insertion.ts
index 49c2ffc66..495869b7f 100644
--- a/src/lib/ai-edition/document/insertion.ts
+++ b/src/lib/ai-edition/document/insertion.ts
@@ -281,7 +281,15 @@ export function retextGeneratedClip(
timeline: {
...document.timeline,
clips: resequenceClips(
- document.timeline.clips.map((c) => (c.id === id ? { ...c, sourceEndSec: durationSec } : c)),
+ document.timeline.clips.map((c) =>
+ c.id === id
+ ? // Zeroing the ruler extent is how `setClipSourceRange` asks `resequenceClips`
+ // to take the clip's length from its SOURCE window. Without it the window
+ // grew and the length did not: the clip kept playing the old duration and
+ // the film never got longer.
+ { ...c, sourceEndSec: durationSec, timelineStartSec: 0, timelineEndSec: 0 }
+ : c,
+ ),
),
},
};
From f57326cead21041e5107b2d2c187c0c55bb2ffc0 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:11:10 +0200
Subject: [PATCH 104/113] test(insertions): shortening an insertion is a second
claim, pinned separately
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The path is not directional, so the fix covered it — but "the clip grew" and "the clip
shrank back" are two assertions and only one of them was on the record.
---
src/lib/ai-edition/document/insertion.test.ts | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/lib/ai-edition/document/insertion.test.ts b/src/lib/ai-edition/document/insertion.test.ts
index 39e430230..ec0192829 100644
--- a/src/lib/ai-edition/document/insertion.test.ts
+++ b/src/lib/ai-edition/document/insertion.test.ts
@@ -268,6 +268,18 @@ describe("editing an insertion's text, through the path the pane actually calls"
);
});
+ it("shrinks it back when the text gets shorter", () => {
+ // The same path, and nothing in it is directional — but "grew" and "shrank" are two
+ // claims and only one of them was pinned.
+ const long = insertGeneratedClip(doc(), "a1", "w1", "after", "a much longer line");
+ const short = setDocumentWordText(long, "ext:synth_1", "synth_1", "hi");
+ const clip = short.timeline.clips.find((c) => c.id === "ext:synth_1");
+ expect(clip?.sourceEndSec).toBeCloseTo(GEN_SEC, 6);
+ expect((clip?.timelineEndSec ?? 0) - (clip?.timelineStartSec ?? 0)).toBeCloseTo(GEN_SEC, 6);
+ const clips = short.timeline.clips;
+ expect(clips[clips.length - 1].timelineEndSec).toBeCloseTo(10 + GEN_SEC, 6);
+ });
+
it("renames the file, so the save generates the media the new text needs", () => {
const after = setDocumentWordText(withInsertion(), "ext:synth_1", "synth_1", "longer");
expect(after.assets.find((a) => a.id === "ext:synth_1")?.originalPath).toBe(
From c8c8b7c7f0b5b20a3d1395a16eaa9a16462336f3 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:24:57 +0200
Subject: [PATCH 105/113] feat(insertions): an insertion in a voice-over is a
track fragment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The same move as the recording lane, in the coordinates a take has. The take splits in two
and the generated audio goes between the halves; both keep the file seconds they always had,
and the right one's `offsetMs` advances by exactly what the left consumed — the repair
`anchorAudioTrackFragments` already does for a take spanning two clips.
The lane the caret was in is the only thing that differs between the two: a clip in the film,
a fragment in the take. `insertDocumentWord` picks by asking whether the asset is spoken by a
take or played by a clip, and everything downstream is the paths that already existed.
Two rules were settled before this and both are respected, and pinned:
- A take insertion does NOT lengthen the film. The clips decide the length. It pushes the
take's later content later inside the same timeline, and what that pushes past the last
frame is clamped at export, as it always was.
- A film insertion does not touch a take. The clips under it become three, so the take is
stored as three fragments — that is ventilation, and the take itself is one pill of the
same length holding the same audio.
Undoing it is the audio lane's half of the clip list's invariant: contiguous pills of one
file whose timecodes continue across the join are one take, folded back in
`reanchorAudioTracks` — at the PILL level, before ventilation, because ventilation
deliberately produces fragments that meet and whose offsets continue.
One thing the clip lane gets for free and this had to do by hand: a track lane is not re-laid,
pills hold absolute ruler positions, so nothing closes the gap the removal leaves. Only the
half the insertion pushed comes back, by the amount it was pushed — taking it back has to be
as narrow as making it was. The first version left a 150ms hole and the halves never met; the
round-trip test is what caught it.
The transcript pane's voice-over lane was refused outright, citing `insertRangeSchema` — a
schema deleted in the reset. Gate lifted.
Unit-tested only; not yet exercised in the app.
---
src/components/ai-edition/RightPanes.tsx | 10 +-
src/lib/ai-edition/document/audioTracks.ts | 44 +++-
src/lib/ai-edition/document/insertion.ts | 8 +-
.../document/insertionTrack.test.ts | 172 +++++++++++++
src/lib/ai-edition/document/insertionTrack.ts | 228 ++++++++++++++++++
src/lib/ai-edition/document/transcript.ts | 25 +-
6 files changed, 468 insertions(+), 19 deletions(-)
create mode 100644 src/lib/ai-edition/document/insertionTrack.test.ts
create mode 100644 src/lib/ai-edition/document/insertionTrack.ts
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 79f9f2e00..b7f9562af 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -1012,7 +1012,6 @@ export function TranscriptPane({
{sections.map((section, idx) => (
{
diff --git a/src/lib/ai-edition/document/audioTracks.ts b/src/lib/ai-edition/document/audioTracks.ts
index 33f25e904..19ca98964 100644
--- a/src/lib/ai-edition/document/audioTracks.ts
+++ b/src/lib/ai-edition/document/audioTracks.ts
@@ -76,11 +76,53 @@ export function reanchorAudioTracks(
): AxcutAudioTrack[] {
// Coalesce back to one raw span per track FIRST: re-anchoring the stored
// fragments individually would re-ventilate each one and multiply them.
- return collapseTracksToPills(tracks).flatMap((track) =>
+ //
+ // Joined at the PILL level, before ventilation, for the same reason: ventilation
+ // deliberately produces fragments that meet and whose offsets continue, so joining
+ // after it would undo the split it just made.
+ return joinContiguousTakes(collapseTracksToPills(tracks)).flatMap((track) =>
anchorAudioTrackFragments(track, clips, makeId),
);
}
+/**
+ * Two takes that are one continuous stretch of one file are one take.
+ *
+ * The audio lane's half of the clip list's invariant, and it is what puts a take back
+ * together when the insertion that split it is removed. Nothing marks the split: the two
+ * halves meet on the ruler and the file continues across the join, which is all the
+ * evidence there is and all there needs to be.
+ */
+function joinContiguousTakes(pills: AxcutAudioTrack[]): AxcutAudioTrack[] {
+ const ordered = [...pills].sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id));
+ const out: AxcutAudioTrack[] = [];
+ for (const pill of ordered) {
+ const previous = out[out.length - 1];
+ if (previous && takesJoin(previous, pill)) {
+ out[out.length - 1] = { ...previous, endMs: pill.endMs, fadeOutMs: pill.fadeOutMs };
+ continue;
+ }
+ out.push(pill);
+ }
+ return out;
+}
+
+/** Same file, meeting on the ruler, and the file's own timecode continuing across the join —
+ * plus every payload the two would otherwise have to disagree about. */
+function takesJoin(left: AxcutAudioTrack, right: AxcutAudioTrack): boolean {
+ const spanMs = left.endMs - left.startMs;
+ return (
+ left.assetId === right.assetId &&
+ left.kind === right.kind &&
+ !left.loop &&
+ !right.loop &&
+ left.gainDb === right.gainDb &&
+ left.muted === right.muted &&
+ Math.abs(left.endMs - right.startMs) < 1 &&
+ Math.abs(left.offsetMs + spanMs - right.offsetMs) < 1
+ );
+}
+
/**
* The user-visible tracks: fragments folded back into one span per `trackId`,
* carrying the FIRST fragment's payload (its `offsetMs` is the track's real
diff --git a/src/lib/ai-edition/document/insertion.ts b/src/lib/ai-edition/document/insertion.ts
index 495869b7f..32a726294 100644
--- a/src/lib/ai-edition/document/insertion.ts
+++ b/src/lib/ai-edition/document/insertion.ts
@@ -63,7 +63,7 @@ function anchorRulerSec(
}
/** `synth_N`, numbered past every generated asset the document already carries. */
-function nextGeneratedWordId(document: AxcutDocument): string {
+export function nextGeneratedWordId(document: AxcutDocument): string {
let highest = 0;
for (const asset of document.assets) {
const match = /^ext:synth_(\d+)$/.exec(asset.id);
@@ -74,13 +74,13 @@ function nextGeneratedWordId(document: AxcutDocument): string {
/** The recording the generated files are written beside. One rule, so the renderer and the
* main process arrive at the same folder without asking each other. */
-function hostAsset(document: AxcutDocument): AxcutAsset | null {
+export function hostAsset(document: AxcutDocument): AxcutAsset | null {
const primary = document.assets.find((a) => a.id === document.project.primaryAssetId);
if (primary?.originalPath && !isGeneratedAssetId(primary.id)) return primary;
return document.assets.find((a) => a.originalPath && !isGeneratedAssetId(a.id)) ?? null;
}
-function generatedAsset(
+export function generatedAsset(
host: AxcutAsset,
wordId: string,
durationSec: number,
@@ -98,7 +98,7 @@ function generatedAsset(
};
}
-function generatedTranscript(
+export function generatedTranscript(
wordId: string,
durationSec: number,
text: string,
diff --git a/src/lib/ai-edition/document/insertionTrack.test.ts b/src/lib/ai-edition/document/insertionTrack.test.ts
new file mode 100644
index 000000000..cf5695464
--- /dev/null
+++ b/src/lib/ai-edition/document/insertionTrack.test.ts
@@ -0,0 +1,172 @@
+// The voice-over half of the insertion model. Same shape as the clips, different
+// coordinates — and one rule that is the opposite of the clip lane's, pinned here because it
+// was settled deliberately: a take insertion does NOT lengthen the film.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutDocument } from "../schema";
+import { collapseTracksToPills } from "./audioTracks";
+import { insertGeneratedClip } from "./insertion";
+import {
+ insertGeneratedTrack,
+ removeGeneratedTracks,
+ retextGeneratedTrack,
+} from "./insertionTrack";
+
+const doc = (): AxcutDocument =>
+ ({
+ schemaVersion: 5,
+ project: { id: "p1", title: "t", createdAt: "", updatedAt: "", primaryAssetId: "a1" },
+ assets: [
+ {
+ id: "a1",
+ kind: "video",
+ label: "take",
+ originalPath: "C:/rec/take.mp4",
+ video: { width: 1920, height: 1080, fps: 30 },
+ cameraTrack: null,
+ },
+ { id: "vo", kind: "audio", label: "vo", originalPath: "C:/rec/vo.wav", cameraTrack: null },
+ ],
+ transcript: null,
+ transcripts: [
+ {
+ assetId: "a1",
+ language: "en",
+ segments: [],
+ words: [{ id: "w1", segmentId: "s1", startSec: 1, endSec: 4, text: "hello" }],
+ },
+ {
+ assetId: "vo",
+ language: "en",
+ segments: [],
+ words: [
+ { id: "v1", segmentId: "s1", startSec: 1, endSec: 4, text: "spoken" },
+ { id: "v2", segmentId: "s1", startSec: 6, endSec: 8, text: "later" },
+ ],
+ },
+ ],
+ timeline: {
+ clips: [
+ {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks: [
+ {
+ id: "t1",
+ startMs: 2000,
+ endMs: 12000,
+ assetId: "vo",
+ kind: "voiceover",
+ durationSec: 10,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 200,
+ fadeOutMs: 300,
+ muted: false,
+ label: "vo",
+ origin: "user",
+ },
+ ],
+ legacyEditor: null,
+ }) as unknown as AxcutDocument;
+
+/** Insert "hi" after `v1`, which ends at file second 4 — ruler second 6. */
+const inserted = () => insertGeneratedTrack(doc(), "vo", "v1", "after", "hi");
+const GEN_MS = 150;
+
+const takes = (d: AxcutDocument) =>
+ [...d.audioTracks]
+ .sort((a, b) => a.startMs - b.startMs)
+ .map((t) => ({
+ assetId: t.assetId,
+ startMs: t.startMs,
+ endMs: t.endMs,
+ offsetMs: t.offsetMs,
+ }));
+
+describe("insertGeneratedTrack", () => {
+ it("cuts the take in two and puts the generated audio between the halves", () => {
+ expect(takes(inserted())).toEqual([
+ { assetId: "vo", startMs: 2000, endMs: 6000, offsetMs: 0 },
+ { assetId: "ext:synth_1", startMs: 6000, endMs: 6000 + GEN_MS, offsetMs: 0 },
+ // The file picks up where it stopped: 4s consumed, so the tail starts at 4s in.
+ { assetId: "vo", startMs: 6000 + GEN_MS, endMs: 12000 + GEN_MS, offsetMs: 4000 },
+ ]);
+ });
+
+ it("does NOT lengthen the film — that is the clips' business, and they did not move", () => {
+ expect(inserted().timeline.clips).toEqual(doc().timeline.clips);
+ });
+
+ it("fades once at each real edge, not at the seam it just made", () => {
+ const ordered = [...inserted().audioTracks].sort((a, b) => a.startMs - b.startMs);
+ expect(ordered.map((t) => [t.fadeInMs, t.fadeOutMs])).toEqual([
+ [200, 0],
+ [0, 0],
+ [0, 300],
+ ]);
+ });
+
+ it("gives the generated audio its own transcript, to be read like any other", () => {
+ const t = inserted().transcripts.find((x) => x.assetId === "ext:synth_1");
+ expect(t?.words.map((w) => [w.text, w.source])).toEqual([["hi", "synth"]]);
+ });
+});
+
+describe("removeGeneratedTracks", () => {
+ it("puts the take back exactly as it was", () => {
+ const back = removeGeneratedTracks(inserted(), ["synth_1"]);
+ expect(takes(back)).toEqual([{ assetId: "vo", startMs: 2000, endMs: 12000, offsetMs: 0 }]);
+ });
+
+ it("takes the media it was the only user of with it", () => {
+ const back = removeGeneratedTracks(inserted(), ["synth_1"]);
+ expect(back.assets.some((a) => a.id === "ext:synth_1")).toBe(false);
+ expect(back.transcripts.some((t) => t.assetId === "ext:synth_1")).toBe(false);
+ });
+});
+
+describe("retextGeneratedTrack", () => {
+ it("resizes it and pushes only what came after, by the difference", () => {
+ const longer = retextGeneratedTrack(inserted(), "synth_1", "a much longer line");
+ const spanMs = Math.round((("a much longer line".length / 15) as number) * 1000);
+ expect(takes(longer)).toEqual([
+ { assetId: "vo", startMs: 2000, endMs: 6000, offsetMs: 0 },
+ { assetId: "ext:synth_1", startMs: 6000, endMs: 6000 + spanMs, offsetMs: 0 },
+ { assetId: "vo", startMs: 6000 + spanMs, endMs: 12000 + spanMs, offsetMs: 4000 },
+ ]);
+ });
+});
+
+describe("the two lanes stay out of each other's way", () => {
+ it("a word added to the FILM leaves the take's own span alone", () => {
+ // Settled deliberately: the take has its own audio and keeps talking against a picture
+ // that has slid. Its ruler span is re-anchored, never stretched.
+ const before = doc();
+ const after = insertGeneratedClip(before, "a1", "w1", "after", "hi");
+ // The clips it covers became three, so the take is stored as three fragments — that is
+ // ventilation, and it is what keeps a take playing continuously across a cut. What must
+ // not change is the take itself: one pill, the same length, holding the same audio.
+ const vo = collapseTracksToPills(after.audioTracks).filter((t) => t.assetId === "vo");
+ expect(vo).toHaveLength(1);
+ expect(vo[0].endMs - vo[0].startMs).toBe(10000);
+ });
+});
diff --git a/src/lib/ai-edition/document/insertionTrack.ts b/src/lib/ai-edition/document/insertionTrack.ts
new file mode 100644
index 000000000..eb7797124
--- /dev/null
+++ b/src/lib/ai-edition/document/insertionTrack.ts
@@ -0,0 +1,228 @@
+// An insertion in a VOICE-OVER is a track fragment.
+//
+// The same move as the recording lane one file over, in the coordinates a take has:
+//
+// [ take 0→5.3 ] [ generated 0→0.4 ] [ take 5.3→20.9 ]
+//
+// The take splits in two and the generated audio goes between the halves. Both halves keep
+// the file seconds they always had; the right one's `offsetMs` advances by exactly what the
+// left consumed, which is the repair `anchorAudioTrackFragments` already does for a take
+// spanning two clips.
+//
+// Two rules the maintainer settled before this, and this respects both:
+//
+// - A recording-lane insertion does not touch a take. The take has its own audio and keeps
+// talking against a picture that has slid.
+// - A take insertion does NOT lengthen the programme. The clips decide the length. It
+// pushes the take's later content later inside the SAME timeline, and whatever that
+// pushes past the last frame is clamped at export, exactly as it always was.
+//
+// Undoing it is not spelled out here either: contiguous pills of one take whose file
+// timecodes continue are one take, and `reanchorAudioTracks` folds them back.
+
+import type { AxcutAudioTrack, AxcutDocument } from "../schema";
+
+const EPS = 1e-6;
+
+import { extensionAssetId, extensionDurationSec } from "../timeline/clip-parts";
+import { removedRawSpans } from "../timeline/programme-time";
+import { takeProgramme } from "../timeline/take-programme";
+import { collapseTracksToPills, reanchorAudioTracks, trackGroupId } from "./audioTracks";
+import { createId } from "./ids";
+import {
+ generatedAsset,
+ generatedTranscript,
+ hostAsset,
+ type InsertSide,
+ nextGeneratedWordId,
+} from "./insertion";
+
+/** The take that plays this asset and actually contains that second of its file. */
+function takeFor(
+ document: AxcutDocument,
+ assetId: string,
+ fileSec: number,
+): { pill: AxcutAudioTrack; rawSec: number } | null {
+ const removed = removedRawSpans(document.timeline.clips, document.timeline.trimRanges);
+ for (const pill of collapseTracksToPills(document.audioTracks)) {
+ if (pill.assetId !== assetId || pill.loop) continue;
+ for (const piece of takeProgramme(pill, removed)) {
+ if (fileSec < piece.sourceStartSec - EPS || fileSec > piece.sourceEndSec + EPS) continue;
+ return { pill, rawSec: piece.rawStartSec + (fileSec - piece.sourceStartSec) };
+ }
+ }
+ return null;
+}
+
+/** True when this asset is spoken by a take rather than played by a clip. */
+export function isTrackAsset(document: AxcutDocument, assetId: string): boolean {
+ return document.audioTracks.some((track) => track.assetId === assetId);
+}
+
+/**
+ * Insert a word nobody said into a take, as a track of its own.
+ *
+ * The take is cut at that moment and the generated track goes between the halves. Everything
+ * of the take that followed moves later by its length; nothing else on the timeline does.
+ */
+export function insertGeneratedTrack(
+ document: AxcutDocument,
+ assetId: string,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutDocument {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return document;
+ const host = hostAsset(document);
+ if (!host) {
+ throw new Error("Cannot insert a word: the project has no recording to generate beside");
+ }
+ const word = document.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find((w) => w.id === anchorWordId);
+ if (!word) throw new Error(`Cannot insert beside word "${anchorWordId}": it has no transcript`);
+
+ const found = takeFor(document, assetId, side === "after" ? word.endSec : word.startSec);
+ if (!found) {
+ throw new Error(`Cannot insert beside word "${anchorWordId}": no take speaks that moment`);
+ }
+ const { pill, rawSec } = found;
+
+ const wordId = nextGeneratedWordId(document);
+ const durationSec = extensionDurationSec(trimmed);
+ const asset = generatedAsset(host, wordId, durationSec, trimmed);
+ const atMs = Math.round(rawSec * 1000);
+ const spanMs = Math.round(durationSec * 1000);
+
+ // The take's own head and tail keep their fades; the generated stretch has neither.
+ const left: AxcutAudioTrack = {
+ ...pill,
+ id: createId("take"),
+ trackId: undefined,
+ endMs: atMs,
+ fadeOutMs: 0,
+ };
+ const generated: AxcutAudioTrack = {
+ ...pill,
+ id: asset.id,
+ trackId: undefined,
+ assetId: asset.id,
+ label: trimmed.slice(0, 40),
+ startMs: atMs,
+ endMs: atMs + spanMs,
+ offsetMs: 0,
+ durationSec,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ };
+ const right: AxcutAudioTrack = {
+ ...pill,
+ id: createId("take"),
+ trackId: undefined,
+ startMs: atMs + spanMs,
+ // The take is as long as the audio it holds, so its tail moves by the whole insertion.
+ endMs: pill.endMs + spanMs,
+ offsetMs: pill.offsetMs + (atMs - pill.startMs),
+ fadeInMs: 0,
+ };
+
+ const kept = document.audioTracks.filter((t) => trackGroupId(t) !== trackGroupId(pill));
+ const pieces = [left, generated, right].filter((t) => t.endMs - t.startMs > 0);
+ return {
+ ...document,
+ assets: [...document.assets, asset],
+ transcripts: [
+ ...document.transcripts,
+ generatedTranscript(
+ wordId,
+ durationSec,
+ trimmed,
+ document.transcripts.find((t) => t.assetId === assetId)?.language ?? "en",
+ ),
+ ],
+ audioTracks: reanchorAudioTracks([...kept, ...pieces], document.timeline.clips, () =>
+ createId("take"),
+ ),
+ };
+}
+
+/**
+ * Delete inserted words spoken over a take.
+ *
+ * The exact inverse of the insertion: the generated track goes, and the half it pushed later
+ * comes back by the same amount. The two halves then meet with the file continuing across the
+ * join, which is all `reanchorAudioTracks` needs to fold them into one take again.
+ *
+ * A track lane is not re-laid the way the clip list is — pills hold absolute ruler positions —
+ * so nothing closes this gap on its own. Only the pushed half moves: the insertion moved only
+ * that, and taking it back has to be as narrow as making it was.
+ */
+export function removeGeneratedTracks(
+ document: AxcutDocument,
+ wordIds: readonly string[],
+): AxcutDocument {
+ return wordIds.reduce((next, wordId) => {
+ const id = extensionAssetId(wordId);
+ const generated = collapseTracksToPills(next.audioTracks).find((t) => t.assetId === id);
+ if (!generated) return next;
+ const spanMs = generated.endMs - generated.startMs;
+ const pills = collapseTracksToPills(next.audioTracks)
+ .filter((pill) => pill.assetId !== id)
+ .map((pill) => shiftIfAfter(pill, generated.endMs, -spanMs));
+ return {
+ ...next,
+ assets: next.assets.filter((a) => a.id !== id),
+ transcripts: next.transcripts.filter((t) => t.assetId !== id),
+ audioTracks: reanchorAudioTracks(pills, next.timeline.clips, () => createId("take")),
+ };
+ }, document);
+}
+
+/** The pill the insertion pushed: the one that starts where the generated stretch ends.
+ * Nothing else on the lane moved when it was made, so nothing else moves now. */
+function shiftIfAfter(pill: AxcutAudioTrack, atMs: number, deltaMs: number): AxcutAudioTrack {
+ if (deltaMs === 0 || Math.abs(pill.startMs - atMs) > 1) return pill;
+ return { ...pill, startMs: pill.startMs + deltaMs, endMs: pill.endMs + deltaMs };
+}
+
+/**
+ * Rewrite the text of a word inserted into a take.
+ *
+ * Its length is its text, so the track resizes and the file it plays is renamed. Everything
+ * of the take after it moves by the difference — the same push the insertion itself made.
+ */
+export function retextGeneratedTrack(
+ document: AxcutDocument,
+ wordId: string,
+ text: string,
+): AxcutDocument {
+ const trimmed = text.trim();
+ const id = extensionAssetId(wordId);
+ const host = hostAsset(document);
+ const current = document.audioTracks.find((t) => t.assetId === id);
+ if (trimmed.length === 0 || !host || !current) return document;
+
+ const durationSec = extensionDurationSec(trimmed);
+ const spanMs = Math.round(durationSec * 1000);
+ const deltaMs = spanMs - (current.endMs - current.startMs);
+ const language = document.transcripts.find((t) => t.assetId === id)?.language ?? "en";
+
+ const pills = collapseTracksToPills(document.audioTracks).map((pill) =>
+ pill.assetId === id
+ ? { ...pill, endMs: pill.startMs + spanMs, durationSec, label: trimmed.slice(0, 40) }
+ : // Only the half the insertion pushed moves again, and only by the difference.
+ shiftIfAfter(pill, current.endMs, deltaMs),
+ );
+
+ return {
+ ...document,
+ assets: document.assets.map((a) =>
+ a.id === id ? generatedAsset(host, wordId, durationSec, trimmed) : a,
+ ),
+ transcripts: document.transcripts.map((t) =>
+ t.assetId === id ? generatedTranscript(wordId, durationSec, trimmed, language) : t,
+ ),
+ audioTracks: reanchorAudioTracks(pills, document.timeline.clips, () => createId("take")),
+ };
+}
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
index 6262803f6..1bfdb7ccf 100644
--- a/src/lib/ai-edition/document/transcript.ts
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -6,6 +6,12 @@ import {
removeGeneratedClips,
retextGeneratedClip,
} from "./insertion";
+import {
+ insertGeneratedTrack,
+ isTrackAsset,
+ removeGeneratedTracks,
+ retextGeneratedTrack,
+} from "./insertionTrack";
const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
@@ -147,9 +153,13 @@ export function setDocumentWordText(
wordId: string,
text: string,
): AxcutDocument {
- // An inserted word's length IS its text, so rewriting it resizes the clip it plays on
- // and renames the file. Nothing a plain transcript write can express.
- if (isGeneratedAssetId(assetId)) return retextGeneratedClip(document, wordId, text);
+ // An inserted word's length IS its text, so rewriting it resizes the clip — or the take
+ // fragment — it plays on, and renames the file. Nothing a plain transcript write can say.
+ if (isGeneratedAssetId(assetId)) {
+ return isTrackAsset(document, assetId)
+ ? retextGeneratedTrack(document, wordId, text)
+ : retextGeneratedClip(document, wordId, text);
+ }
const transcript = document.transcripts.find((t) => t.assetId === assetId);
if (!transcript) {
throw new Error(`Cannot edit a word of asset "${assetId}": it has no transcript`);
@@ -168,7 +178,11 @@ export function insertDocumentWord(
side: InsertSide,
text: string,
): AxcutDocument {
- return insertGeneratedClip(document, assetId, anchorWordId, side, text);
+ // Which lane the caret was in decides which shape the insertion takes — a clip in the
+ // film, a fragment in the take. It is the only thing that differs between them.
+ return isTrackAsset(document, assetId)
+ ? insertGeneratedTrack(document, assetId, anchorWordId, side, text)
+ : insertGeneratedClip(document, assetId, anchorWordId, side, text);
}
/** Delete inserted words, taking the whole set at once: a Backspace over several of them
@@ -181,7 +195,8 @@ export function removeDocumentWords(
_assetId: string,
wordIds: readonly string[],
): AxcutDocument {
- return removeGeneratedClips(document, wordIds);
+ // Each is a no-op for a word the other lane owns, so the set can span both.
+ return removeGeneratedTracks(removeGeneratedClips(document, wordIds), wordIds);
}
/** What {@link carryOverWordEdits} managed to save from the previous transcript. */
From e91bd14888b69c6d1790c52b4ef56721a3f1f7a7 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:28:39 +0200
Subject: [PATCH 106/113] refactor(voiceover): the take walk is a subtraction
again
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`takeProgramme` still carried the machinery of the first insertion attempt: a hold the source
clock parked in, a boundary resolver that could not map insertions up front, a pass counter
and a `maxPasses` guard against a hang the two-force loop could produce. None of it had any
input left — the insertion half was deleted in the reset — so the loop only ever handled
cuts, with four passes budgeted per cut for the passes that no longer exist.
A cut advances both cursors. That is the only thing left, so the source clock is a plain
shift of the raw one and the walk is a subtraction: iterate the cuts, play up to each, mute
through it. No cursor pair, no pass budget, no termination argument to make.
Its header, and the comments in `cues.ts` and `aggregated-transcript.ts`, still explained the
model in terms of a pause being a held CLIP frame and of `insertRanges`, a schema deleted in
the reset. Reasoning that describes a design the code no longer has is worse than none: it is
the thing a reader trusts. Rewritten to say what is actually true, which is simpler.
93 lines out, 38 in.
---
src/components/ai-edition/RightPanes.tsx | 6 +-
src/lib/ai-edition/captions/cues.ts | 13 +--
.../timeline/aggregated-transcript.ts | 11 +-
src/lib/ai-edition/timeline/take-programme.ts | 101 +++++-------------
4 files changed, 38 insertions(+), 93 deletions(-)
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index b7f9562af..47574f65a 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -1254,9 +1254,9 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
const openInsertion = useCallback(
(seed: string) => {
- // ponytail: word insertion ships dev-only until a voice can be synthesized for
- // the word — without one it only borrows free silence, and once it creates
- // timeline time (the pause gesture) it is a silent freeze frame. Drop this gate
+ // ponytail: word insertion ships dev-only until a voice can be synthesized for the
+ // word. The media it creates is a test pattern over noise — real media, in the
+ // right place, for the right length, but nobody says the sentence. Drop this gate
// when TTS lands.
if (!import.meta.env.DEV) return;
if (busy || !seed.trim()) return;
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index ed881ab72..bde765a46 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -202,20 +202,17 @@ export function deriveCaptionCues(
const placements = lanePlacements(
resolveCaptionLane(document, settings),
document.timeline.clips,
- // `?? []` for the same reason `insertRanges` has one: the key is additive, so a
- // document written before it — or hand-built, never through the schema — has none.
+ // `?? []` because the key is additive: a document written before it — or hand-built,
+ // never through the schema — simply has none.
document.audioTracks ?? [],
removedRawSpans(document.timeline.clips, document.timeline.trimRanges),
);
if (placements.length === 0) return [];
const transcripts = new Map(document.transcripts.map((t) => [t.assetId, t]));
- // `?? []` because the key is additive: a document written before it — or a hand-built
- // one that never went through the schema — simply has no pauses.
- // CLIPS-derived on both lanes, deliberately. A pause is a held CLIP frame: it
- // lengthens the ruler under everything, including a voiceover laid over it. Feeding it
- // placements would measure the pause against the take instead of the film, and land
- // every voiceover cue early.
+ // What the film no longer contains is CLIPS-derived on both lanes, deliberately: a cut
+ // is authored on the film, and a take laid over it is silent through it without its own
+ // span saying so. Asking the placements instead would measure the cut against the take.
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
const linesByAsset = new Map();
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 309e3c6aa..1527a7f37 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -311,10 +311,8 @@ export function buildAggregatedSections(
* all (STT on a bed is noise we pay for), so a music placement could only ever
* produce an empty section that reads as a failed transcription.
*
- * One placement per PLAY PIECE of the take's own walk, which is what makes the words after
- * an insertion map to the raw moment they actually occupy. It used to be one per stored
- * FRAGMENT — equivalent while a take could only lose time, wrong the moment it can gain
- * some, because a fragment's source window knows nothing about the pause before it.
+ * One placement per PLAY PIECE of the take's own walk: a cut under the take splits it into
+ * stretches that sit at different raw moments, and a placement is one uninterrupted shift.
*
* A LOOPING take contributes nothing at all. `anchorAudioTrackFragments` deliberately
* does not advance `offsetMs` across the fragments of a looping track, so their words map
@@ -323,10 +321,9 @@ export function buildAggregatedSections(
*/
export function voiceoverPlacements(
audioTracks: AxcutAudioTrack[],
- /** What the film no longer contains. Empty is the honest default: with no cuts and no
- * insertions the walk yields one piece per take, which is what this always produced. */
+ /** What the film no longer contains. Empty is the honest default: with no cuts the walk
+ * yields one piece per take, which is what this always produced. */
removed: readonly RemovedRawSpan[] = [],
- /** This take's own insertions, by group id. */
): TranscriptPlacement[] {
return collapseTracksToPills(audioTracks)
.filter((pill) => pill.kind === "voiceover" && !pill.loop)
diff --git a/src/lib/ai-edition/timeline/take-programme.ts b/src/lib/ai-edition/timeline/take-programme.ts
index 8ea999428..1d0017054 100644
--- a/src/lib/ai-edition/timeline/take-programme.ts
+++ b/src/lib/ai-edition/timeline/take-programme.ts
@@ -1,28 +1,18 @@
// One walk over a voice-over take (issue #560).
//
-// A take is subject to two opposite forces at once and they must not be two passes. A CUT
-// under it takes time away — step 3 already slices the mix by `removedRawSpans`. An
-// INSERTION inside it adds time: a word added to the take's transcript needs somewhere to
-// be spoken, so the voice stops and resumes on the same word.
+// A CUT under a take takes time away: the voice is silent through it with its own clock
+// still running, which is what keeps the words after a cut landing on the picture they
+// belong to. That is the whole of what this does.
//
-// What this walk deliberately does NOT do:
+// It used to also add time, for an insertion inside the take. It does not any more: an
+// insertion IS a track of its own (`document/insertionTrack.ts`), so the take arrives here
+// already split into pills that each play an uninterrupted stretch of their file. Both
+// cursors therefore advance together everywhere, and the walk is a subtraction again.
//
-// - It does not react to an insertion in the RECORDING lane. A word added to the film
-// freezes the picture; the take has its own audio and keeps talking, finishing that
-// much earlier against a picture that has slid. The maintainer settled this: "the
-// voice-over is not impacted by the insertion in the recording track". A take is as
-// long as the audio it holds, and nothing under it changes that.
-// - It does not lengthen the programme. The clips decide the length, full stop. A take
-// insertion pushes the take's later content later inside the SAME timeline, and
-// whatever that pushes past the last frame is lost at export — `mix_external_tracks`
-// clamps every track to the programme. Placing content inside the useful span of the
-// timeline is the user's job.
-//
-// It runs on the PILL, never on a stored fragment. The document stores one fragment per
-// clip a take covers; growing one fragment leaves its successor's head where it was, and
-// the mixer sums with `+=` at an absolute offset — so a fragment-wise walk ships a take
-// playing on top of itself. `anchorAudioTrackFragments` is untouched and stays correct for
-// the music and loop paths that still read it.
+// It runs on the PILL, never on a stored fragment. The document stores one fragment per clip
+// a take covers, and the mixer sums with `+=` at an absolute offset — so a fragment-wise walk
+// ships a take playing on top of itself. `anchorAudioTrackFragments` is untouched and stays
+// correct for the music and loop paths that still read it.
import type { AxcutAudioTrack } from "../schema";
import type { RemovedRawSpan } from "./programme-time";
@@ -44,17 +34,7 @@ export interface TakePiece {
const EPSILON_SEC = 1e-9;
-/**
- * The take, stretch by stretch, in playback order.
- *
- * Both cursors advance together except inside a `hold`, where the source parks. A `removed`
- * stretch advances BOTH — the cut mutes the take without rewinding it, which is what keeps
- * the words after a cut landing on the picture they belong to (the behaviour step 3 shipped
- * and its tests pin).
- *
- * With no insertions this reduces exactly to `subtractRemoved` over the take's span, which
- * is the property that lets the export and the preview keep their current answers.
- */
+/** The take, stretch by stretch, in playback order: what is heard, and what a cut mutes. */
export function takeProgramme(
pill: Pick,
removed: readonly RemovedRawSpan[],
@@ -63,60 +43,32 @@ export function takeProgramme(
const rawEnd = Math.max(rawStart, pill.endMs / 1000);
const sourceStart = Math.max(0, pill.offsetMs / 1000);
- // Every boundary the walk has to stop at, on the RAW ruler, resolved sequentially:
- // an insertion's raw moment depends on the holds before it, so it cannot be mapped in
- // one pass up front.
-
const cuts = [...removed]
.filter((span) => span.endSec > rawStart && span.startSec < rawEnd)
.sort((a, b) => a.startSec - b.startSec);
const pieces: TakePiece[] = [];
let raw = rawStart;
- let source = sourceStart;
- let nextCut = 0;
-
- const push = (kind: TakePiece["kind"], rawTo: number, sourceTo: number) => {
- if (rawTo - raw <= EPSILON_SEC) return;
+ // The source clock never parks, so it is a plain shift of the raw one.
+ const push = (kind: TakePiece["kind"], to: number) => {
+ if (to - raw <= EPSILON_SEC) return;
pieces.push({
kind,
rawStartSec: raw,
- rawEndSec: rawTo,
- sourceStartSec: source,
- sourceEndSec: sourceTo,
+ rawEndSec: to,
+ sourceStartSec: sourceStart + (raw - rawStart),
+ sourceEndSec: sourceStart + (to - rawStart),
});
- raw = rawTo;
- source = sourceTo;
+ raw = to;
};
- // A hang is the worst failure a renderer can have. This loop terminates because every
- // pass either advances `raw` or consumes a boundary, so the passes are bounded by the
- // boundary count — but that is an argument, and an argument is not a guarantee. A
- // mutation of the boundary arithmetic span it forever rather than failing an assertion,
- // so the bound is enforced. Counting passes rather than watching `raw` on purpose: a
- // pass that consumes a zero-length insertion makes real progress without moving `raw`,
- // and a no-progress test would cut the walk short on a legitimate document.
- const maxPasses = 4 * (removed.length + 2);
- let passes = 0;
- while (raw < rawEnd - EPSILON_SEC) {
- if (passes++ > maxPasses) break;
- // Skip cuts the walk has already passed.
- while (nextCut < cuts.length && cuts[nextCut].endSec <= raw + EPSILON_SEC) nextCut++;
-
- const cut = cuts[nextCut];
-
- // Inside a cut: silent to the cut's end, both cursors running.
- if (cut && cut.startSec <= raw + EPSILON_SEC) {
- const to = Math.min(cut.endSec, rawEnd);
- push("removed", to, source + (to - raw));
- continue;
- }
-
- // Otherwise play up to whichever boundary comes first.
- let to = rawEnd;
- if (cut) to = Math.min(to, cut.startSec);
- push("play", Math.max(raw, to), source + (Math.max(raw, to) - raw));
+ for (const cut of cuts) {
+ // `max(_, raw)` because sorted cuts can still overlap; the second one then starts
+ // behind the cursor and contributes only whatever it reaches past it.
+ push("play", Math.min(Math.max(cut.startSec, raw), rawEnd));
+ push("removed", Math.min(cut.endSec, rawEnd));
}
+ push("play", rawEnd);
return pieces;
}
@@ -134,8 +86,7 @@ export function takeRulerExtent(pieces: readonly TakePiece[]): {
* Seconds of the take's FILE the walk consumes.
*
* Deliberately not called `spanSec`: that name already means the trim-projected OUTPUT span
- * at the preview's call site, and the fades are measured against the consumed source — feed
- * them a span grown by a hold and the fade-out starts early in the preview only.
+ * at the preview's call site, and the fades are measured against the consumed source.
*/
export function consumedSourceSec(pieces: readonly TakePiece[]): number {
return pieces.reduce((sum, piece) => sum + (piece.sourceEndSec - piece.sourceStartSec), 0);
From 3fa5aa71200af845ca7434e8525508ec2bf929a8 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:40:24 +0200
Subject: [PATCH 107/113] fix(insertions): a release build cannot retype an
inserted word either
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The DEV gate sat on one gesture — the typing/paste that opens the insertion editor in the
transcript pane. Creating an insertion is genuinely unreachable in a release: that gesture is
the only caller of `insertDocumentWord`, there is no agent operation for it, and the branch
is folded out of the bundle at build time.
What was not gated: retyping an insertion that already exists. Correcting a transcribed word
is a shipped feature, and the same path retexts a generated one — resizing the mire clip and
asking the save for a new mire file, in a release. Reachable by anyone opening a project that
was edited with a dev build.
Both funnels now read one named flag in the shell, where every renderer path reaches the
document, so an entry point added later is refused by default instead of by whoever remembers
the gesture gate. Deleting an inserted word stays open on purpose: getting generated media
OUT of a release build is the behaviour we want.
Verified on a real production build rather than asserted: the dev-only code references are
absent from the bundle.
---
src/components/ai-edition/NewEditorShell.tsx | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 416ea8022..7b30cb1c2 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -32,6 +32,7 @@ import {
import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
+import { isGeneratedAssetId } from "@/lib/ai-edition/timeline/clip-parts";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import {
@@ -87,6 +88,20 @@ interface SeekTarget {
*/
// Stable empty list: a fresh `[]` each render would churn the preview's audio
// element set on every playhead tick.
+/**
+ * Word insertion is DEV-ONLY until there is TTS and frame generation.
+ *
+ * What it creates today is a test pattern over noise — real media, in the right place, for
+ * the right length, but nobody says the sentence. Shipping that to a release would put a
+ * mire in someone's film.
+ *
+ * Gated HERE as well as on the gesture in the transcript pane: this is where every renderer
+ * path reaches the document, so an entry point added later is refused by default rather than
+ * by whoever remembers. `import.meta.env.DEV` is folded at build time, so the branch is not
+ * in the release bundle at all.
+ */
+const INSERTIONS_ENABLED = import.meta.env.DEV;
+
const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
function NativePlaybackSync({
@@ -688,6 +703,10 @@ export function NewEditorShell() {
// holds the document this edit has to be applied to.
const doc = useProjectStore.getState().document;
if (!doc) return;
+ // Correcting a transcribed word is a shipped feature; retyping an INSERTED one
+ // resizes generated media and asks the save for a new file of it, which is the
+ // same thing the gate above refuses. A release build must not do either.
+ if (!INSERTIONS_ENABLED && isGeneratedAssetId(assetId)) return;
try {
await saveDocument(setDocumentWordText(doc, assetId, wordId, text), { history: true });
} catch (err) {
@@ -707,6 +726,7 @@ export function NewEditorShell() {
// captions and stops there.
const handleInsertWord = useCallback(
(assetId: string, anchorWordId: string, side: InsertSide, text: string) => {
+ if (!INSERTIONS_ENABLED) return;
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
From 79cc55de6280205d3ab28e88dd2000e025513279 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:41:06 +0200
Subject: [PATCH 108/113] docs(insertions): the gate is the runtime refusal,
not the dead-code elimination
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The comment claimed the DEV branch is absent from the release bundle. True today, and beside
the point: dropping the body is the minifier's optimisation, not the protection. The guard
returns early on its own, whatever the bundler decides — which is what a reader has to know
before touching it.
---
src/components/ai-edition/NewEditorShell.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 7b30cb1c2..9cc3d2688 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -97,8 +97,11 @@ interface SeekTarget {
*
* Gated HERE as well as on the gesture in the transcript pane: this is where every renderer
* path reaches the document, so an entry point added later is refused by default rather than
- * by whoever remembers. `import.meta.env.DEV` is folded at build time, so the branch is not
- * in the release bundle at all.
+ * by whoever remembers.
+ *
+ * A plain runtime refusal, deliberately. The bundler does fold `import.meta.env.DEV` and will
+ * usually drop the body, but that is an optimisation and not the protection — the guard has
+ * to hold on its own, whatever the minifier decides.
*/
const INSERTIONS_ENABLED = import.meta.env.DEV;
From c3c25ab8587f9659aa971d3f4b47e66df19cc58b Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 17:47:39 +0200
Subject: [PATCH 109/113] refactor(insertions): one flag for the dev gate, read
where the gesture happens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four `import.meta.env.DEV` expressions across two files were saying one thing, and three of
them were written for the model that no longer exists. One definition now, `insertionsEnabled`,
with the reasoning in one place and the upgrade path named once.
Deleted rather than rewritten: `helpInsert` was a second, longer copy of the hint beside it,
promising that an inserted word "reaches the captions and leaves the film alone" — true of the
first attempt, false since an insertion became a clip. Thirteen translations of a wrong
sentence, gone; `editingHintDev` already said the true half.
A function, not a constant, and that is not cosmetic. As a module-level const it is captured
at import, which silently turned `vi.stubEnv` into a no-op — the existing test proving a
release refuses the gesture went from passing to failing to passing-by-not-running. Read at
the moment of the gesture instead, and the check can actually drive it.
The retext half is now refused where the affordance is, not only after the fact: a release
does not open the editor on an amber word at all. Both directions pinned, and both fall over
when the flag is forced true.
---
src/components/ai-edition/NewEditorShell.tsx | 22 +-
src/components/ai-edition/RightPanes.tsx | 19 +-
.../TranscriptPane.wordInsert.test.tsx | 33 +
.../ai-edition/insertionsEnabled.ts | 25 +
src/i18n/locales/ar/settings.json | 579 +++++++++---------
src/i18n/locales/en/settings.json | 579 +++++++++---------
src/i18n/locales/es/settings.json | 579 +++++++++---------
src/i18n/locales/fr/settings.json | 579 +++++++++---------
src/i18n/locales/it/settings.json | 579 +++++++++---------
src/i18n/locales/ja-JP/settings.json | 579 +++++++++---------
src/i18n/locales/ko-KR/settings.json | 579 +++++++++---------
src/i18n/locales/pt-BR/settings.json | 579 +++++++++---------
src/i18n/locales/ru/settings.json | 579 +++++++++---------
src/i18n/locales/tr/settings.json | 579 +++++++++---------
src/i18n/locales/vi/settings.json | 579 +++++++++---------
src/i18n/locales/zh-CN/settings.json | 579 +++++++++---------
src/i18n/locales/zh-TW/settings.json | 579 +++++++++---------
17 files changed, 3828 insertions(+), 3798 deletions(-)
create mode 100644 src/components/ai-edition/insertionsEnabled.ts
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 9cc3d2688..508b004d5 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -45,6 +45,7 @@ import type { AiEditionProjectSummary } from "@/native/contracts";
import { resolveVisibleClips } from "@/native/sceneDescription";
import { useNativePlaybackSync } from "@/native/useNativePlaybackSync";
import { ExportDialog } from "./ExportDialog";
+import { insertionsEnabled } from "./insertionsEnabled";
import { ChatStripPanel } from "./LeftPanel";
import {
EditClipModal,
@@ -88,23 +89,6 @@ interface SeekTarget {
*/
// Stable empty list: a fresh `[]` each render would churn the preview's audio
// element set on every playhead tick.
-/**
- * Word insertion is DEV-ONLY until there is TTS and frame generation.
- *
- * What it creates today is a test pattern over noise — real media, in the right place, for
- * the right length, but nobody says the sentence. Shipping that to a release would put a
- * mire in someone's film.
- *
- * Gated HERE as well as on the gesture in the transcript pane: this is where every renderer
- * path reaches the document, so an entry point added later is refused by default rather than
- * by whoever remembers.
- *
- * A plain runtime refusal, deliberately. The bundler does fold `import.meta.env.DEV` and will
- * usually drop the body, but that is an optimisation and not the protection — the guard has
- * to hold on its own, whatever the minifier decides.
- */
-const INSERTIONS_ENABLED = import.meta.env.DEV;
-
const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
function NativePlaybackSync({
@@ -709,7 +693,7 @@ export function NewEditorShell() {
// Correcting a transcribed word is a shipped feature; retyping an INSERTED one
// resizes generated media and asks the save for a new file of it, which is the
// same thing the gate above refuses. A release build must not do either.
- if (!INSERTIONS_ENABLED && isGeneratedAssetId(assetId)) return;
+ if (!insertionsEnabled() && isGeneratedAssetId(assetId)) return;
try {
await saveDocument(setDocumentWordText(doc, assetId, wordId, text), { history: true });
} catch (err) {
@@ -729,7 +713,7 @@ export function NewEditorShell() {
// captions and stops there.
const handleInsertWord = useCallback(
(assetId: string, anchorWordId: string, side: InsertSide, text: string) => {
- if (!INSERTIONS_ENABLED) return;
+ if (!insertionsEnabled()) return;
void enqueueTimelineWrite(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 47574f65a..296b1948f 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -106,6 +106,7 @@ import {
} from "@/utils/aspectRatioUtils";
import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport";
import { CaptionsPane } from "./CaptionsPane";
+import { insertionsEnabled } from "./insertionsEnabled";
import styles from "./NewEditorShell.module.css";
import { useTranscriptionLabel } from "./TranscriptionStatus";
import { transcriptionBusyLabel } from "./transcriptionBusyLabel";
@@ -931,10 +932,9 @@ export function TranscriptPane({
// The insert gesture is dev-only until TTS (see openInsertion), so the copy follows
// the same gate: release builds must not advertise a dead gesture.
- const helpText =
- ts("transcript.help") + (import.meta.env.DEV ? ` ${ts("transcript.helpInsert")}` : "");
+ const helpText = ts("transcript.help");
const editingHint = ts(
- import.meta.env.DEV ? "transcript.editingHintDev" : "transcript.editingHint",
+ insertionsEnabled() ? "transcript.editingHintDev" : "transcript.editingHint",
);
if (placements.length === 0 || !hasAnyTranscript) {
@@ -1254,11 +1254,8 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
const openInsertion = useCallback(
(seed: string) => {
- // ponytail: word insertion ships dev-only until a voice can be synthesized for the
- // word. The media it creates is a test pattern over noise — real media, in the
- // right place, for the right length, but nobody says the sentence. Drop this gate
- // when TTS lands.
- if (!import.meta.env.DEV) return;
+ // The gesture, hidden. The shell refuses again where it would reach the document.
+ if (!insertionsEnabled()) return;
if (busy || !seed.trim()) return;
const editor = editorRef.current;
const selection = globalThis.getSelection();
@@ -1595,8 +1592,12 @@ const TranscriptWord = memo(function TranscriptWord({
const startEditing = useCallback(() => {
if (!editable) return;
+ // Correcting a transcribed word is a shipped feature; retyping an INSERTED one asks
+ // for generated media of a new length, which is the same thing the insert gesture is
+ // gated on. Not offered rather than silently refused — the shell refuses too.
+ if (!insertionsEnabled() && isInsertedWord(cw.word)) return;
setDraft(cw.word.text);
- }, [editable, cw.word.text]);
+ }, [editable, cw.word]);
const commitDraft = useCallback(() => {
const next = (draft ?? "").trim();
diff --git a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
index 355e73972..e778dd46a 100644
--- a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -151,6 +151,39 @@ describe("typing between two words", () => {
}
});
+ it("will not let a release retype an inserted word either", () => {
+ // The other half of the same gate, and the one that was missing: correcting a
+ // transcribed word ships, and the very same gesture on an INSERTED word asks for
+ // generated media of a new length. A release must not offer it.
+ const withInserted: AxcutWord[] = [
+ WORDS[0],
+ { id: "synth_1", segmentId: "s", startSec: 1, endSec: 1, text: "ajouté", source: "synth" },
+ ...WORDS.slice(1),
+ ];
+ vi.stubEnv("DEV", false);
+ try {
+ const view = renderPane(withInserted);
+ const word = view.editor.querySelector('[data-word-id$=":synth_1"]');
+ expect(word).not.toBeNull();
+ fireEvent.doubleClick(word as HTMLElement);
+ expect(view.editor.querySelector("input,textarea")).toBeNull();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ it("lets a dev build retype it, which is the whole point of the flag", () => {
+ const withInserted: AxcutWord[] = [
+ WORDS[0],
+ { id: "synth_1", segmentId: "s", startSec: 1, endSec: 1, text: "ajouté", source: "synth" },
+ ...WORDS.slice(1),
+ ];
+ const view = renderPane(withInserted);
+ const word = view.editor.querySelector('[data-word-id$=":synth_1"]');
+ fireEvent.doubleClick(word as HTMLElement);
+ expect(view.editor.querySelector("input,textarea")).not.toBeNull();
+ });
+
it("never writes the typed text into the block itself", () => {
// The whole reason inserts were blocked: a run of text with no word id behind it
// desynchronises the DOM from `words`.
diff --git a/src/components/ai-edition/insertionsEnabled.ts b/src/components/ai-edition/insertionsEnabled.ts
new file mode 100644
index 000000000..7742a1698
--- /dev/null
+++ b/src/components/ai-edition/insertionsEnabled.ts
@@ -0,0 +1,25 @@
+/**
+ * Whether a word nobody said can be added to a transcript.
+ *
+ * DEV-only until there is TTS and frame generation. What an insertion creates today is a test
+ * pattern over noise — real media, in the right place, for the right length, but nobody says
+ * the sentence. Shipping that to a release would put a mire in someone's film.
+ *
+ * One definition, read by every gate: the transcript pane hides the gesture, and the shell
+ * refuses again at the point every renderer path reaches the document, so an entry point
+ * added later is refused by default rather than by whoever remembers.
+ *
+ * A plain runtime refusal, deliberately. The bundler does fold `import.meta.env.DEV` and will
+ * usually drop the guarded bodies, but that is an optimisation and not the protection — the
+ * refusal has to hold on its own, whatever the minifier decides.
+ *
+ * A function, not a constant: read at the moment of the gesture, so the gate is something a
+ * test can actually drive. A module-level constant is captured at import and silently makes
+ * `vi.stubEnv` a no-op — the one check that proves a release refuses would pass by not
+ * running.
+ *
+ * ponytail: drop the flag and every reader when TTS and frame generation land.
+ */
+export function insertionsEnabled(): boolean {
+ return import.meta.env.DEV;
+}
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 4abae2859..fd910497f 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "صورة GIF متحركة",
- "mp4Description": "ملف فيديو عالي الجودة",
- "mp4": "MP4",
- "mp4Video": "فيديو MP4",
- "gif": "GIF",
- "gifDescription": "صورة متحركة للمشاركة"
- },
- "customFont": {
- "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "إضافة خط Google",
- "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
- "nameLabel": "اسم العرض",
- "failedToAdd": "فشل في إضافة الخط",
- "urlLabel": "رابط استيراد خطوط Google",
- "namePlaceholder": "خطي المخصص",
- "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
- "addingButton": "جاري الإضافة...",
- "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
- "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
- "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
- "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
- "errorEmptyName": "يرجى إدخال اسم الخط",
- "addButton": "إضافة خط",
- "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google"
- },
- "annotation": {
- "colorWheel": "عجلة الألوان",
- "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
- "typeArrow": "سهم",
- "selectStyle": "حدد النمط",
- "blurColorWhite": "أبيض",
- "blurType": "نوع التمويه",
- "blurShapeRectangle": "مستطيل",
- "blurColor": "لون التمويه",
- "arrowColor": "لون السهم",
- "textContent": "محتوى النص",
- "background": "الخلفية",
- "clearBackground": "مسح الخلفية",
- "blurShapeFreehand": "رسم حر",
- "blurIntensity": "كثافة التمويه",
- "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
- "active": "نشط",
- "size": "الحجم",
- "blurColorBlack": "أسود",
- "typeImage": "صورة",
- "mosaicBlockSize": "حجم كتلة الفسيفساء",
- "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
- "strokeWidth": "عرض الخط: {{width}}px",
- "textColor": "لون النص",
- "defaultText": "مرحبا",
- "blurShape": "شكل التمويه",
- "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
- "type": "النوع",
- "typeText": "نص",
- "textPlaceholder": "أدخل النص هنا...",
- "fontStyle": "نمط الخط",
- "imageUploadSuccess": "تم رفع الصورة بنجاح!",
- "colorPalette": "لوحة الألوان",
- "color": "لون",
- "shortcutsAndTips": "اختصارات ونصائح",
- "none": "بدون",
- "invalidImageType": "نوع ملف غير صالح",
- "arrowDirection": "اتجاه السهم",
- "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
- "uploadImage": "رفع صورة",
- "customFonts": "خطوط مخصصة",
- "blurTypeMosaic": "فسيفساء",
- "blurTypeBlur": "غاوسي",
- "typeBlur": "تمويه",
- "title": "إعدادات الشروح",
- "deleteAnnotation": "حذف الشرح",
- "blurShapeOval": "بيضاوي"
- },
- "transcript": {
- "restoreSilence": "استعادة الصمت ({{duration}} ث)",
- "editWord": "تحرير \"{{word}}\"",
- "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
- "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
- "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
- "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
- "insertAria": "كلمة جديدة",
- "transcribing": "جارٍ التفريغ…",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
- "noTranscript": "لا يوجد نص بعد",
- "silence": "[صمت {{duration}} ث]",
- "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
- "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
- "noClips": "لا توجد مقاطع بعد",
- "laneVoiceover": "التعليق الصوتي",
- "laneLabel": "اقرأ النص من",
- "revertWord": "استعادة \"{{original}}\"",
- "helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
- "blankedWord": "مُفرَّغة",
- "clipLabel": "المقطع {{index}}",
- "title": "النص الحالي",
- "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
- "transcribeNow": "فرّغ النص الآن",
- "laneRecording": "التسجيل",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
- "removeInserted": "حذف \"{{word}}\"",
- "trimSilence": "قص الصمت ({{duration}} ث)",
- "editorAria": "نص {{filename}}",
- "restoreWord": "استعادة \"{{word}}\""
- },
- "effects": {
- "shadow": "ظل",
- "fitClipOne": "مقطع واحد",
- "blurBg": "تمويه الخلفية",
- "fitClip": "ملاءمة",
- "formatOriginal": "الأصلي",
- "title": "التركيب",
- "format": "التنسيق",
- "motionBlur": "ضبابية الحركة",
- "fitClipMany": "{{count}} مقاطع",
- "roundness": "الاستدارة",
- "fitClipFew": "{{count}} مقاطع",
- "motion": "الحركة",
- "on": "تشغيل",
- "frame": "الإطار",
- "padding": "المسافة البادئة",
- "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
- "off": "إيقاف"
- },
- "audioTrack": {
- "mute": "كتم",
- "fadeIn": "تلاشٍ للداخل",
- "loop": "تكرار",
- "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
- "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
- "importFailed": "تعذّر إضافة الصوت",
- "fadeOut": "تلاشٍ للخارج",
- "add": "إضافة مسار صوتي",
- "defaultLabel": "مسار صوتي",
- "remove": "حذف المسار"
- },
"layout": {
+ "webcamBlurIntensity": "شدة الضبابية",
+ "bgModes": {
+ "transparent": "تفريغ",
+ "none": "الأصلي",
+ "blur": "تمويه",
+ "custom": "مخصص"
+ },
+ "selectPreset": "حدد إعدادًا مسبقًا",
+ "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
+ "reactiveWebcam": "تصغير عند التكبير",
"shapes": {
"circle": "دائرة",
"square": "مربع",
"rectangle": "مستطيل",
"rounded": "زوايا مستديرة"
},
+ "webcamBackground": "خلفية الكاميرا",
"verticalStack": "تكدس عمودي",
- "webcamSize": "حجم كاميرا الويب",
- "preset": "الإعداد المسبق",
- "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
- "dualFrame": "إطار مزدوج",
- "title": "تخطيط الكاميرا",
- "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
- "bgModes": {
- "transparent": "تفريغ",
- "custom": "مخصص",
- "none": "الأصلي",
- "blur": "تمويه"
- },
- "webcamCropZoom": "تكبير الاقتصاص",
- "selectPreset": "حدد إعدادًا مسبقًا",
- "webcamCropY": "تحريك عمودي",
- "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
"pictureInPicture": "صورة داخل صورة",
- "reactiveWebcam": "تصغير عند التكبير",
- "webcamBackground": "خلفية الكاميرا",
- "webcamBlurIntensity": "شدة الضبابية",
- "mirrorWebcam": "عكس كاميرا الويب",
"webcamShape": "شكل الكاميرا",
+ "webcamCropY": "تحريك عمودي",
+ "webcamSize": "حجم كاميرا الويب",
+ "mirrorWebcam": "عكس كاميرا الويب",
+ "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
+ "webcamFraming": "تأطير كاميرا الويب",
"noWebcam": "بدون كاميرا",
+ "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
+ "webcamCropZoom": "تكبير الاقتصاص",
+ "dualFrame": "إطار مزدوج",
"webcamCropX": "تحريك أفقي",
- "webcamFraming": "تأطير كاميرا الويب"
- },
- "imageUpload": {
- "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
- "failedToUpload": "فشل رفع الصورة",
- "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
- "errorReading": "حدث خطأ أثناء قراءة الملف.",
- "invalidFileType": "نوع ملف غير صالح"
- },
- "cursor": {
- "themeDefault": "افتراضي",
- "motionBlur": "ضبابية الحركة",
- "smoothing": "التنعيم",
- "title": "المؤشر",
- "theme": "نمط المؤشر",
- "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
- "show": "إظهار المؤشر",
- "clipToBounds": "القص ضمن اللوحة",
- "size": "الحجم",
- "clickBounce": "ارتداد النقر",
- "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر."
+ "title": "تخطيط الكاميرا",
+ "preset": "الإعداد المسبق"
},
- "captions": {
- "original": "الأصل (النص المفرّغ)",
- "alignRight": "يمين",
- "backgroundOpacity": "العتامة",
- "anchorBottom": "أسفل",
- "minWords": "أقل عدد كلمات في السطر",
- "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
- "translate": "ترجمة",
- "maxWords": "أكثر عدد كلمات في السطر",
- "lineLength": "طول السطر",
- "anchorTop": "أعلى",
- "font": "الخط",
- "translating": "جارٍ الترجمة…",
- "position": "الموضع",
- "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
- "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
- "backgroundColor": "لون الخلفية",
- "text": "النص",
- "textColor": "لون النص",
- "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
- "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
- "distanceFromTop": "المسافة من الأعلى",
- "alignLeft": "يسار",
- "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
- "deleteTranslation": "حذف هذه الترجمة",
- "distanceFromBottom": "المسافة من الأسفل",
- "displayLanguage": "العرض",
- "background": "الخلفية",
- "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
- "distanceFromLeft": "المسافة من اليسار",
- "alignCenter": "توسيط",
- "fontSize": "الحجم",
- "bold": "عريض",
- "translateFailed": "فشلت الترجمة.",
- "distanceFromRight": "المسافة من اليمين",
- "showBackground": "إظهار الخلفية",
- "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
- "show": "إظهار الترجمة",
- "language": "اللغة",
- "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ."
+ "crop": {
+ "done": "تم",
+ "ratio": "النسبة",
+ "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
+ "title": "اقتصاص",
+ "free": "حر",
+ "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
+ "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
+ "cropVideo": "اقتصاص الفيديو"
},
"zoom": {
+ "position": {
+ "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "موضع التركيز"
+ },
"threeD": {
"preset": {
"right": "يمين",
@@ -241,115 +59,296 @@
"none": "بلا",
"title": "دوران ثلاثي الأبعاد"
},
+ "deleteZoom": "حذف التكبير",
+ "customScale": "تكبير مخصص",
+ "selectRegion": "حدد منطقة التكبير للتعديل",
"focusMode": {
+ "manual": "يدوي",
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
"lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
"title": "وضع التركيز",
- "manual": "يدوي",
"auto": "تلقائي"
},
- "position": {
- "title": "موضع التركيز",
- "x": "X (%)",
- "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
- "y": "Y (%)"
- },
- "deleteZoom": "حذف التكبير",
- "level": "مستوى التكبير",
- "selectRegion": "حدد منطقة التكبير للتعديل",
"previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
- "customScale": "تكبير مخصص"
- },
- "textAnimation": {
- "pop": "ظهور",
- "rise": "ارتفاع",
- "selectAnimation": "حدد الحركة",
- "fade": "تلاشي",
- "pulse": "نبض",
- "typewriter": "آلة كاتبة",
- "title": "تحريك النص",
- "none": "بدون",
- "slideLeft": "انزلاق لليسار"
- },
- "crop": {
- "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
- "title": "اقتصاص",
- "done": "تم",
- "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
- "ratio": "النسبة",
- "free": "حر",
- "cropVideo": "اقتصاص الفيديو",
- "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
+ "level": "مستوى التكبير"
},
"background": {
- "imageLabel": "الخلفية {{index}}",
"color": "لون",
- "gradient": "تدرج لوني",
"colorLabel": "اللون {{color}}",
- "colorWheel": "عجلة الألوان",
- "customWallpaper": "خلفية مخصصة",
- "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
- "image": "صورة",
- "gradientLabel": "تدرج لوني {{index}}",
+ "gradient": "تدرج لوني",
"imageReadFailed": "تعذّر قراءة ملف الصورة.",
- "presets": "إعدادات مسبقة",
+ "gradientLabel": "تدرج لوني {{index}}",
"custom": "مخصص",
+ "customWallpaper": "خلفية مخصصة",
+ "presets": "إعدادات مسبقة",
+ "image": "صورة",
"colorPalette": "لوحة الألوان",
- "uploadCustom": "رفع صورة مخصصة",
+ "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
+ "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
+ "imageLabel": "الخلفية {{index}}",
"title": "الخلفية",
- "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG."
+ "uploadCustom": "رفع صورة مخصصة",
+ "colorWheel": "عجلة الألوان"
},
- "audio": {
- "title": "الصوت",
- "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
- "reset": "إعادة ضبط الصوت",
- "outputGain": "ضبط مستوى الإخراج"
+ "cursor": {
+ "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
+ "clickBounce": "ارتداد النقر",
+ "clipToBounds": "القص ضمن اللوحة",
+ "title": "المؤشر",
+ "size": "الحجم",
+ "themeDefault": "افتراضي",
+ "smoothing": "التنعيم",
+ "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
+ "motionBlur": "ضبابية الحركة",
+ "theme": "نمط المؤشر",
+ "show": "إظهار المؤشر"
+ },
+ "captions": {
+ "text": "النص",
+ "showBackground": "إظهار الخلفية",
+ "minWords": "أقل عدد كلمات في السطر",
+ "translateFailed": "فشلت الترجمة.",
+ "distanceFromTop": "المسافة من الأعلى",
+ "fontSize": "الحجم",
+ "distanceFromRight": "المسافة من اليمين",
+ "bold": "عريض",
+ "textColor": "لون النص",
+ "original": "الأصل (النص المفرّغ)",
+ "translating": "جارٍ الترجمة…",
+ "language": "اللغة",
+ "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
+ "alignLeft": "يسار",
+ "distanceFromBottom": "المسافة من الأسفل",
+ "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
+ "show": "إظهار الترجمة",
+ "background": "الخلفية",
+ "backgroundOpacity": "العتامة",
+ "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
+ "anchorTop": "أعلى",
+ "translate": "ترجمة",
+ "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
+ "alignCenter": "توسيط",
+ "alignRight": "يمين",
+ "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "displayLanguage": "العرض",
+ "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
+ "distanceFromLeft": "المسافة من اليسار",
+ "maxWords": "أكثر عدد كلمات في السطر",
+ "anchorBottom": "أسفل",
+ "position": "الموضع",
+ "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
+ "deleteTranslation": "حذف هذه الترجمة",
+ "backgroundColor": "لون الخلفية",
+ "font": "الخط",
+ "lineLength": "طول السطر",
+ "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
+ "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "دقة التصدير"
},
- "gifSettings": {
- "loop": "تكرار GIF",
- "frameRate": "معدل إطارات GIF",
- "size": "حجم GIF"
+ "transcript": {
+ "transcribing": "جارٍ التفريغ…",
+ "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "laneVoiceover": "التعليق الصوتي",
+ "blankedWord": "مُفرَّغة",
+ "noTranscript": "لا يوجد نص بعد",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "revertWord": "استعادة \"{{original}}\"",
+ "silence": "[صمت {{duration}} ث]",
+ "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
+ "transcribeNow": "فرّغ النص الآن",
+ "restoreWord": "استعادة \"{{word}}\"",
+ "clipLabel": "المقطع {{index}}",
+ "title": "النص الحالي",
+ "insertAria": "كلمة جديدة",
+ "laneRecording": "التسجيل",
+ "trimSilence": "قص الصمت ({{duration}} ث)",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
+ "removeInserted": "حذف \"{{word}}\"",
+ "editorAria": "نص {{filename}}",
+ "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
+ "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
+ "editWord": "تحرير \"{{word}}\"",
+ "noClips": "لا توجد مقاطع بعد",
+ "laneLabel": "اقرأ النص من",
+ "restoreSilence": "استعادة الصمت ({{duration}} ث)"
},
- "export": {
- "chooseSaveLocation": "اختيار موقع الحفظ",
- "gifButton": "تصدير GIF",
- "videoButton": "تصدير الفيديو"
+ "customFont": {
+ "addingButton": "جاري الإضافة...",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
+ "addButton": "إضافة خط",
+ "dialogTitle": "إضافة خط Google",
+ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
+ "nameLabel": "اسم العرض",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
+ "urlLabel": "رابط استيراد خطوط Google",
+ "errorEmptyName": "يرجى إدخال اسم الخط",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
+ "namePlaceholder": "خطي المخصص",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "فشل في إضافة الخط",
+ "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
+ "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
+ "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح"
},
- "project": {
- "load": "تحميل المشروع",
- "save": "حفظ المشروع",
- "new": "مشروع جديد"
+ "annotation": {
+ "arrowColor": "لون السهم",
+ "colorWheel": "عجلة الألوان",
+ "blurType": "نوع التمويه",
+ "active": "نشط",
+ "deleteAnnotation": "حذف الشرح",
+ "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
+ "strokeWidth": "عرض الخط: {{width}}px",
+ "background": "الخلفية",
+ "imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "blurColor": "لون التمويه",
+ "blurTypeBlur": "غاوسي",
+ "textColor": "لون النص",
+ "blurColorWhite": "أبيض",
+ "title": "إعدادات الشروح",
+ "type": "النوع",
+ "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
+ "typeImage": "صورة",
+ "textContent": "محتوى النص",
+ "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
+ "typeText": "نص",
+ "blurIntensity": "كثافة التمويه",
+ "none": "بدون",
+ "mosaicBlockSize": "حجم كتلة الفسيفساء",
+ "textPlaceholder": "أدخل النص هنا...",
+ "typeArrow": "سهم",
+ "color": "لون",
+ "blurColorBlack": "أسود",
+ "size": "الحجم",
+ "invalidImageType": "نوع ملف غير صالح",
+ "blurShapeFreehand": "رسم حر",
+ "shortcutsAndTips": "اختصارات ونصائح",
+ "uploadImage": "رفع صورة",
+ "blurTypeMosaic": "فسيفساء",
+ "selectStyle": "حدد النمط",
+ "defaultText": "مرحبا",
+ "blurShapeRectangle": "مستطيل",
+ "colorPalette": "لوحة الألوان",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "clearBackground": "مسح الخلفية",
+ "customFonts": "خطوط مخصصة",
+ "typeBlur": "تمويه",
+ "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
+ "fontStyle": "نمط الخط",
+ "blurShape": "شكل التمويه",
+ "arrowDirection": "اتجاه السهم",
+ "blurShapeOval": "بيضاوي"
},
"speed": {
- "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير.",
"customPlaybackSpeed": "سرعة تشغيل مخصصة",
- "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
"deleteRegion": "حذف منطقة السرعة",
"selectRegion": "حدد منطقة السرعة للتعديل",
- "playbackSpeed": "سرعة التشغيل"
+ "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
+ "playbackSpeed": "سرعة التشغيل",
+ "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير."
+ },
+ "textAnimation": {
+ "selectAnimation": "حدد الحركة",
+ "pulse": "نبض",
+ "rise": "ارتفاع",
+ "none": "بدون",
+ "slideLeft": "انزلاق لليسار",
+ "title": "تحريك النص",
+ "fade": "تلاشي",
+ "pop": "ظهور",
+ "typewriter": "آلة كاتبة"
+ },
+ "effects": {
+ "motion": "الحركة",
+ "title": "التركيب",
+ "format": "التنسيق",
+ "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
+ "fitClipFew": "{{count}} مقاطع",
+ "motionBlur": "ضبابية الحركة",
+ "fitClipMany": "{{count}} مقاطع",
+ "frame": "الإطار",
+ "padding": "المسافة البادئة",
+ "roundness": "الاستدارة",
+ "off": "إيقاف",
+ "blurBg": "تمويه الخلفية",
+ "shadow": "ظل",
+ "on": "تشغيل",
+ "fitClipOne": "مقطع واحد",
+ "formatOriginal": "الأصلي",
+ "fitClip": "ملاءمة"
+ },
+ "exportFormat": {
+ "gifDescription": "صورة متحركة للمشاركة",
+ "mp4": "MP4",
+ "mp4Video": "فيديو MP4",
+ "gif": "GIF",
+ "gifAnimation": "صورة GIF متحركة",
+ "mp4Description": "ملف فيديو عالي الجودة"
+ },
+ "imageUpload": {
+ "failedToUpload": "فشل رفع الصورة",
+ "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
+ "errorReading": "حدث خطأ أثناء قراءة الملف.",
+ "invalidFileType": "نوع ملف غير صالح",
+ "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG."
+ },
+ "facets": {
+ "captions": "الترجمة",
+ "transcript": "النص"
+ },
+ "audio": {
+ "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
+ "reset": "إعادة ضبط الصوت",
+ "title": "الصوت",
+ "outputGain": "ضبط مستوى الإخراج"
},
"language": {
"title": "اللغة"
},
- "support": {
- "starOnGithub": "إعطاء نجمة على GitHub",
- "reportBug": "الإبلاغ عن خطأ",
- "saveDiagnostics": "حفظ التشخيصات"
+ "audioTrack": {
+ "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "importFailed": "تعذّر إضافة الصوت",
+ "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
+ "fadeOut": "تلاشٍ للخارج",
+ "defaultLabel": "مسار صوتي",
+ "mute": "كتم",
+ "remove": "حذف المسار",
+ "add": "إضافة مسار صوتي",
+ "loop": "تكرار",
+ "fadeIn": "تلاشٍ للداخل"
+ },
+ "project": {
+ "load": "تحميل المشروع",
+ "save": "حفظ المشروع",
+ "new": "مشروع جديد"
+ },
+ "panes": {
+ "help": "مساعدة"
},
"trim": {
"deleteRegion": "حذف منطقة القص"
},
- "facets": {
- "transcript": "النص",
- "captions": "الترجمة"
+ "export": {
+ "videoButton": "تصدير الفيديو",
+ "gifButton": "تصدير GIF",
+ "chooseSaveLocation": "اختيار موقع الحفظ"
},
- "panes": {
- "help": "مساعدة"
+ "support": {
+ "starOnGithub": "إعطاء نجمة على GitHub",
+ "saveDiagnostics": "حفظ التشخيصات",
+ "reportBug": "الإبلاغ عن خطأ"
+ },
+ "gifSettings": {
+ "size": "حجم GIF",
+ "frameRate": "معدل إطارات GIF",
+ "loop": "تكرار GIF"
}
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 88fed708e..22c295610 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF Animation",
- "mp4Description": "High quality video file",
- "mp4": "MP4",
- "mp4Video": "MP4 Video",
- "gif": "GIF",
- "gifDescription": "Animated image for sharing"
- },
- "customFont": {
- "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Add Google Font",
- "errorTimeout": "Font took too long to load. Please check the URL and try again.",
- "nameLabel": "Display Name",
- "failedToAdd": "Failed to add font",
- "urlLabel": "Google Fonts Import URL",
- "namePlaceholder": "My Custom Font",
- "errorExtractFailed": "Could not extract font family from URL",
- "addingButton": "Adding...",
- "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
- "errorEmptyUrl": "Please enter a Google Fonts import URL",
- "successMessage": "Font \"{{fontName}}\" added successfully",
- "nameHelp": "This is how the font will appear in the font selector",
- "errorEmptyName": "Please enter a font name",
- "addButton": "Add Font",
- "errorInvalidUrl": "Please enter a valid Google Fonts URL"
- },
- "annotation": {
- "colorWheel": "Color Wheel",
- "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
- "typeArrow": "Arrow",
- "selectStyle": "Select style",
- "blurColorWhite": "White",
- "blurType": "Blur Type",
- "blurShapeRectangle": "Rectangle",
- "blurColor": "Blur Color",
- "arrowColor": "Arrow Color",
- "textContent": "Text Content",
- "background": "Background",
- "clearBackground": "Clear Background",
- "blurShapeFreehand": "Freehand",
- "blurIntensity": "Blur Intensity",
- "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
- "active": "Active",
- "size": "Size",
- "blurColorBlack": "Black",
- "typeImage": "Image",
- "mosaicBlockSize": "Mosaic Block Size",
- "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
- "strokeWidth": "Stroke Width: {{width}}px",
- "textColor": "Text Color",
- "defaultText": "Hello",
- "blurShape": "Blur Shape",
- "tipTabCycle": "Use Tab to cycle through overlapping items.",
- "type": "Type",
- "typeText": "Text",
- "textPlaceholder": "Enter your text...",
- "fontStyle": "Font Style",
- "imageUploadSuccess": "Image uploaded successfully!",
- "colorPalette": "Color Palette",
- "color": "Color",
- "shortcutsAndTips": "Shortcuts & Tips",
- "none": "None",
- "invalidImageType": "Invalid file type",
- "arrowDirection": "Arrow Direction",
- "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
- "uploadImage": "Upload Image",
- "customFonts": "Custom Fonts",
- "blurTypeMosaic": "Mosaic",
- "blurTypeBlur": "Gaussian",
- "typeBlur": "Blur",
- "title": "Annotation Settings",
- "deleteAnnotation": "Delete Annotation",
- "blurShapeOval": "Oval"
- },
- "transcript": {
- "restoreSilence": "Restore silence ({{duration}}s)",
- "editWord": "Edit \"{{word}}\"",
- "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
- "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
- "insertedWord": "Added by you — no audio behind it",
- "laneFeedsCaptions": "Captions are burnt from this lane.",
- "insertAria": "New word",
- "transcribing": "Transcribing…",
- "noAudio": "This media has no audio track",
- "noTranscript": "No transcript yet",
- "silence": "[silence {{duration}}s]",
- "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
- "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
- "noClips": "No clips yet",
- "laneVoiceover": "Voice-over",
- "laneLabel": "Read the transcript from",
- "revertWord": "Restore \"{{original}}\"",
- "helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
- "blankedWord": "blanked",
- "clipLabel": "Clip {{index}}",
- "title": "Current transcription",
- "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
- "transcribeNow": "Transcribe now",
- "laneRecording": "Recording",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
- "removeInserted": "Delete \"{{word}}\"",
- "trimSilence": "Trim silence ({{duration}}s)",
- "editorAria": "Transcript for {{filename}}",
- "restoreWord": "Restore \"{{word}}\""
- },
- "effects": {
- "shadow": "Shadow",
- "fitClipOne": "{{count}} clip",
- "blurBg": "Blur BG",
- "fitClip": "Fit",
- "formatOriginal": "Original",
- "title": "Composition",
- "format": "Format",
- "motionBlur": "Motion Blur",
- "fitClipMany": "{{count}} clips",
- "roundness": "Roundness",
- "fitClipFew": "{{count}} clips",
- "motion": "Motion",
- "on": "on",
- "frame": "Frame",
- "padding": "Padding",
- "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
- "off": "off"
- },
- "audioTrack": {
- "mute": "Mute",
- "fadeIn": "Fade in",
- "loop": "Loop",
- "slipHint": "Alt-drag to slide the audio inside it",
- "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
- "importFailed": "Could not add audio",
- "fadeOut": "Fade out",
- "add": "Add audio track",
- "defaultLabel": "Audio track",
- "remove": "Delete track"
- },
"layout": {
+ "webcamBlurIntensity": "Blur Intensity",
+ "bgModes": {
+ "transparent": "Cutout",
+ "none": "Original",
+ "blur": "Blur",
+ "custom": "Custom"
+ },
+ "selectPreset": "Select preset",
+ "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
+ "reactiveWebcam": "Shrink on Zoom",
"shapes": {
"circle": "Circle",
"square": "Square",
"rectangle": "Rect",
"rounded": "Rounded"
},
+ "webcamBackground": "Camera Background",
"verticalStack": "Vertical Stack",
- "webcamSize": "Webcam Size",
- "preset": "Preset",
- "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
- "dualFrame": "Dual Frame",
- "title": "Camera layout",
- "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
- "bgModes": {
- "transparent": "Cutout",
- "custom": "Custom",
- "none": "Original",
- "blur": "Blur"
- },
- "webcamCropZoom": "Zoom",
- "selectPreset": "Select preset",
- "webcamCropY": "Pan vertically",
- "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
"pictureInPicture": "Picture in Picture",
- "reactiveWebcam": "Shrink on Zoom",
- "webcamBackground": "Camera Background",
- "webcamBlurIntensity": "Blur Intensity",
- "mirrorWebcam": "Mirror Webcam",
"webcamShape": "Camera Shape",
+ "webcamCropY": "Pan vertically",
+ "webcamSize": "Webcam Size",
+ "mirrorWebcam": "Mirror Webcam",
+ "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
+ "webcamFraming": "Webcam crop",
"noWebcam": "No Webcam",
+ "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
+ "webcamCropZoom": "Zoom",
+ "dualFrame": "Dual Frame",
"webcamCropX": "Pan horizontally",
- "webcamFraming": "Webcam crop"
- },
- "imageUpload": {
- "uploadSuccess": "Custom image uploaded successfully!",
- "failedToUpload": "Failed to upload image",
- "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
- "errorReading": "There was an error reading the file.",
- "invalidFileType": "Invalid file type"
- },
- "cursor": {
- "themeDefault": "Default",
- "motionBlur": "Motion Blur",
- "smoothing": "Smoothing",
- "title": "Cursor",
- "theme": "Cursor Style",
- "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
- "show": "Show Cursor",
- "clipToBounds": "Clip to Canvas",
- "size": "Size",
- "clickBounce": "Click Bounce",
- "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis."
+ "title": "Camera layout",
+ "preset": "Preset"
},
- "captions": {
- "original": "Original (transcript)",
- "alignRight": "Right",
- "backgroundOpacity": "Opacity",
- "anchorBottom": "Bottom",
- "minWords": "Min words per line",
- "anchorHintTop": "Long captions grow downward — the top edge stays put.",
- "translate": "Translate",
- "maxWords": "Max words per line",
- "lineLength": "Line length",
- "anchorTop": "Top",
- "font": "Font",
- "translating": "Translating…",
- "position": "Position",
- "removeLegacyAnnotations": "Remove old caption annotations",
- "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
- "backgroundColor": "Background color",
- "text": "Text",
- "textColor": "Text color",
- "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
- "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
- "distanceFromTop": "Distance from top",
- "alignLeft": "Left",
- "anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
- "deleteTranslation": "Delete this translation",
- "distanceFromBottom": "Distance from bottom",
- "displayLanguage": "Display",
- "background": "Background",
- "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
- "distanceFromLeft": "Distance from left",
- "alignCenter": "Center",
- "fontSize": "Size",
- "bold": "Bold",
- "translateFailed": "Translation failed.",
- "distanceFromRight": "Distance from right",
- "showBackground": "Show background",
- "translateHint": "Translate the transcript with the configured AI provider",
- "show": "Show captions",
- "language": "Language",
- "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript."
+ "crop": {
+ "done": "Done",
+ "ratio": "Ratio",
+ "dragInstruction": "Drag on each side to adjust the crop area",
+ "title": "Crop",
+ "free": "Free",
+ "lockAspectRatio": "Lock aspect ratio",
+ "unlockAspectRatio": "Unlock aspect ratio",
+ "cropVideo": "Crop Video"
},
"zoom": {
+ "position": {
+ "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Focus Position"
+ },
"threeD": {
"preset": {
"right": "Right",
@@ -241,115 +59,296 @@
"none": "None",
"title": "3D Rotation"
},
+ "deleteZoom": "Delete Zoom",
+ "customScale": "Custom Zoom",
+ "selectRegion": "Select a zoom region to adjust",
"focusMode": {
+ "manual": "Manual",
"autoDescription": "Camera follows the recorded cursor position",
"lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
"title": "Focus Mode",
- "manual": "Manual",
"auto": "Auto"
},
- "position": {
- "title": "Focus Position",
- "x": "X (%)",
- "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
- "y": "Y (%)"
- },
- "deleteZoom": "Delete Zoom",
- "level": "Zoom Level",
- "selectRegion": "Select a zoom region to adjust",
"previewHold": "Hold to preview zoom effect",
- "customScale": "Custom Zoom"
- },
- "textAnimation": {
- "pop": "Pop",
- "rise": "Rise",
- "selectAnimation": "Select animation",
- "fade": "Fade",
- "pulse": "Pulse",
- "typewriter": "Typewriter",
- "title": "Text Animation",
- "none": "None",
- "slideLeft": "Slide Left"
- },
- "crop": {
- "lockAspectRatio": "Lock aspect ratio",
- "title": "Crop",
- "done": "Done",
- "dragInstruction": "Drag on each side to adjust the crop area",
- "ratio": "Ratio",
- "free": "Free",
- "cropVideo": "Crop Video",
- "unlockAspectRatio": "Unlock aspect ratio"
+ "level": "Zoom Level"
},
"background": {
- "imageLabel": "Background {{index}}",
"color": "Color",
- "gradient": "Gradient",
"colorLabel": "Color {{color}}",
- "colorWheel": "Color Wheel",
- "customWallpaper": "Custom wallpaper",
- "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
- "image": "Image",
- "gradientLabel": "Gradient {{index}}",
+ "gradient": "Gradient",
"imageReadFailed": "Could not read that image file.",
- "presets": "Presets",
+ "gradientLabel": "Gradient {{index}}",
"custom": "Custom",
+ "customWallpaper": "Custom wallpaper",
+ "presets": "Presets",
+ "image": "Image",
"colorPalette": "Color Palette",
- "uploadCustom": "Upload Custom",
+ "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
+ "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
+ "imageLabel": "Background {{index}}",
"title": "Background",
- "unsupportedImage": "Unsupported image. Use a JPG or PNG file."
+ "uploadCustom": "Upload Custom",
+ "colorWheel": "Color Wheel"
},
- "audio": {
- "title": "Audio",
- "help": "Adjust the audio output level. It applies identically in the preview and the export.",
- "reset": "Reset audio",
- "outputGain": "Output level"
+ "cursor": {
+ "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
+ "clickBounce": "Click Bounce",
+ "clipToBounds": "Clip to Canvas",
+ "title": "Cursor",
+ "size": "Size",
+ "themeDefault": "Default",
+ "smoothing": "Smoothing",
+ "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
+ "motionBlur": "Motion Blur",
+ "theme": "Cursor Style",
+ "show": "Show Cursor"
+ },
+ "captions": {
+ "text": "Text",
+ "showBackground": "Show background",
+ "minWords": "Min words per line",
+ "translateFailed": "Translation failed.",
+ "distanceFromTop": "Distance from top",
+ "fontSize": "Size",
+ "distanceFromRight": "Distance from right",
+ "bold": "Bold",
+ "textColor": "Text color",
+ "original": "Original (transcript)",
+ "translating": "Translating…",
+ "language": "Language",
+ "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
+ "alignLeft": "Left",
+ "distanceFromBottom": "Distance from bottom",
+ "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
+ "show": "Show captions",
+ "background": "Background",
+ "backgroundOpacity": "Opacity",
+ "removeLegacyAnnotations": "Remove old caption annotations",
+ "anchorTop": "Top",
+ "translate": "Translate",
+ "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
+ "alignCenter": "Center",
+ "alignRight": "Right",
+ "translateHint": "Translate the transcript with the configured AI provider",
+ "displayLanguage": "Display",
+ "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
+ "distanceFromLeft": "Distance from left",
+ "maxWords": "Max words per line",
+ "anchorBottom": "Bottom",
+ "position": "Position",
+ "anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
+ "deleteTranslation": "Delete this translation",
+ "backgroundColor": "Background color",
+ "font": "Font",
+ "lineLength": "Line length",
+ "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
+ "anchorHintTop": "Long captions grow downward — the top edge stays put."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "Export resolution"
},
- "gifSettings": {
- "loop": "Loop GIF",
- "frameRate": "GIF Frame Rate",
- "size": "GIF Size"
+ "transcript": {
+ "transcribing": "Transcribing…",
+ "laneFeedsCaptions": "Captions are burnt from this lane.",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
+ "laneVoiceover": "Voice-over",
+ "blankedWord": "blanked",
+ "noTranscript": "No transcript yet",
+ "noAudio": "This media has no audio track",
+ "revertWord": "Restore \"{{original}}\"",
+ "silence": "[silence {{duration}}s]",
+ "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
+ "transcribeNow": "Transcribe now",
+ "restoreWord": "Restore \"{{word}}\"",
+ "clipLabel": "Clip {{index}}",
+ "title": "Current transcription",
+ "insertAria": "New word",
+ "laneRecording": "Recording",
+ "trimSilence": "Trim silence ({{duration}}s)",
+ "insertedWord": "Added by you — no audio behind it",
+ "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
+ "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
+ "removeInserted": "Delete \"{{word}}\"",
+ "editorAria": "Transcript for {{filename}}",
+ "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
+ "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
+ "editWord": "Edit \"{{word}}\"",
+ "noClips": "No clips yet",
+ "laneLabel": "Read the transcript from",
+ "restoreSilence": "Restore silence ({{duration}}s)"
},
- "export": {
- "chooseSaveLocation": "Choose Save Location",
- "gifButton": "Export GIF",
- "videoButton": "Export Video"
+ "customFont": {
+ "addingButton": "Adding...",
+ "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
+ "addButton": "Add Font",
+ "dialogTitle": "Add Google Font",
+ "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
+ "nameLabel": "Display Name",
+ "errorTimeout": "Font took too long to load. Please check the URL and try again.",
+ "urlLabel": "Google Fonts Import URL",
+ "errorEmptyName": "Please enter a font name",
+ "errorEmptyUrl": "Please enter a Google Fonts import URL",
+ "namePlaceholder": "My Custom Font",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Failed to add font",
+ "nameHelp": "This is how the font will appear in the font selector",
+ "errorExtractFailed": "Could not extract font family from URL",
+ "errorInvalidUrl": "Please enter a valid Google Fonts URL",
+ "successMessage": "Font \"{{fontName}}\" added successfully"
},
- "project": {
- "load": "Load Project",
- "save": "Save Project",
- "new": "New Project"
+ "annotation": {
+ "arrowColor": "Arrow Color",
+ "colorWheel": "Color Wheel",
+ "blurType": "Blur Type",
+ "active": "Active",
+ "deleteAnnotation": "Delete Annotation",
+ "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
+ "strokeWidth": "Stroke Width: {{width}}px",
+ "background": "Background",
+ "imageUploadSuccess": "Image uploaded successfully!",
+ "blurColor": "Blur Color",
+ "blurTypeBlur": "Gaussian",
+ "textColor": "Text Color",
+ "blurColorWhite": "White",
+ "title": "Annotation Settings",
+ "type": "Type",
+ "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
+ "typeImage": "Image",
+ "textContent": "Text Content",
+ "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
+ "typeText": "Text",
+ "blurIntensity": "Blur Intensity",
+ "none": "None",
+ "mosaicBlockSize": "Mosaic Block Size",
+ "textPlaceholder": "Enter your text...",
+ "typeArrow": "Arrow",
+ "color": "Color",
+ "blurColorBlack": "Black",
+ "size": "Size",
+ "invalidImageType": "Invalid file type",
+ "blurShapeFreehand": "Freehand",
+ "shortcutsAndTips": "Shortcuts & Tips",
+ "uploadImage": "Upload Image",
+ "blurTypeMosaic": "Mosaic",
+ "selectStyle": "Select style",
+ "defaultText": "Hello",
+ "blurShapeRectangle": "Rectangle",
+ "colorPalette": "Color Palette",
+ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
+ "clearBackground": "Clear Background",
+ "customFonts": "Custom Fonts",
+ "typeBlur": "Blur",
+ "tipTabCycle": "Use Tab to cycle through overlapping items.",
+ "fontStyle": "Font Style",
+ "blurShape": "Blur Shape",
+ "arrowDirection": "Arrow Direction",
+ "blurShapeOval": "Oval"
},
"speed": {
- "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected.",
"customPlaybackSpeed": "Custom Playback Speed",
- "maxSpeedError": "Speed can't go higher than {{max}}×",
"deleteRegion": "Delete Speed Region",
"selectRegion": "Select a speed region to adjust",
- "playbackSpeed": "Playback Speed"
+ "maxSpeedError": "Speed can't go higher than {{max}}×",
+ "playbackSpeed": "Playback Speed",
+ "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected."
+ },
+ "textAnimation": {
+ "selectAnimation": "Select animation",
+ "pulse": "Pulse",
+ "rise": "Rise",
+ "none": "None",
+ "slideLeft": "Slide Left",
+ "title": "Text Animation",
+ "fade": "Fade",
+ "pop": "Pop",
+ "typewriter": "Typewriter"
+ },
+ "effects": {
+ "motion": "Motion",
+ "title": "Composition",
+ "format": "Format",
+ "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
+ "fitClipFew": "{{count}} clips",
+ "motionBlur": "Motion Blur",
+ "fitClipMany": "{{count}} clips",
+ "frame": "Frame",
+ "padding": "Padding",
+ "roundness": "Roundness",
+ "off": "off",
+ "blurBg": "Blur BG",
+ "shadow": "Shadow",
+ "on": "on",
+ "fitClipOne": "{{count}} clip",
+ "formatOriginal": "Original",
+ "fitClip": "Fit"
+ },
+ "exportFormat": {
+ "gifDescription": "Animated image for sharing",
+ "mp4": "MP4",
+ "mp4Video": "MP4 Video",
+ "gif": "GIF",
+ "gifAnimation": "GIF Animation",
+ "mp4Description": "High quality video file"
+ },
+ "imageUpload": {
+ "failedToUpload": "Failed to upload image",
+ "uploadSuccess": "Custom image uploaded successfully!",
+ "errorReading": "There was an error reading the file.",
+ "invalidFileType": "Invalid file type",
+ "jpgOnly": "Please upload a JPG, JPEG, or PNG image file."
+ },
+ "facets": {
+ "captions": "Captions",
+ "transcript": "Transcript"
+ },
+ "audio": {
+ "help": "Adjust the audio output level. It applies identically in the preview and the export.",
+ "reset": "Reset audio",
+ "title": "Audio",
+ "outputGain": "Output level"
},
"language": {
"title": "Language"
},
- "support": {
- "starOnGithub": "Star on GitHub",
- "reportBug": "Report Bug",
- "saveDiagnostics": "Save Diagnostics"
+ "audioTrack": {
+ "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "importFailed": "Could not add audio",
+ "slipHint": "Alt-drag to slide the audio inside it",
+ "fadeOut": "Fade out",
+ "defaultLabel": "Audio track",
+ "mute": "Mute",
+ "remove": "Delete track",
+ "add": "Add audio track",
+ "loop": "Loop",
+ "fadeIn": "Fade in"
+ },
+ "project": {
+ "load": "Load Project",
+ "save": "Save Project",
+ "new": "New Project"
+ },
+ "panes": {
+ "help": "Help"
},
"trim": {
"deleteRegion": "Delete Trim Region"
},
- "facets": {
- "transcript": "Transcript",
- "captions": "Captions"
+ "export": {
+ "videoButton": "Export Video",
+ "gifButton": "Export GIF",
+ "chooseSaveLocation": "Choose Save Location"
},
- "panes": {
- "help": "Help"
+ "support": {
+ "starOnGithub": "Star on GitHub",
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "Report Bug"
+ },
+ "gifSettings": {
+ "size": "GIF Size",
+ "frameRate": "GIF Frame Rate",
+ "loop": "Loop GIF"
}
}
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 3681fc652..f7b0a897f 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "Animación GIF",
- "mp4Description": "Archivo de video de alta calidad",
- "mp4": "MP4",
- "mp4Video": "Video MP4",
- "gif": "GIF",
- "gifDescription": "Imagen animada para compartir"
- },
- "customFont": {
- "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Agregar fuente de Google",
- "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
- "nameLabel": "Nombre para mostrar",
- "failedToAdd": "Error al agregar la fuente",
- "urlLabel": "URL de importación de Google Fonts",
- "namePlaceholder": "Mi fuente personalizada",
- "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
- "addingButton": "Agregando...",
- "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
- "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
- "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
- "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
- "errorEmptyName": "Por favor ingresa un nombre de fuente",
- "addButton": "Agregar fuente",
- "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts"
- },
- "annotation": {
- "colorWheel": "Rueda de colores",
- "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
- "typeArrow": "Flecha",
- "selectStyle": "Seleccionar estilo",
- "blurColorWhite": "Blanco",
- "blurType": "Tipo de desenfoque",
- "blurShapeRectangle": "Rectángulo",
- "blurColor": "Color del desenfoque",
- "arrowColor": "Color de la flecha",
- "textContent": "Contenido de texto",
- "background": "Fondo",
- "clearBackground": "Quitar fondo",
- "blurShapeFreehand": "Mano alzada",
- "blurIntensity": "Intensidad del desenfoque",
- "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
- "active": "Activo",
- "size": "Tamaño",
- "blurColorBlack": "Negro",
- "typeImage": "Imagen",
- "mosaicBlockSize": "Tamano del bloque mosaico",
- "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
- "strokeWidth": "Grosor del trazo: {{width}}px",
- "textColor": "Color de texto",
- "defaultText": "Hola",
- "blurShape": "Forma del desenfoque",
- "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
- "type": "Tipo",
- "typeText": "Texto",
- "textPlaceholder": "Escribe tu texto...",
- "fontStyle": "Estilo de fuente",
- "imageUploadSuccess": "¡Imagen subida exitosamente!",
- "colorPalette": "Paleta de colores",
- "color": "Color",
- "shortcutsAndTips": "Atajos y consejos",
- "none": "Ninguno",
- "invalidImageType": "Tipo de archivo no válido",
- "arrowDirection": "Dirección de la flecha",
- "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
- "uploadImage": "Subir imagen",
- "customFonts": "Fuentes personalizadas",
- "blurTypeMosaic": "Mosaico",
- "blurTypeBlur": "Gaussiano",
- "typeBlur": "Desenfoque",
- "title": "Configuración de anotaciones",
- "deleteAnnotation": "Eliminar anotación",
- "blurShapeOval": "Óvalo"
- },
- "transcript": {
- "restoreSilence": "Restaurar silencio ({{duration}} s)",
- "editWord": "Editar «{{word}}»",
- "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
- "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
- "insertedWord": "Añadida por ti: no hay audio detrás",
- "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
- "insertAria": "Palabra nueva",
- "transcribing": "Transcribiendo…",
- "noAudio": "Este medio no tiene pista de audio",
- "noTranscript": "Aún no hay transcripción",
- "silence": "[silencio {{duration}} s]",
- "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
- "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
- "noClips": "Aún no hay clips",
- "laneVoiceover": "Voz en off",
- "laneLabel": "Leer la transcripción desde",
- "revertWord": "Restaurar «{{original}}»",
- "helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
- "blankedWord": "vaciada",
- "clipLabel": "Clip {{index}}",
- "title": "Transcripción actual",
- "correctedWord": "Corregida: la transcripción decía «{{original}}»",
- "transcribeNow": "Transcribir ahora",
- "laneRecording": "Grabación",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
- "removeInserted": "Eliminar «{{word}}»",
- "trimSilence": "Recortar silencio ({{duration}} s)",
- "editorAria": "Transcripción de {{filename}}",
- "restoreWord": "Restaurar «{{word}}»"
- },
- "effects": {
- "shadow": "Sombra",
- "fitClipOne": "{{count}} clip",
- "blurBg": "Desenfocar fondo",
- "fitClip": "Ajustar",
- "formatOriginal": "Original",
- "title": "Composición",
- "format": "Formato",
- "motionBlur": "Desenfoque de movimiento",
- "fitClipMany": "{{count}} clips",
- "roundness": "Redondez",
- "fitClipFew": "{{count}} clips",
- "motion": "Movimiento",
- "on": "activado",
- "frame": "Marco",
- "padding": "Relleno",
- "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
- "off": "desactivado"
- },
- "audioTrack": {
- "mute": "Silenciar",
- "fadeIn": "Aparición",
- "loop": "Bucle",
- "slipHint": "Alt + arrastrar para desplazar el audio dentro",
- "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
- "importFailed": "No se pudo añadir el audio",
- "fadeOut": "Desvanecido",
- "add": "Añadir pista de audio",
- "defaultLabel": "Pista de audio",
- "remove": "Eliminar pista"
- },
"layout": {
+ "webcamBlurIntensity": "Intensidad del desenfoque",
+ "bgModes": {
+ "transparent": "Recortado",
+ "none": "Original",
+ "blur": "Desenfocado",
+ "custom": "Personalizado"
+ },
+ "selectPreset": "Seleccionar predefinido",
+ "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
+ "reactiveWebcam": "Reducir al ampliar",
"shapes": {
"circle": "Círculo",
"square": "Cuadrado",
"rectangle": "Rect.",
"rounded": "Redondeado"
},
+ "webcamBackground": "Fondo de la cámara",
"verticalStack": "Apilado vertical",
- "webcamSize": "Tamaño de cámara",
- "preset": "Predefinido",
- "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
- "dualFrame": "Marco dual",
- "title": "Disposición de cámara",
- "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
- "bgModes": {
- "transparent": "Recortado",
- "custom": "Personalizado",
- "none": "Original",
- "blur": "Desenfocado"
- },
- "webcamCropZoom": "Zoom de recorte",
- "selectPreset": "Seleccionar predefinido",
- "webcamCropY": "Desplazamiento vertical",
- "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
"pictureInPicture": "Imagen en imagen",
- "reactiveWebcam": "Reducir al ampliar",
- "webcamBackground": "Fondo de la cámara",
- "webcamBlurIntensity": "Intensidad del desenfoque",
- "mirrorWebcam": "Reflejar cámara",
"webcamShape": "Forma de cámara",
+ "webcamCropY": "Desplazamiento vertical",
+ "webcamSize": "Tamaño de cámara",
+ "mirrorWebcam": "Reflejar cámara",
+ "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
+ "webcamFraming": "Encuadre de cámara",
"noWebcam": "Sin cámara",
+ "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
+ "webcamCropZoom": "Zoom de recorte",
+ "dualFrame": "Marco dual",
"webcamCropX": "Desplazamiento horizontal",
- "webcamFraming": "Encuadre de cámara"
- },
- "imageUpload": {
- "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
- "failedToUpload": "Error al subir la imagen",
- "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
- "errorReading": "Hubo un error al leer el archivo.",
- "invalidFileType": "Tipo de archivo no válido"
- },
- "cursor": {
- "themeDefault": "Predeterminado",
- "motionBlur": "Desenfoque de movimiento",
- "smoothing": "Suavizado",
- "title": "Cursor",
- "theme": "Estilo del cursor",
- "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
- "show": "Mostrar cursor",
- "clipToBounds": "Recortar al lienzo",
- "size": "Tamaño",
- "clickBounce": "Rebote al clic",
- "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic."
+ "title": "Disposición de cámara",
+ "preset": "Predefinido"
},
- "captions": {
- "original": "Original (transcripción)",
- "alignRight": "Derecha",
- "backgroundOpacity": "Opacidad",
- "anchorBottom": "Abajo",
- "minWords": "Mín. palabras por línea",
- "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
- "translate": "Traducir",
- "maxWords": "Máx. palabras por línea",
- "lineLength": "Longitud de línea",
- "anchorTop": "Arriba",
- "font": "Fuente",
- "translating": "Traduciendo…",
- "position": "Posición",
- "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
- "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
- "backgroundColor": "Color del fondo",
- "text": "Texto",
- "textColor": "Color del texto",
- "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
- "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
- "distanceFromTop": "Distancia desde arriba",
- "alignLeft": "Izquierda",
- "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
- "deleteTranslation": "Eliminar esta traducción",
- "distanceFromBottom": "Distancia desde abajo",
- "displayLanguage": "Visualización",
- "background": "Fondo",
- "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
- "distanceFromLeft": "Distancia desde la izquierda",
- "alignCenter": "Centro",
- "fontSize": "Tamaño",
- "bold": "Negrita",
- "translateFailed": "La traducción ha fallado.",
- "distanceFromRight": "Distancia desde la derecha",
- "showBackground": "Mostrar fondo",
- "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
- "show": "Mostrar subtítulos",
- "language": "Idioma",
- "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción."
+ "crop": {
+ "done": "Listo",
+ "ratio": "Proporción",
+ "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
+ "title": "Recortar",
+ "free": "Libre",
+ "lockAspectRatio": "Bloquear relación de aspecto",
+ "unlockAspectRatio": "Desbloquear relación de aspecto",
+ "cropVideo": "Recortar video"
},
"zoom": {
+ "position": {
+ "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Posición de enfoque"
+ },
"threeD": {
"preset": {
"right": "Derecha",
@@ -241,115 +59,296 @@
"none": "Ninguna",
"title": "Rotación 3D"
},
+ "deleteZoom": "Eliminar zoom",
+ "customScale": "Zoom personalizado",
+ "selectRegion": "Selecciona una región de zoom para ajustar",
"focusMode": {
+ "manual": "Manual",
"autoDescription": "La cámara sigue la posición del cursor grabado",
"lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
"title": "Modo de enfoque",
- "manual": "Manual",
"auto": "Auto"
},
- "position": {
- "title": "Posición de enfoque",
- "x": "X (%)",
- "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
- "y": "Y (%)"
- },
- "deleteZoom": "Eliminar zoom",
- "level": "Nivel de zoom",
- "selectRegion": "Selecciona una región de zoom para ajustar",
"previewHold": "Mantener para previsualizar el efecto de zoom",
- "customScale": "Zoom personalizado"
- },
- "textAnimation": {
- "pop": "Aparecer",
- "rise": "Ascender",
- "selectAnimation": "Seleccionar animación",
- "fade": "Desvanecimiento",
- "pulse": "Pulso",
- "typewriter": "Máquina de escribir",
- "title": "Animación de texto",
- "none": "Ninguna",
- "slideLeft": "Deslizar izquierda"
- },
- "crop": {
- "lockAspectRatio": "Bloquear relación de aspecto",
- "title": "Recortar",
- "done": "Listo",
- "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
- "ratio": "Proporción",
- "free": "Libre",
- "cropVideo": "Recortar video",
- "unlockAspectRatio": "Desbloquear relación de aspecto"
+ "level": "Nivel de zoom"
},
"background": {
- "imageLabel": "Fondo {{index}}",
"color": "Color",
- "gradient": "Degradado",
"colorLabel": "Color {{color}}",
- "colorWheel": "Rueda de colores",
- "customWallpaper": "Fondo personalizado",
- "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
- "image": "Imagen",
- "gradientLabel": "Degradado {{index}}",
+ "gradient": "Degradado",
"imageReadFailed": "No se pudo leer ese archivo de imagen.",
- "presets": "Ajustes preestablecidos",
+ "gradientLabel": "Degradado {{index}}",
"custom": "Personalizado",
+ "customWallpaper": "Fondo personalizado",
+ "presets": "Ajustes preestablecidos",
+ "image": "Imagen",
"colorPalette": "Paleta de colores",
- "uploadCustom": "Subir personalizado",
+ "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
+ "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
+ "imageLabel": "Fondo {{index}}",
"title": "Fondo",
- "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG."
+ "uploadCustom": "Subir personalizado",
+ "colorWheel": "Rueda de colores"
},
- "audio": {
- "title": "Audio",
- "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
- "reset": "Restablecer audio",
- "outputGain": "Ajuste de salida"
+ "cursor": {
+ "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
+ "clickBounce": "Rebote al clic",
+ "clipToBounds": "Recortar al lienzo",
+ "title": "Cursor",
+ "size": "Tamaño",
+ "themeDefault": "Predeterminado",
+ "smoothing": "Suavizado",
+ "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
+ "motionBlur": "Desenfoque de movimiento",
+ "theme": "Estilo del cursor",
+ "show": "Mostrar cursor"
+ },
+ "captions": {
+ "text": "Texto",
+ "showBackground": "Mostrar fondo",
+ "minWords": "Mín. palabras por línea",
+ "translateFailed": "La traducción ha fallado.",
+ "distanceFromTop": "Distancia desde arriba",
+ "fontSize": "Tamaño",
+ "distanceFromRight": "Distancia desde la derecha",
+ "bold": "Negrita",
+ "textColor": "Color del texto",
+ "original": "Original (transcripción)",
+ "translating": "Traduciendo…",
+ "language": "Idioma",
+ "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
+ "alignLeft": "Izquierda",
+ "distanceFromBottom": "Distancia desde abajo",
+ "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
+ "show": "Mostrar subtítulos",
+ "background": "Fondo",
+ "backgroundOpacity": "Opacidad",
+ "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
+ "anchorTop": "Arriba",
+ "translate": "Traducir",
+ "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
+ "alignCenter": "Centro",
+ "alignRight": "Derecha",
+ "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
+ "displayLanguage": "Visualización",
+ "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
+ "distanceFromLeft": "Distancia desde la izquierda",
+ "maxWords": "Máx. palabras por línea",
+ "anchorBottom": "Abajo",
+ "position": "Posición",
+ "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
+ "deleteTranslation": "Eliminar esta traducción",
+ "backgroundColor": "Color del fondo",
+ "font": "Fuente",
+ "lineLength": "Longitud de línea",
+ "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
+ "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "Resolución de exportación"
},
- "gifSettings": {
- "loop": "Repetir GIF",
- "frameRate": "Velocidad de cuadros del GIF",
- "size": "Tamaño del GIF"
+ "transcript": {
+ "transcribing": "Transcribiendo…",
+ "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "laneVoiceover": "Voz en off",
+ "blankedWord": "vaciada",
+ "noTranscript": "Aún no hay transcripción",
+ "noAudio": "Este medio no tiene pista de audio",
+ "revertWord": "Restaurar «{{original}}»",
+ "silence": "[silencio {{duration}} s]",
+ "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
+ "transcribeNow": "Transcribir ahora",
+ "restoreWord": "Restaurar «{{word}}»",
+ "clipLabel": "Clip {{index}}",
+ "title": "Transcripción actual",
+ "insertAria": "Palabra nueva",
+ "laneRecording": "Grabación",
+ "trimSilence": "Recortar silencio ({{duration}} s)",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
+ "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
+ "removeInserted": "Eliminar «{{word}}»",
+ "editorAria": "Transcripción de {{filename}}",
+ "correctedWord": "Corregida: la transcripción decía «{{original}}»",
+ "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
+ "editWord": "Editar «{{word}}»",
+ "noClips": "Aún no hay clips",
+ "laneLabel": "Leer la transcripción desde",
+ "restoreSilence": "Restaurar silencio ({{duration}} s)"
},
- "export": {
- "chooseSaveLocation": "Elegir ubicación de guardado",
- "gifButton": "Exportar GIF",
- "videoButton": "Exportar video"
+ "customFont": {
+ "addingButton": "Agregando...",
+ "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
+ "addButton": "Agregar fuente",
+ "dialogTitle": "Agregar fuente de Google",
+ "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
+ "nameLabel": "Nombre para mostrar",
+ "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
+ "urlLabel": "URL de importación de Google Fonts",
+ "errorEmptyName": "Por favor ingresa un nombre de fuente",
+ "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
+ "namePlaceholder": "Mi fuente personalizada",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Error al agregar la fuente",
+ "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
+ "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
+ "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
+ "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente"
},
- "project": {
- "load": "Cargar proyecto",
- "save": "Guardar proyecto",
- "new": "Nuevo proyecto"
+ "annotation": {
+ "arrowColor": "Color de la flecha",
+ "colorWheel": "Rueda de colores",
+ "blurType": "Tipo de desenfoque",
+ "active": "Activo",
+ "deleteAnnotation": "Eliminar anotación",
+ "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
+ "strokeWidth": "Grosor del trazo: {{width}}px",
+ "background": "Fondo",
+ "imageUploadSuccess": "¡Imagen subida exitosamente!",
+ "blurColor": "Color del desenfoque",
+ "blurTypeBlur": "Gaussiano",
+ "textColor": "Color de texto",
+ "blurColorWhite": "Blanco",
+ "title": "Configuración de anotaciones",
+ "type": "Tipo",
+ "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
+ "typeImage": "Imagen",
+ "textContent": "Contenido de texto",
+ "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
+ "typeText": "Texto",
+ "blurIntensity": "Intensidad del desenfoque",
+ "none": "Ninguno",
+ "mosaicBlockSize": "Tamano del bloque mosaico",
+ "textPlaceholder": "Escribe tu texto...",
+ "typeArrow": "Flecha",
+ "color": "Color",
+ "blurColorBlack": "Negro",
+ "size": "Tamaño",
+ "invalidImageType": "Tipo de archivo no válido",
+ "blurShapeFreehand": "Mano alzada",
+ "shortcutsAndTips": "Atajos y consejos",
+ "uploadImage": "Subir imagen",
+ "blurTypeMosaic": "Mosaico",
+ "selectStyle": "Seleccionar estilo",
+ "defaultText": "Hola",
+ "blurShapeRectangle": "Rectángulo",
+ "colorPalette": "Paleta de colores",
+ "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
+ "clearBackground": "Quitar fondo",
+ "customFonts": "Fuentes personalizadas",
+ "typeBlur": "Desenfoque",
+ "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
+ "fontStyle": "Estilo de fuente",
+ "blurShape": "Forma del desenfoque",
+ "arrowDirection": "Dirección de la flecha",
+ "blurShapeOval": "Óvalo"
},
"speed": {
- "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada.",
"customPlaybackSpeed": "Velocidad personalizada",
- "maxSpeedError": "La velocidad no puede superar {{max}}×",
"deleteRegion": "Eliminar región de velocidad",
"selectRegion": "Selecciona una región de velocidad para ajustar",
- "playbackSpeed": "Velocidad de reproducción"
+ "maxSpeedError": "La velocidad no puede superar {{max}}×",
+ "playbackSpeed": "Velocidad de reproducción",
+ "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada."
+ },
+ "textAnimation": {
+ "selectAnimation": "Seleccionar animación",
+ "pulse": "Pulso",
+ "rise": "Ascender",
+ "none": "Ninguna",
+ "slideLeft": "Deslizar izquierda",
+ "title": "Animación de texto",
+ "fade": "Desvanecimiento",
+ "pop": "Aparecer",
+ "typewriter": "Máquina de escribir"
+ },
+ "effects": {
+ "motion": "Movimiento",
+ "title": "Composición",
+ "format": "Formato",
+ "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
+ "fitClipFew": "{{count}} clips",
+ "motionBlur": "Desenfoque de movimiento",
+ "fitClipMany": "{{count}} clips",
+ "frame": "Marco",
+ "padding": "Relleno",
+ "roundness": "Redondez",
+ "off": "desactivado",
+ "blurBg": "Desenfocar fondo",
+ "shadow": "Sombra",
+ "on": "activado",
+ "fitClipOne": "{{count}} clip",
+ "formatOriginal": "Original",
+ "fitClip": "Ajustar"
+ },
+ "exportFormat": {
+ "gifDescription": "Imagen animada para compartir",
+ "mp4": "MP4",
+ "mp4Video": "Video MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animación GIF",
+ "mp4Description": "Archivo de video de alta calidad"
+ },
+ "imageUpload": {
+ "failedToUpload": "Error al subir la imagen",
+ "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
+ "errorReading": "Hubo un error al leer el archivo.",
+ "invalidFileType": "Tipo de archivo no válido",
+ "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG."
+ },
+ "facets": {
+ "captions": "Subtítulos",
+ "transcript": "Transcripción"
+ },
+ "audio": {
+ "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
+ "reset": "Restablecer audio",
+ "title": "Audio",
+ "outputGain": "Ajuste de salida"
},
"language": {
"title": "Idioma"
},
- "support": {
- "starOnGithub": "Dar estrella en GitHub",
- "reportBug": "Reportar error",
- "saveDiagnostics": "Guardar diagnósticos"
+ "audioTrack": {
+ "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "importFailed": "No se pudo añadir el audio",
+ "slipHint": "Alt + arrastrar para desplazar el audio dentro",
+ "fadeOut": "Desvanecido",
+ "defaultLabel": "Pista de audio",
+ "mute": "Silenciar",
+ "remove": "Eliminar pista",
+ "add": "Añadir pista de audio",
+ "loop": "Bucle",
+ "fadeIn": "Aparición"
+ },
+ "project": {
+ "load": "Cargar proyecto",
+ "save": "Guardar proyecto",
+ "new": "Nuevo proyecto"
+ },
+ "panes": {
+ "help": "Ayuda"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
},
- "facets": {
- "transcript": "Transcripción",
- "captions": "Subtítulos"
+ "export": {
+ "videoButton": "Exportar video",
+ "gifButton": "Exportar GIF",
+ "chooseSaveLocation": "Elegir ubicación de guardado"
},
- "panes": {
- "help": "Ayuda"
+ "support": {
+ "starOnGithub": "Dar estrella en GitHub",
+ "saveDiagnostics": "Guardar diagnósticos",
+ "reportBug": "Reportar error"
+ },
+ "gifSettings": {
+ "size": "Tamaño del GIF",
+ "frameRate": "Velocidad de cuadros del GIF",
+ "loop": "Repetir GIF"
}
}
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 2bdb79684..4be212355 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "Animation GIF",
- "mp4Description": "Fichier vidéo haute qualité",
- "mp4": "MP4",
- "mp4Video": "Vidéo MP4",
- "gif": "GIF",
- "gifDescription": "Image animée pour le partage"
- },
- "customFont": {
- "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Ajouter une police Google",
- "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
- "nameLabel": "Nom d'affichage",
- "failedToAdd": "Échec de l'ajout de la police",
- "urlLabel": "URL d'import Google Fonts",
- "namePlaceholder": "Ma police personnalisée",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
- "addingButton": "Ajout en cours...",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
- "successMessage": "Police « {{fontName}} » ajoutée avec succès",
- "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "errorEmptyName": "Veuillez saisir un nom de police",
- "addButton": "Ajouter la police",
- "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide"
- },
- "annotation": {
- "colorWheel": "Roue chromatique",
- "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
- "typeArrow": "Flèche",
- "selectStyle": "Choisir un style",
- "blurColorWhite": "Blanc",
- "blurType": "Type de flou",
- "blurShapeRectangle": "Rectangle",
- "blurColor": "Couleur du flou",
- "arrowColor": "Couleur de la flèche",
- "textContent": "Contenu du texte",
- "background": "Arrière-plan",
- "clearBackground": "Supprimer l'arrière-plan",
- "blurShapeFreehand": "Main levée",
- "blurIntensity": "Intensité du flou",
- "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "active": "Actif",
- "size": "Taille",
- "blurColorBlack": "Noir",
- "typeImage": "Image",
- "mosaicBlockSize": "Taille des blocs de mosaique",
- "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
- "textColor": "Couleur du texte",
- "defaultText": "Bonjour",
- "blurShape": "Forme du flou",
- "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
- "type": "Type",
- "typeText": "Texte",
- "textPlaceholder": "Saisissez votre texte...",
- "fontStyle": "Style de police",
- "imageUploadSuccess": "Image téléversée avec succès !",
- "colorPalette": "Palette de couleurs",
- "color": "Couleur",
- "shortcutsAndTips": "Raccourcis & Astuces",
- "none": "Aucun",
- "invalidImageType": "Type de fichier invalide",
- "arrowDirection": "Direction de la flèche",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
- "uploadImage": "Téléverser une image",
- "customFonts": "Polices personnalisées",
- "blurTypeMosaic": "Mosaïque",
- "blurTypeBlur": "Gaussien",
- "typeBlur": "Flou",
- "title": "Paramètres d'annotation",
- "deleteAnnotation": "Supprimer l'annotation",
- "blurShapeOval": "Ovale"
- },
- "transcript": {
- "restoreSilence": "Restaurer le silence ({{duration}} s)",
- "editWord": "Modifier « {{word}} »",
- "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
- "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
- "insertedWord": "Ajouté par vous — aucun son derrière",
- "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
- "insertAria": "Nouveau mot",
- "transcribing": "Transcription…",
- "noAudio": "Ce média n'a pas de piste audio",
- "noTranscript": "Aucune transcription pour l'instant",
- "silence": "[silence {{duration}} s]",
- "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
- "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
- "noClips": "Aucun clip pour l'instant",
- "laneVoiceover": "Voix off",
- "laneLabel": "Lire la transcription depuis",
- "revertWord": "Rétablir « {{original}} »",
- "helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
- "blankedWord": "vidé",
- "clipLabel": "Clip {{index}}",
- "title": "Transcription actuelle",
- "correctedWord": "Corrigé — la transcription disait « {{original}} »",
- "transcribeNow": "Transcrire maintenant",
- "laneRecording": "Enregistrement",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
- "removeInserted": "Supprimer « {{word}} »",
- "trimSilence": "Couper le silence ({{duration}} s)",
- "editorAria": "Transcription de {{filename}}",
- "restoreWord": "Restaurer « {{word}} »"
- },
- "effects": {
- "shadow": "Ombre",
- "fitClipOne": "{{count}} clip",
- "blurBg": "Flou arrière-plan",
- "fitClip": "Ajuster",
- "formatOriginal": "Original",
- "title": "Composition",
- "format": "Format",
- "motionBlur": "Flou de mouvement",
- "fitClipMany": "{{count}} clips",
- "roundness": "Arrondi",
- "fitClipFew": "{{count}} clips",
- "motion": "Mouvement",
- "on": "activé",
- "frame": "Cadre",
- "padding": "Marge",
- "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
- "off": "désactivé"
- },
- "audioTrack": {
- "mute": "Muet",
- "fadeIn": "Fondu d'entrée",
- "loop": "Boucle",
- "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
- "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
- "importFailed": "Impossible d’ajouter l’audio",
- "fadeOut": "Fondu de sortie",
- "add": "Ajouter une piste audio",
- "defaultLabel": "Piste audio",
- "remove": "Supprimer la piste"
- },
"layout": {
+ "webcamBlurIntensity": "Intensité du flou",
+ "bgModes": {
+ "transparent": "Détouré",
+ "none": "Original",
+ "blur": "Flouté",
+ "custom": "Personnalisé"
+ },
+ "selectPreset": "Choisir un préréglage",
+ "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
+ "reactiveWebcam": "Réduire au zoom",
"shapes": {
"circle": "Cercle",
"square": "Carré",
"rectangle": "Rect.",
"rounded": "Arrondi"
},
+ "webcamBackground": "Arrière-plan de la caméra",
"verticalStack": "Empilement vertical",
- "webcamSize": "Taille de la caméra",
- "preset": "Préréglage",
- "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
- "dualFrame": "Double cadre",
- "title": "Disposition caméra",
- "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
- "bgModes": {
- "transparent": "Détouré",
- "custom": "Personnalisé",
- "none": "Original",
- "blur": "Flouté"
- },
- "webcamCropZoom": "Zoom du recadrage",
- "selectPreset": "Choisir un préréglage",
- "webcamCropY": "Déplacement vertical",
- "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
"pictureInPicture": "Incrustation d'image",
- "reactiveWebcam": "Réduire au zoom",
- "webcamBackground": "Arrière-plan de la caméra",
- "webcamBlurIntensity": "Intensité du flou",
- "mirrorWebcam": "Inverser la webcam",
"webcamShape": "Forme de la caméra",
+ "webcamCropY": "Déplacement vertical",
+ "webcamSize": "Taille de la caméra",
+ "mirrorWebcam": "Inverser la webcam",
+ "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
+ "webcamFraming": "Cadrage de la webcam",
"noWebcam": "Sans webcam",
+ "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
+ "webcamCropZoom": "Zoom du recadrage",
+ "dualFrame": "Double cadre",
"webcamCropX": "Déplacement horizontal",
- "webcamFraming": "Cadrage de la webcam"
- },
- "imageUpload": {
- "uploadSuccess": "Image personnalisée téléversée avec succès !",
- "failedToUpload": "Échec du téléversement de l'image",
- "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
- "errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
- "invalidFileType": "Type de fichier invalide"
- },
- "cursor": {
- "themeDefault": "Par défaut",
- "motionBlur": "Flou de mouvement",
- "smoothing": "Lissage",
- "title": "Curseur",
- "theme": "Style du curseur",
- "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
- "show": "Afficher le curseur",
- "clipToBounds": "Rogner au canevas",
- "size": "Taille",
- "clickBounce": "Rebond au clic",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic."
+ "title": "Disposition caméra",
+ "preset": "Préréglage"
},
- "captions": {
- "original": "Original (transcription)",
- "alignRight": "Droite",
- "backgroundOpacity": "Opacité",
- "anchorBottom": "Bas",
- "minWords": "Mots min. par ligne",
- "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
- "translate": "Traduire",
- "maxWords": "Mots max. par ligne",
- "lineLength": "Longueur des lignes",
- "anchorTop": "Haut",
- "font": "Police",
- "translating": "Traduction…",
- "position": "Position",
- "removeLegacyAnnotations": "Supprimer les anciennes annotations",
- "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
- "backgroundColor": "Couleur du fond",
- "text": "Texte",
- "textColor": "Couleur du texte",
- "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
- "distanceFromTop": "Distance depuis le haut",
- "alignLeft": "Gauche",
- "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
- "deleteTranslation": "Supprimer cette traduction",
- "distanceFromBottom": "Distance depuis le bas",
- "displayLanguage": "Affichage",
- "background": "Fond",
- "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
- "distanceFromLeft": "Distance depuis la gauche",
- "alignCenter": "Centre",
- "fontSize": "Taille",
- "bold": "Gras",
- "translateFailed": "La traduction a échoué.",
- "distanceFromRight": "Distance depuis la droite",
- "showBackground": "Afficher le fond",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
- "show": "Afficher les sous-titres",
- "language": "Langue",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct."
+ "crop": {
+ "done": "Terminer",
+ "ratio": "Ratio",
+ "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
+ "title": "Recadrage",
+ "free": "Libre",
+ "lockAspectRatio": "Verrouiller le ratio",
+ "unlockAspectRatio": "Déverrouiller le ratio",
+ "cropVideo": "Recadrer la vidéo"
},
"zoom": {
+ "position": {
+ "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Position du focus"
+ },
"threeD": {
"preset": {
"right": "Droite",
@@ -241,115 +59,296 @@
"none": "Aucune",
"title": "Rotation 3D"
},
+ "deleteZoom": "Supprimer le zoom",
+ "customScale": "Zoom personnalisé",
+ "selectRegion": "Sélectionnez une région de zoom à ajuster",
"focusMode": {
+ "manual": "Manuel",
"autoDescription": "La caméra suit la position du curseur enregistré",
"lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
"title": "Mode focus",
- "manual": "Manuel",
"auto": "Auto"
},
- "position": {
- "title": "Position du focus",
- "x": "X (%)",
- "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
- "y": "Y (%)"
- },
- "deleteZoom": "Supprimer le zoom",
- "level": "Niveau de zoom",
- "selectRegion": "Sélectionnez une région de zoom à ajuster",
"previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "customScale": "Zoom personnalisé"
- },
- "textAnimation": {
- "pop": "Apparition",
- "rise": "Monter",
- "selectAnimation": "Sélectionner une animation",
- "fade": "Fondu",
- "pulse": "Pulsation",
- "typewriter": "Machine à écrire",
- "title": "Animation de texte",
- "none": "Aucune",
- "slideLeft": "Glisser à gauche"
- },
- "crop": {
- "lockAspectRatio": "Verrouiller le ratio",
- "title": "Recadrage",
- "done": "Terminer",
- "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
- "ratio": "Ratio",
- "free": "Libre",
- "cropVideo": "Recadrer la vidéo",
- "unlockAspectRatio": "Déverrouiller le ratio"
+ "level": "Niveau de zoom"
},
"background": {
- "imageLabel": "Fond {{index}}",
"color": "Couleur",
- "gradient": "Dégradé",
"colorLabel": "Couleur {{color}}",
- "colorWheel": "Roue chromatique",
- "customWallpaper": "Fond personnalisé",
- "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
- "image": "Image",
- "gradientLabel": "Dégradé {{index}}",
+ "gradient": "Dégradé",
"imageReadFailed": "Impossible de lire ce fichier image.",
- "presets": "Préréglages",
+ "gradientLabel": "Dégradé {{index}}",
"custom": "Personnalisé",
+ "customWallpaper": "Fond personnalisé",
+ "presets": "Préréglages",
+ "image": "Image",
"colorPalette": "Palette de couleurs",
- "uploadCustom": "Téléverser une image",
+ "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
+ "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
+ "imageLabel": "Fond {{index}}",
"title": "Arrière-plan",
- "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG."
+ "uploadCustom": "Téléverser une image",
+ "colorWheel": "Roue chromatique"
},
- "audio": {
- "title": "Audio",
- "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
- "reset": "Réinitialiser l’audio",
- "outputGain": "Niveau de sortie"
+ "cursor": {
+ "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
+ "clickBounce": "Rebond au clic",
+ "clipToBounds": "Rogner au canevas",
+ "title": "Curseur",
+ "size": "Taille",
+ "themeDefault": "Par défaut",
+ "smoothing": "Lissage",
+ "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
+ "motionBlur": "Flou de mouvement",
+ "theme": "Style du curseur",
+ "show": "Afficher le curseur"
+ },
+ "captions": {
+ "text": "Texte",
+ "showBackground": "Afficher le fond",
+ "minWords": "Mots min. par ligne",
+ "translateFailed": "La traduction a échoué.",
+ "distanceFromTop": "Distance depuis le haut",
+ "fontSize": "Taille",
+ "distanceFromRight": "Distance depuis la droite",
+ "bold": "Gras",
+ "textColor": "Couleur du texte",
+ "original": "Original (transcription)",
+ "translating": "Traduction…",
+ "language": "Langue",
+ "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
+ "alignLeft": "Gauche",
+ "distanceFromBottom": "Distance depuis le bas",
+ "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
+ "show": "Afficher les sous-titres",
+ "background": "Fond",
+ "backgroundOpacity": "Opacité",
+ "removeLegacyAnnotations": "Supprimer les anciennes annotations",
+ "anchorTop": "Haut",
+ "translate": "Traduire",
+ "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
+ "alignCenter": "Centre",
+ "alignRight": "Droite",
+ "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "displayLanguage": "Affichage",
+ "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
+ "distanceFromLeft": "Distance depuis la gauche",
+ "maxWords": "Mots max. par ligne",
+ "anchorBottom": "Bas",
+ "position": "Position",
+ "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
+ "deleteTranslation": "Supprimer cette traduction",
+ "backgroundColor": "Couleur du fond",
+ "font": "Police",
+ "lineLength": "Longueur des lignes",
+ "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
+ "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "Résolution d'export"
},
- "gifSettings": {
- "loop": "GIF en boucle",
- "frameRate": "Fréquence d'images GIF",
- "size": "Taille du GIF"
+ "transcript": {
+ "transcribing": "Transcription…",
+ "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
+ "laneVoiceover": "Voix off",
+ "blankedWord": "vidé",
+ "noTranscript": "Aucune transcription pour l'instant",
+ "noAudio": "Ce média n'a pas de piste audio",
+ "revertWord": "Rétablir « {{original}} »",
+ "silence": "[silence {{duration}} s]",
+ "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
+ "transcribeNow": "Transcrire maintenant",
+ "restoreWord": "Restaurer « {{word}} »",
+ "clipLabel": "Clip {{index}}",
+ "title": "Transcription actuelle",
+ "insertAria": "Nouveau mot",
+ "laneRecording": "Enregistrement",
+ "trimSilence": "Couper le silence ({{duration}} s)",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
+ "removeInserted": "Supprimer « {{word}} »",
+ "editorAria": "Transcription de {{filename}}",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
+ "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
+ "editWord": "Modifier « {{word}} »",
+ "noClips": "Aucun clip pour l'instant",
+ "laneLabel": "Lire la transcription depuis",
+ "restoreSilence": "Restaurer le silence ({{duration}} s)"
},
- "export": {
- "chooseSaveLocation": "Choisir l'emplacement d'enregistrement",
- "gifButton": "Exporter le GIF",
- "videoButton": "Exporter la vidéo"
+ "customFont": {
+ "addingButton": "Ajout en cours...",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
+ "addButton": "Ajouter la police",
+ "dialogTitle": "Ajouter une police Google",
+ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
+ "nameLabel": "Nom d'affichage",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
+ "urlLabel": "URL d'import Google Fonts",
+ "errorEmptyName": "Veuillez saisir un nom de police",
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "namePlaceholder": "Ma police personnalisée",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Échec de l'ajout de la police",
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
+ "successMessage": "Police « {{fontName}} » ajoutée avec succès"
},
- "project": {
- "load": "Charger un projet",
- "save": "Enregistrer le projet",
- "new": "Nouveau projet"
+ "annotation": {
+ "arrowColor": "Couleur de la flèche",
+ "colorWheel": "Roue chromatique",
+ "blurType": "Type de flou",
+ "active": "Actif",
+ "deleteAnnotation": "Supprimer l'annotation",
+ "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "background": "Arrière-plan",
+ "imageUploadSuccess": "Image téléversée avec succès !",
+ "blurColor": "Couleur du flou",
+ "blurTypeBlur": "Gaussien",
+ "textColor": "Couleur du texte",
+ "blurColorWhite": "Blanc",
+ "title": "Paramètres d'annotation",
+ "type": "Type",
+ "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "typeImage": "Image",
+ "textContent": "Contenu du texte",
+ "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "typeText": "Texte",
+ "blurIntensity": "Intensité du flou",
+ "none": "Aucun",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
+ "textPlaceholder": "Saisissez votre texte...",
+ "typeArrow": "Flèche",
+ "color": "Couleur",
+ "blurColorBlack": "Noir",
+ "size": "Taille",
+ "invalidImageType": "Type de fichier invalide",
+ "blurShapeFreehand": "Main levée",
+ "shortcutsAndTips": "Raccourcis & Astuces",
+ "uploadImage": "Téléverser une image",
+ "blurTypeMosaic": "Mosaïque",
+ "selectStyle": "Choisir un style",
+ "defaultText": "Bonjour",
+ "blurShapeRectangle": "Rectangle",
+ "colorPalette": "Palette de couleurs",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "clearBackground": "Supprimer l'arrière-plan",
+ "customFonts": "Polices personnalisées",
+ "typeBlur": "Flou",
+ "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
+ "fontStyle": "Style de police",
+ "blurShape": "Forme du flou",
+ "arrowDirection": "Direction de la flèche",
+ "blurShapeOval": "Ovale"
},
"speed": {
- "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
- "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
"deleteRegion": "Supprimer la région de vitesse",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
- "playbackSpeed": "Vitesse de lecture"
+ "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
+ "playbackSpeed": "Vitesse de lecture",
+ "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté."
+ },
+ "textAnimation": {
+ "selectAnimation": "Sélectionner une animation",
+ "pulse": "Pulsation",
+ "rise": "Monter",
+ "none": "Aucune",
+ "slideLeft": "Glisser à gauche",
+ "title": "Animation de texte",
+ "fade": "Fondu",
+ "pop": "Apparition",
+ "typewriter": "Machine à écrire"
+ },
+ "effects": {
+ "motion": "Mouvement",
+ "title": "Composition",
+ "format": "Format",
+ "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
+ "fitClipFew": "{{count}} clips",
+ "motionBlur": "Flou de mouvement",
+ "fitClipMany": "{{count}} clips",
+ "frame": "Cadre",
+ "padding": "Marge",
+ "roundness": "Arrondi",
+ "off": "désactivé",
+ "blurBg": "Flou arrière-plan",
+ "shadow": "Ombre",
+ "on": "activé",
+ "fitClipOne": "{{count}} clip",
+ "formatOriginal": "Original",
+ "fitClip": "Ajuster"
+ },
+ "exportFormat": {
+ "gifDescription": "Image animée pour le partage",
+ "mp4": "MP4",
+ "mp4Video": "Vidéo MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animation GIF",
+ "mp4Description": "Fichier vidéo haute qualité"
+ },
+ "imageUpload": {
+ "failedToUpload": "Échec du téléversement de l'image",
+ "uploadSuccess": "Image personnalisée téléversée avec succès !",
+ "errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
+ "invalidFileType": "Type de fichier invalide",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG."
+ },
+ "facets": {
+ "captions": "Sous-titres",
+ "transcript": "Transcription"
+ },
+ "audio": {
+ "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
+ "reset": "Réinitialiser l’audio",
+ "title": "Audio",
+ "outputGain": "Niveau de sortie"
},
"language": {
"title": "Langue"
},
- "support": {
- "starOnGithub": "Étoile sur GitHub",
- "reportBug": "Signaler un bug",
- "saveDiagnostics": "Enregistrer les diagnostics"
+ "audioTrack": {
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
+ "importFailed": "Impossible d’ajouter l’audio",
+ "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
+ "fadeOut": "Fondu de sortie",
+ "defaultLabel": "Piste audio",
+ "mute": "Muet",
+ "remove": "Supprimer la piste",
+ "add": "Ajouter une piste audio",
+ "loop": "Boucle",
+ "fadeIn": "Fondu d'entrée"
+ },
+ "project": {
+ "load": "Charger un projet",
+ "save": "Enregistrer le projet",
+ "new": "Nouveau projet"
+ },
+ "panes": {
+ "help": "Aide"
},
"trim": {
"deleteRegion": "Supprimer la région de coupe"
},
- "facets": {
- "transcript": "Transcription",
- "captions": "Sous-titres"
+ "export": {
+ "videoButton": "Exporter la vidéo",
+ "gifButton": "Exporter le GIF",
+ "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
},
- "panes": {
- "help": "Aide"
+ "support": {
+ "starOnGithub": "Étoile sur GitHub",
+ "saveDiagnostics": "Enregistrer les diagnostics",
+ "reportBug": "Signaler un bug"
+ },
+ "gifSettings": {
+ "size": "Taille du GIF",
+ "frameRate": "Fréquence d'images GIF",
+ "loop": "GIF en boucle"
}
}
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 03415b750..9b9ba6763 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "Animazione GIF",
- "mp4Description": "File video di alta qualità",
- "mp4": "MP4",
- "mp4Video": "Video MP4",
- "gif": "GIF",
- "gifDescription": "Immagine animata per la condivisione"
- },
- "customFont": {
- "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Aggiungi font Google",
- "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
- "nameLabel": "Nome visualizzato",
- "failedToAdd": "Impossibile aggiungere il font",
- "urlLabel": "URL importazione Google Fonts",
- "namePlaceholder": "Il mio font personalizzato",
- "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
- "addingButton": "Aggiunta in corso...",
- "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
- "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
- "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
- "nameHelp": "Così apparirà il font nel selettore",
- "errorEmptyName": "Inserisci un nome per il font",
- "addButton": "Aggiungi font",
- "errorInvalidUrl": "Inserisci un URL Google Fonts valido"
- },
- "annotation": {
- "colorWheel": "Ruota dei colori",
- "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
- "typeArrow": "Freccia",
- "selectStyle": "Seleziona stile",
- "blurColorWhite": "Bianco",
- "blurType": "Tipo sfocatura",
- "blurShapeRectangle": "Rettangolo",
- "blurColor": "Colore sfocatura",
- "arrowColor": "Colore freccia",
- "textContent": "Contenuto testo",
- "background": "Sfondo",
- "clearBackground": "Rimuovi sfondo",
- "blurShapeFreehand": "A mano libera",
- "blurIntensity": "Intensità sfocatura",
- "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
- "active": "Attivo",
- "size": "Dimensione",
- "blurColorBlack": "Nero",
- "typeImage": "Immagine",
- "mosaicBlockSize": "Dimensione blocco mosaico",
- "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
- "strokeWidth": "Larghezza tratto: {{width}}px",
- "textColor": "Colore testo",
- "defaultText": "Ciao",
- "blurShape": "Forma sfocatura",
- "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
- "type": "Tipo",
- "typeText": "Testo",
- "textPlaceholder": "Inserisci il tuo testo...",
- "fontStyle": "Stile carattere",
- "imageUploadSuccess": "Immagine caricata con successo!",
- "colorPalette": "Tavolozza dei colori",
- "color": "Colore",
- "shortcutsAndTips": "Scorciatoie e suggerimenti",
- "none": "Nessuno",
- "invalidImageType": "Tipo di file non valido",
- "arrowDirection": "Direzione freccia",
- "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
- "uploadImage": "Carica immagine",
- "customFonts": "Caratteri personalizzati",
- "blurTypeMosaic": "Mosaico",
- "blurTypeBlur": "Gaussiano",
- "typeBlur": "Sfocatura",
- "title": "Impostazioni annotazione",
- "deleteAnnotation": "Elimina annotazione",
- "blurShapeOval": "Ovale"
- },
- "transcript": {
- "restoreSilence": "Ripristina silenzio ({{duration}} s)",
- "editWord": "Modifica «{{word}}»",
- "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
- "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
- "insertedWord": "Aggiunta da te — nessun audio dietro",
- "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
- "insertAria": "Nuova parola",
- "transcribing": "Trascrizione…",
- "noAudio": "Questo contenuto non ha una traccia audio",
- "noTranscript": "Ancora nessuna trascrizione",
- "silence": "[silenzio {{duration}} s]",
- "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
- "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
- "noClips": "Ancora nessun clip",
- "laneVoiceover": "Voce fuori campo",
- "laneLabel": "Leggi la trascrizione da",
- "revertWord": "Ripristina «{{original}}»",
- "helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
- "blankedWord": "svuotata",
- "clipLabel": "Clip {{index}}",
- "title": "Trascrizione corrente",
- "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
- "transcribeNow": "Trascrivi ora",
- "laneRecording": "Registrazione",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
- "removeInserted": "Elimina «{{word}}»",
- "trimSilence": "Taglia silenzio ({{duration}} s)",
- "editorAria": "Trascrizione di {{filename}}",
- "restoreWord": "Ripristina «{{word}}»"
- },
- "effects": {
- "shadow": "Ombra",
- "fitClipOne": "{{count}} clip",
- "blurBg": "Sfuma sfondo",
- "fitClip": "Adatta",
- "formatOriginal": "Originale",
- "title": "Composizione",
- "format": "Formato",
- "motionBlur": "Sfocatura movimento",
- "fitClipMany": "{{count}} clip",
- "roundness": "Arrotondamento",
- "fitClipFew": "{{count}} clip",
- "motion": "Movimento",
- "on": "acceso",
- "frame": "Cornice",
- "padding": "Spaziatura",
- "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
- "off": "spento"
- },
- "audioTrack": {
- "mute": "Muto",
- "fadeIn": "Dissolvenza in entrata",
- "loop": "Ripeti",
- "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
- "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
- "importFailed": "Impossibile aggiungere l’audio",
- "fadeOut": "Dissolvenza in uscita",
- "add": "Aggiungi traccia audio",
- "defaultLabel": "Traccia audio",
- "remove": "Elimina traccia"
- },
"layout": {
+ "webcamBlurIntensity": "Intensità sfocatura",
+ "bgModes": {
+ "transparent": "Scontornato",
+ "none": "Originale",
+ "blur": "Sfocato",
+ "custom": "Personalizzato"
+ },
+ "selectPreset": "Seleziona predefinito",
+ "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
+ "reactiveWebcam": "Riduci con lo zoom",
"shapes": {
"circle": "Cerchio",
"square": "Quadrato",
"rectangle": "Rett.",
"rounded": "Arrotondato"
},
+ "webcamBackground": "Sfondo della fotocamera",
"verticalStack": "Pila verticale",
- "webcamSize": "Dimensione webcam",
- "preset": "Predefinito",
- "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
- "dualFrame": "Doppio frame",
- "title": "Disposizione camera",
- "reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
- "bgModes": {
- "transparent": "Scontornato",
- "custom": "Personalizzato",
- "none": "Originale",
- "blur": "Sfocato"
- },
- "webcamCropZoom": "Zoom ritaglio",
- "selectPreset": "Seleziona predefinito",
- "webcamCropY": "Spostamento verticale",
- "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
"pictureInPicture": "Immagine nell'immagine",
- "reactiveWebcam": "Riduci con lo zoom",
- "webcamBackground": "Sfondo della fotocamera",
- "webcamBlurIntensity": "Intensità sfocatura",
- "mirrorWebcam": "Specchia webcam",
"webcamShape": "Forma fotocamera",
+ "webcamCropY": "Spostamento verticale",
+ "webcamSize": "Dimensione webcam",
+ "mirrorWebcam": "Specchia webcam",
+ "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
+ "webcamFraming": "Inquadratura webcam",
"noWebcam": "Nessuna webcam",
+ "reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
+ "webcamCropZoom": "Zoom ritaglio",
+ "dualFrame": "Doppio frame",
"webcamCropX": "Spostamento orizzontale",
- "webcamFraming": "Inquadratura webcam"
- },
- "imageUpload": {
- "uploadSuccess": "Immagine personalizzata caricata con successo!",
- "failedToUpload": "Impossibile caricare l'immagine",
- "jpgOnly": "Carica un file immagine JPG o JPEG.",
- "errorReading": "Si è verificato un errore durante la lettura del file.",
- "invalidFileType": "Tipo di file non valido"
- },
- "cursor": {
- "themeDefault": "Predefinito",
- "motionBlur": "Sfocatura movimento",
- "smoothing": "Smussatura",
- "title": "Cursore",
- "theme": "Stile del cursore",
- "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
- "show": "Mostra cursore",
- "clipToBounds": "Ritaglia al canvas",
- "size": "Dimensione",
- "clickBounce": "Rimbalzo clic",
- "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic."
+ "title": "Disposizione camera",
+ "preset": "Predefinito"
},
- "captions": {
- "original": "Originale (trascrizione)",
- "alignRight": "Destra",
- "backgroundOpacity": "Opacità",
- "anchorBottom": "Basso",
- "minWords": "Parole min. per riga",
- "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
- "translate": "Traduci",
- "maxWords": "Parole max. per riga",
- "lineLength": "Lunghezza riga",
- "anchorTop": "Alto",
- "font": "Carattere",
- "translating": "Traduzione…",
- "position": "Posizione",
- "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
- "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
- "backgroundColor": "Colore dello sfondo",
- "text": "Testo",
- "textColor": "Colore del testo",
- "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
- "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
- "distanceFromTop": "Distanza dall'alto",
- "alignLeft": "Sinistra",
- "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
- "deleteTranslation": "Elimina questa traduzione",
- "distanceFromBottom": "Distanza dal basso",
- "displayLanguage": "Visualizzazione",
- "background": "Sfondo",
- "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
- "distanceFromLeft": "Distanza da sinistra",
- "alignCenter": "Centro",
- "fontSize": "Dimensione",
- "bold": "Grassetto",
- "translateFailed": "Traduzione non riuscita.",
- "distanceFromRight": "Distanza da destra",
- "showBackground": "Mostra sfondo",
- "translateHint": "Traduci la trascrizione con il provider IA configurato",
- "show": "Mostra sottotitoli",
- "language": "Lingua",
- "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione."
+ "crop": {
+ "done": "Fatto",
+ "ratio": "Proporzioni",
+ "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
+ "title": "Ritaglia",
+ "free": "Libero",
+ "lockAspectRatio": "Blocca proporzioni",
+ "unlockAspectRatio": "Sblocca proporzioni",
+ "cropVideo": "Ritaglia video"
},
"zoom": {
+ "position": {
+ "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Posizione messa a fuoco"
+ },
"threeD": {
"preset": {
"right": "Destra",
@@ -241,115 +59,296 @@
"none": "Nessuna",
"title": "Rotazione 3D"
},
+ "deleteZoom": "Elimina zoom",
+ "customScale": "Zoom personalizzato",
+ "selectRegion": "Seleziona una regione zoom da regolare",
"focusMode": {
+ "manual": "Manuale",
"autoDescription": "La fotocamera segue la posizione del cursore registrato",
"lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
"title": "Modalità messa a fuoco",
- "manual": "Manuale",
"auto": "Automatico"
},
- "position": {
- "title": "Posizione messa a fuoco",
- "x": "X (%)",
- "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
- "y": "Y (%)"
- },
- "deleteZoom": "Elimina zoom",
- "level": "Livello zoom",
- "selectRegion": "Seleziona una regione zoom da regolare",
"previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
- "customScale": "Zoom personalizzato"
- },
- "textAnimation": {
- "pop": "Apparizione",
- "rise": "Ascesa",
- "selectAnimation": "Seleziona animazione",
- "fade": "Dissolvenza",
- "pulse": "Pulsazione",
- "typewriter": "Macchina da scrivere",
- "title": "Animazione testo",
- "none": "Nessuna",
- "slideLeft": "Scivola a sinistra"
- },
- "crop": {
- "lockAspectRatio": "Blocca proporzioni",
- "title": "Ritaglia",
- "done": "Fatto",
- "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
- "ratio": "Proporzioni",
- "free": "Libero",
- "cropVideo": "Ritaglia video",
- "unlockAspectRatio": "Sblocca proporzioni"
+ "level": "Livello zoom"
},
"background": {
- "imageLabel": "Sfondo {{index}}",
"color": "Colore",
- "gradient": "Sfumatura",
"colorLabel": "Colore {{color}}",
- "colorWheel": "Ruota dei colori",
- "customWallpaper": "Sfondo personalizzato",
- "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
- "image": "Immagine",
- "gradientLabel": "Sfumatura {{index}}",
+ "gradient": "Sfumatura",
"imageReadFailed": "Impossibile leggere quel file immagine.",
- "presets": "Predefiniti",
+ "gradientLabel": "Sfumatura {{index}}",
"custom": "Personalizzato",
+ "customWallpaper": "Sfondo personalizzato",
+ "presets": "Predefiniti",
+ "image": "Immagine",
"colorPalette": "Tavolozza dei colori",
- "uploadCustom": "Carica personalizzato",
+ "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
+ "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
+ "imageLabel": "Sfondo {{index}}",
"title": "Sfondo",
- "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG."
+ "uploadCustom": "Carica personalizzato",
+ "colorWheel": "Ruota dei colori"
},
- "audio": {
- "title": "Audio",
- "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
- "reset": "Reimposta audio",
- "outputGain": "Livello di uscita"
+ "cursor": {
+ "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
+ "clickBounce": "Rimbalzo clic",
+ "clipToBounds": "Ritaglia al canvas",
+ "title": "Cursore",
+ "size": "Dimensione",
+ "themeDefault": "Predefinito",
+ "smoothing": "Smussatura",
+ "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
+ "motionBlur": "Sfocatura movimento",
+ "theme": "Stile del cursore",
+ "show": "Mostra cursore"
+ },
+ "captions": {
+ "text": "Testo",
+ "showBackground": "Mostra sfondo",
+ "minWords": "Parole min. per riga",
+ "translateFailed": "Traduzione non riuscita.",
+ "distanceFromTop": "Distanza dall'alto",
+ "fontSize": "Dimensione",
+ "distanceFromRight": "Distanza da destra",
+ "bold": "Grassetto",
+ "textColor": "Colore del testo",
+ "original": "Originale (trascrizione)",
+ "translating": "Traduzione…",
+ "language": "Lingua",
+ "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
+ "alignLeft": "Sinistra",
+ "distanceFromBottom": "Distanza dal basso",
+ "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
+ "show": "Mostra sottotitoli",
+ "background": "Sfondo",
+ "backgroundOpacity": "Opacità",
+ "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
+ "anchorTop": "Alto",
+ "translate": "Traduci",
+ "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
+ "alignCenter": "Centro",
+ "alignRight": "Destra",
+ "translateHint": "Traduci la trascrizione con il provider IA configurato",
+ "displayLanguage": "Visualizzazione",
+ "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
+ "distanceFromLeft": "Distanza da sinistra",
+ "maxWords": "Parole max. per riga",
+ "anchorBottom": "Basso",
+ "position": "Posizione",
+ "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
+ "deleteTranslation": "Elimina questa traduzione",
+ "backgroundColor": "Colore dello sfondo",
+ "font": "Carattere",
+ "lineLength": "Lunghezza riga",
+ "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
+ "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Originale",
"title": "Risoluzione esportazione"
},
- "gifSettings": {
- "loop": "GIF in loop",
- "frameRate": "Frequenza fotogrammi GIF",
- "size": "Dimensione GIF"
+ "transcript": {
+ "transcribing": "Trascrizione…",
+ "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
+ "laneVoiceover": "Voce fuori campo",
+ "blankedWord": "svuotata",
+ "noTranscript": "Ancora nessuna trascrizione",
+ "noAudio": "Questo contenuto non ha una traccia audio",
+ "revertWord": "Ripristina «{{original}}»",
+ "silence": "[silenzio {{duration}} s]",
+ "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
+ "transcribeNow": "Trascrivi ora",
+ "restoreWord": "Ripristina «{{word}}»",
+ "clipLabel": "Clip {{index}}",
+ "title": "Trascrizione corrente",
+ "insertAria": "Nuova parola",
+ "laneRecording": "Registrazione",
+ "trimSilence": "Taglia silenzio ({{duration}} s)",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
+ "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
+ "removeInserted": "Elimina «{{word}}»",
+ "editorAria": "Trascrizione di {{filename}}",
+ "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
+ "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
+ "editWord": "Modifica «{{word}}»",
+ "noClips": "Ancora nessun clip",
+ "laneLabel": "Leggi la trascrizione da",
+ "restoreSilence": "Ripristina silenzio ({{duration}} s)"
},
- "export": {
- "chooseSaveLocation": "Scegli posizione di salvataggio",
- "gifButton": "Esporta GIF",
- "videoButton": "Esporta video"
+ "customFont": {
+ "addingButton": "Aggiunta in corso...",
+ "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
+ "addButton": "Aggiungi font",
+ "dialogTitle": "Aggiungi font Google",
+ "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
+ "nameLabel": "Nome visualizzato",
+ "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
+ "urlLabel": "URL importazione Google Fonts",
+ "errorEmptyName": "Inserisci un nome per il font",
+ "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
+ "namePlaceholder": "Il mio font personalizzato",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Impossibile aggiungere il font",
+ "nameHelp": "Così apparirà il font nel selettore",
+ "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
+ "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
+ "successMessage": "Font \"{{fontName}}\" aggiunto con successo"
},
- "project": {
- "load": "Carica progetto",
- "save": "Salva progetto",
- "new": "Nuovo progetto"
+ "annotation": {
+ "arrowColor": "Colore freccia",
+ "colorWheel": "Ruota dei colori",
+ "blurType": "Tipo sfocatura",
+ "active": "Attivo",
+ "deleteAnnotation": "Elimina annotazione",
+ "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
+ "strokeWidth": "Larghezza tratto: {{width}}px",
+ "background": "Sfondo",
+ "imageUploadSuccess": "Immagine caricata con successo!",
+ "blurColor": "Colore sfocatura",
+ "blurTypeBlur": "Gaussiano",
+ "textColor": "Colore testo",
+ "blurColorWhite": "Bianco",
+ "title": "Impostazioni annotazione",
+ "type": "Tipo",
+ "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
+ "typeImage": "Immagine",
+ "textContent": "Contenuto testo",
+ "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
+ "typeText": "Testo",
+ "blurIntensity": "Intensità sfocatura",
+ "none": "Nessuno",
+ "mosaicBlockSize": "Dimensione blocco mosaico",
+ "textPlaceholder": "Inserisci il tuo testo...",
+ "typeArrow": "Freccia",
+ "color": "Colore",
+ "blurColorBlack": "Nero",
+ "size": "Dimensione",
+ "invalidImageType": "Tipo di file non valido",
+ "blurShapeFreehand": "A mano libera",
+ "shortcutsAndTips": "Scorciatoie e suggerimenti",
+ "uploadImage": "Carica immagine",
+ "blurTypeMosaic": "Mosaico",
+ "selectStyle": "Seleziona stile",
+ "defaultText": "Ciao",
+ "blurShapeRectangle": "Rettangolo",
+ "colorPalette": "Tavolozza dei colori",
+ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
+ "clearBackground": "Rimuovi sfondo",
+ "customFonts": "Caratteri personalizzati",
+ "typeBlur": "Sfocatura",
+ "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
+ "fontStyle": "Stile carattere",
+ "blurShape": "Forma sfocatura",
+ "arrowDirection": "Direzione freccia",
+ "blurShapeOval": "Ovale"
},
"speed": {
- "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata.",
"customPlaybackSpeed": "Velocità di riproduzione personalizzata",
- "maxSpeedError": "La velocità non può superare {{max}}×",
"deleteRegion": "Elimina regione velocità",
"selectRegion": "Seleziona una regione velocità da regolare",
- "playbackSpeed": "Velocità di riproduzione"
+ "maxSpeedError": "La velocità non può superare {{max}}×",
+ "playbackSpeed": "Velocità di riproduzione",
+ "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata."
+ },
+ "textAnimation": {
+ "selectAnimation": "Seleziona animazione",
+ "pulse": "Pulsazione",
+ "rise": "Ascesa",
+ "none": "Nessuna",
+ "slideLeft": "Scivola a sinistra",
+ "title": "Animazione testo",
+ "fade": "Dissolvenza",
+ "pop": "Apparizione",
+ "typewriter": "Macchina da scrivere"
+ },
+ "effects": {
+ "motion": "Movimento",
+ "title": "Composizione",
+ "format": "Formato",
+ "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
+ "fitClipFew": "{{count}} clip",
+ "motionBlur": "Sfocatura movimento",
+ "fitClipMany": "{{count}} clip",
+ "frame": "Cornice",
+ "padding": "Spaziatura",
+ "roundness": "Arrotondamento",
+ "off": "spento",
+ "blurBg": "Sfuma sfondo",
+ "shadow": "Ombra",
+ "on": "acceso",
+ "fitClipOne": "{{count}} clip",
+ "formatOriginal": "Originale",
+ "fitClip": "Adatta"
+ },
+ "exportFormat": {
+ "gifDescription": "Immagine animata per la condivisione",
+ "mp4": "MP4",
+ "mp4Video": "Video MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animazione GIF",
+ "mp4Description": "File video di alta qualità"
+ },
+ "imageUpload": {
+ "failedToUpload": "Impossibile caricare l'immagine",
+ "uploadSuccess": "Immagine personalizzata caricata con successo!",
+ "errorReading": "Si è verificato un errore durante la lettura del file.",
+ "invalidFileType": "Tipo di file non valido",
+ "jpgOnly": "Carica un file immagine JPG o JPEG."
+ },
+ "facets": {
+ "captions": "Sottotitoli",
+ "transcript": "Trascrizione"
+ },
+ "audio": {
+ "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
+ "reset": "Reimposta audio",
+ "title": "Audio",
+ "outputGain": "Livello di uscita"
},
"language": {
"title": "Lingua"
},
- "support": {
- "starOnGithub": "Metti stella su GitHub",
- "reportBug": "Segnala bug",
- "saveDiagnostics": "Salva dati diagnostici"
+ "audioTrack": {
+ "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "importFailed": "Impossibile aggiungere l’audio",
+ "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
+ "fadeOut": "Dissolvenza in uscita",
+ "defaultLabel": "Traccia audio",
+ "mute": "Muto",
+ "remove": "Elimina traccia",
+ "add": "Aggiungi traccia audio",
+ "loop": "Ripeti",
+ "fadeIn": "Dissolvenza in entrata"
+ },
+ "project": {
+ "load": "Carica progetto",
+ "save": "Salva progetto",
+ "new": "Nuovo progetto"
+ },
+ "panes": {
+ "help": "Aiuto"
},
"trim": {
"deleteRegion": "Elimina regione taglio"
},
- "facets": {
- "transcript": "Trascrizione",
- "captions": "Sottotitoli"
+ "export": {
+ "videoButton": "Esporta video",
+ "gifButton": "Esporta GIF",
+ "chooseSaveLocation": "Scegli posizione di salvataggio"
},
- "panes": {
- "help": "Aiuto"
+ "support": {
+ "starOnGithub": "Metti stella su GitHub",
+ "saveDiagnostics": "Salva dati diagnostici",
+ "reportBug": "Segnala bug"
+ },
+ "gifSettings": {
+ "size": "Dimensione GIF",
+ "frameRate": "Frequenza fotogrammi GIF",
+ "loop": "GIF in loop"
}
}
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 92be6ec28..e9cba9377 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF アニメーション",
- "mp4Description": "高品質の動画ファイル",
- "mp4": "MP4",
- "mp4Video": "MP4 動画",
- "gif": "GIF",
- "gifDescription": "共有用のアニメーション画像"
- },
- "customFont": {
- "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Googleフォントを追加",
- "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
- "nameLabel": "表示名",
- "failedToAdd": "フォントの追加に失敗しました",
- "urlLabel": "GoogleフォントのインポートURL",
- "namePlaceholder": "マイカスタムフォント",
- "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
- "addingButton": "追加中...",
- "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
- "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
- "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
- "nameHelp": "フォントセレクターに表示される名前です",
- "errorEmptyName": "フォント名を入力してください",
- "addButton": "フォントを追加",
- "errorInvalidUrl": "有効なGoogleフォントURLを入力してください"
- },
- "annotation": {
- "colorWheel": "カラーホイール",
- "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "typeArrow": "矢印",
- "selectStyle": "スタイルを選択",
- "blurColorWhite": "白",
- "blurType": "ぼかしの種類",
- "blurShapeRectangle": "長方形",
- "blurColor": "ぼかしの色",
- "arrowColor": "矢印の色",
- "textContent": "テキスト内容",
- "background": "背景",
- "clearBackground": "背景をクリア",
- "blurShapeFreehand": "自由形状",
- "blurIntensity": "ぼかしの強さ",
- "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
- "active": "アクティブ",
- "size": "サイズ",
- "blurColorBlack": "黒",
- "typeImage": "画像",
- "mosaicBlockSize": "モザイクブロックのサイズ",
- "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
- "strokeWidth": "線の太さ: {{width}}px",
- "textColor": "文字色",
- "defaultText": "こんにちは",
- "blurShape": "ぼかしの形状",
- "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
- "type": "種類",
- "typeText": "テキスト",
- "textPlaceholder": "テキストを入力してください...",
- "fontStyle": "フォントスタイル",
- "imageUploadSuccess": "画像を読み込みました。",
- "colorPalette": "カラーパレット",
- "color": "色",
- "shortcutsAndTips": "ショートカットとヒント",
- "none": "なし",
- "invalidImageType": "無効なファイル形式",
- "arrowDirection": "矢印の方向",
- "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
- "uploadImage": "画像を読み込む",
- "customFonts": "カスタムフォント",
- "blurTypeMosaic": "モザイク",
- "blurTypeBlur": "ガウス",
- "typeBlur": "ぼかし",
- "title": "注釈設定",
- "deleteAnnotation": "注釈を削除",
- "blurShapeOval": "楕円"
- },
- "transcript": {
- "restoreSilence": "無音を元に戻す({{duration}} 秒)",
- "editWord": "「{{word}}」を編集",
- "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
- "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
- "insertedWord": "あなたが追加した単語 — 音声はありません",
- "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
- "insertAria": "新しい単語",
- "transcribing": "文字起こし中…",
- "noAudio": "このメディアには音声トラックがありません",
- "noTranscript": "文字起こしがまだありません",
- "silence": "[無音 {{duration}} 秒]",
- "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
- "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
- "noClips": "クリップがまだありません",
- "laneVoiceover": "ナレーション",
- "laneLabel": "文字起こしの読み込み元",
- "revertWord": "「{{original}}」に戻す",
- "helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
- "blankedWord": "空欄",
- "clipLabel": "クリップ {{index}}",
- "title": "現在の文字起こし",
- "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
- "transcribeNow": "今すぐ文字起こし",
- "laneRecording": "録画",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
- "removeInserted": "「{{word}}」を削除",
- "trimSilence": "無音をトリム({{duration}} 秒)",
- "editorAria": "{{filename}} の文字起こし",
- "restoreWord": "「{{word}}」を元に戻す"
- },
- "effects": {
- "shadow": "影",
- "fitClipOne": "{{count}} クリップ",
- "blurBg": "背景をぼかす",
- "fitClip": "合わせる",
- "formatOriginal": "元のサイズ",
- "title": "コンポジション",
- "format": "フォーマット",
- "motionBlur": "モーションブラー",
- "fitClipMany": "{{count}} クリップ",
- "roundness": "丸み",
- "fitClipFew": "{{count}} クリップ",
- "motion": "モーション",
- "on": "オン",
- "frame": "フレーム",
- "padding": "余白",
- "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
- "off": "オフ"
- },
- "audioTrack": {
- "mute": "ミュート",
- "fadeIn": "フェードイン",
- "loop": "ループ",
- "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
- "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
- "importFailed": "オーディオを追加できませんでした",
- "fadeOut": "フェードアウト",
- "add": "オーディオトラックを追加",
- "defaultLabel": "オーディオトラック",
- "remove": "トラックを削除"
- },
"layout": {
+ "webcamBlurIntensity": "ぼかしの強さ",
+ "bgModes": {
+ "transparent": "切り抜き",
+ "none": "オリジナル",
+ "blur": "ぼかし",
+ "custom": "カスタム"
+ },
+ "selectPreset": "プリセットを選択",
+ "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
+ "reactiveWebcam": "ズーム時に縮小",
"shapes": {
"circle": "円",
"square": "正方形",
"rectangle": "長方形",
"rounded": "角丸"
},
+ "webcamBackground": "カメラ背景",
"verticalStack": "縦並び",
- "webcamSize": "カメラのサイズ",
- "preset": "プリセット",
- "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
- "dualFrame": "デュアルフレーム",
- "title": "カメラレイアウト",
- "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
- "bgModes": {
- "transparent": "切り抜き",
- "custom": "カスタム",
- "none": "オリジナル",
- "blur": "ぼかし"
- },
- "webcamCropZoom": "クロップのズーム",
- "selectPreset": "プリセットを選択",
- "webcamCropY": "垂直方向に移動",
- "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
"pictureInPicture": "ピクチャーインピクチャ",
- "reactiveWebcam": "ズーム時に縮小",
- "webcamBackground": "カメラ背景",
- "webcamBlurIntensity": "ぼかしの強さ",
- "mirrorWebcam": "Webカメラを反転",
"webcamShape": "カメラの形状",
+ "webcamCropY": "垂直方向に移動",
+ "webcamSize": "カメラのサイズ",
+ "mirrorWebcam": "Webカメラを反転",
+ "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
+ "webcamFraming": "ウェブカメラの構図",
"noWebcam": "Webカメラなし",
+ "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
+ "webcamCropZoom": "クロップのズーム",
+ "dualFrame": "デュアルフレーム",
"webcamCropX": "水平方向に移動",
- "webcamFraming": "ウェブカメラの構図"
- },
- "imageUpload": {
- "uploadSuccess": "カスタム画像を読み込みました。",
- "failedToUpload": "画像の読み込みに失敗しました",
- "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "errorReading": "ファイルの読み取り中にエラーが発生しました。",
- "invalidFileType": "無効なファイル形式"
- },
- "cursor": {
- "themeDefault": "デフォルト",
- "motionBlur": "モーションブラー",
- "smoothing": "スムージング",
- "title": "カーソル",
- "theme": "カーソルのスタイル",
- "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
- "show": "カーソルを表示",
- "clipToBounds": "キャンバスにクリップ",
- "size": "サイズ",
- "clickBounce": "クリックバウンス",
- "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。"
+ "title": "カメラレイアウト",
+ "preset": "プリセット"
},
- "captions": {
- "original": "オリジナル(文字起こし)",
- "alignRight": "右",
- "backgroundOpacity": "不透明度",
- "anchorBottom": "下",
- "minWords": "1 行の最小単語数",
- "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
- "translate": "翻訳",
- "maxWords": "1 行の最大単語数",
- "lineLength": "行の長さ",
- "anchorTop": "上",
- "font": "フォント",
- "translating": "翻訳中…",
- "position": "位置",
- "removeLegacyAnnotations": "古い字幕の注釈を削除",
- "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
- "backgroundColor": "背景色",
- "text": "テキスト",
- "textColor": "文字色",
- "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
- "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
- "distanceFromTop": "上端からの距離",
- "alignLeft": "左",
- "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
- "deleteTranslation": "この翻訳を削除",
- "distanceFromBottom": "下端からの距離",
- "displayLanguage": "表示",
- "background": "背景",
- "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
- "distanceFromLeft": "左端からの距離",
- "alignCenter": "中央",
- "fontSize": "サイズ",
- "bold": "太字",
- "translateFailed": "翻訳に失敗しました。",
- "distanceFromRight": "右端からの距離",
- "showBackground": "背景を表示",
- "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
- "show": "字幕を表示",
- "language": "言語",
- "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。"
+ "crop": {
+ "done": "完了",
+ "ratio": "比率",
+ "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
+ "title": "クロップ",
+ "free": "自由",
+ "lockAspectRatio": "アスペクト比を固定",
+ "unlockAspectRatio": "アスペクト比の固定を解除",
+ "cropVideo": "動画をクロップ"
},
"zoom": {
+ "position": {
+ "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "フォーカス位置"
+ },
"threeD": {
"preset": {
"right": "右",
@@ -241,115 +59,296 @@
"none": "なし",
"title": "3D回転"
},
+ "deleteZoom": "ズームを削除",
+ "customScale": "カスタムズーム",
+ "selectRegion": "ズーム範囲を選択して調整",
"focusMode": {
+ "manual": "手動",
"autoDescription": "表示範囲が録画中のカーソル位置に追従します",
"lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
"title": "フォーカスモード",
- "manual": "手動",
"auto": "自動"
},
- "position": {
- "title": "フォーカス位置",
- "x": "X (%)",
- "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
- "y": "Y (%)"
- },
- "deleteZoom": "ズームを削除",
- "level": "ズーム倍率",
- "selectRegion": "ズーム範囲を選択して調整",
"previewHold": "押している間ズーム効果をプレビュー",
- "customScale": "カスタムズーム"
- },
- "textAnimation": {
- "pop": "ポップ",
- "rise": "上昇",
- "selectAnimation": "アニメーションを選択",
- "fade": "フェード",
- "pulse": "パルス",
- "typewriter": "タイプライター",
- "title": "テキストアニメーション",
- "none": "なし",
- "slideLeft": "左へスライド"
- },
- "crop": {
- "lockAspectRatio": "アスペクト比を固定",
- "title": "クロップ",
- "done": "完了",
- "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
- "ratio": "比率",
- "free": "自由",
- "cropVideo": "動画をクロップ",
- "unlockAspectRatio": "アスペクト比の固定を解除"
+ "level": "ズーム倍率"
},
"background": {
- "imageLabel": "背景 {{index}}",
"color": "色",
- "gradient": "グラデーション",
"colorLabel": "色 {{color}}",
- "colorWheel": "カラーホイール",
- "customWallpaper": "カスタム壁紙",
- "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
- "image": "画像",
- "gradientLabel": "グラデーション {{index}}",
+ "gradient": "グラデーション",
"imageReadFailed": "この画像ファイルを読み込めませんでした。",
- "presets": "プリセット",
+ "gradientLabel": "グラデーション {{index}}",
"custom": "カスタム",
+ "customWallpaper": "カスタム壁紙",
+ "presets": "プリセット",
+ "image": "画像",
"colorPalette": "カラーパレット",
- "uploadCustom": "カスタム画像を読み込む",
+ "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
+ "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
+ "imageLabel": "背景 {{index}}",
"title": "背景",
- "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。"
+ "uploadCustom": "カスタム画像を読み込む",
+ "colorWheel": "カラーホイール"
},
- "audio": {
- "title": "オーディオ",
- "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
- "reset": "オーディオをリセット",
- "outputGain": "出力レベル"
+ "cursor": {
+ "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
+ "clickBounce": "クリックバウンス",
+ "clipToBounds": "キャンバスにクリップ",
+ "title": "カーソル",
+ "size": "サイズ",
+ "themeDefault": "デフォルト",
+ "smoothing": "スムージング",
+ "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
+ "motionBlur": "モーションブラー",
+ "theme": "カーソルのスタイル",
+ "show": "カーソルを表示"
+ },
+ "captions": {
+ "text": "テキスト",
+ "showBackground": "背景を表示",
+ "minWords": "1 行の最小単語数",
+ "translateFailed": "翻訳に失敗しました。",
+ "distanceFromTop": "上端からの距離",
+ "fontSize": "サイズ",
+ "distanceFromRight": "右端からの距離",
+ "bold": "太字",
+ "textColor": "文字色",
+ "original": "オリジナル(文字起こし)",
+ "translating": "翻訳中…",
+ "language": "言語",
+ "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
+ "alignLeft": "左",
+ "distanceFromBottom": "下端からの距離",
+ "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
+ "show": "字幕を表示",
+ "background": "背景",
+ "backgroundOpacity": "不透明度",
+ "removeLegacyAnnotations": "古い字幕の注釈を削除",
+ "anchorTop": "上",
+ "translate": "翻訳",
+ "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
+ "alignCenter": "中央",
+ "alignRight": "右",
+ "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "displayLanguage": "表示",
+ "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
+ "distanceFromLeft": "左端からの距離",
+ "maxWords": "1 行の最大単語数",
+ "anchorBottom": "下",
+ "position": "位置",
+ "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
+ "deleteTranslation": "この翻訳を削除",
+ "backgroundColor": "背景色",
+ "font": "フォント",
+ "lineLength": "行の長さ",
+ "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
+ "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。"
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "書き出し解像度"
},
- "gifSettings": {
- "loop": "GIF をループする",
- "frameRate": "GIF フレームレート",
- "size": "GIF サイズ"
+ "transcript": {
+ "transcribing": "文字起こし中…",
+ "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "laneVoiceover": "ナレーション",
+ "blankedWord": "空欄",
+ "noTranscript": "文字起こしがまだありません",
+ "noAudio": "このメディアには音声トラックがありません",
+ "revertWord": "「{{original}}」に戻す",
+ "silence": "[無音 {{duration}} 秒]",
+ "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
+ "transcribeNow": "今すぐ文字起こし",
+ "restoreWord": "「{{word}}」を元に戻す",
+ "clipLabel": "クリップ {{index}}",
+ "title": "現在の文字起こし",
+ "insertAria": "新しい単語",
+ "laneRecording": "録画",
+ "trimSilence": "無音をトリム({{duration}} 秒)",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
+ "removeInserted": "「{{word}}」を削除",
+ "editorAria": "{{filename}} の文字起こし",
+ "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
+ "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
+ "editWord": "「{{word}}」を編集",
+ "noClips": "クリップがまだありません",
+ "laneLabel": "文字起こしの読み込み元",
+ "restoreSilence": "無音を元に戻す({{duration}} 秒)"
},
- "export": {
- "chooseSaveLocation": "保存場所を選択",
- "gifButton": "GIF をエクスポート",
- "videoButton": "動画をエクスポート"
+ "customFont": {
+ "addingButton": "追加中...",
+ "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
+ "addButton": "フォントを追加",
+ "dialogTitle": "Googleフォントを追加",
+ "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
+ "nameLabel": "表示名",
+ "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
+ "urlLabel": "GoogleフォントのインポートURL",
+ "errorEmptyName": "フォント名を入力してください",
+ "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
+ "namePlaceholder": "マイカスタムフォント",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "フォントの追加に失敗しました",
+ "nameHelp": "フォントセレクターに表示される名前です",
+ "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
+ "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
+ "successMessage": "フォント \"{{fontName}}\" が正常に追加されました"
},
- "project": {
- "load": "プロジェクトを読み込む",
- "save": "プロジェクトを保存",
- "new": "新規プロジェクト"
+ "annotation": {
+ "arrowColor": "矢印の色",
+ "colorWheel": "カラーホイール",
+ "blurType": "ぼかしの種類",
+ "active": "アクティブ",
+ "deleteAnnotation": "注釈を削除",
+ "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
+ "strokeWidth": "線の太さ: {{width}}px",
+ "background": "背景",
+ "imageUploadSuccess": "画像を読み込みました。",
+ "blurColor": "ぼかしの色",
+ "blurTypeBlur": "ガウス",
+ "textColor": "文字色",
+ "blurColorWhite": "白",
+ "title": "注釈設定",
+ "type": "種類",
+ "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "typeImage": "画像",
+ "textContent": "テキスト内容",
+ "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
+ "typeText": "テキスト",
+ "blurIntensity": "ぼかしの強さ",
+ "none": "なし",
+ "mosaicBlockSize": "モザイクブロックのサイズ",
+ "textPlaceholder": "テキストを入力してください...",
+ "typeArrow": "矢印",
+ "color": "色",
+ "blurColorBlack": "黒",
+ "size": "サイズ",
+ "invalidImageType": "無効なファイル形式",
+ "blurShapeFreehand": "自由形状",
+ "shortcutsAndTips": "ショートカットとヒント",
+ "uploadImage": "画像を読み込む",
+ "blurTypeMosaic": "モザイク",
+ "selectStyle": "スタイルを選択",
+ "defaultText": "こんにちは",
+ "blurShapeRectangle": "長方形",
+ "colorPalette": "カラーパレット",
+ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
+ "clearBackground": "背景をクリア",
+ "customFonts": "カスタムフォント",
+ "typeBlur": "ぼかし",
+ "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
+ "fontStyle": "フォントスタイル",
+ "blurShape": "ぼかしの形状",
+ "arrowDirection": "矢印の方向",
+ "blurShapeOval": "楕円"
},
"speed": {
- "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。",
"customPlaybackSpeed": "カスタム再生速度",
- "maxSpeedError": "速度は{{max}}×を超えることはできません",
"deleteRegion": "再生速度の範囲を削除",
"selectRegion": "再生速度の範囲を選択して調整",
- "playbackSpeed": "再生速度"
+ "maxSpeedError": "速度は{{max}}×を超えることはできません",
+ "playbackSpeed": "再生速度",
+ "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。"
+ },
+ "textAnimation": {
+ "selectAnimation": "アニメーションを選択",
+ "pulse": "パルス",
+ "rise": "上昇",
+ "none": "なし",
+ "slideLeft": "左へスライド",
+ "title": "テキストアニメーション",
+ "fade": "フェード",
+ "pop": "ポップ",
+ "typewriter": "タイプライター"
+ },
+ "effects": {
+ "motion": "モーション",
+ "title": "コンポジション",
+ "format": "フォーマット",
+ "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
+ "fitClipFew": "{{count}} クリップ",
+ "motionBlur": "モーションブラー",
+ "fitClipMany": "{{count}} クリップ",
+ "frame": "フレーム",
+ "padding": "余白",
+ "roundness": "丸み",
+ "off": "オフ",
+ "blurBg": "背景をぼかす",
+ "shadow": "影",
+ "on": "オン",
+ "fitClipOne": "{{count}} クリップ",
+ "formatOriginal": "元のサイズ",
+ "fitClip": "合わせる"
+ },
+ "exportFormat": {
+ "gifDescription": "共有用のアニメーション画像",
+ "mp4": "MP4",
+ "mp4Video": "MP4 動画",
+ "gif": "GIF",
+ "gifAnimation": "GIF アニメーション",
+ "mp4Description": "高品質の動画ファイル"
+ },
+ "imageUpload": {
+ "failedToUpload": "画像の読み込みに失敗しました",
+ "uploadSuccess": "カスタム画像を読み込みました。",
+ "errorReading": "ファイルの読み取り中にエラーが発生しました。",
+ "invalidFileType": "無効なファイル形式",
+ "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。"
+ },
+ "facets": {
+ "captions": "字幕",
+ "transcript": "文字起こし"
+ },
+ "audio": {
+ "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
+ "reset": "オーディオをリセット",
+ "title": "オーディオ",
+ "outputGain": "出力レベル"
},
"language": {
"title": "言語"
},
- "support": {
- "starOnGithub": "GitHub でスターを付ける",
- "reportBug": "バグを報告",
- "saveDiagnostics": "診断情報を保存"
+ "audioTrack": {
+ "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "importFailed": "オーディオを追加できませんでした",
+ "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
+ "fadeOut": "フェードアウト",
+ "defaultLabel": "オーディオトラック",
+ "mute": "ミュート",
+ "remove": "トラックを削除",
+ "add": "オーディオトラックを追加",
+ "loop": "ループ",
+ "fadeIn": "フェードイン"
+ },
+ "project": {
+ "load": "プロジェクトを読み込む",
+ "save": "プロジェクトを保存",
+ "new": "新規プロジェクト"
+ },
+ "panes": {
+ "help": "ヘルプ"
},
"trim": {
"deleteRegion": "トリム範囲を削除"
},
- "facets": {
- "transcript": "文字起こし",
- "captions": "字幕"
+ "export": {
+ "videoButton": "動画をエクスポート",
+ "gifButton": "GIF をエクスポート",
+ "chooseSaveLocation": "保存場所を選択"
},
- "panes": {
- "help": "ヘルプ"
+ "support": {
+ "starOnGithub": "GitHub でスターを付ける",
+ "saveDiagnostics": "診断情報を保存",
+ "reportBug": "バグを報告"
+ },
+ "gifSettings": {
+ "size": "GIF サイズ",
+ "frameRate": "GIF フレームレート",
+ "loop": "GIF をループする"
}
}
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 94cf3e251..1de5717e1 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF 애니메이션",
- "mp4Description": "고화질 비디오 파일",
- "mp4": "MP4",
- "mp4Video": "MP4 비디오",
- "gif": "GIF",
- "gifDescription": "공유용 애니메이션 이미지"
- },
- "customFont": {
- "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Google 폰트 추가",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
- "nameLabel": "표시 이름",
- "failedToAdd": "폰트 추가에 실패했습니다",
- "urlLabel": "Google Fonts 가져오기 URL",
- "namePlaceholder": "내 커스텀 폰트",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
- "addingButton": "추가 중...",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
- "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
- "nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "errorEmptyName": "폰트 이름을 입력해 주세요",
- "addButton": "폰트 추가",
- "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요"
- },
- "annotation": {
- "colorWheel": "색상 휠",
- "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
- "typeArrow": "화살표",
- "selectStyle": "스타일 선택",
- "blurColorWhite": "흰색",
- "blurType": "블러 종류",
- "blurShapeRectangle": "사각형",
- "blurColor": "블러 색상",
- "arrowColor": "화살표 색상",
- "textContent": "텍스트 내용",
- "background": "배경",
- "clearBackground": "배경 지우기",
- "blurShapeFreehand": "자유 곡선",
- "blurIntensity": "블러 강도",
- "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "active": "활성",
- "size": "크기",
- "blurColorBlack": "검정",
- "typeImage": "이미지",
- "mosaicBlockSize": "모자이크 블록 크기",
- "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
- "strokeWidth": "선 두께: {{width}}px",
- "textColor": "텍스트 색상",
- "defaultText": "안녕하세요",
- "blurShape": "블러 모양",
- "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
- "type": "유형",
- "typeText": "텍스트",
- "textPlaceholder": "텍스트를 입력하세요...",
- "fontStyle": "폰트 스타일",
- "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
- "colorPalette": "색상 팔레트",
- "color": "색상",
- "shortcutsAndTips": "단축키 및 팁",
- "none": "없음",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "arrowDirection": "화살표 방향",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
- "uploadImage": "이미지 업로드",
- "customFonts": "커스텀 폰트",
- "blurTypeMosaic": "모자이크",
- "blurTypeBlur": "가우시안",
- "typeBlur": "블러",
- "title": "주석 설정",
- "deleteAnnotation": "주석 삭제",
- "blurShapeOval": "타원"
- },
- "transcript": {
- "restoreSilence": "무음 복원 ({{duration}}초)",
- "editWord": "\"{{word}}\" 편집",
- "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
- "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
- "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
- "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
- "insertAria": "새 단어",
- "transcribing": "전사 중…",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
- "noTranscript": "아직 전사가 없습니다",
- "silence": "[무음 {{duration}}초]",
- "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
- "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "noClips": "아직 클립이 없습니다",
- "laneVoiceover": "내레이션",
- "laneLabel": "전사본을 읽어올 소스",
- "revertWord": "\"{{original}}\"(으)로 되돌리기",
- "helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
- "blankedWord": "비움",
- "clipLabel": "클립 {{index}}",
- "title": "현재 전사",
- "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
- "transcribeNow": "지금 전사하기",
- "laneRecording": "녹화",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
- "removeInserted": "\"{{word}}\" 삭제",
- "trimSilence": "무음 자르기 ({{duration}}초)",
- "editorAria": "{{filename}}의 전사",
- "restoreWord": "\"{{word}}\" 복원"
- },
- "effects": {
- "shadow": "그림자",
- "fitClipOne": "{{count}}개 클립",
- "blurBg": "배경 흐림",
- "fitClip": "맞추기",
- "formatOriginal": "원본",
- "title": "컴포지션",
- "format": "형식",
- "motionBlur": "모션 블러",
- "fitClipMany": "{{count}}개 클립",
- "roundness": "모서리 둥글기",
- "fitClipFew": "{{count}}개 클립",
- "motion": "모션",
- "on": "켜기",
- "frame": "프레임",
- "padding": "여백",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
- "off": "끄기"
- },
- "audioTrack": {
- "mute": "음소거",
- "fadeIn": "페이드 인",
- "loop": "반복",
- "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
- "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
- "importFailed": "오디오를 추가할 수 없습니다",
- "fadeOut": "페이드 아웃",
- "add": "오디오 트랙 추가",
- "defaultLabel": "오디오 트랙",
- "remove": "트랙 삭제"
- },
"layout": {
+ "webcamBlurIntensity": "블러 강도",
+ "bgModes": {
+ "transparent": "누끼",
+ "none": "원본",
+ "blur": "블러",
+ "custom": "사용자 지정"
+ },
+ "selectPreset": "프리셋 선택",
+ "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
+ "reactiveWebcam": "확대 시 축소",
"shapes": {
"circle": "원형",
"square": "정사각형",
"rectangle": "직사각형",
"rounded": "둥근 모서리"
},
+ "webcamBackground": "카메라 배경",
"verticalStack": "세로 배치",
- "webcamSize": "웹캠 크기",
- "preset": "프리셋",
- "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
- "dualFrame": "듀얼 프레임",
- "title": "카메라 레이아웃",
- "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
- "bgModes": {
- "transparent": "누끼",
- "custom": "사용자 지정",
- "none": "원본",
- "blur": "블러"
- },
- "webcamCropZoom": "자르기 확대",
- "selectPreset": "프리셋 선택",
- "webcamCropY": "세로 이동",
- "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
"pictureInPicture": "화면 속 화면",
- "reactiveWebcam": "확대 시 축소",
- "webcamBackground": "카메라 배경",
- "webcamBlurIntensity": "블러 강도",
- "mirrorWebcam": "웹캠 미러링",
"webcamShape": "카메라 모양",
+ "webcamCropY": "세로 이동",
+ "webcamSize": "웹캠 크기",
+ "mirrorWebcam": "웹캠 미러링",
+ "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
+ "webcamFraming": "웹캠 구도",
"noWebcam": "웹캠 없음",
+ "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
+ "webcamCropZoom": "자르기 확대",
+ "dualFrame": "듀얼 프레임",
"webcamCropX": "가로 이동",
- "webcamFraming": "웹캠 구도"
- },
- "imageUpload": {
- "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
- "failedToUpload": "이미지 업로드에 실패했습니다",
- "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
- "errorReading": "파일을 읽는 중 오류가 발생했습니다.",
- "invalidFileType": "지원하지 않는 파일 형식입니다"
- },
- "cursor": {
- "themeDefault": "기본",
- "motionBlur": "모션 블러",
- "smoothing": "부드러움",
- "title": "커서",
- "theme": "커서 스타일",
- "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
- "show": "커서 표시",
- "clipToBounds": "캔버스에 맞춰 자르기",
- "size": "크기",
- "clickBounce": "클릭 바운스",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스."
+ "title": "카메라 레이아웃",
+ "preset": "프리셋"
},
- "captions": {
- "original": "원본 (전사)",
- "alignRight": "오른쪽",
- "backgroundOpacity": "불투명도",
- "anchorBottom": "아래",
- "minWords": "줄당 최소 단어 수",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
- "translate": "번역",
- "maxWords": "줄당 최대 단어 수",
- "lineLength": "줄 길이",
- "anchorTop": "위",
- "font": "글꼴",
- "translating": "번역 중…",
- "position": "위치",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
- "backgroundColor": "배경 색",
- "text": "텍스트",
- "textColor": "글자 색",
- "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
- "distanceFromTop": "위에서의 거리",
- "alignLeft": "왼쪽",
- "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
- "deleteTranslation": "이 번역 삭제",
- "distanceFromBottom": "아래에서의 거리",
- "displayLanguage": "표시",
- "background": "배경",
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "distanceFromLeft": "왼쪽에서의 거리",
- "alignCenter": "가운데",
- "fontSize": "크기",
- "bold": "굵게",
- "translateFailed": "번역에 실패했습니다.",
- "distanceFromRight": "오른쪽에서의 거리",
- "showBackground": "배경 표시",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
- "show": "자막 표시",
- "language": "언어",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄."
+ "crop": {
+ "done": "완료",
+ "ratio": "비율",
+ "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
+ "title": "자르기",
+ "free": "자유",
+ "lockAspectRatio": "화면 비율 고정",
+ "unlockAspectRatio": "화면 비율 해제",
+ "cropVideo": "비디오 자르기"
},
"zoom": {
+ "position": {
+ "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "포커스 위치"
+ },
"threeD": {
"preset": {
"right": "오른쪽",
@@ -241,115 +59,296 @@
"none": "없음",
"title": "3D 회전"
},
+ "deleteZoom": "줌 삭제",
+ "customScale": "커스텀 줌",
+ "selectRegion": "조정할 줌 구간을 선택하세요",
"focusMode": {
+ "manual": "수동",
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
"lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
"title": "포커스 모드",
- "manual": "수동",
"auto": "자동"
},
- "position": {
- "title": "포커스 위치",
- "x": "X (%)",
- "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
- "y": "Y (%)"
- },
- "deleteZoom": "줌 삭제",
- "level": "줌 레벨",
- "selectRegion": "조정할 줌 구간을 선택하세요",
"previewHold": "누르고 있으면 줌 효과 미리보기",
- "customScale": "커스텀 줌"
- },
- "textAnimation": {
- "pop": "팝",
- "rise": "상승",
- "selectAnimation": "애니메이션 선택",
- "fade": "페이드",
- "pulse": "펄스",
- "typewriter": "타자기",
- "title": "텍스트 애니메이션",
- "none": "없음",
- "slideLeft": "왼쪽 슬라이드"
- },
- "crop": {
- "lockAspectRatio": "화면 비율 고정",
- "title": "자르기",
- "done": "완료",
- "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
- "ratio": "비율",
- "free": "자유",
- "cropVideo": "비디오 자르기",
- "unlockAspectRatio": "화면 비율 해제"
+ "level": "줌 레벨"
},
"background": {
- "imageLabel": "배경 {{index}}",
"color": "색상",
- "gradient": "그라디언트",
"colorLabel": "색상 {{color}}",
- "colorWheel": "색상 휠",
- "customWallpaper": "사용자 배경",
- "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
- "image": "이미지",
- "gradientLabel": "그라디언트 {{index}}",
+ "gradient": "그라디언트",
"imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "presets": "프리셋",
+ "gradientLabel": "그라디언트 {{index}}",
"custom": "사용자 지정",
+ "customWallpaper": "사용자 배경",
+ "presets": "프리셋",
+ "image": "이미지",
"colorPalette": "색상 팔레트",
- "uploadCustom": "직접 업로드",
+ "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
+ "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
+ "imageLabel": "배경 {{index}}",
"title": "배경",
- "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요."
+ "uploadCustom": "직접 업로드",
+ "colorWheel": "색상 휠"
},
- "audio": {
- "title": "오디오",
- "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
- "reset": "오디오 재설정",
- "outputGain": "출력 레벨"
+ "cursor": {
+ "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
+ "clickBounce": "클릭 바운스",
+ "clipToBounds": "캔버스에 맞춰 자르기",
+ "title": "커서",
+ "size": "크기",
+ "themeDefault": "기본",
+ "smoothing": "부드러움",
+ "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
+ "motionBlur": "모션 블러",
+ "theme": "커서 스타일",
+ "show": "커서 표시"
+ },
+ "captions": {
+ "text": "텍스트",
+ "showBackground": "배경 표시",
+ "minWords": "줄당 최소 단어 수",
+ "translateFailed": "번역에 실패했습니다.",
+ "distanceFromTop": "위에서의 거리",
+ "fontSize": "크기",
+ "distanceFromRight": "오른쪽에서의 거리",
+ "bold": "굵게",
+ "textColor": "글자 색",
+ "original": "원본 (전사)",
+ "translating": "번역 중…",
+ "language": "언어",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
+ "alignLeft": "왼쪽",
+ "distanceFromBottom": "아래에서의 거리",
+ "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
+ "show": "자막 표시",
+ "background": "배경",
+ "backgroundOpacity": "불투명도",
+ "removeLegacyAnnotations": "이전 자막 주석 제거",
+ "anchorTop": "위",
+ "translate": "번역",
+ "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "alignCenter": "가운데",
+ "alignRight": "오른쪽",
+ "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "displayLanguage": "표시",
+ "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "maxWords": "줄당 최대 단어 수",
+ "anchorBottom": "아래",
+ "position": "위치",
+ "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
+ "deleteTranslation": "이 번역 삭제",
+ "backgroundColor": "배경 색",
+ "font": "글꼴",
+ "lineLength": "줄 길이",
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "내보내기 해상도"
},
- "gifSettings": {
- "loop": "GIF 반복",
- "frameRate": "GIF 프레임 속도",
- "size": "GIF 크기"
+ "transcript": {
+ "transcribing": "전사 중…",
+ "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "laneVoiceover": "내레이션",
+ "blankedWord": "비움",
+ "noTranscript": "아직 전사가 없습니다",
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "revertWord": "\"{{original}}\"(으)로 되돌리기",
+ "silence": "[무음 {{duration}}초]",
+ "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
+ "transcribeNow": "지금 전사하기",
+ "restoreWord": "\"{{word}}\" 복원",
+ "clipLabel": "클립 {{index}}",
+ "title": "현재 전사",
+ "insertAria": "새 단어",
+ "laneRecording": "녹화",
+ "trimSilence": "무음 자르기 ({{duration}}초)",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
+ "removeInserted": "\"{{word}}\" 삭제",
+ "editorAria": "{{filename}}의 전사",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
+ "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
+ "editWord": "\"{{word}}\" 편집",
+ "noClips": "아직 클립이 없습니다",
+ "laneLabel": "전사본을 읽어올 소스",
+ "restoreSilence": "무음 복원 ({{duration}}초)"
},
- "export": {
- "chooseSaveLocation": "저장 위치 선택",
- "gifButton": "GIF 내보내기",
- "videoButton": "비디오 내보내기"
+ "customFont": {
+ "addingButton": "추가 중...",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "addButton": "폰트 추가",
+ "dialogTitle": "Google 폰트 추가",
+ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
+ "nameLabel": "표시 이름",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "namePlaceholder": "내 커스텀 폰트",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "폰트 추가에 실패했습니다",
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
+ "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다"
},
- "project": {
- "load": "프로젝트 불러오기",
- "save": "프로젝트 저장",
- "new": "새 프로젝트"
+ "annotation": {
+ "arrowColor": "화살표 색상",
+ "colorWheel": "색상 휠",
+ "blurType": "블러 종류",
+ "active": "활성",
+ "deleteAnnotation": "주석 삭제",
+ "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
+ "strokeWidth": "선 두께: {{width}}px",
+ "background": "배경",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "blurColor": "블러 색상",
+ "blurTypeBlur": "가우시안",
+ "textColor": "텍스트 색상",
+ "blurColorWhite": "흰색",
+ "title": "주석 설정",
+ "type": "유형",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "typeImage": "이미지",
+ "textContent": "텍스트 내용",
+ "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "typeText": "텍스트",
+ "blurIntensity": "블러 강도",
+ "none": "없음",
+ "mosaicBlockSize": "모자이크 블록 크기",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "typeArrow": "화살표",
+ "color": "색상",
+ "blurColorBlack": "검정",
+ "size": "크기",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "blurShapeFreehand": "자유 곡선",
+ "shortcutsAndTips": "단축키 및 팁",
+ "uploadImage": "이미지 업로드",
+ "blurTypeMosaic": "모자이크",
+ "selectStyle": "스타일 선택",
+ "defaultText": "안녕하세요",
+ "blurShapeRectangle": "사각형",
+ "colorPalette": "색상 팔레트",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "clearBackground": "배경 지우기",
+ "customFonts": "커스텀 폰트",
+ "typeBlur": "블러",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "fontStyle": "폰트 스타일",
+ "blurShape": "블러 모양",
+ "arrowDirection": "화살표 방향",
+ "blurShapeOval": "타원"
},
"speed": {
- "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
"customPlaybackSpeed": "재생 속도 직접 입력",
- "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
"deleteRegion": "속도 구간 삭제",
"selectRegion": "조정할 속도 구간을 선택하세요",
- "playbackSpeed": "재생 속도"
+ "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
+ "playbackSpeed": "재생 속도",
+ "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다."
+ },
+ "textAnimation": {
+ "selectAnimation": "애니메이션 선택",
+ "pulse": "펄스",
+ "rise": "상승",
+ "none": "없음",
+ "slideLeft": "왼쪽 슬라이드",
+ "title": "텍스트 애니메이션",
+ "fade": "페이드",
+ "pop": "팝",
+ "typewriter": "타자기"
+ },
+ "effects": {
+ "motion": "모션",
+ "title": "컴포지션",
+ "format": "형식",
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "fitClipFew": "{{count}}개 클립",
+ "motionBlur": "모션 블러",
+ "fitClipMany": "{{count}}개 클립",
+ "frame": "프레임",
+ "padding": "여백",
+ "roundness": "모서리 둥글기",
+ "off": "끄기",
+ "blurBg": "배경 흐림",
+ "shadow": "그림자",
+ "on": "켜기",
+ "fitClipOne": "{{count}}개 클립",
+ "formatOriginal": "원본",
+ "fitClip": "맞추기"
+ },
+ "exportFormat": {
+ "gifDescription": "공유용 애니메이션 이미지",
+ "mp4": "MP4",
+ "mp4Video": "MP4 비디오",
+ "gif": "GIF",
+ "gifAnimation": "GIF 애니메이션",
+ "mp4Description": "고화질 비디오 파일"
+ },
+ "imageUpload": {
+ "failedToUpload": "이미지 업로드에 실패했습니다",
+ "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
+ "errorReading": "파일을 읽는 중 오류가 발생했습니다.",
+ "invalidFileType": "지원하지 않는 파일 형식입니다",
+ "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요."
+ },
+ "facets": {
+ "captions": "자막",
+ "transcript": "대본"
+ },
+ "audio": {
+ "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
+ "reset": "오디오 재설정",
+ "title": "오디오",
+ "outputGain": "출력 레벨"
},
"language": {
"title": "언어"
},
- "support": {
- "starOnGithub": "GitHub에 Star 남기기",
- "reportBug": "버그 신고",
- "saveDiagnostics": "Save Diagnostics"
+ "audioTrack": {
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "importFailed": "오디오를 추가할 수 없습니다",
+ "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
+ "fadeOut": "페이드 아웃",
+ "defaultLabel": "오디오 트랙",
+ "mute": "음소거",
+ "remove": "트랙 삭제",
+ "add": "오디오 트랙 추가",
+ "loop": "반복",
+ "fadeIn": "페이드 인"
+ },
+ "project": {
+ "load": "프로젝트 불러오기",
+ "save": "프로젝트 저장",
+ "new": "새 프로젝트"
+ },
+ "panes": {
+ "help": "도움말"
},
"trim": {
"deleteRegion": "트림 구간 삭제"
},
- "facets": {
- "transcript": "대본",
- "captions": "자막"
+ "export": {
+ "videoButton": "비디오 내보내기",
+ "gifButton": "GIF 내보내기",
+ "chooseSaveLocation": "저장 위치 선택"
},
- "panes": {
- "help": "도움말"
+ "support": {
+ "starOnGithub": "GitHub에 Star 남기기",
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "버그 신고"
+ },
+ "gifSettings": {
+ "size": "GIF 크기",
+ "frameRate": "GIF 프레임 속도",
+ "loop": "GIF 반복"
}
}
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 499bd1df6..1f464ac21 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "Animação GIF",
- "mp4Description": "Arquivo de vídeo de alta qualidade",
- "mp4": "MP4",
- "mp4Video": "Vídeo MP4",
- "gif": "GIF",
- "gifDescription": "Imagem animada para compartilhamento"
- },
- "customFont": {
- "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Adicionar Google Font",
- "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
- "nameLabel": "Nome de Exibição",
- "failedToAdd": "Falha ao adicionar fonte",
- "urlLabel": "URL de Importação do Google Fonts",
- "namePlaceholder": "Minha Fonte Personalizada",
- "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
- "addingButton": "Adicionando...",
- "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
- "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
- "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
- "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
- "errorEmptyName": "Por favor, insira um nome para a fonte",
- "addButton": "Adicionar Fonte",
- "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts"
- },
- "annotation": {
- "colorWheel": "Roda de Cores",
- "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
- "typeArrow": "Seta",
- "selectStyle": "Selecionar estilo",
- "blurColorWhite": "Branco",
- "blurType": "Tipo de Desfoque",
- "blurShapeRectangle": "Retângulo",
- "blurColor": "Cor do Desfoque",
- "arrowColor": "Cor da Seta",
- "textContent": "Conteúdo do Texto",
- "background": "Fundo",
- "clearBackground": "Limpar Fundo",
- "blurShapeFreehand": "Mão Livre",
- "blurIntensity": "Intensidade do Desfoque",
- "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
- "active": "Ativo",
- "size": "Tamanho",
- "blurColorBlack": "Preto",
- "typeImage": "Imagem",
- "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
- "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
- "strokeWidth": "Largura do Traço: {{width}}px",
- "textColor": "Cor do Texto",
- "defaultText": "Olá",
- "blurShape": "Formato do Desfoque",
- "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
- "type": "Tipo",
- "typeText": "Texto",
- "textPlaceholder": "Digite seu texto...",
- "fontStyle": "Estilo da Fonte",
- "imageUploadSuccess": "Imagem enviada com sucesso!",
- "colorPalette": "Paleta de Cores",
- "color": "Cor",
- "shortcutsAndTips": "Atalhos e Dicas",
- "none": "Nenhum",
- "invalidImageType": "Tipo de imagem inválido",
- "arrowDirection": "Direção da Seta",
- "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
- "uploadImage": "Enviar Imagem",
- "customFonts": "Fontes Personalizadas",
- "blurTypeMosaic": "Mosaico",
- "blurTypeBlur": "Gaussiano",
- "typeBlur": "Desfoque",
- "title": "Configurações de Anotação",
- "deleteAnnotation": "Excluir Anotação",
- "blurShapeOval": "Oval"
- },
- "transcript": {
- "restoreSilence": "Restaurar silêncio ({{duration}} s)",
- "editWord": "Editar \"{{word}}\"",
- "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
- "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
- "insertedWord": "Adicionada por você — sem áudio por trás",
- "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
- "insertAria": "Nova palavra",
- "transcribing": "Transcrevendo…",
- "noAudio": "Esta mídia não tem faixa de áudio",
- "noTranscript": "Nenhuma transcrição ainda",
- "silence": "[silêncio {{duration}} s]",
- "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
- "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
- "noClips": "Nenhum clipe ainda",
- "laneVoiceover": "Narração",
- "laneLabel": "Ler a transcrição de",
- "revertWord": "Restaurar \"{{original}}\"",
- "helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
- "blankedWord": "apagada",
- "clipLabel": "Clipe {{index}}",
- "title": "Transcrição atual",
- "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
- "transcribeNow": "Transcrever agora",
- "laneRecording": "Gravação",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
- "removeInserted": "Excluir \"{{word}}\"",
- "trimSilence": "Cortar silêncio ({{duration}} s)",
- "editorAria": "Transcrição de {{filename}}",
- "restoreWord": "Restaurar \"{{word}}\""
- },
- "effects": {
- "shadow": "Sombra",
- "fitClipOne": "{{count}} clipe",
- "blurBg": "Desfocar Fundo",
- "fitClip": "Ajustar",
- "formatOriginal": "Original",
- "title": "Composição",
- "format": "Formato",
- "motionBlur": "Desfoque de Movimento",
- "fitClipMany": "{{count}} clipes",
- "roundness": "Arredondamento",
- "fitClipFew": "{{count}} clipes",
- "motion": "Movimento",
- "on": "ativado",
- "frame": "Moldura",
- "padding": "Espaçamento",
- "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
- "off": "desativado"
- },
- "audioTrack": {
- "mute": "Silenciar",
- "fadeIn": "Fade in",
- "loop": "Repetir",
- "slipHint": "Alt + arrastar para deslizar o áudio dentro",
- "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
- "importFailed": "Não foi possível adicionar o áudio",
- "fadeOut": "Fade out",
- "add": "Adicionar faixa de áudio",
- "defaultLabel": "Faixa de áudio",
- "remove": "Excluir faixa"
- },
"layout": {
+ "webcamBlurIntensity": "Intensidade do desfoque",
+ "bgModes": {
+ "transparent": "Recorte",
+ "none": "Original",
+ "blur": "Desfocado",
+ "custom": "Personalizado"
+ },
+ "selectPreset": "Selecionar predefinição",
+ "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
+ "reactiveWebcam": "Encolher ao ampliar",
"shapes": {
"circle": "Círculo",
"square": "Quadrado",
"rectangle": "Ret.",
"rounded": "Arredondado"
},
+ "webcamBackground": "Plano de fundo da câmera",
"verticalStack": "Empilhamento Vertical",
- "webcamSize": "Tamanho da Webcam",
- "preset": "Predefinição",
- "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
- "dualFrame": "Quadro Duplo",
- "title": "Layout da câmera",
- "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
- "bgModes": {
- "transparent": "Recorte",
- "custom": "Personalizado",
- "none": "Original",
- "blur": "Desfocado"
- },
- "webcamCropZoom": "Zoom do recorte",
- "selectPreset": "Selecionar predefinição",
- "webcamCropY": "Deslocamento vertical",
- "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
"pictureInPicture": "Picture in Picture",
- "reactiveWebcam": "Encolher ao ampliar",
- "webcamBackground": "Plano de fundo da câmera",
- "webcamBlurIntensity": "Intensidade do desfoque",
- "mirrorWebcam": "Espelhar Webcam",
"webcamShape": "Formato da Câmera",
+ "webcamCropY": "Deslocamento vertical",
+ "webcamSize": "Tamanho da Webcam",
+ "mirrorWebcam": "Espelhar Webcam",
+ "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
+ "webcamFraming": "Enquadramento da webcam",
"noWebcam": "Sem Webcam",
+ "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
+ "webcamCropZoom": "Zoom do recorte",
+ "dualFrame": "Quadro Duplo",
"webcamCropX": "Deslocamento horizontal",
- "webcamFraming": "Enquadramento da webcam"
- },
- "imageUpload": {
- "uploadSuccess": "Imagem personalizada enviada com sucesso!",
- "failedToUpload": "Falha ao enviar imagem",
- "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
- "errorReading": "Ocorreu um erro ao ler o arquivo.",
- "invalidFileType": "Tipo de arquivo inválido"
- },
- "cursor": {
- "themeDefault": "Padrão",
- "motionBlur": "Desfoque de movimento",
- "smoothing": "Suavização",
- "title": "Cursor",
- "theme": "Estilo do cursor",
- "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
- "show": "Mostrar cursor",
- "clipToBounds": "Recortar à tela",
- "size": "Tamanho",
- "clickBounce": "Rebote ao clicar",
- "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar."
+ "title": "Layout da câmera",
+ "preset": "Predefinição"
},
- "captions": {
- "original": "Original (transcrição)",
- "alignRight": "Direita",
- "backgroundOpacity": "Opacidade",
- "anchorBottom": "Base",
- "minWords": "Mín. de palavras por linha",
- "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
- "translate": "Traduzir",
- "maxWords": "Máx. de palavras por linha",
- "lineLength": "Comprimento da linha",
- "anchorTop": "Topo",
- "font": "Fonte",
- "translating": "Traduzindo…",
- "position": "Posição",
- "removeLegacyAnnotations": "Remover anotações de legenda antigas",
- "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
- "backgroundColor": "Cor do fundo",
- "text": "Texto",
- "textColor": "Cor do texto",
- "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
- "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
- "distanceFromTop": "Distância do topo",
- "alignLeft": "Esquerda",
- "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
- "deleteTranslation": "Excluir esta tradução",
- "distanceFromBottom": "Distância da base",
- "displayLanguage": "Exibição",
- "background": "Fundo",
- "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
- "distanceFromLeft": "Distância da esquerda",
- "alignCenter": "Centro",
- "fontSize": "Tamanho",
- "bold": "Negrito",
- "translateFailed": "A tradução falhou.",
- "distanceFromRight": "Distância da direita",
- "showBackground": "Mostrar fundo",
- "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
- "show": "Mostrar legendas",
- "language": "Idioma",
- "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição."
+ "crop": {
+ "done": "Concluir",
+ "ratio": "Proporção",
+ "dragInstruction": "Arraste cada lado para ajustar a área de corte",
+ "title": "Cortar",
+ "free": "Livre",
+ "lockAspectRatio": "Bloquear proporção",
+ "unlockAspectRatio": "Desbloquear proporção",
+ "cropVideo": "Cortar Vídeo"
},
"zoom": {
+ "position": {
+ "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Posição do Foco"
+ },
"threeD": {
"preset": {
"right": "Direita",
@@ -241,115 +59,296 @@
"none": "Nenhuma",
"title": "Rotação 3D"
},
+ "deleteZoom": "Excluir Zoom",
+ "customScale": "Zoom Personalizado",
+ "selectRegion": "Selecione uma região de zoom para ajustar",
"focusMode": {
+ "manual": "Manual",
"autoDescription": "A câmera segue a posição do cursor gravado",
"lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
"title": "Modo de Foco",
- "manual": "Manual",
"auto": "Automático"
},
- "position": {
- "title": "Posição do Foco",
- "x": "X (%)",
- "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
- "y": "Y (%)"
- },
- "deleteZoom": "Excluir Zoom",
- "level": "Nível de Zoom",
- "selectRegion": "Selecione uma região de zoom para ajustar",
"previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
- "customScale": "Zoom Personalizado"
- },
- "textAnimation": {
- "pop": "Aparecer",
- "rise": "Subir",
- "selectAnimation": "Selecionar animação",
- "fade": "Esmaecer",
- "pulse": "Pulsar",
- "typewriter": "Máquina de Escrever",
- "title": "Animação de Texto",
- "none": "Nenhuma",
- "slideLeft": "Deslizar à Esquerda"
- },
- "crop": {
- "lockAspectRatio": "Bloquear proporção",
- "title": "Cortar",
- "done": "Concluir",
- "dragInstruction": "Arraste cada lado para ajustar a área de corte",
- "ratio": "Proporção",
- "free": "Livre",
- "cropVideo": "Cortar Vídeo",
- "unlockAspectRatio": "Desbloquear proporção"
+ "level": "Nível de Zoom"
},
"background": {
- "imageLabel": "Fundo {{index}}",
"color": "Cor",
- "gradient": "Gradiente",
"colorLabel": "Cor {{color}}",
- "colorWheel": "Roda de Cores",
- "customWallpaper": "Papel de parede personalizado",
- "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
- "image": "Imagem",
- "gradientLabel": "Gradiente {{index}}",
+ "gradient": "Gradiente",
"imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
- "presets": "Predefinições",
+ "gradientLabel": "Gradiente {{index}}",
"custom": "Personalizado",
+ "customWallpaper": "Papel de parede personalizado",
+ "presets": "Predefinições",
+ "image": "Imagem",
"colorPalette": "Paleta de Cores",
- "uploadCustom": "Enviar Personalizada",
+ "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
+ "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
+ "imageLabel": "Fundo {{index}}",
"title": "Fundo",
- "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG."
+ "uploadCustom": "Enviar Personalizada",
+ "colorWheel": "Roda de Cores"
},
- "audio": {
- "title": "Áudio",
- "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
- "reset": "Redefinir áudio",
- "outputGain": "Nível de saída"
+ "cursor": {
+ "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
+ "clickBounce": "Rebote ao clicar",
+ "clipToBounds": "Recortar à tela",
+ "title": "Cursor",
+ "size": "Tamanho",
+ "themeDefault": "Padrão",
+ "smoothing": "Suavização",
+ "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
+ "motionBlur": "Desfoque de movimento",
+ "theme": "Estilo do cursor",
+ "show": "Mostrar cursor"
+ },
+ "captions": {
+ "text": "Texto",
+ "showBackground": "Mostrar fundo",
+ "minWords": "Mín. de palavras por linha",
+ "translateFailed": "A tradução falhou.",
+ "distanceFromTop": "Distância do topo",
+ "fontSize": "Tamanho",
+ "distanceFromRight": "Distância da direita",
+ "bold": "Negrito",
+ "textColor": "Cor do texto",
+ "original": "Original (transcrição)",
+ "translating": "Traduzindo…",
+ "language": "Idioma",
+ "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
+ "alignLeft": "Esquerda",
+ "distanceFromBottom": "Distância da base",
+ "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
+ "show": "Mostrar legendas",
+ "background": "Fundo",
+ "backgroundOpacity": "Opacidade",
+ "removeLegacyAnnotations": "Remover anotações de legenda antigas",
+ "anchorTop": "Topo",
+ "translate": "Traduzir",
+ "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
+ "alignCenter": "Centro",
+ "alignRight": "Direita",
+ "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
+ "displayLanguage": "Exibição",
+ "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
+ "distanceFromLeft": "Distância da esquerda",
+ "maxWords": "Máx. de palavras por linha",
+ "anchorBottom": "Base",
+ "position": "Posição",
+ "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
+ "deleteTranslation": "Excluir esta tradução",
+ "backgroundColor": "Cor do fundo",
+ "font": "Fonte",
+ "lineLength": "Comprimento da linha",
+ "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
+ "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move."
},
"exportQuality": {
- "low": "Baixa",
"medium": "Média",
+ "low": "Baixa",
"high": "Alta",
"title": "Qualidade de Exportação"
},
- "gifSettings": {
- "loop": "Loop no GIF",
- "frameRate": "Taxa de Quadros do GIF",
- "size": "Tamanho do GIF"
+ "transcript": {
+ "transcribing": "Transcrevendo…",
+ "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "laneVoiceover": "Narração",
+ "blankedWord": "apagada",
+ "noTranscript": "Nenhuma transcrição ainda",
+ "noAudio": "Esta mídia não tem faixa de áudio",
+ "revertWord": "Restaurar \"{{original}}\"",
+ "silence": "[silêncio {{duration}} s]",
+ "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
+ "transcribeNow": "Transcrever agora",
+ "restoreWord": "Restaurar \"{{word}}\"",
+ "clipLabel": "Clipe {{index}}",
+ "title": "Transcrição atual",
+ "insertAria": "Nova palavra",
+ "laneRecording": "Gravação",
+ "trimSilence": "Cortar silêncio ({{duration}} s)",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
+ "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
+ "removeInserted": "Excluir \"{{word}}\"",
+ "editorAria": "Transcrição de {{filename}}",
+ "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
+ "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
+ "editWord": "Editar \"{{word}}\"",
+ "noClips": "Nenhum clipe ainda",
+ "laneLabel": "Ler a transcrição de",
+ "restoreSilence": "Restaurar silêncio ({{duration}} s)"
},
- "export": {
- "chooseSaveLocation": "Escolher Local para Salvar",
- "gifButton": "Exportar GIF",
- "videoButton": "Exportar Vídeo"
+ "customFont": {
+ "addingButton": "Adicionando...",
+ "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
+ "addButton": "Adicionar Fonte",
+ "dialogTitle": "Adicionar Google Font",
+ "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
+ "nameLabel": "Nome de Exibição",
+ "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
+ "urlLabel": "URL de Importação do Google Fonts",
+ "errorEmptyName": "Por favor, insira um nome para a fonte",
+ "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
+ "namePlaceholder": "Minha Fonte Personalizada",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Falha ao adicionar fonte",
+ "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
+ "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
+ "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
+ "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso"
},
- "project": {
- "load": "Carregar Projeto",
- "save": "Salvar Projeto",
- "new": "Novo Projeto"
+ "annotation": {
+ "arrowColor": "Cor da Seta",
+ "colorWheel": "Roda de Cores",
+ "blurType": "Tipo de Desfoque",
+ "active": "Ativo",
+ "deleteAnnotation": "Excluir Anotação",
+ "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
+ "strokeWidth": "Largura do Traço: {{width}}px",
+ "background": "Fundo",
+ "imageUploadSuccess": "Imagem enviada com sucesso!",
+ "blurColor": "Cor do Desfoque",
+ "blurTypeBlur": "Gaussiano",
+ "textColor": "Cor do Texto",
+ "blurColorWhite": "Branco",
+ "title": "Configurações de Anotação",
+ "type": "Tipo",
+ "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
+ "typeImage": "Imagem",
+ "textContent": "Conteúdo do Texto",
+ "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
+ "typeText": "Texto",
+ "blurIntensity": "Intensidade do Desfoque",
+ "none": "Nenhum",
+ "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
+ "textPlaceholder": "Digite seu texto...",
+ "typeArrow": "Seta",
+ "color": "Cor",
+ "blurColorBlack": "Preto",
+ "size": "Tamanho",
+ "invalidImageType": "Tipo de imagem inválido",
+ "blurShapeFreehand": "Mão Livre",
+ "shortcutsAndTips": "Atalhos e Dicas",
+ "uploadImage": "Enviar Imagem",
+ "blurTypeMosaic": "Mosaico",
+ "selectStyle": "Selecionar estilo",
+ "defaultText": "Olá",
+ "blurShapeRectangle": "Retângulo",
+ "colorPalette": "Paleta de Cores",
+ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
+ "clearBackground": "Limpar Fundo",
+ "customFonts": "Fontes Personalizadas",
+ "typeBlur": "Desfoque",
+ "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
+ "fontStyle": "Estilo da Fonte",
+ "blurShape": "Formato do Desfoque",
+ "arrowDirection": "Direção da Seta",
+ "blurShapeOval": "Oval"
},
"speed": {
- "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada.",
"customPlaybackSpeed": "Velocidade Personalizada",
- "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
"deleteRegion": "Excluir Região de Velocidade",
"selectRegion": "Selecione uma região de velocidade para ajustar",
- "playbackSpeed": "Velocidade de Reprodução"
+ "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
+ "playbackSpeed": "Velocidade de Reprodução",
+ "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada."
+ },
+ "textAnimation": {
+ "selectAnimation": "Selecionar animação",
+ "pulse": "Pulsar",
+ "rise": "Subir",
+ "none": "Nenhuma",
+ "slideLeft": "Deslizar à Esquerda",
+ "title": "Animação de Texto",
+ "fade": "Esmaecer",
+ "pop": "Aparecer",
+ "typewriter": "Máquina de Escrever"
+ },
+ "effects": {
+ "motion": "Movimento",
+ "title": "Composição",
+ "format": "Formato",
+ "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
+ "fitClipFew": "{{count}} clipes",
+ "motionBlur": "Desfoque de Movimento",
+ "fitClipMany": "{{count}} clipes",
+ "frame": "Moldura",
+ "padding": "Espaçamento",
+ "roundness": "Arredondamento",
+ "off": "desativado",
+ "blurBg": "Desfocar Fundo",
+ "shadow": "Sombra",
+ "on": "ativado",
+ "fitClipOne": "{{count}} clipe",
+ "formatOriginal": "Original",
+ "fitClip": "Ajustar"
+ },
+ "exportFormat": {
+ "gifDescription": "Imagem animada para compartilhamento",
+ "mp4": "MP4",
+ "mp4Video": "Vídeo MP4",
+ "gif": "GIF",
+ "gifAnimation": "Animação GIF",
+ "mp4Description": "Arquivo de vídeo de alta qualidade"
+ },
+ "imageUpload": {
+ "failedToUpload": "Falha ao enviar imagem",
+ "uploadSuccess": "Imagem personalizada enviada com sucesso!",
+ "errorReading": "Ocorreu um erro ao ler o arquivo.",
+ "invalidFileType": "Tipo de arquivo inválido",
+ "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG."
+ },
+ "facets": {
+ "captions": "Legendas",
+ "transcript": "Transcrição"
+ },
+ "audio": {
+ "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
+ "reset": "Redefinir áudio",
+ "title": "Áudio",
+ "outputGain": "Nível de saída"
},
"language": {
"title": "Idioma"
},
- "support": {
- "starOnGithub": "Dar Estrela no GitHub",
- "reportBug": "Relatar Bug",
- "saveDiagnostics": "Salvar Diagnósticos"
+ "audioTrack": {
+ "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "importFailed": "Não foi possível adicionar o áudio",
+ "slipHint": "Alt + arrastar para deslizar o áudio dentro",
+ "fadeOut": "Fade out",
+ "defaultLabel": "Faixa de áudio",
+ "mute": "Silenciar",
+ "remove": "Excluir faixa",
+ "add": "Adicionar faixa de áudio",
+ "loop": "Repetir",
+ "fadeIn": "Fade in"
+ },
+ "project": {
+ "load": "Carregar Projeto",
+ "save": "Salvar Projeto",
+ "new": "Novo Projeto"
+ },
+ "panes": {
+ "help": "Ajuda"
},
"trim": {
"deleteRegion": "Excluir Região de Recorte"
},
- "facets": {
- "transcript": "Transcrição",
- "captions": "Legendas"
+ "export": {
+ "videoButton": "Exportar Vídeo",
+ "gifButton": "Exportar GIF",
+ "chooseSaveLocation": "Escolher Local para Salvar"
},
- "panes": {
- "help": "Ajuda"
+ "support": {
+ "starOnGithub": "Dar Estrela no GitHub",
+ "saveDiagnostics": "Salvar Diagnósticos",
+ "reportBug": "Relatar Bug"
+ },
+ "gifSettings": {
+ "size": "Tamanho do GIF",
+ "frameRate": "Taxa de Quadros do GIF",
+ "loop": "Loop no GIF"
}
}
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index dce1fd195..c3199653f 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF анимация",
- "mp4Description": "Видеофайл высокого качества",
- "mp4": "MP4",
- "mp4Video": "MP4 видео",
- "gif": "GIF",
- "gifDescription": "Анимированное изображение для обмена"
- },
- "customFont": {
- "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Добавить шрифт Google",
- "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
- "nameLabel": "Отображаемое имя",
- "failedToAdd": "Не удалось добавить шрифт",
- "urlLabel": "URL импорта Google Fonts",
- "namePlaceholder": "Мой пользовательский шрифт",
- "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
- "addingButton": "Добавление...",
- "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
- "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
- "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
- "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
- "errorEmptyName": "Пожалуйста, введите имя шрифта",
- "addButton": "Добавить шрифт",
- "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts"
- },
- "annotation": {
- "colorWheel": "Цветовой круг",
- "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
- "typeArrow": "Стрелка",
- "selectStyle": "Выбрать стиль",
- "blurColorWhite": "Белый",
- "blurType": "Тип размытия",
- "blurShapeRectangle": "Прямоугольник",
- "blurColor": "Цвет размытия",
- "arrowColor": "Цвет стрелки",
- "textContent": "Содержание текста",
- "background": "Фон",
- "clearBackground": "Очистить фон",
- "blurShapeFreehand": "От руки",
- "blurIntensity": "Интенсивность размытия",
- "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
- "active": "Активно",
- "size": "Размер",
- "blurColorBlack": "Чёрный",
- "typeImage": "Изображение",
- "mosaicBlockSize": "Размер блока мозаики",
- "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
- "strokeWidth": "Толщина линии: {{width}}px",
- "textColor": "Цвет текста",
- "defaultText": "Привет",
- "blurShape": "Форма размытия",
- "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
- "type": "Тип",
- "typeText": "Текст",
- "textPlaceholder": "Введите ваш текст...",
- "fontStyle": "Стиль шрифта",
- "imageUploadSuccess": "Изображение успешно загружено!",
- "colorPalette": "Палитра цветов",
- "color": "Цвет",
- "shortcutsAndTips": "Горячие клавиши и советы",
- "none": "Нет",
- "invalidImageType": "Неверный тип файла",
- "arrowDirection": "Направление стрелки",
- "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
- "uploadImage": "Загрузить изображение",
- "customFonts": "Пользовательские шрифты",
- "blurTypeMosaic": "Мозаика",
- "blurTypeBlur": "Гауссово",
- "typeBlur": "Размытие",
- "title": "Настройки аннотаций",
- "deleteAnnotation": "Удалить аннотацию",
- "blurShapeOval": "Овал"
- },
- "transcript": {
- "restoreSilence": "Вернуть тишину ({{duration}} с)",
- "editWord": "Изменить «{{word}}»",
- "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
- "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
- "insertedWord": "Добавлено вами — за ним нет звука",
- "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
- "insertAria": "Новое слово",
- "transcribing": "Расшифровка…",
- "noAudio": "В этом медиафайле нет аудиодорожки",
- "noTranscript": "Расшифровки пока нет",
- "silence": "[тишина {{duration}} с]",
- "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
- "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
- "noClips": "Клипов пока нет",
- "laneVoiceover": "Закадровый голос",
- "laneLabel": "Читать расшифровку из",
- "revertWord": "Вернуть «{{original}}»",
- "helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
- "blankedWord": "очищено",
- "clipLabel": "Клип {{index}}",
- "title": "Текущая расшифровка",
- "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
- "transcribeNow": "Расшифровать сейчас",
- "laneRecording": "Запись",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
- "removeInserted": "Удалить «{{word}}»",
- "trimSilence": "Вырезать тишину ({{duration}} с)",
- "editorAria": "Расшифровка «{{filename}}»",
- "restoreWord": "Вернуть «{{word}}»"
- },
- "effects": {
- "shadow": "Тень",
- "fitClipOne": "{{count}} клип",
- "blurBg": "Размытие фона",
- "fitClip": "Подогнать",
- "formatOriginal": "Исходный",
- "title": "Композиция",
- "format": "Формат",
- "motionBlur": "Размытие движения",
- "fitClipMany": "{{count}} клипов",
- "roundness": "Скругление",
- "fitClipFew": "{{count}} клипа",
- "motion": "Движение",
- "on": "вкл",
- "frame": "Рамка",
- "padding": "Отступ",
- "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
- "off": "выкл"
- },
- "audioTrack": {
- "mute": "Без звука",
- "fadeIn": "Нарастание",
- "loop": "Повтор",
- "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
- "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
- "importFailed": "Не удалось добавить аудио",
- "fadeOut": "Затухание",
- "add": "Добавить аудиодорожку",
- "defaultLabel": "Аудиодорожка",
- "remove": "Удалить дорожку"
- },
"layout": {
+ "webcamBlurIntensity": "Интенсивность размытия",
+ "bgModes": {
+ "transparent": "Вырезка",
+ "none": "Оригинал",
+ "blur": "Размытие",
+ "custom": "Пользовательский"
+ },
+ "selectPreset": "Выбрать пресет",
+ "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
+ "reactiveWebcam": "Уменьшать при зуме",
"shapes": {
"circle": "Круг",
"square": "Квадрат",
"rectangle": "Прямоуг.",
"rounded": "Скруглённый"
},
+ "webcamBackground": "Фон камеры",
"verticalStack": "Вертикальный стек",
- "webcamSize": "Размер веб-камеры",
- "preset": "Пресет",
- "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
- "dualFrame": "Двойной кадр",
- "title": "Расположение камеры",
- "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
- "bgModes": {
- "transparent": "Вырезка",
- "custom": "Пользовательский",
- "none": "Оригинал",
- "blur": "Размытие"
- },
- "webcamCropZoom": "Масштаб обрезки",
- "selectPreset": "Выбрать пресет",
- "webcamCropY": "Смещение по вертикали",
- "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
"pictureInPicture": "Картинка в картинке",
- "reactiveWebcam": "Уменьшать при зуме",
- "webcamBackground": "Фон камеры",
- "webcamBlurIntensity": "Интенсивность размытия",
- "mirrorWebcam": "Зеркалить веб-камеру",
"webcamShape": "Форма камеры",
+ "webcamCropY": "Смещение по вертикали",
+ "webcamSize": "Размер веб-камеры",
+ "mirrorWebcam": "Зеркалить веб-камеру",
+ "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
+ "webcamFraming": "Кадрирование веб-камеры",
"noWebcam": "Без веб-камеры",
+ "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
+ "webcamCropZoom": "Масштаб обрезки",
+ "dualFrame": "Двойной кадр",
"webcamCropX": "Смещение по горизонтали",
- "webcamFraming": "Кадрирование веб-камеры"
- },
- "imageUpload": {
- "uploadSuccess": "Пользовательское изображение успешно загружено!",
- "failedToUpload": "Не удалось загрузить изображение",
- "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
- "errorReading": "Произошла ошибка при чтении файла.",
- "invalidFileType": "Неверный тип файла"
- },
- "cursor": {
- "themeDefault": "По умолчанию",
- "motionBlur": "Размытие движения",
- "smoothing": "Сглаживание",
- "title": "Курсор",
- "theme": "Стиль курсора",
- "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
- "show": "Показывать курсор",
- "clipToBounds": "Обрезать по холсту",
- "size": "Размер",
- "clickBounce": "Отскок при клике",
- "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике."
+ "title": "Расположение камеры",
+ "preset": "Пресет"
},
- "captions": {
- "original": "Оригинал (расшифровка)",
- "alignRight": "Справа",
- "backgroundOpacity": "Непрозрачность",
- "anchorBottom": "Снизу",
- "minWords": "Мин. слов в строке",
- "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
- "translate": "Перевести",
- "maxWords": "Макс. слов в строке",
- "lineLength": "Длина строки",
- "anchorTop": "Сверху",
- "font": "Шрифт",
- "translating": "Перевод…",
- "position": "Положение",
- "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
- "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
- "backgroundColor": "Цвет фона",
- "text": "Текст",
- "textColor": "Цвет текста",
- "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
- "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
- "distanceFromTop": "Отступ сверху",
- "alignLeft": "Слева",
- "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
- "deleteTranslation": "Удалить этот перевод",
- "distanceFromBottom": "Отступ снизу",
- "displayLanguage": "Отображение",
- "background": "Фон",
- "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
- "distanceFromLeft": "Отступ слева",
- "alignCenter": "По центру",
- "fontSize": "Размер",
- "bold": "Полужирный",
- "translateFailed": "Не удалось перевести.",
- "distanceFromRight": "Отступ справа",
- "showBackground": "Показывать фон",
- "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
- "show": "Показывать субтитры",
- "language": "Язык",
- "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени."
+ "crop": {
+ "done": "Готово",
+ "ratio": "Соотношение сторон",
+ "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
+ "title": "Обрезка",
+ "free": "Свободно",
+ "lockAspectRatio": "Заблокировать соотношение сторон",
+ "unlockAspectRatio": "Разблокировать соотношение сторон",
+ "cropVideo": "Обрезать видео"
},
"zoom": {
+ "position": {
+ "hint": "0 = край слева / сверху, 100 = край справа / снизу",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Положение фокуса"
+ },
"threeD": {
"preset": {
"right": "Справа",
@@ -241,115 +59,296 @@
"none": "Нет",
"title": "3D вращение"
},
+ "deleteZoom": "Удалить масштабирование",
+ "customScale": "Пользовательский масштаб",
+ "selectRegion": "Выберите область масштабирования для настройки",
"focusMode": {
+ "manual": "Ручной",
"autoDescription": "Камера следует за записанной позицией курсора",
"lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
"title": "Режим фокуса",
- "manual": "Ручной",
"auto": "Авто"
},
- "position": {
- "title": "Положение фокуса",
- "x": "X (%)",
- "hint": "0 = край слева / сверху, 100 = край справа / снизу",
- "y": "Y (%)"
- },
- "deleteZoom": "Удалить масштабирование",
- "level": "Уровень масштабирования",
- "selectRegion": "Выберите область масштабирования для настройки",
"previewHold": "Удерживайте для предпросмотра эффекта зума",
- "customScale": "Пользовательский масштаб"
- },
- "textAnimation": {
- "pop": "Всплытие",
- "rise": "Подъем",
- "selectAnimation": "Выбрать анимацию",
- "fade": "Затухание",
- "pulse": "Импульс",
- "typewriter": "Пишущая машинка",
- "title": "Анимация текста",
- "none": "Нет",
- "slideLeft": "Скольжение влево"
- },
- "crop": {
- "lockAspectRatio": "Заблокировать соотношение сторон",
- "title": "Обрезка",
- "done": "Готово",
- "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
- "ratio": "Соотношение сторон",
- "free": "Свободно",
- "cropVideo": "Обрезать видео",
- "unlockAspectRatio": "Разблокировать соотношение сторон"
+ "level": "Уровень масштабирования"
},
"background": {
- "imageLabel": "Фон {{index}}",
"color": "Цвет",
- "gradient": "Градиент",
"colorLabel": "Цвет {{color}}",
- "colorWheel": "Цветовой круг",
- "customWallpaper": "Свои обои",
- "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
- "image": "Изображение",
- "gradientLabel": "Градиент {{index}}",
+ "gradient": "Градиент",
"imageReadFailed": "Не удалось прочитать этот файл изображения.",
- "presets": "Пресеты",
+ "gradientLabel": "Градиент {{index}}",
"custom": "Свой",
+ "customWallpaper": "Свои обои",
+ "presets": "Пресеты",
+ "image": "Изображение",
"colorPalette": "Палитра цветов",
- "uploadCustom": "Загрузить свой",
+ "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
+ "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
+ "imageLabel": "Фон {{index}}",
"title": "Фон",
- "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG."
+ "uploadCustom": "Загрузить свой",
+ "colorWheel": "Цветовой круг"
},
- "audio": {
- "title": "Аудио",
- "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
- "reset": "Сбросить аудио",
- "outputGain": "Уровень выхода"
+ "cursor": {
+ "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
+ "clickBounce": "Отскок при клике",
+ "clipToBounds": "Обрезать по холсту",
+ "title": "Курсор",
+ "size": "Размер",
+ "themeDefault": "По умолчанию",
+ "smoothing": "Сглаживание",
+ "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
+ "motionBlur": "Размытие движения",
+ "theme": "Стиль курсора",
+ "show": "Показывать курсор"
+ },
+ "captions": {
+ "text": "Текст",
+ "showBackground": "Показывать фон",
+ "minWords": "Мин. слов в строке",
+ "translateFailed": "Не удалось перевести.",
+ "distanceFromTop": "Отступ сверху",
+ "fontSize": "Размер",
+ "distanceFromRight": "Отступ справа",
+ "bold": "Полужирный",
+ "textColor": "Цвет текста",
+ "original": "Оригинал (расшифровка)",
+ "translating": "Перевод…",
+ "language": "Язык",
+ "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
+ "alignLeft": "Слева",
+ "distanceFromBottom": "Отступ снизу",
+ "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
+ "show": "Показывать субтитры",
+ "background": "Фон",
+ "backgroundOpacity": "Непрозрачность",
+ "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
+ "anchorTop": "Сверху",
+ "translate": "Перевести",
+ "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
+ "alignCenter": "По центру",
+ "alignRight": "Справа",
+ "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "displayLanguage": "Отображение",
+ "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
+ "distanceFromLeft": "Отступ слева",
+ "maxWords": "Макс. слов в строке",
+ "anchorBottom": "Снизу",
+ "position": "Положение",
+ "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
+ "deleteTranslation": "Удалить этот перевод",
+ "backgroundColor": "Цвет фона",
+ "font": "Шрифт",
+ "lineLength": "Длина строки",
+ "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
+ "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "Разрешение экспорта"
},
- "gifSettings": {
- "loop": "Зациклить GIF",
- "frameRate": "Частота кадров GIF",
- "size": "Размер GIF"
+ "transcript": {
+ "transcribing": "Расшифровка…",
+ "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "laneVoiceover": "Закадровый голос",
+ "blankedWord": "очищено",
+ "noTranscript": "Расшифровки пока нет",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "revertWord": "Вернуть «{{original}}»",
+ "silence": "[тишина {{duration}} с]",
+ "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
+ "transcribeNow": "Расшифровать сейчас",
+ "restoreWord": "Вернуть «{{word}}»",
+ "clipLabel": "Клип {{index}}",
+ "title": "Текущая расшифровка",
+ "insertAria": "Новое слово",
+ "laneRecording": "Запись",
+ "trimSilence": "Вырезать тишину ({{duration}} с)",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
+ "removeInserted": "Удалить «{{word}}»",
+ "editorAria": "Расшифровка «{{filename}}»",
+ "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
+ "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
+ "editWord": "Изменить «{{word}}»",
+ "noClips": "Клипов пока нет",
+ "laneLabel": "Читать расшифровку из",
+ "restoreSilence": "Вернуть тишину ({{duration}} с)"
},
- "export": {
- "chooseSaveLocation": "Выбрать место сохранения",
- "gifButton": "Экспорт GIF",
- "videoButton": "Экспорт видео"
+ "customFont": {
+ "addingButton": "Добавление...",
+ "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "addButton": "Добавить шрифт",
+ "dialogTitle": "Добавить шрифт Google",
+ "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
+ "nameLabel": "Отображаемое имя",
+ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
+ "urlLabel": "URL импорта Google Fonts",
+ "errorEmptyName": "Пожалуйста, введите имя шрифта",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Не удалось добавить шрифт",
+ "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
+ "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
+ "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен"
},
- "project": {
- "load": "Загрузить проект",
- "save": "Сохранить проект",
- "new": "Новый проект"
+ "annotation": {
+ "arrowColor": "Цвет стрелки",
+ "colorWheel": "Цветовой круг",
+ "blurType": "Тип размытия",
+ "active": "Активно",
+ "deleteAnnotation": "Удалить аннотацию",
+ "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
+ "strokeWidth": "Толщина линии: {{width}}px",
+ "background": "Фон",
+ "imageUploadSuccess": "Изображение успешно загружено!",
+ "blurColor": "Цвет размытия",
+ "blurTypeBlur": "Гауссово",
+ "textColor": "Цвет текста",
+ "blurColorWhite": "Белый",
+ "title": "Настройки аннотаций",
+ "type": "Тип",
+ "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
+ "typeImage": "Изображение",
+ "textContent": "Содержание текста",
+ "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
+ "typeText": "Текст",
+ "blurIntensity": "Интенсивность размытия",
+ "none": "Нет",
+ "mosaicBlockSize": "Размер блока мозаики",
+ "textPlaceholder": "Введите ваш текст...",
+ "typeArrow": "Стрелка",
+ "color": "Цвет",
+ "blurColorBlack": "Чёрный",
+ "size": "Размер",
+ "invalidImageType": "Неверный тип файла",
+ "blurShapeFreehand": "От руки",
+ "shortcutsAndTips": "Горячие клавиши и советы",
+ "uploadImage": "Загрузить изображение",
+ "blurTypeMosaic": "Мозаика",
+ "selectStyle": "Выбрать стиль",
+ "defaultText": "Привет",
+ "blurShapeRectangle": "Прямоугольник",
+ "colorPalette": "Палитра цветов",
+ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
+ "clearBackground": "Очистить фон",
+ "customFonts": "Пользовательские шрифты",
+ "typeBlur": "Размытие",
+ "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
+ "fontStyle": "Стиль шрифта",
+ "blurShape": "Форма размытия",
+ "arrowDirection": "Направление стрелки",
+ "blurShapeOval": "Овал"
},
"speed": {
- "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет.",
"customPlaybackSpeed": "Пользовательская скорость воспроизведения",
- "maxSpeedError": "Скорость не может быть выше {{max}}×",
"deleteRegion": "Удалить область скорости",
"selectRegion": "Выберите область скорости для настройки",
- "playbackSpeed": "Скорость воспроизведения"
+ "maxSpeedError": "Скорость не может быть выше {{max}}×",
+ "playbackSpeed": "Скорость воспроизведения",
+ "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет."
+ },
+ "textAnimation": {
+ "selectAnimation": "Выбрать анимацию",
+ "pulse": "Импульс",
+ "rise": "Подъем",
+ "none": "Нет",
+ "slideLeft": "Скольжение влево",
+ "title": "Анимация текста",
+ "fade": "Затухание",
+ "pop": "Всплытие",
+ "typewriter": "Пишущая машинка"
+ },
+ "effects": {
+ "motion": "Движение",
+ "title": "Композиция",
+ "format": "Формат",
+ "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
+ "fitClipFew": "{{count}} клипа",
+ "motionBlur": "Размытие движения",
+ "fitClipMany": "{{count}} клипов",
+ "frame": "Рамка",
+ "padding": "Отступ",
+ "roundness": "Скругление",
+ "off": "выкл",
+ "blurBg": "Размытие фона",
+ "shadow": "Тень",
+ "on": "вкл",
+ "fitClipOne": "{{count}} клип",
+ "formatOriginal": "Исходный",
+ "fitClip": "Подогнать"
+ },
+ "exportFormat": {
+ "gifDescription": "Анимированное изображение для обмена",
+ "mp4": "MP4",
+ "mp4Video": "MP4 видео",
+ "gif": "GIF",
+ "gifAnimation": "GIF анимация",
+ "mp4Description": "Видеофайл высокого качества"
+ },
+ "imageUpload": {
+ "failedToUpload": "Не удалось загрузить изображение",
+ "uploadSuccess": "Пользовательское изображение успешно загружено!",
+ "errorReading": "Произошла ошибка при чтении файла.",
+ "invalidFileType": "Неверный тип файла",
+ "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG."
+ },
+ "facets": {
+ "captions": "Субтитры",
+ "transcript": "Транскрипт"
+ },
+ "audio": {
+ "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
+ "reset": "Сбросить аудио",
+ "title": "Аудио",
+ "outputGain": "Уровень выхода"
},
"language": {
"title": "Язык"
},
- "support": {
- "starOnGithub": "Звезда на GitHub",
- "reportBug": "Сообщить об ошибке",
- "saveDiagnostics": "Сохранить диагностику"
+ "audioTrack": {
+ "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "importFailed": "Не удалось добавить аудио",
+ "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
+ "fadeOut": "Затухание",
+ "defaultLabel": "Аудиодорожка",
+ "mute": "Без звука",
+ "remove": "Удалить дорожку",
+ "add": "Добавить аудиодорожку",
+ "loop": "Повтор",
+ "fadeIn": "Нарастание"
+ },
+ "project": {
+ "load": "Загрузить проект",
+ "save": "Сохранить проект",
+ "new": "Новый проект"
+ },
+ "panes": {
+ "help": "Справка"
},
"trim": {
"deleteRegion": "Удалить область обрезки"
},
- "facets": {
- "transcript": "Транскрипт",
- "captions": "Субтитры"
+ "export": {
+ "videoButton": "Экспорт видео",
+ "gifButton": "Экспорт GIF",
+ "chooseSaveLocation": "Выбрать место сохранения"
},
- "panes": {
- "help": "Справка"
+ "support": {
+ "starOnGithub": "Звезда на GitHub",
+ "saveDiagnostics": "Сохранить диагностику",
+ "reportBug": "Сообщить об ошибке"
+ },
+ "gifSettings": {
+ "size": "Размер GIF",
+ "frameRate": "Частота кадров GIF",
+ "loop": "Зациклить GIF"
}
}
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 1156c4b31..ec4916edc 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF Animasyon",
- "mp4Description": "Yüksek kaliteli video dosyası",
- "mp4": "MP4",
- "mp4Video": "MP4 Video",
- "gif": "GIF",
- "gifDescription": "Paylaşım için hareketli görüntü"
- },
- "customFont": {
- "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Google Yazı Tipi Ekle",
- "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
- "nameLabel": "Görünen Ad",
- "failedToAdd": "Yazı tipi eklenemedi",
- "urlLabel": "Google Fonts İçe Aktarım URL'si",
- "namePlaceholder": "Özel Yazı Tipim",
- "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
- "addingButton": "Ekleniyor...",
- "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
- "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
- "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
- "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
- "errorEmptyName": "Lütfen bir yazı tipi adı girin",
- "addButton": "Yazı Tipi Ekle",
- "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin"
- },
- "annotation": {
- "colorWheel": "Renk çarkı",
- "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
- "typeArrow": "Ok",
- "selectStyle": "Stil seçin",
- "blurColorWhite": "Beyaz",
- "blurType": "Bulanıklık Türü",
- "blurShapeRectangle": "Dikdörtgen",
- "blurColor": "Bulanıklık Rengi",
- "arrowColor": "Ok Rengi",
- "textContent": "Metin İçeriği",
- "background": "Arka Plan",
- "clearBackground": "Arka Planı Temizle",
- "blurShapeFreehand": "Serbest",
- "blurIntensity": "Bulanıklık Yoğunluğu",
- "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
- "active": "Aktif",
- "size": "Boyut",
- "blurColorBlack": "Siyah",
- "typeImage": "Görüntü",
- "mosaicBlockSize": "Mozaik Blok Boyutu",
- "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
- "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
- "textColor": "Metin Rengi",
- "defaultText": "Merhaba",
- "blurShape": "Bulanık Şekli",
- "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
- "type": "Tür",
- "typeText": "Metin",
- "textPlaceholder": "Metninizi girin...",
- "fontStyle": "Yazı Tipi Stili",
- "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
- "colorPalette": "Renk paleti",
- "color": "Renk",
- "shortcutsAndTips": "Kısayollar ve İpuçları",
- "none": "Yok",
- "invalidImageType": "Geçersiz dosya türü",
- "arrowDirection": "Ok Yönü",
- "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
- "uploadImage": "Görüntü Yükle",
- "customFonts": "Özel Yazı Tipleri",
- "blurTypeMosaic": "Mozaik",
- "blurTypeBlur": "Gauss",
- "typeBlur": "Bulanık",
- "title": "Açıklama Ayarları",
- "deleteAnnotation": "Açıklamayı Sil",
- "blurShapeOval": "Oval"
- },
- "transcript": {
- "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
- "editWord": "\"{{word}}\" kelimesini düzenle",
- "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
- "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
- "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
- "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
- "insertAria": "Yeni kelime",
- "transcribing": "Döküm çıkarılıyor…",
- "noAudio": "Bu medyada ses parçası yok",
- "noTranscript": "Henüz döküm yok",
- "silence": "[sessizlik {{duration}} sn]",
- "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
- "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
- "noClips": "Henüz klip yok",
- "laneVoiceover": "Dış ses",
- "laneLabel": "Deşifreyi şuradan oku",
- "revertWord": "\"{{original}}\" haline getir",
- "helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
- "blankedWord": "boşaltıldı",
- "clipLabel": "Klip {{index}}",
- "title": "Geçerli döküm",
- "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
- "transcribeNow": "Şimdi dökümünü çıkar",
- "laneRecording": "Kayıt",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
- "removeInserted": "\"{{word}}\" kelimesini sil",
- "trimSilence": "Sessizliği kırp ({{duration}} sn)",
- "editorAria": "{{filename}} dökümü",
- "restoreWord": "\"{{word}}\" kelimesini geri al"
- },
- "effects": {
- "shadow": "Gölge",
- "fitClipOne": "{{count}} klip",
- "blurBg": "Arka Planı Bulanıklaştır",
- "fitClip": "Sığdır",
- "formatOriginal": "Orijinal",
- "title": "Kompozisyon",
- "format": "Biçim",
- "motionBlur": "Hareket Bulanıklığı",
- "fitClipMany": "{{count}} klip",
- "roundness": "Yuvarlaklık",
- "fitClipFew": "{{count}} klip",
- "motion": "Hareket",
- "on": "açık",
- "frame": "Çerçeve",
- "padding": "Dolgu",
- "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
- "off": "kapalı"
- },
- "audioTrack": {
- "mute": "Sessiz",
- "fadeIn": "Açılma",
- "loop": "Döngü",
- "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
- "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
- "importFailed": "Ses eklenemedi",
- "fadeOut": "Kararma",
- "add": "Ses parçası ekle",
- "defaultLabel": "Ses parçası",
- "remove": "Parçayı sil"
- },
"layout": {
+ "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "bgModes": {
+ "transparent": "Kesme",
+ "none": "Orijinal",
+ "blur": "Bulanık",
+ "custom": "Özel"
+ },
+ "selectPreset": "Ön ayar seçin",
+ "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
+ "reactiveWebcam": "Yakınlaştırınca küçült",
"shapes": {
"circle": "Daire",
"square": "Kare",
"rectangle": "Dikdörtgen",
"rounded": "Yuvarlatılmış"
},
+ "webcamBackground": "Kamera Arka Planı",
"verticalStack": "Dikey Yığın",
- "webcamSize": "Webcam Boyutu",
- "preset": "Ön Ayar",
- "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
- "dualFrame": "Çift Kare",
- "title": "Kamera düzeni",
- "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
- "bgModes": {
- "transparent": "Kesme",
- "custom": "Özel",
- "none": "Orijinal",
- "blur": "Bulanık"
- },
- "webcamCropZoom": "Kırpma yakınlaştırması",
- "selectPreset": "Ön ayar seçin",
- "webcamCropY": "Dikey kaydırma",
- "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
"pictureInPicture": "Resim İçinde Resim",
- "reactiveWebcam": "Yakınlaştırınca küçült",
- "webcamBackground": "Kamera Arka Planı",
- "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
- "mirrorWebcam": "Web kamerasını aynala",
"webcamShape": "Kamera Şekli",
+ "webcamCropY": "Dikey kaydırma",
+ "webcamSize": "Webcam Boyutu",
+ "mirrorWebcam": "Web kamerasını aynala",
+ "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
+ "webcamFraming": "Webcam kadrajı",
"noWebcam": "Web kamerası yok",
+ "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
+ "webcamCropZoom": "Kırpma yakınlaştırması",
+ "dualFrame": "Çift Kare",
"webcamCropX": "Yatay kaydırma",
- "webcamFraming": "Webcam kadrajı"
- },
- "imageUpload": {
- "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
- "failedToUpload": "Görüntü yüklenemedi",
- "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.",
- "errorReading": "Dosya okunurken bir hata oluştu.",
- "invalidFileType": "Geçersiz dosya türü"
- },
- "cursor": {
- "themeDefault": "Varsayılan",
- "motionBlur": "Hareket Bulanıklığı",
- "smoothing": "Yumuşatma",
- "title": "İmleç",
- "theme": "İmleç Stili",
- "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
- "show": "İmleci Göster",
- "clipToBounds": "Tuvale Kırp",
- "size": "Boyut",
- "clickBounce": "Tıklama Sıçraması",
- "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması."
+ "title": "Kamera düzeni",
+ "preset": "Ön Ayar"
},
- "captions": {
- "original": "Özgün (döküm)",
- "alignRight": "Sağ",
- "backgroundOpacity": "Saydamlık",
- "anchorBottom": "Alt",
- "minWords": "Satır başına en az kelime",
- "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
- "translate": "Çevir",
- "maxWords": "Satır başına en çok kelime",
- "lineLength": "Satır uzunluğu",
- "anchorTop": "Üst",
- "font": "Yazı tipi",
- "translating": "Çevriliyor…",
- "position": "Konum",
- "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
- "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
- "backgroundColor": "Arka plan rengi",
- "text": "Metin",
- "textColor": "Metin rengi",
- "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
- "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
- "distanceFromTop": "Üstten uzaklık",
- "alignLeft": "Sol",
- "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
- "deleteTranslation": "Bu çeviriyi sil",
- "distanceFromBottom": "Alttan uzaklık",
- "displayLanguage": "Görüntüleme",
- "background": "Arka plan",
- "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
- "distanceFromLeft": "Soldan uzaklık",
- "alignCenter": "Orta",
- "fontSize": "Boyut",
- "bold": "Kalın",
- "translateFailed": "Çeviri başarısız oldu.",
- "distanceFromRight": "Sağdan uzaklık",
- "showBackground": "Arka planı göster",
- "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
- "show": "Altyazıları göster",
- "language": "Dil",
- "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi."
+ "crop": {
+ "done": "Tamam",
+ "ratio": "Oran",
+ "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
+ "title": "Kırpma",
+ "free": "Serbest",
+ "lockAspectRatio": "En boy oranını kilitle",
+ "unlockAspectRatio": "En boy oranının kilidini aç",
+ "cropVideo": "Videoyu Kırp"
},
"zoom": {
+ "position": {
+ "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Odak Konumu"
+ },
"threeD": {
"preset": {
"right": "Sağ",
@@ -241,115 +59,296 @@
"none": "Yok",
"title": "3D Döndürme"
},
+ "deleteZoom": "Yakınlaştırmayı Sil",
+ "customScale": "Özel Yakınlaştırma",
+ "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
"focusMode": {
+ "manual": "Manuel",
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
"lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
"title": "Odak Modu",
- "manual": "Manuel",
"auto": "Otomatik"
},
- "position": {
- "title": "Odak Konumu",
- "x": "X (%)",
- "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
- "y": "Y (%)"
- },
- "deleteZoom": "Yakınlaştırmayı Sil",
- "level": "Yakınlaştırma Seviyesi",
- "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
"previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
- "customScale": "Özel Yakınlaştırma"
- },
- "textAnimation": {
- "pop": "Fırlama",
- "rise": "Yükselme",
- "selectAnimation": "Animasyon seçin",
- "fade": "Belirme",
- "pulse": "Nabız",
- "typewriter": "Daktilo",
- "title": "Metin Animasyonu",
- "none": "Yok",
- "slideLeft": "Sola Kaydırma"
- },
- "crop": {
- "lockAspectRatio": "En boy oranını kilitle",
- "title": "Kırpma",
- "done": "Tamam",
- "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
- "ratio": "Oran",
- "free": "Serbest",
- "cropVideo": "Videoyu Kırp",
- "unlockAspectRatio": "En boy oranının kilidini aç"
+ "level": "Yakınlaştırma Seviyesi"
},
"background": {
- "imageLabel": "Arka plan {{index}}",
"color": "Renk",
- "gradient": "Gradyan",
"colorLabel": "Renk {{color}}",
- "colorWheel": "Renk çarkı",
- "customWallpaper": "Özel duvar kâğıdı",
- "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
- "image": "Görüntü",
- "gradientLabel": "Gradyan {{index}}",
+ "gradient": "Gradyan",
"imageReadFailed": "Bu görsel dosyası okunamadı.",
- "presets": "Ön ayarlar",
+ "gradientLabel": "Gradyan {{index}}",
"custom": "Özel",
+ "customWallpaper": "Özel duvar kâğıdı",
+ "presets": "Ön ayarlar",
+ "image": "Görüntü",
"colorPalette": "Renk paleti",
- "uploadCustom": "Özel Yükle",
+ "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
+ "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
+ "imageLabel": "Arka plan {{index}}",
"title": "Arka Plan",
- "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın."
+ "uploadCustom": "Özel Yükle",
+ "colorWheel": "Renk çarkı"
},
- "audio": {
- "title": "Ses",
- "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
- "reset": "Sesi sıfırla",
- "outputGain": "Çıkış seviyesi"
+ "cursor": {
+ "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
+ "clickBounce": "Tıklama Sıçraması",
+ "clipToBounds": "Tuvale Kırp",
+ "title": "İmleç",
+ "size": "Boyut",
+ "themeDefault": "Varsayılan",
+ "smoothing": "Yumuşatma",
+ "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
+ "motionBlur": "Hareket Bulanıklığı",
+ "theme": "İmleç Stili",
+ "show": "İmleci Göster"
+ },
+ "captions": {
+ "text": "Metin",
+ "showBackground": "Arka planı göster",
+ "minWords": "Satır başına en az kelime",
+ "translateFailed": "Çeviri başarısız oldu.",
+ "distanceFromTop": "Üstten uzaklık",
+ "fontSize": "Boyut",
+ "distanceFromRight": "Sağdan uzaklık",
+ "bold": "Kalın",
+ "textColor": "Metin rengi",
+ "original": "Özgün (döküm)",
+ "translating": "Çevriliyor…",
+ "language": "Dil",
+ "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
+ "alignLeft": "Sol",
+ "distanceFromBottom": "Alttan uzaklık",
+ "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
+ "show": "Altyazıları göster",
+ "background": "Arka plan",
+ "backgroundOpacity": "Saydamlık",
+ "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
+ "anchorTop": "Üst",
+ "translate": "Çevir",
+ "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
+ "alignCenter": "Orta",
+ "alignRight": "Sağ",
+ "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
+ "displayLanguage": "Görüntüleme",
+ "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
+ "distanceFromLeft": "Soldan uzaklık",
+ "maxWords": "Satır başına en çok kelime",
+ "anchorBottom": "Alt",
+ "position": "Konum",
+ "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
+ "deleteTranslation": "Bu çeviriyi sil",
+ "backgroundColor": "Arka plan rengi",
+ "font": "Yazı tipi",
+ "lineLength": "Satır uzunluğu",
+ "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
+ "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "Dışa aktarma çözünürlüğü"
},
- "gifSettings": {
- "loop": "GIF Döngüsü",
- "frameRate": "GIF Kare Hızı",
- "size": "GIF Boyutu"
+ "transcript": {
+ "transcribing": "Döküm çıkarılıyor…",
+ "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "laneVoiceover": "Dış ses",
+ "blankedWord": "boşaltıldı",
+ "noTranscript": "Henüz döküm yok",
+ "noAudio": "Bu medyada ses parçası yok",
+ "revertWord": "\"{{original}}\" haline getir",
+ "silence": "[sessizlik {{duration}} sn]",
+ "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
+ "transcribeNow": "Şimdi dökümünü çıkar",
+ "restoreWord": "\"{{word}}\" kelimesini geri al",
+ "clipLabel": "Klip {{index}}",
+ "title": "Geçerli döküm",
+ "insertAria": "Yeni kelime",
+ "laneRecording": "Kayıt",
+ "trimSilence": "Sessizliği kırp ({{duration}} sn)",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
+ "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
+ "editorAria": "{{filename}} dökümü",
+ "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
+ "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
+ "editWord": "\"{{word}}\" kelimesini düzenle",
+ "noClips": "Henüz klip yok",
+ "laneLabel": "Deşifreyi şuradan oku",
+ "restoreSilence": "Sessizliği geri al ({{duration}} sn)"
},
- "export": {
- "chooseSaveLocation": "Kayıt Konumu Seç",
- "gifButton": "GIF Olarak Dışa Aktar",
- "videoButton": "Videoyu Dışa Aktar"
+ "customFont": {
+ "addingButton": "Ekleniyor...",
+ "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
+ "addButton": "Yazı Tipi Ekle",
+ "dialogTitle": "Google Yazı Tipi Ekle",
+ "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
+ "nameLabel": "Görünen Ad",
+ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
+ "urlLabel": "Google Fonts İçe Aktarım URL'si",
+ "errorEmptyName": "Lütfen bir yazı tipi adı girin",
+ "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
+ "namePlaceholder": "Özel Yazı Tipim",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Yazı tipi eklenemedi",
+ "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
+ "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
+ "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
+ "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi"
},
- "project": {
- "load": "Proje Yükle",
- "save": "Projeyi Kaydet",
- "new": "Yeni Proje"
+ "annotation": {
+ "arrowColor": "Ok Rengi",
+ "colorWheel": "Renk çarkı",
+ "blurType": "Bulanıklık Türü",
+ "active": "Aktif",
+ "deleteAnnotation": "Açıklamayı Sil",
+ "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "background": "Arka Plan",
+ "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
+ "blurColor": "Bulanıklık Rengi",
+ "blurTypeBlur": "Gauss",
+ "textColor": "Metin Rengi",
+ "blurColorWhite": "Beyaz",
+ "title": "Açıklama Ayarları",
+ "type": "Tür",
+ "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
+ "typeImage": "Görüntü",
+ "textContent": "Metin İçeriği",
+ "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
+ "typeText": "Metin",
+ "blurIntensity": "Bulanıklık Yoğunluğu",
+ "none": "Yok",
+ "mosaicBlockSize": "Mozaik Blok Boyutu",
+ "textPlaceholder": "Metninizi girin...",
+ "typeArrow": "Ok",
+ "color": "Renk",
+ "blurColorBlack": "Siyah",
+ "size": "Boyut",
+ "invalidImageType": "Geçersiz dosya türü",
+ "blurShapeFreehand": "Serbest",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
+ "uploadImage": "Görüntü Yükle",
+ "blurTypeMosaic": "Mozaik",
+ "selectStyle": "Stil seçin",
+ "defaultText": "Merhaba",
+ "blurShapeRectangle": "Dikdörtgen",
+ "colorPalette": "Renk paleti",
+ "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
+ "clearBackground": "Arka Planı Temizle",
+ "customFonts": "Özel Yazı Tipleri",
+ "typeBlur": "Bulanık",
+ "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
+ "fontStyle": "Yazı Tipi Stili",
+ "blurShape": "Bulanık Şekli",
+ "arrowDirection": "Ok Yönü",
+ "blurShapeOval": "Oval"
},
"speed": {
- "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez.",
"customPlaybackSpeed": "Özel Oynatma Hızı",
- "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
"deleteRegion": "Hız Bölgesini Sil",
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
- "playbackSpeed": "Oynatma Hızı"
+ "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
+ "playbackSpeed": "Oynatma Hızı",
+ "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez."
+ },
+ "textAnimation": {
+ "selectAnimation": "Animasyon seçin",
+ "pulse": "Nabız",
+ "rise": "Yükselme",
+ "none": "Yok",
+ "slideLeft": "Sola Kaydırma",
+ "title": "Metin Animasyonu",
+ "fade": "Belirme",
+ "pop": "Fırlama",
+ "typewriter": "Daktilo"
+ },
+ "effects": {
+ "motion": "Hareket",
+ "title": "Kompozisyon",
+ "format": "Biçim",
+ "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
+ "fitClipFew": "{{count}} klip",
+ "motionBlur": "Hareket Bulanıklığı",
+ "fitClipMany": "{{count}} klip",
+ "frame": "Çerçeve",
+ "padding": "Dolgu",
+ "roundness": "Yuvarlaklık",
+ "off": "kapalı",
+ "blurBg": "Arka Planı Bulanıklaştır",
+ "shadow": "Gölge",
+ "on": "açık",
+ "fitClipOne": "{{count}} klip",
+ "formatOriginal": "Orijinal",
+ "fitClip": "Sığdır"
+ },
+ "exportFormat": {
+ "gifDescription": "Paylaşım için hareketli görüntü",
+ "mp4": "MP4",
+ "mp4Video": "MP4 Video",
+ "gif": "GIF",
+ "gifAnimation": "GIF Animasyon",
+ "mp4Description": "Yüksek kaliteli video dosyası"
+ },
+ "imageUpload": {
+ "failedToUpload": "Görüntü yüklenemedi",
+ "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
+ "errorReading": "Dosya okunurken bir hata oluştu.",
+ "invalidFileType": "Geçersiz dosya türü",
+ "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin."
+ },
+ "facets": {
+ "captions": "Altyazılar",
+ "transcript": "Metin Dökümü"
+ },
+ "audio": {
+ "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
+ "reset": "Sesi sıfırla",
+ "title": "Ses",
+ "outputGain": "Çıkış seviyesi"
},
"language": {
"title": "Dil"
},
- "support": {
- "starOnGithub": "GitHub'da Yıldızla",
- "reportBug": "Hata Bildir",
- "saveDiagnostics": "Teşhis Verilerini Kaydet"
+ "audioTrack": {
+ "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "importFailed": "Ses eklenemedi",
+ "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
+ "fadeOut": "Kararma",
+ "defaultLabel": "Ses parçası",
+ "mute": "Sessiz",
+ "remove": "Parçayı sil",
+ "add": "Ses parçası ekle",
+ "loop": "Döngü",
+ "fadeIn": "Açılma"
+ },
+ "project": {
+ "load": "Proje Yükle",
+ "save": "Projeyi Kaydet",
+ "new": "Yeni Proje"
+ },
+ "panes": {
+ "help": "Yardım"
},
"trim": {
"deleteRegion": "Kırpma Bölgesini Sil"
},
- "facets": {
- "transcript": "Metin Dökümü",
- "captions": "Altyazılar"
+ "export": {
+ "videoButton": "Videoyu Dışa Aktar",
+ "gifButton": "GIF Olarak Dışa Aktar",
+ "chooseSaveLocation": "Kayıt Konumu Seç"
},
- "panes": {
- "help": "Yardım"
+ "support": {
+ "starOnGithub": "GitHub'da Yıldızla",
+ "saveDiagnostics": "Teşhis Verilerini Kaydet",
+ "reportBug": "Hata Bildir"
+ },
+ "gifSettings": {
+ "size": "GIF Boyutu",
+ "frameRate": "GIF Kare Hızı",
+ "loop": "GIF Döngüsü"
}
}
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index d59b3ec4d..1cbc815bb 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "Ảnh động GIF",
- "mp4Description": "Tệp video chất lượng cao",
- "mp4": "MP4",
- "mp4Video": "Video MP4",
- "gif": "GIF",
- "gifDescription": "Hình ảnh động để chia sẻ"
- },
- "customFont": {
- "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "Thêm Google Font",
- "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
- "nameLabel": "Tên hiển thị",
- "failedToAdd": "Thêm phông chữ thất bại",
- "urlLabel": "URL nhập Google Fonts",
- "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
- "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
- "addingButton": "Đang thêm...",
- "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
- "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
- "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
- "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
- "errorEmptyName": "Vui lòng nhập tên phông chữ",
- "addButton": "Thêm phông chữ",
- "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ"
- },
- "annotation": {
- "colorWheel": "Vòng màu",
- "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
- "typeArrow": "Mũi tên",
- "selectStyle": "Chọn kiểu",
- "blurColorWhite": "Trắng",
- "blurType": "Loại làm mờ",
- "blurShapeRectangle": "Chữ nhật",
- "blurColor": "Màu làm mờ",
- "arrowColor": "Màu mũi tên",
- "textContent": "Nội dung văn bản",
- "background": "Nền",
- "clearBackground": "Xóa nền",
- "blurShapeFreehand": "Vẽ tự do",
- "blurIntensity": "Cường độ làm mờ",
- "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
- "active": "Hoạt động",
- "size": "Kích thước",
- "blurColorBlack": "Đen",
- "typeImage": "Hình ảnh",
- "mosaicBlockSize": "Kích thước khối khảm",
- "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
- "strokeWidth": "Độ dày nét: {{width}}px",
- "textColor": "Màu văn bản",
- "defaultText": "Xin chào",
- "blurShape": "Hình dạng làm mờ",
- "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
- "type": "Loại",
- "typeText": "Văn bản",
- "textPlaceholder": "Nhập văn bản của bạn...",
- "fontStyle": "Kiểu phông chữ",
- "imageUploadSuccess": "Tải lên hình ảnh thành công!",
- "colorPalette": "Bảng màu",
- "color": "Màu sắc",
- "shortcutsAndTips": "Phím tắt & Mẹo",
- "none": "Không có",
- "invalidImageType": "Loại tệp không hợp lệ",
- "arrowDirection": "Hướng mũi tên",
- "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
- "uploadImage": "Tải lên hình ảnh",
- "customFonts": "Phông chữ tùy chỉnh",
- "blurTypeMosaic": "Khảm",
- "blurTypeBlur": "Gaussian",
- "typeBlur": "Làm mờ",
- "title": "Cài đặt chú thích",
- "deleteAnnotation": "Xóa chú thích",
- "blurShapeOval": "Bầu dục"
- },
- "transcript": {
- "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
- "editWord": "Sửa \"{{word}}\"",
- "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
- "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
- "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
- "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
- "insertAria": "Từ mới",
- "transcribing": "Đang chép lời…",
- "noAudio": "Media này không có bản âm thanh",
- "noTranscript": "Chưa có bản chép lời",
- "silence": "[khoảng lặng {{duration}} giây]",
- "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
- "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
- "noClips": "Chưa có clip nào",
- "laneVoiceover": "Lời thuyết minh",
- "laneLabel": "Đọc bản chép lời từ",
- "revertWord": "Khôi phục \"{{original}}\"",
- "helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
- "blankedWord": "đã xoá",
- "clipLabel": "Clip {{index}}",
- "title": "Bản chép lời hiện tại",
- "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
- "transcribeNow": "Chép lời ngay",
- "laneRecording": "Bản ghi",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
- "removeInserted": "Xoá \"{{word}}\"",
- "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
- "editorAria": "Bản chép lời của {{filename}}",
- "restoreWord": "Khôi phục \"{{word}}\""
- },
- "effects": {
- "shadow": "Bóng đổ",
- "fitClipOne": "{{count}} clip",
- "blurBg": "Làm mờ nền",
- "fitClip": "Vừa khít",
- "formatOriginal": "Gốc",
- "title": "Bố cục hình ảnh",
- "format": "Định dạng",
- "motionBlur": "Làm mờ chuyển động",
- "fitClipMany": "{{count}} clip",
- "roundness": "Độ bo tròn",
- "fitClipFew": "{{count}} clip",
- "motion": "Chuyển động",
- "on": "bật",
- "frame": "Khung",
- "padding": "Phần đệm",
- "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
- "off": "tắt"
- },
- "audioTrack": {
- "mute": "Tắt tiếng",
- "fadeIn": "Mờ vào",
- "loop": "Lặp",
- "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
- "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
- "importFailed": "Không thể thêm âm thanh",
- "fadeOut": "Mờ ra",
- "add": "Thêm bản âm thanh",
- "defaultLabel": "Bản âm thanh",
- "remove": "Xóa bản nhạc"
- },
"layout": {
+ "webcamBlurIntensity": "Độ mờ",
+ "bgModes": {
+ "transparent": "Tách nền",
+ "none": "Gốc",
+ "blur": "Làm mờ",
+ "custom": "Tùy chỉnh"
+ },
+ "selectPreset": "Chọn cài đặt sẵn",
+ "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
+ "reactiveWebcam": "Thu nhỏ khi phóng to",
"shapes": {
"circle": "Tròn",
"square": "Vuông",
"rectangle": "Chữ nhật",
"rounded": "Bo góc"
},
+ "webcamBackground": "Nền máy ảnh",
"verticalStack": "Xếp chồng dọc",
- "webcamSize": "Kích thước Webcam",
- "preset": "Cài đặt sẵn",
- "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
- "dualFrame": "Khung kép",
- "title": "Bố cục camera",
- "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
- "bgModes": {
- "transparent": "Tách nền",
- "custom": "Tùy chỉnh",
- "none": "Gốc",
- "blur": "Làm mờ"
- },
- "webcamCropZoom": "Thu phóng vùng cắt",
- "selectPreset": "Chọn cài đặt sẵn",
- "webcamCropY": "Dịch chuyển dọc",
- "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
"pictureInPicture": "Hình trong hình",
- "reactiveWebcam": "Thu nhỏ khi phóng to",
- "webcamBackground": "Nền máy ảnh",
- "webcamBlurIntensity": "Độ mờ",
- "mirrorWebcam": "Lật webcam",
"webcamShape": "Hình dạng máy ảnh",
+ "webcamCropY": "Dịch chuyển dọc",
+ "webcamSize": "Kích thước Webcam",
+ "mirrorWebcam": "Lật webcam",
+ "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
+ "webcamFraming": "Khung hình webcam",
"noWebcam": "Không có webcam",
+ "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
+ "webcamCropZoom": "Thu phóng vùng cắt",
+ "dualFrame": "Khung kép",
"webcamCropX": "Dịch chuyển ngang",
- "webcamFraming": "Khung hình webcam"
- },
- "imageUpload": {
- "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
- "failedToUpload": "Tải lên hình ảnh thất bại",
- "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
- "errorReading": "Đã xảy ra lỗi khi đọc tệp.",
- "invalidFileType": "Loại tệp không hợp lệ"
- },
- "cursor": {
- "themeDefault": "Mặc định",
- "motionBlur": "Làm mờ chuyển động",
- "smoothing": "Làm mượt",
- "title": "Con trỏ",
- "theme": "Kiểu con trỏ",
- "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
- "show": "Hiện con trỏ",
- "clipToBounds": "Cắt theo khung",
- "size": "Kích thước",
- "clickBounce": "Nảy khi nhấp",
- "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp."
+ "title": "Bố cục camera",
+ "preset": "Cài đặt sẵn"
},
- "captions": {
- "original": "Gốc (bản chép lời)",
- "alignRight": "Phải",
- "backgroundOpacity": "Độ mờ",
- "anchorBottom": "Dưới",
- "minWords": "Số từ tối thiểu mỗi dòng",
- "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
- "translate": "Dịch",
- "maxWords": "Số từ tối đa mỗi dòng",
- "lineLength": "Độ dài dòng",
- "anchorTop": "Trên",
- "font": "Phông chữ",
- "translating": "Đang dịch…",
- "position": "Vị trí",
- "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
- "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
- "backgroundColor": "Màu nền",
- "text": "Văn bản",
- "textColor": "Màu chữ",
- "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
- "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
- "distanceFromTop": "Khoảng cách từ trên",
- "alignLeft": "Trái",
- "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
- "deleteTranslation": "Xóa bản dịch này",
- "distanceFromBottom": "Khoảng cách từ dưới",
- "displayLanguage": "Hiển thị",
- "background": "Nền",
- "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
- "distanceFromLeft": "Khoảng cách từ trái",
- "alignCenter": "Giữa",
- "fontSize": "Cỡ chữ",
- "bold": "Đậm",
- "translateFailed": "Dịch thất bại.",
- "distanceFromRight": "Khoảng cách từ phải",
- "showBackground": "Hiện nền",
- "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
- "show": "Hiện phụ đề",
- "language": "Ngôn ngữ",
- "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời."
+ "crop": {
+ "done": "Hoàn tất",
+ "ratio": "Tỷ lệ",
+ "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
+ "title": "Cắt xén",
+ "free": "Tự do",
+ "lockAspectRatio": "Khóa tỷ lệ khung hình",
+ "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
+ "cropVideo": "Cắt xén video"
},
"zoom": {
+ "position": {
+ "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "Vị trí tiêu điểm"
+ },
"threeD": {
"preset": {
"right": "Phải",
@@ -241,115 +59,296 @@
"none": "Không",
"title": "Xoay 3D"
},
+ "deleteZoom": "Xóa thu phóng",
+ "customScale": "Thu phóng tùy chỉnh",
+ "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
"focusMode": {
+ "manual": "Thủ công",
"autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
"lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
"title": "Chế độ lấy nét",
- "manual": "Thủ công",
"auto": "Tự động"
},
- "position": {
- "title": "Vị trí tiêu điểm",
- "x": "X (%)",
- "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
- "y": "Y (%)"
- },
- "deleteZoom": "Xóa thu phóng",
- "level": "Mức độ thu phóng",
- "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
"previewHold": "Giữ để xem trước hiệu ứng phóng to",
- "customScale": "Thu phóng tùy chỉnh"
- },
- "textAnimation": {
- "pop": "Bật lên",
- "rise": "Trồi lên",
- "selectAnimation": "Chọn hoạt ảnh",
- "fade": "Mờ dần",
- "pulse": "Nhấp nháy",
- "typewriter": "Máy đánh chữ",
- "title": "Hoạt ảnh văn bản",
- "none": "Không có",
- "slideLeft": "Trượt sang trái"
- },
- "crop": {
- "lockAspectRatio": "Khóa tỷ lệ khung hình",
- "title": "Cắt xén",
- "done": "Hoàn tất",
- "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
- "ratio": "Tỷ lệ",
- "free": "Tự do",
- "cropVideo": "Cắt xén video",
- "unlockAspectRatio": "Mở khóa tỷ lệ khung hình"
+ "level": "Mức độ thu phóng"
},
"background": {
- "imageLabel": "Nền {{index}}",
"color": "Màu sắc",
- "gradient": "Dải màu",
"colorLabel": "Màu {{color}}",
- "colorWheel": "Vòng màu",
- "customWallpaper": "Ảnh nền tùy chỉnh",
- "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
- "image": "Hình ảnh",
- "gradientLabel": "Dải màu {{index}}",
+ "gradient": "Dải màu",
"imageReadFailed": "Không thể đọc tệp ảnh này.",
- "presets": "Có sẵn",
+ "gradientLabel": "Dải màu {{index}}",
"custom": "Tùy chỉnh",
+ "customWallpaper": "Ảnh nền tùy chỉnh",
+ "presets": "Có sẵn",
+ "image": "Hình ảnh",
"colorPalette": "Bảng màu",
- "uploadCustom": "Tải lên tùy chỉnh",
+ "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
+ "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
+ "imageLabel": "Nền {{index}}",
"title": "Nền",
- "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG."
+ "uploadCustom": "Tải lên tùy chỉnh",
+ "colorWheel": "Vòng màu"
},
- "audio": {
- "title": "Âm thanh",
- "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
- "reset": "Đặt lại âm thanh",
- "outputGain": "Mức đầu ra"
+ "cursor": {
+ "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
+ "clickBounce": "Nảy khi nhấp",
+ "clipToBounds": "Cắt theo khung",
+ "title": "Con trỏ",
+ "size": "Kích thước",
+ "themeDefault": "Mặc định",
+ "smoothing": "Làm mượt",
+ "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
+ "motionBlur": "Làm mờ chuyển động",
+ "theme": "Kiểu con trỏ",
+ "show": "Hiện con trỏ"
+ },
+ "captions": {
+ "text": "Văn bản",
+ "showBackground": "Hiện nền",
+ "minWords": "Số từ tối thiểu mỗi dòng",
+ "translateFailed": "Dịch thất bại.",
+ "distanceFromTop": "Khoảng cách từ trên",
+ "fontSize": "Cỡ chữ",
+ "distanceFromRight": "Khoảng cách từ phải",
+ "bold": "Đậm",
+ "textColor": "Màu chữ",
+ "original": "Gốc (bản chép lời)",
+ "translating": "Đang dịch…",
+ "language": "Ngôn ngữ",
+ "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
+ "alignLeft": "Trái",
+ "distanceFromBottom": "Khoảng cách từ dưới",
+ "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
+ "show": "Hiện phụ đề",
+ "background": "Nền",
+ "backgroundOpacity": "Độ mờ",
+ "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
+ "anchorTop": "Trên",
+ "translate": "Dịch",
+ "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
+ "alignCenter": "Giữa",
+ "alignRight": "Phải",
+ "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
+ "displayLanguage": "Hiển thị",
+ "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
+ "distanceFromLeft": "Khoảng cách từ trái",
+ "maxWords": "Số từ tối đa mỗi dòng",
+ "anchorBottom": "Dưới",
+ "position": "Vị trí",
+ "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
+ "deleteTranslation": "Xóa bản dịch này",
+ "backgroundColor": "Màu nền",
+ "font": "Phông chữ",
+ "lineLength": "Độ dài dòng",
+ "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
+ "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi."
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "Độ phân giải xuất"
},
- "gifSettings": {
- "loop": "Lặp lại GIF",
- "frameRate": "Tốc độ khung hình GIF",
- "size": "Kích thước GIF"
+ "transcript": {
+ "transcribing": "Đang chép lời…",
+ "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "laneVoiceover": "Lời thuyết minh",
+ "blankedWord": "đã xoá",
+ "noTranscript": "Chưa có bản chép lời",
+ "noAudio": "Media này không có bản âm thanh",
+ "revertWord": "Khôi phục \"{{original}}\"",
+ "silence": "[khoảng lặng {{duration}} giây]",
+ "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
+ "transcribeNow": "Chép lời ngay",
+ "restoreWord": "Khôi phục \"{{word}}\"",
+ "clipLabel": "Clip {{index}}",
+ "title": "Bản chép lời hiện tại",
+ "insertAria": "Từ mới",
+ "laneRecording": "Bản ghi",
+ "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
+ "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
+ "removeInserted": "Xoá \"{{word}}\"",
+ "editorAria": "Bản chép lời của {{filename}}",
+ "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
+ "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
+ "editWord": "Sửa \"{{word}}\"",
+ "noClips": "Chưa có clip nào",
+ "laneLabel": "Đọc bản chép lời từ",
+ "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)"
},
- "export": {
- "chooseSaveLocation": "Chọn vị trí lưu",
- "gifButton": "Xuất GIF",
- "videoButton": "Xuất Video"
+ "customFont": {
+ "addingButton": "Đang thêm...",
+ "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
+ "addButton": "Thêm phông chữ",
+ "dialogTitle": "Thêm Google Font",
+ "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
+ "nameLabel": "Tên hiển thị",
+ "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
+ "urlLabel": "URL nhập Google Fonts",
+ "errorEmptyName": "Vui lòng nhập tên phông chữ",
+ "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
+ "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Thêm phông chữ thất bại",
+ "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
+ "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
+ "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
+ "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công"
},
- "project": {
- "load": "Tải dự án",
- "save": "Lưu dự án",
- "new": "Dự án mới"
+ "annotation": {
+ "arrowColor": "Màu mũi tên",
+ "colorWheel": "Vòng màu",
+ "blurType": "Loại làm mờ",
+ "active": "Hoạt động",
+ "deleteAnnotation": "Xóa chú thích",
+ "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
+ "strokeWidth": "Độ dày nét: {{width}}px",
+ "background": "Nền",
+ "imageUploadSuccess": "Tải lên hình ảnh thành công!",
+ "blurColor": "Màu làm mờ",
+ "blurTypeBlur": "Gaussian",
+ "textColor": "Màu văn bản",
+ "blurColorWhite": "Trắng",
+ "title": "Cài đặt chú thích",
+ "type": "Loại",
+ "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
+ "typeImage": "Hình ảnh",
+ "textContent": "Nội dung văn bản",
+ "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
+ "typeText": "Văn bản",
+ "blurIntensity": "Cường độ làm mờ",
+ "none": "Không có",
+ "mosaicBlockSize": "Kích thước khối khảm",
+ "textPlaceholder": "Nhập văn bản của bạn...",
+ "typeArrow": "Mũi tên",
+ "color": "Màu sắc",
+ "blurColorBlack": "Đen",
+ "size": "Kích thước",
+ "invalidImageType": "Loại tệp không hợp lệ",
+ "blurShapeFreehand": "Vẽ tự do",
+ "shortcutsAndTips": "Phím tắt & Mẹo",
+ "uploadImage": "Tải lên hình ảnh",
+ "blurTypeMosaic": "Khảm",
+ "selectStyle": "Chọn kiểu",
+ "defaultText": "Xin chào",
+ "blurShapeRectangle": "Chữ nhật",
+ "colorPalette": "Bảng màu",
+ "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
+ "clearBackground": "Xóa nền",
+ "customFonts": "Phông chữ tùy chỉnh",
+ "typeBlur": "Làm mờ",
+ "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
+ "fontStyle": "Kiểu phông chữ",
+ "blurShape": "Hình dạng làm mờ",
+ "arrowDirection": "Hướng mũi tên",
+ "blurShapeOval": "Bầu dục"
},
"speed": {
- "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng.",
"customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
- "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
"deleteRegion": "Xóa vùng tốc độ",
"selectRegion": "Chọn vùng tốc độ để điều chỉnh",
- "playbackSpeed": "Tốc độ phát"
+ "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
+ "playbackSpeed": "Tốc độ phát",
+ "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng."
+ },
+ "textAnimation": {
+ "selectAnimation": "Chọn hoạt ảnh",
+ "pulse": "Nhấp nháy",
+ "rise": "Trồi lên",
+ "none": "Không có",
+ "slideLeft": "Trượt sang trái",
+ "title": "Hoạt ảnh văn bản",
+ "fade": "Mờ dần",
+ "pop": "Bật lên",
+ "typewriter": "Máy đánh chữ"
+ },
+ "effects": {
+ "motion": "Chuyển động",
+ "title": "Bố cục hình ảnh",
+ "format": "Định dạng",
+ "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
+ "fitClipFew": "{{count}} clip",
+ "motionBlur": "Làm mờ chuyển động",
+ "fitClipMany": "{{count}} clip",
+ "frame": "Khung",
+ "padding": "Phần đệm",
+ "roundness": "Độ bo tròn",
+ "off": "tắt",
+ "blurBg": "Làm mờ nền",
+ "shadow": "Bóng đổ",
+ "on": "bật",
+ "fitClipOne": "{{count}} clip",
+ "formatOriginal": "Gốc",
+ "fitClip": "Vừa khít"
+ },
+ "exportFormat": {
+ "gifDescription": "Hình ảnh động để chia sẻ",
+ "mp4": "MP4",
+ "mp4Video": "Video MP4",
+ "gif": "GIF",
+ "gifAnimation": "Ảnh động GIF",
+ "mp4Description": "Tệp video chất lượng cao"
+ },
+ "imageUpload": {
+ "failedToUpload": "Tải lên hình ảnh thất bại",
+ "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
+ "errorReading": "Đã xảy ra lỗi khi đọc tệp.",
+ "invalidFileType": "Loại tệp không hợp lệ",
+ "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG."
+ },
+ "facets": {
+ "captions": "Phụ đề",
+ "transcript": "Bản ghi lời thoại"
+ },
+ "audio": {
+ "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
+ "reset": "Đặt lại âm thanh",
+ "title": "Âm thanh",
+ "outputGain": "Mức đầu ra"
},
"language": {
"title": "Ngôn ngữ"
},
- "support": {
- "starOnGithub": "Đánh giá sao trên GitHub",
- "reportBug": "Báo cáo lỗi",
- "saveDiagnostics": "Lưu thông tin chẩn đoán"
+ "audioTrack": {
+ "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "importFailed": "Không thể thêm âm thanh",
+ "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
+ "fadeOut": "Mờ ra",
+ "defaultLabel": "Bản âm thanh",
+ "mute": "Tắt tiếng",
+ "remove": "Xóa bản nhạc",
+ "add": "Thêm bản âm thanh",
+ "loop": "Lặp",
+ "fadeIn": "Mờ vào"
+ },
+ "project": {
+ "load": "Tải dự án",
+ "save": "Lưu dự án",
+ "new": "Dự án mới"
+ },
+ "panes": {
+ "help": "Trợ giúp"
},
"trim": {
"deleteRegion": "Xóa vùng cắt"
},
- "facets": {
- "transcript": "Bản ghi lời thoại",
- "captions": "Phụ đề"
+ "export": {
+ "videoButton": "Xuất Video",
+ "gifButton": "Xuất GIF",
+ "chooseSaveLocation": "Chọn vị trí lưu"
},
- "panes": {
- "help": "Trợ giúp"
+ "support": {
+ "starOnGithub": "Đánh giá sao trên GitHub",
+ "saveDiagnostics": "Lưu thông tin chẩn đoán",
+ "reportBug": "Báo cáo lỗi"
+ },
+ "gifSettings": {
+ "size": "Kích thước GIF",
+ "frameRate": "Tốc độ khung hình GIF",
+ "loop": "Lặp lại GIF"
}
}
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 48112744e..cf2c729db 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF 动画",
- "mp4Description": "高质量视频文件",
- "mp4": "MP4",
- "mp4Video": "MP4 视频",
- "gif": "GIF",
- "gifDescription": "可分享的动态图片"
- },
- "customFont": {
- "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "添加 Google 字体",
- "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
- "nameLabel": "显示名称",
- "failedToAdd": "添加字体失败",
- "urlLabel": "Google Fonts 导入 URL",
- "namePlaceholder": "我的自定义字体",
- "errorExtractFailed": "无法从 URL 中提取字体系列",
- "addingButton": "添加中...",
- "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
- "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
- "successMessage": "字体 \"{{fontName}}\" 添加成功",
- "nameHelp": "这是字体在字体选择器中显示的名称",
- "errorEmptyName": "请输入字体名称",
- "addButton": "添加字体",
- "errorInvalidUrl": "请输入有效的 Google Fonts URL"
- },
- "annotation": {
- "colorWheel": "颜色轮",
- "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
- "typeArrow": "箭头",
- "selectStyle": "选择样式",
- "blurColorWhite": "白色",
- "blurType": "模糊类型",
- "blurShapeRectangle": "矩形",
- "blurColor": "模糊颜色",
- "arrowColor": "箭头颜色",
- "textContent": "文本内容",
- "background": "背景",
- "clearBackground": "清除背景",
- "blurShapeFreehand": "自由手绘",
- "blurIntensity": "模糊强度",
- "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
- "active": "活动",
- "size": "大小",
- "blurColorBlack": "黑色",
- "typeImage": "图片",
- "mosaicBlockSize": "马赛克块大小",
- "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
- "strokeWidth": "描边宽度:{{width}}px",
- "textColor": "文本颜色",
- "defaultText": "你好",
- "blurShape": "模糊形状",
- "tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
- "type": "类型",
- "typeText": "文本",
- "textPlaceholder": "输入您的文本...",
- "fontStyle": "字体样式",
- "imageUploadSuccess": "图片上传成功!",
- "colorPalette": "颜色调色板",
- "color": "颜色",
- "shortcutsAndTips": "快捷键与提示",
- "none": "无",
- "invalidImageType": "无效的文件类型",
- "arrowDirection": "箭头方向",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
- "uploadImage": "上传图片",
- "customFonts": "自定义字体",
- "blurTypeMosaic": "马赛克",
- "blurTypeBlur": "高斯",
- "typeBlur": "模糊",
- "title": "标注设置",
- "deleteAnnotation": "删除标注",
- "blurShapeOval": "椭圆"
- },
- "transcript": {
- "restoreSilence": "恢复静音({{duration}} 秒)",
- "editWord": "编辑“{{word}}”",
- "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
- "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
- "insertedWord": "你添加的词 — 背后没有声音",
- "laneFeedsCaptions": "字幕从这条轨道烧录。",
- "insertAria": "新词",
- "transcribing": "转录中…",
- "noAudio": "此媒体没有音频轨道",
- "noTranscript": "暂无转录",
- "silence": "[静音 {{duration}} 秒]",
- "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
- "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
- "noClips": "暂无片段",
- "laneVoiceover": "配音",
- "laneLabel": "转写文本读取自",
- "revertWord": "还原为“{{original}}”",
- "helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
- "blankedWord": "已清空",
- "clipLabel": "片段 {{index}}",
- "title": "当前转录",
- "correctedWord": "已更正 — 转录原文为“{{original}}”",
- "transcribeNow": "立即转录",
- "laneRecording": "录制",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
- "removeInserted": "删除“{{word}}”",
- "trimSilence": "修剪静音({{duration}} 秒)",
- "editorAria": "{{filename}} 的转录",
- "restoreWord": "恢复“{{word}}”"
- },
- "effects": {
- "shadow": "阴影",
- "fitClipOne": "{{count}} 个片段",
- "blurBg": "模糊背景",
- "fitClip": "适配",
- "formatOriginal": "原始",
- "title": "画面合成",
- "format": "格式",
- "motionBlur": "运动模糊",
- "fitClipMany": "{{count}} 个片段",
- "roundness": "圆角",
- "fitClipFew": "{{count}} 个片段",
- "motion": "运动",
- "on": "开",
- "frame": "画框",
- "padding": "内边距",
- "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
- "off": "关"
- },
- "audioTrack": {
- "mute": "静音",
- "fadeIn": "淡入",
- "loop": "循环",
- "slipHint": "按住 Alt 拖动可在其中滑动音频",
- "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
- "importFailed": "无法添加音频",
- "fadeOut": "淡出",
- "add": "添加音频轨道",
- "defaultLabel": "音频轨道",
- "remove": "删除轨道"
- },
"layout": {
+ "webcamBlurIntensity": "模糊强度",
+ "bgModes": {
+ "transparent": "抠图",
+ "none": "原画",
+ "blur": "模糊",
+ "custom": "自定义"
+ },
+ "selectPreset": "选择预设",
+ "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
+ "reactiveWebcam": "缩放时缩小",
"shapes": {
"circle": "圆形",
"square": "正方形",
"rectangle": "矩形",
"rounded": "圆角"
},
+ "webcamBackground": "摄像头背景",
"verticalStack": "垂直堆叠",
- "webcamSize": "摄像头大小",
- "preset": "预设",
- "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
- "dualFrame": "双画框",
- "title": "摄像头布局",
- "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
- "bgModes": {
- "transparent": "抠图",
- "custom": "自定义",
- "none": "原画",
- "blur": "模糊"
- },
- "webcamCropZoom": "裁剪缩放",
- "selectPreset": "选择预设",
- "webcamCropY": "垂直移动",
- "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
"pictureInPicture": "画中画",
- "reactiveWebcam": "缩放时缩小",
- "webcamBackground": "摄像头背景",
- "webcamBlurIntensity": "模糊强度",
- "mirrorWebcam": "镜像摄像头",
"webcamShape": "摄像头形状",
+ "webcamCropY": "垂直移动",
+ "webcamSize": "摄像头大小",
+ "mirrorWebcam": "镜像摄像头",
+ "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
+ "webcamFraming": "摄像头构图",
"noWebcam": "无摄像头",
+ "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
+ "webcamCropZoom": "裁剪缩放",
+ "dualFrame": "双画框",
"webcamCropX": "水平移动",
- "webcamFraming": "摄像头构图"
- },
- "imageUpload": {
- "uploadSuccess": "自定义图片上传成功!",
- "failedToUpload": "上传图片失败",
- "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。",
- "errorReading": "读取文件时出错。",
- "invalidFileType": "无效的文件类型"
- },
- "cursor": {
- "themeDefault": "默认",
- "motionBlur": "运动模糊",
- "smoothing": "平滑",
- "title": "光标",
- "theme": "光标样式",
- "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
- "show": "显示光标",
- "clipToBounds": "裁剪到画布",
- "size": "大小",
- "clickBounce": "点击弹跳",
- "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。"
+ "title": "摄像头布局",
+ "preset": "预设"
},
- "captions": {
- "original": "原文(转录)",
- "alignRight": "右对齐",
- "backgroundOpacity": "不透明度",
- "anchorBottom": "底部",
- "minWords": "每行最少词数",
- "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
- "translate": "翻译",
- "maxWords": "每行最多词数",
- "lineLength": "行长",
- "anchorTop": "顶部",
- "font": "字体",
- "translating": "翻译中…",
- "position": "位置",
- "removeLegacyAnnotations": "移除旧的字幕批注",
- "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
- "backgroundColor": "背景颜色",
- "text": "文本",
- "textColor": "文字颜色",
- "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
- "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
- "distanceFromTop": "距顶部",
- "alignLeft": "左对齐",
- "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
- "deleteTranslation": "删除此翻译",
- "distanceFromBottom": "距底部",
- "displayLanguage": "显示",
- "background": "背景",
- "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
- "distanceFromLeft": "距左侧",
- "alignCenter": "居中",
- "fontSize": "字号",
- "bold": "粗体",
- "translateFailed": "翻译失败。",
- "distanceFromRight": "距右侧",
- "showBackground": "显示背景",
- "translateHint": "使用已配置的 AI 提供方翻译转录",
- "show": "显示字幕",
- "language": "语言",
- "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。"
+ "crop": {
+ "done": "完成",
+ "ratio": "比例",
+ "dragInstruction": "拖动每一侧来调整裁剪区域",
+ "title": "裁剪",
+ "free": "自由",
+ "lockAspectRatio": "锁定宽高比",
+ "unlockAspectRatio": "解锁宽高比",
+ "cropVideo": "裁剪视频"
},
"zoom": {
+ "position": {
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "焦点位置"
+ },
"threeD": {
"preset": {
"right": "右",
@@ -241,115 +59,296 @@
"none": "无",
"title": "3D 旋转"
},
+ "deleteZoom": "删除缩放",
+ "customScale": "自定义缩放",
+ "selectRegion": "选择要调整的缩放区域",
"focusMode": {
+ "manual": "手动",
"autoDescription": "摄像头跟随录制时的光标位置",
"lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
"title": "对焦模式",
- "manual": "手动",
"auto": "自动"
},
- "position": {
- "title": "焦点位置",
- "x": "X (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "y": "Y (%)"
- },
- "deleteZoom": "删除缩放",
- "level": "缩放级别",
- "selectRegion": "选择要调整的缩放区域",
"previewHold": "按住预览放大效果",
- "customScale": "自定义缩放"
- },
- "textAnimation": {
- "pop": "弹出",
- "rise": "上升",
- "selectAnimation": "选择动画",
- "fade": "淡入淡出",
- "pulse": "脉动",
- "typewriter": "打字机",
- "title": "文本动画",
- "none": "无",
- "slideLeft": "向左滑动"
- },
- "crop": {
- "lockAspectRatio": "锁定宽高比",
- "title": "裁剪",
- "done": "完成",
- "dragInstruction": "拖动每一侧来调整裁剪区域",
- "ratio": "比例",
- "free": "自由",
- "cropVideo": "裁剪视频",
- "unlockAspectRatio": "解锁宽高比"
+ "level": "缩放级别"
},
"background": {
- "imageLabel": "背景 {{index}}",
"color": "颜色",
- "gradient": "渐变",
"colorLabel": "颜色 {{color}}",
- "colorWheel": "颜色轮",
- "customWallpaper": "自定义壁纸",
- "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
- "image": "图片",
- "gradientLabel": "渐变 {{index}}",
+ "gradient": "渐变",
"imageReadFailed": "无法读取该图片文件。",
- "presets": "预设",
+ "gradientLabel": "渐变 {{index}}",
"custom": "自定义",
+ "customWallpaper": "自定义壁纸",
+ "presets": "预设",
+ "image": "图片",
"colorPalette": "颜色调色板",
- "uploadCustom": "上传自定义",
+ "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
+ "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
+ "imageLabel": "背景 {{index}}",
"title": "背景",
- "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。"
+ "uploadCustom": "上传自定义",
+ "colorWheel": "颜色轮"
},
- "audio": {
- "title": "音频",
- "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
- "reset": "重置音频",
- "outputGain": "输出电平"
+ "cursor": {
+ "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
+ "clickBounce": "点击弹跳",
+ "clipToBounds": "裁剪到画布",
+ "title": "光标",
+ "size": "大小",
+ "themeDefault": "默认",
+ "smoothing": "平滑",
+ "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
+ "motionBlur": "运动模糊",
+ "theme": "光标样式",
+ "show": "显示光标"
+ },
+ "captions": {
+ "text": "文本",
+ "showBackground": "显示背景",
+ "minWords": "每行最少词数",
+ "translateFailed": "翻译失败。",
+ "distanceFromTop": "距顶部",
+ "fontSize": "字号",
+ "distanceFromRight": "距右侧",
+ "bold": "粗体",
+ "textColor": "文字颜色",
+ "original": "原文(转录)",
+ "translating": "翻译中…",
+ "language": "语言",
+ "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
+ "alignLeft": "左对齐",
+ "distanceFromBottom": "距底部",
+ "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
+ "show": "显示字幕",
+ "background": "背景",
+ "backgroundOpacity": "不透明度",
+ "removeLegacyAnnotations": "移除旧的字幕批注",
+ "anchorTop": "顶部",
+ "translate": "翻译",
+ "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
+ "alignCenter": "居中",
+ "alignRight": "右对齐",
+ "translateHint": "使用已配置的 AI 提供方翻译转录",
+ "displayLanguage": "显示",
+ "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
+ "distanceFromLeft": "距左侧",
+ "maxWords": "每行最多词数",
+ "anchorBottom": "底部",
+ "position": "位置",
+ "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
+ "deleteTranslation": "删除此翻译",
+ "backgroundColor": "背景颜色",
+ "font": "字体",
+ "lineLength": "行长",
+ "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
+ "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。"
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "导出分辨率"
},
- "gifSettings": {
- "loop": "循环 GIF",
- "frameRate": "GIF 帧率",
- "size": "GIF 尺寸"
+ "transcript": {
+ "transcribing": "转录中…",
+ "laneFeedsCaptions": "字幕从这条轨道烧录。",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
+ "laneVoiceover": "配音",
+ "blankedWord": "已清空",
+ "noTranscript": "暂无转录",
+ "noAudio": "此媒体没有音频轨道",
+ "revertWord": "还原为“{{original}}”",
+ "silence": "[静音 {{duration}} 秒]",
+ "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
+ "transcribeNow": "立即转录",
+ "restoreWord": "恢复“{{word}}”",
+ "clipLabel": "片段 {{index}}",
+ "title": "当前转录",
+ "insertAria": "新词",
+ "laneRecording": "录制",
+ "trimSilence": "修剪静音({{duration}} 秒)",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
+ "removeInserted": "删除“{{word}}”",
+ "editorAria": "{{filename}} 的转录",
+ "correctedWord": "已更正 — 转录原文为“{{original}}”",
+ "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
+ "editWord": "编辑“{{word}}”",
+ "noClips": "暂无片段",
+ "laneLabel": "转写文本读取自",
+ "restoreSilence": "恢复静音({{duration}} 秒)"
},
- "export": {
- "chooseSaveLocation": "选择保存位置",
- "gifButton": "导出 GIF",
- "videoButton": "导出视频"
+ "customFont": {
+ "addingButton": "添加中...",
+ "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
+ "addButton": "添加字体",
+ "dialogTitle": "添加 Google 字体",
+ "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
+ "nameLabel": "显示名称",
+ "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
+ "urlLabel": "Google Fonts 导入 URL",
+ "errorEmptyName": "请输入字体名称",
+ "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
+ "namePlaceholder": "我的自定义字体",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "添加字体失败",
+ "nameHelp": "这是字体在字体选择器中显示的名称",
+ "errorExtractFailed": "无法从 URL 中提取字体系列",
+ "errorInvalidUrl": "请输入有效的 Google Fonts URL",
+ "successMessage": "字体 \"{{fontName}}\" 添加成功"
},
- "project": {
- "load": "加载项目",
- "save": "保存项目",
- "new": "新建项目"
+ "annotation": {
+ "arrowColor": "箭头颜色",
+ "colorWheel": "颜色轮",
+ "blurType": "模糊类型",
+ "active": "活动",
+ "deleteAnnotation": "删除标注",
+ "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+ "strokeWidth": "描边宽度:{{width}}px",
+ "background": "背景",
+ "imageUploadSuccess": "图片上传成功!",
+ "blurColor": "模糊颜色",
+ "blurTypeBlur": "高斯",
+ "textColor": "文本颜色",
+ "blurColorWhite": "白色",
+ "title": "标注设置",
+ "type": "类型",
+ "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
+ "typeImage": "图片",
+ "textContent": "文本内容",
+ "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
+ "typeText": "文本",
+ "blurIntensity": "模糊强度",
+ "none": "无",
+ "mosaicBlockSize": "马赛克块大小",
+ "textPlaceholder": "输入您的文本...",
+ "typeArrow": "箭头",
+ "color": "颜色",
+ "blurColorBlack": "黑色",
+ "size": "大小",
+ "invalidImageType": "无效的文件类型",
+ "blurShapeFreehand": "自由手绘",
+ "shortcutsAndTips": "快捷键与提示",
+ "uploadImage": "上传图片",
+ "blurTypeMosaic": "马赛克",
+ "selectStyle": "选择样式",
+ "defaultText": "你好",
+ "blurShapeRectangle": "矩形",
+ "colorPalette": "颜色调色板",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
+ "clearBackground": "清除背景",
+ "customFonts": "自定义字体",
+ "typeBlur": "模糊",
+ "tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
+ "fontStyle": "字体样式",
+ "blurShape": "模糊形状",
+ "arrowDirection": "箭头方向",
+ "blurShapeOval": "椭圆"
},
"speed": {
- "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。",
"customPlaybackSpeed": "自定义播放速度",
- "maxSpeedError": "速度不能超过 {{max}}×",
"deleteRegion": "删除速度区域",
"selectRegion": "选择要调整的速度区域",
- "playbackSpeed": "播放速度"
+ "maxSpeedError": "速度不能超过 {{max}}×",
+ "playbackSpeed": "播放速度",
+ "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。"
+ },
+ "textAnimation": {
+ "selectAnimation": "选择动画",
+ "pulse": "脉动",
+ "rise": "上升",
+ "none": "无",
+ "slideLeft": "向左滑动",
+ "title": "文本动画",
+ "fade": "淡入淡出",
+ "pop": "弹出",
+ "typewriter": "打字机"
+ },
+ "effects": {
+ "motion": "运动",
+ "title": "画面合成",
+ "format": "格式",
+ "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
+ "fitClipFew": "{{count}} 个片段",
+ "motionBlur": "运动模糊",
+ "fitClipMany": "{{count}} 个片段",
+ "frame": "画框",
+ "padding": "内边距",
+ "roundness": "圆角",
+ "off": "关",
+ "blurBg": "模糊背景",
+ "shadow": "阴影",
+ "on": "开",
+ "fitClipOne": "{{count}} 个片段",
+ "formatOriginal": "原始",
+ "fitClip": "适配"
+ },
+ "exportFormat": {
+ "gifDescription": "可分享的动态图片",
+ "mp4": "MP4",
+ "mp4Video": "MP4 视频",
+ "gif": "GIF",
+ "gifAnimation": "GIF 动画",
+ "mp4Description": "高质量视频文件"
+ },
+ "imageUpload": {
+ "failedToUpload": "上传图片失败",
+ "uploadSuccess": "自定义图片上传成功!",
+ "errorReading": "读取文件时出错。",
+ "invalidFileType": "无效的文件类型",
+ "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。"
+ },
+ "facets": {
+ "captions": "字幕",
+ "transcript": "转录文本"
+ },
+ "audio": {
+ "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
+ "reset": "重置音频",
+ "title": "音频",
+ "outputGain": "输出电平"
},
"language": {
"title": "语言"
},
- "support": {
- "starOnGithub": "在 GitHub 上加星",
- "reportBug": "报告错误",
- "saveDiagnostics": "保存诊断信息"
+ "audioTrack": {
+ "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "importFailed": "无法添加音频",
+ "slipHint": "按住 Alt 拖动可在其中滑动音频",
+ "fadeOut": "淡出",
+ "defaultLabel": "音频轨道",
+ "mute": "静音",
+ "remove": "删除轨道",
+ "add": "添加音频轨道",
+ "loop": "循环",
+ "fadeIn": "淡入"
+ },
+ "project": {
+ "load": "加载项目",
+ "save": "保存项目",
+ "new": "新建项目"
+ },
+ "panes": {
+ "help": "帮助"
},
"trim": {
"deleteRegion": "删除剪辑区域"
},
- "facets": {
- "transcript": "转录文本",
- "captions": "字幕"
+ "export": {
+ "videoButton": "导出视频",
+ "gifButton": "导出 GIF",
+ "chooseSaveLocation": "选择保存位置"
},
- "panes": {
- "help": "帮助"
+ "support": {
+ "starOnGithub": "在 GitHub 上加星",
+ "saveDiagnostics": "保存诊断信息",
+ "reportBug": "报告错误"
+ },
+ "gifSettings": {
+ "size": "GIF 尺寸",
+ "frameRate": "GIF 帧率",
+ "loop": "循环 GIF"
}
}
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 93365f38c..5842e9470 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -1,237 +1,55 @@
{
- "exportFormat": {
- "gifAnimation": "GIF 動畫",
- "mp4Description": "高品質影片檔案",
- "mp4": "MP4",
- "mp4Video": "MP4 影片",
- "gif": "GIF",
- "gifDescription": "可分享的動態圖片"
- },
- "customFont": {
- "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "dialogTitle": "新增 Google 字體",
- "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
- "nameLabel": "顯示名稱",
- "failedToAdd": "新增字體失敗",
- "urlLabel": "Google Fonts 匯入 URL",
- "namePlaceholder": "我的自訂字體",
- "errorExtractFailed": "無法從 URL 中提取字體系列",
- "addingButton": "新增中...",
- "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
- "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
- "successMessage": "字體 \"{{fontName}}\" 新增成功",
- "nameHelp": "這是字體在字體選擇器中顯示的名稱",
- "errorEmptyName": "請輸入字體名稱",
- "addButton": "新增字體",
- "errorInvalidUrl": "請輸入有效的 Google Fonts URL"
- },
- "annotation": {
- "colorWheel": "色輪",
- "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
- "typeArrow": "箭頭",
- "selectStyle": "選擇樣式",
- "blurColorWhite": "白色",
- "blurType": "模糊類型",
- "blurShapeRectangle": "矩形",
- "blurColor": "模糊顏色",
- "arrowColor": "箭頭顏色",
- "textContent": "文字內容",
- "background": "背景",
- "clearBackground": "清除背景",
- "blurShapeFreehand": "自由手繪",
- "blurIntensity": "模糊強度",
- "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
- "active": "啟用",
- "size": "大小",
- "blurColorBlack": "黑色",
- "typeImage": "圖片",
- "mosaicBlockSize": "馬賽克區塊大小",
- "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
- "strokeWidth": "描邊寬度:{{width}}px",
- "textColor": "文字顏色",
- "defaultText": "你好",
- "blurShape": "模糊形狀",
- "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
- "type": "類型",
- "typeText": "文字",
- "textPlaceholder": "輸入您的文字...",
- "fontStyle": "字體樣式",
- "imageUploadSuccess": "圖片上傳成功!",
- "colorPalette": "調色盤",
- "color": "顏色",
- "shortcutsAndTips": "快捷鍵與提示",
- "none": "無",
- "invalidImageType": "無效的檔案類型",
- "arrowDirection": "箭頭方向",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
- "uploadImage": "上傳圖片",
- "customFonts": "自訂字體",
- "blurTypeMosaic": "馬賽克",
- "blurTypeBlur": "高斯",
- "typeBlur": "模糊",
- "title": "標註設定",
- "deleteAnnotation": "刪除標註",
- "blurShapeOval": "橢圓"
- },
- "transcript": {
- "restoreSilence": "還原靜音({{duration}} 秒)",
- "editWord": "編輯「{{word}}」",
- "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
- "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
- "insertedWord": "你加入的字詞 — 背後沒有聲音",
- "laneFeedsCaptions": "字幕從這條軌道燒錄。",
- "insertAria": "新字詞",
- "transcribing": "轉錄中…",
- "noAudio": "此媒體沒有音訊軌道",
- "noTranscript": "尚無逐字稿",
- "silence": "[靜音 {{duration}} 秒]",
- "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
- "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
- "noClips": "尚無片段",
- "laneVoiceover": "旁白",
- "laneLabel": "轉錄文字讀取自",
- "revertWord": "還原為「{{original}}」",
- "helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
- "blankedWord": "已清空",
- "clipLabel": "片段 {{index}}",
- "title": "目前的逐字稿",
- "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
- "transcribeNow": "立即產生逐字稿",
- "laneRecording": "錄影",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
- "removeInserted": "刪除「{{word}}」",
- "trimSilence": "修剪靜音({{duration}} 秒)",
- "editorAria": "{{filename}} 的逐字稿",
- "restoreWord": "還原「{{word}}」"
- },
- "effects": {
- "shadow": "陰影",
- "fitClipOne": "{{count}} 個片段",
- "blurBg": "模糊背景",
- "fitClip": "符合",
- "formatOriginal": "原始",
- "title": "畫面合成",
- "format": "格式",
- "motionBlur": "動態模糊",
- "fitClipMany": "{{count}} 個片段",
- "roundness": "圓角",
- "fitClipFew": "{{count}} 個片段",
- "motion": "動態",
- "on": "開",
- "frame": "外框",
- "padding": "內邊距",
- "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
- "off": "關"
- },
- "audioTrack": {
- "mute": "靜音",
- "fadeIn": "淡入",
- "loop": "循環",
- "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
- "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
- "importFailed": "無法新增音訊",
- "fadeOut": "淡出",
- "add": "新增音訊軌道",
- "defaultLabel": "音訊軌道",
- "remove": "刪除軌道"
- },
"layout": {
+ "webcamBlurIntensity": "模糊強度",
+ "bgModes": {
+ "transparent": "去背",
+ "none": "原畫",
+ "blur": "模糊",
+ "custom": "自訂"
+ },
+ "selectPreset": "選擇預設",
+ "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
+ "reactiveWebcam": "縮放時縮小",
"shapes": {
"circle": "圓形",
"square": "正方形",
"rectangle": "矩形",
"rounded": "圓角"
},
+ "webcamBackground": "攝影機背景",
"verticalStack": "垂直堆疊",
- "webcamSize": "攝影機大小",
- "preset": "預設",
- "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
- "dualFrame": "雙畫框",
- "title": "攝影機版面",
- "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
- "bgModes": {
- "transparent": "去背",
- "custom": "自訂",
- "none": "原畫",
- "blur": "模糊"
- },
- "webcamCropZoom": "裁切縮放",
- "selectPreset": "選擇預設",
- "webcamCropY": "垂直移動",
- "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
"pictureInPicture": "子母畫面",
- "reactiveWebcam": "縮放時縮小",
- "webcamBackground": "攝影機背景",
- "webcamBlurIntensity": "模糊強度",
- "mirrorWebcam": "鏡像攝影機",
"webcamShape": "攝影機形狀",
+ "webcamCropY": "垂直移動",
+ "webcamSize": "攝影機大小",
+ "mirrorWebcam": "鏡像攝影機",
+ "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
+ "webcamFraming": "攝影機構圖",
"noWebcam": "無網路攝影機",
+ "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
+ "webcamCropZoom": "裁切縮放",
+ "dualFrame": "雙畫框",
"webcamCropX": "水平移動",
- "webcamFraming": "攝影機構圖"
- },
- "imageUpload": {
- "uploadSuccess": "自訂圖片上傳成功!",
- "failedToUpload": "上傳圖片失敗",
- "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。",
- "errorReading": "讀取檔案時出錯。",
- "invalidFileType": "無效的檔案類型"
- },
- "cursor": {
- "themeDefault": "預設",
- "motionBlur": "動態模糊",
- "smoothing": "平滑",
- "title": "游標",
- "theme": "游標樣式",
- "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
- "show": "顯示游標",
- "clipToBounds": "裁切至畫布",
- "size": "大小",
- "clickBounce": "點擊彈跳",
- "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。"
+ "title": "攝影機版面",
+ "preset": "預設"
},
- "captions": {
- "original": "原文(逐字稿)",
- "alignRight": "靠右",
- "backgroundOpacity": "不透明度",
- "anchorBottom": "下",
- "minWords": "每行最少字數",
- "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
- "translate": "翻譯",
- "maxWords": "每行最多字數",
- "lineLength": "行長",
- "anchorTop": "上",
- "font": "字型",
- "translating": "翻譯中…",
- "position": "位置",
- "removeLegacyAnnotations": "移除舊的字幕註解",
- "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
- "backgroundColor": "背景顏色",
- "text": "文字",
- "textColor": "文字顏色",
- "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
- "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
- "distanceFromTop": "距上緣",
- "alignLeft": "靠左",
- "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
- "deleteTranslation": "刪除這個翻譯",
- "distanceFromBottom": "距下緣",
- "displayLanguage": "顯示",
- "background": "背景",
- "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
- "distanceFromLeft": "距左緣",
- "alignCenter": "置中",
- "fontSize": "大小",
- "bold": "粗體",
- "translateFailed": "翻譯失敗。",
- "distanceFromRight": "距右緣",
- "showBackground": "顯示背景",
- "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
- "show": "顯示字幕",
- "language": "語言",
- "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。"
+ "crop": {
+ "done": "完成",
+ "ratio": "比例",
+ "dragInstruction": "拖動每一側來調整裁剪區域",
+ "title": "裁剪",
+ "free": "自由",
+ "lockAspectRatio": "鎖定長寬比",
+ "unlockAspectRatio": "解鎖長寬比",
+ "cropVideo": "裁剪影片"
},
"zoom": {
+ "position": {
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "title": "焦點位置"
+ },
"threeD": {
"preset": {
"right": "右",
@@ -241,115 +59,296 @@
"none": "無",
"title": "3D 旋轉"
},
+ "deleteZoom": "刪除縮放",
+ "customScale": "自訂縮放",
+ "selectRegion": "選擇要調整的縮放區域",
"focusMode": {
+ "manual": "手動",
"autoDescription": "攝影機跟隨錄製時的游標位置",
"lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
"title": "對焦模式",
- "manual": "手動",
"auto": "自動"
},
- "position": {
- "title": "焦點位置",
- "x": "X (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "y": "Y (%)"
- },
- "deleteZoom": "刪除縮放",
- "level": "縮放級別",
- "selectRegion": "選擇要調整的縮放區域",
"previewHold": "按住預覽放大效果",
- "customScale": "自訂縮放"
- },
- "textAnimation": {
- "pop": "彈出",
- "rise": "上升",
- "selectAnimation": "選擇動畫",
- "fade": "淡入淡出",
- "pulse": "脈動",
- "typewriter": "打字機",
- "title": "文字動畫",
- "none": "無",
- "slideLeft": "向左滑動"
- },
- "crop": {
- "lockAspectRatio": "鎖定長寬比",
- "title": "裁剪",
- "done": "完成",
- "dragInstruction": "拖動每一側來調整裁剪區域",
- "ratio": "比例",
- "free": "自由",
- "cropVideo": "裁剪影片",
- "unlockAspectRatio": "解鎖長寬比"
+ "level": "縮放級別"
},
"background": {
- "imageLabel": "背景 {{index}}",
"color": "顏色",
- "gradient": "漸層",
"colorLabel": "顏色 {{color}}",
- "colorWheel": "色輪",
- "customWallpaper": "自訂桌布",
- "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
- "image": "圖片",
- "gradientLabel": "漸層 {{index}}",
+ "gradient": "漸層",
"imageReadFailed": "無法讀取該圖片檔案。",
- "presets": "預設",
+ "gradientLabel": "漸層 {{index}}",
"custom": "自訂",
+ "customWallpaper": "自訂桌布",
+ "presets": "預設",
+ "image": "圖片",
"colorPalette": "調色盤",
- "uploadCustom": "上傳自訂",
+ "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
+ "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
+ "imageLabel": "背景 {{index}}",
"title": "背景",
- "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。"
+ "uploadCustom": "上傳自訂",
+ "colorWheel": "色輪"
},
- "audio": {
- "title": "音訊",
- "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
- "reset": "重設音訊",
- "outputGain": "輸出音量"
+ "cursor": {
+ "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
+ "clickBounce": "點擊彈跳",
+ "clipToBounds": "裁切至畫布",
+ "title": "游標",
+ "size": "大小",
+ "themeDefault": "預設",
+ "smoothing": "平滑",
+ "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
+ "motionBlur": "動態模糊",
+ "theme": "游標樣式",
+ "show": "顯示游標"
+ },
+ "captions": {
+ "text": "文字",
+ "showBackground": "顯示背景",
+ "minWords": "每行最少字數",
+ "translateFailed": "翻譯失敗。",
+ "distanceFromTop": "距上緣",
+ "fontSize": "大小",
+ "distanceFromRight": "距右緣",
+ "bold": "粗體",
+ "textColor": "文字顏色",
+ "original": "原文(逐字稿)",
+ "translating": "翻譯中…",
+ "language": "語言",
+ "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
+ "alignLeft": "靠左",
+ "distanceFromBottom": "距下緣",
+ "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
+ "show": "顯示字幕",
+ "background": "背景",
+ "backgroundOpacity": "不透明度",
+ "removeLegacyAnnotations": "移除舊的字幕註解",
+ "anchorTop": "上",
+ "translate": "翻譯",
+ "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
+ "alignCenter": "置中",
+ "alignRight": "靠右",
+ "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
+ "displayLanguage": "顯示",
+ "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
+ "distanceFromLeft": "距左緣",
+ "maxWords": "每行最多字數",
+ "anchorBottom": "下",
+ "position": "位置",
+ "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
+ "deleteTranslation": "刪除這個翻譯",
+ "backgroundColor": "背景顏色",
+ "font": "字型",
+ "lineLength": "行長",
+ "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
+ "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。"
},
"exportQuality": {
- "low": "720p",
"medium": "1080p",
+ "low": "720p",
"high": "Source",
"title": "匯出解析度"
},
- "gifSettings": {
- "loop": "循環 GIF",
- "frameRate": "GIF 影格率",
- "size": "GIF 尺寸"
+ "transcript": {
+ "transcribing": "轉錄中…",
+ "laneFeedsCaptions": "字幕從這條軌道燒錄。",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
+ "laneVoiceover": "旁白",
+ "blankedWord": "已清空",
+ "noTranscript": "尚無逐字稿",
+ "noAudio": "此媒體沒有音訊軌道",
+ "revertWord": "還原為「{{original}}」",
+ "silence": "[靜音 {{duration}} 秒]",
+ "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
+ "transcribeNow": "立即產生逐字稿",
+ "restoreWord": "還原「{{word}}」",
+ "clipLabel": "片段 {{index}}",
+ "title": "目前的逐字稿",
+ "insertAria": "新字詞",
+ "laneRecording": "錄影",
+ "trimSilence": "修剪靜音({{duration}} 秒)",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
+ "removeInserted": "刪除「{{word}}」",
+ "editorAria": "{{filename}} 的逐字稿",
+ "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
+ "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
+ "editWord": "編輯「{{word}}」",
+ "noClips": "尚無片段",
+ "laneLabel": "轉錄文字讀取自",
+ "restoreSilence": "還原靜音({{duration}} 秒)"
},
- "export": {
- "chooseSaveLocation": "選擇儲存位置",
- "gifButton": "匯出 GIF",
- "videoButton": "匯出影片"
+ "customFont": {
+ "addingButton": "新增中...",
+ "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "addButton": "新增字體",
+ "dialogTitle": "新增 Google 字體",
+ "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
+ "nameLabel": "顯示名稱",
+ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
+ "urlLabel": "Google Fonts 匯入 URL",
+ "errorEmptyName": "請輸入字體名稱",
+ "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
+ "namePlaceholder": "我的自訂字體",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "新增字體失敗",
+ "nameHelp": "這是字體在字體選擇器中顯示的名稱",
+ "errorExtractFailed": "無法從 URL 中提取字體系列",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
+ "successMessage": "字體 \"{{fontName}}\" 新增成功"
},
- "project": {
- "load": "載入專案",
- "save": "儲存專案",
- "new": "新增專案"
+ "annotation": {
+ "arrowColor": "箭頭顏色",
+ "colorWheel": "色輪",
+ "blurType": "模糊類型",
+ "active": "啟用",
+ "deleteAnnotation": "刪除標註",
+ "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "strokeWidth": "描邊寬度:{{width}}px",
+ "background": "背景",
+ "imageUploadSuccess": "圖片上傳成功!",
+ "blurColor": "模糊顏色",
+ "blurTypeBlur": "高斯",
+ "textColor": "文字顏色",
+ "blurColorWhite": "白色",
+ "title": "標註設定",
+ "type": "類型",
+ "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
+ "typeImage": "圖片",
+ "textContent": "文字內容",
+ "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
+ "typeText": "文字",
+ "blurIntensity": "模糊強度",
+ "none": "無",
+ "mosaicBlockSize": "馬賽克區塊大小",
+ "textPlaceholder": "輸入您的文字...",
+ "typeArrow": "箭頭",
+ "color": "顏色",
+ "blurColorBlack": "黑色",
+ "size": "大小",
+ "invalidImageType": "無效的檔案類型",
+ "blurShapeFreehand": "自由手繪",
+ "shortcutsAndTips": "快捷鍵與提示",
+ "uploadImage": "上傳圖片",
+ "blurTypeMosaic": "馬賽克",
+ "selectStyle": "選擇樣式",
+ "defaultText": "你好",
+ "blurShapeRectangle": "矩形",
+ "colorPalette": "調色盤",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "clearBackground": "清除背景",
+ "customFonts": "自訂字體",
+ "typeBlur": "模糊",
+ "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
+ "fontStyle": "字體樣式",
+ "blurShape": "模糊形狀",
+ "arrowDirection": "箭頭方向",
+ "blurShapeOval": "橢圓"
},
"speed": {
- "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。",
"customPlaybackSpeed": "自訂播放速度",
- "maxSpeedError": "速度不能超過 {{max}}×",
"deleteRegion": "刪除速度區域",
"selectRegion": "選擇要調整的速度區域",
- "playbackSpeed": "播放速度"
+ "maxSpeedError": "速度不能超過 {{max}}×",
+ "playbackSpeed": "播放速度",
+ "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。"
+ },
+ "textAnimation": {
+ "selectAnimation": "選擇動畫",
+ "pulse": "脈動",
+ "rise": "上升",
+ "none": "無",
+ "slideLeft": "向左滑動",
+ "title": "文字動畫",
+ "fade": "淡入淡出",
+ "pop": "彈出",
+ "typewriter": "打字機"
+ },
+ "effects": {
+ "motion": "動態",
+ "title": "畫面合成",
+ "format": "格式",
+ "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
+ "fitClipFew": "{{count}} 個片段",
+ "motionBlur": "動態模糊",
+ "fitClipMany": "{{count}} 個片段",
+ "frame": "外框",
+ "padding": "內邊距",
+ "roundness": "圓角",
+ "off": "關",
+ "blurBg": "模糊背景",
+ "shadow": "陰影",
+ "on": "開",
+ "fitClipOne": "{{count}} 個片段",
+ "formatOriginal": "原始",
+ "fitClip": "符合"
+ },
+ "exportFormat": {
+ "gifDescription": "可分享的動態圖片",
+ "mp4": "MP4",
+ "mp4Video": "MP4 影片",
+ "gif": "GIF",
+ "gifAnimation": "GIF 動畫",
+ "mp4Description": "高品質影片檔案"
+ },
+ "imageUpload": {
+ "failedToUpload": "上傳圖片失敗",
+ "uploadSuccess": "自訂圖片上傳成功!",
+ "errorReading": "讀取檔案時出錯。",
+ "invalidFileType": "無效的檔案類型",
+ "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。"
+ },
+ "facets": {
+ "captions": "字幕",
+ "transcript": "逐字稿"
+ },
+ "audio": {
+ "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
+ "reset": "重設音訊",
+ "title": "音訊",
+ "outputGain": "輸出音量"
},
"language": {
"title": "語言"
},
- "support": {
- "starOnGithub": "在 GitHub 上加星",
- "reportBug": "回報錯誤",
- "saveDiagnostics": "儲存診斷資料"
+ "audioTrack": {
+ "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "importFailed": "無法新增音訊",
+ "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
+ "fadeOut": "淡出",
+ "defaultLabel": "音訊軌道",
+ "mute": "靜音",
+ "remove": "刪除軌道",
+ "add": "新增音訊軌道",
+ "loop": "循環",
+ "fadeIn": "淡入"
+ },
+ "project": {
+ "load": "載入專案",
+ "save": "儲存專案",
+ "new": "新增專案"
+ },
+ "panes": {
+ "help": "說明"
},
"trim": {
"deleteRegion": "刪除剪輯區域"
},
- "facets": {
- "transcript": "逐字稿",
- "captions": "字幕"
+ "export": {
+ "videoButton": "匯出影片",
+ "gifButton": "匯出 GIF",
+ "chooseSaveLocation": "選擇儲存位置"
},
- "panes": {
- "help": "說明"
+ "support": {
+ "starOnGithub": "在 GitHub 上加星",
+ "saveDiagnostics": "儲存診斷資料",
+ "reportBug": "回報錯誤"
+ },
+ "gifSettings": {
+ "size": "GIF 尺寸",
+ "frameRate": "GIF 影格率",
+ "loop": "循環 GIF"
}
}
From c991976ae332e87a743dc7f1e4f7493e6fdd2ecb Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 18:09:54 +0200
Subject: [PATCH 110/113] fix(ai): the chat cannot rewrite a word nobody said
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Word insertion is gated on a dev-only flag, and that gate lives in the renderer. The chat
runs in the main process, where it does not exist — and `setWordText` calls
`setDocumentWordText`, which dispatches to the retext of generated media when the asset is
one of ours. A release could therefore resize a mire clip and ask the save for new generated
media, by asking the assistant.
Refused unconditionally rather than mirroring the flag: this tool exists to fix a name the
transcriber misheard, and an inserted word was never heard. The agent has no business
authoring generated media in a dev build either.
Found auditing what the branches we are about to close still contain, not by looking for it.
---
electron/ai-edition/agent-tools.test.ts | 35 +++++++++++++++++++++++++
electron/ai-edition/agent-tools.ts | 12 +++++++++
2 files changed, 47 insertions(+)
diff --git a/electron/ai-edition/agent-tools.test.ts b/electron/ai-edition/agent-tools.test.ts
index f39510487..20d345298 100644
--- a/electron/ai-edition/agent-tools.test.ts
+++ b/electron/ai-edition/agent-tools.test.ts
@@ -2301,6 +2301,41 @@ describe("setWordText", () => {
expect(next.transcripts[0].segments[0].text).toBe("I use Kubernetes");
});
+ // The editor gates word INSERTION on a dev-only flag, and that gate lives in the renderer.
+ // The chat runs in the main process, so an ungated path here would let a release rewrite
+ // generated media through the agent — the one door the flag cannot see.
+ it("refuses a word that was added rather than heard", () => {
+ const base = documentWithWords();
+ const withInsertion: AxcutDocument = {
+ ...base,
+ transcripts: [
+ ...base.transcripts,
+ {
+ assetId: "ext:synth_1",
+ language: "en",
+ segments: [],
+ words: [
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 0,
+ endSec: 0.15,
+ text: "added",
+ source: "synth",
+ },
+ ],
+ },
+ ],
+ };
+ const result = run(withInsertion, "setWordText", {
+ assetId: "ext:synth_1",
+ wordId: "synth_1",
+ text: "much longer",
+ });
+ expect(result.ok).toBe(false);
+ expect(result.document).toBeUndefined();
+ });
+
// The document carries the transcript twice; a write that reaches only one leaves the
// legacy mirror serving the old text forever.
it("writes the legacy mirror too", () => {
diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts
index 469b5d313..958658f85 100644
--- a/electron/ai-edition/agent-tools.ts
+++ b/electron/ai-edition/agent-tools.ts
@@ -35,6 +35,7 @@ import {
import { setDocumentWordText } from "../../src/lib/ai-edition/document/transcript";
import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
import { hasAnyClipWithCamera } from "../../src/lib/ai-edition/timeline/camera";
+import { isGeneratedAssetId } from "../../src/lib/ai-edition/timeline/clip-parts";
import {
buildCursorTrack,
type CursorTrackSample,
@@ -1341,6 +1342,17 @@ export function executeAgentTool(
if (before.text === text) {
return failure(`Word ${wordId} already reads "${text}" — nothing to change.`);
}
+ // This tool exists to fix a name the transcriber misheard. An INSERTED word was
+ // never heard: retyping it resizes the clip it plays on and asks for generated
+ // media of a new length, which is the gesture the editor gates on `insertionsEnabled`
+ // — and that gate lives in the renderer, where the chat does not run. Refused here
+ // unconditionally rather than mirrored, because the agent has no business authoring
+ // generated media at all.
+ if (isGeneratedAssetId(assetId)) {
+ return failure(
+ `Word ${wordId} was added to the transcript, not heard — the chat cannot rewrite it.`,
+ );
+ }
let next: AxcutDocument;
try {
next = setDocumentWordText(document, assetId, wordId, text);
From c748ebc487816f32bee34be8877296da50699553 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 19:12:31 +0200
Subject: [PATCH 111/113] fix: the six CodeRabbit findings that were still
standing and cheap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `removeModifier`'s refusal listed zoom / speed / annotation / full-camera while the lookup
above it had already gained audio. The code and its own error message disagreed.
- Space on an audio pill selected it AND toggled playback: the shell binds Space on `window`,
above React's root, so stopping the synthetic event alone is not enough. Same fix the
region pill two hundred lines down already had.
- `patchAudioTrack` spreads three payload keys onto every fragment and the test covered two.
`loop` is the one a half-applied patch breaks loudest — a take looping on one fragment and
not the next stops mid-sentence at the clip boundary.
- `slipAudioOffsetMs` guards `undefined` as its own branch and the test only had `null` and
zero. An asset whose duration was never probed carries no key at all.
- fr: `faire défiler` reads as scrolling; the gesture is a slip. `déplacer`.
- ko-KR: a particle attaches to the word before it — `Alt를`, not `Alt 를`.
---
electron/ai-edition/agent-tools.ts | 2 +-
src/components/ai-edition/v4/V4Timeline.tsx | 11 +-
src/i18n/locales/fr/settings.json | 560 +++++++++---------
src/i18n/locales/ko-KR/settings.json | 558 ++++++++---------
.../ai-edition/document/audioTracks.test.ts | 13 +-
5 files changed, 579 insertions(+), 565 deletions(-)
diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts
index 958658f85..cfccf9c06 100644
--- a/electron/ai-edition/agent-tools.ts
+++ b/electron/ai-edition/agent-tools.ts
@@ -2211,7 +2211,7 @@ export function executeAgentTool(
else if (document.audioTracks.some((t) => trackGroupId(t) === id)) kind = "audio";
if (!kind) {
return failure(
- `No zoom / speed / annotation / full-camera modifier with id ${id}. ` +
+ `No zoom / speed / annotation / full-camera / audio modifier with id ${id}. ` +
`For a trim use removeTrim; for a clip use removeClip.`,
);
}
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index a4d121409..3fd5a82ce 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -472,10 +472,13 @@ const AudioLanePill = memo(function AudioLanePill({
// Body drag moves the track; it also selects and stops the .tlTracks scrub.
onPointerDown={(e) => onStartDrag(e, track, "move")}
onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onSelect(track.id);
- }
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ // The shell binds Space to play/pause on `window`, above React's root, so
+ // stopping only the synthetic event selects the pill and toggles playback in
+ // the same keystroke. Same fix as the region pill below.
+ e.nativeEvent.stopPropagation();
+ onSelect(track.id);
}}
title={`${label} — ${slipHint}`}
>
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 4be212355..40d0a84fa 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,354 +1,354 @@
{
+ "captions": {
+ "alignCenter": "Centre",
+ "distanceFromBottom": "Distance depuis le bas",
+ "alignRight": "Droite",
+ "bold": "Gras",
+ "original": "Original (transcription)",
+ "text": "Texte",
+ "distanceFromRight": "Distance depuis la droite",
+ "backgroundOpacity": "Opacité",
+ "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
+ "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
+ "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
+ "translating": "Traduction…",
+ "maxWords": "Mots max. par ligne",
+ "anchorTop": "Haut",
+ "language": "Langue",
+ "deleteTranslation": "Supprimer cette traduction",
+ "alignLeft": "Gauche",
+ "showBackground": "Afficher le fond",
+ "displayLanguage": "Affichage",
+ "position": "Position",
+ "background": "Fond",
+ "distanceFromTop": "Distance depuis le haut",
+ "show": "Afficher les sous-titres",
+ "translateFailed": "La traduction a échoué.",
+ "fontSize": "Taille",
+ "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
+ "font": "Police",
+ "anchorBottom": "Bas",
+ "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
+ "backgroundColor": "Couleur du fond",
+ "lineLength": "Longueur des lignes",
+ "removeLegacyAnnotations": "Supprimer les anciennes annotations",
+ "textColor": "Couleur du texte",
+ "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
+ "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "translate": "Traduire",
+ "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
+ "distanceFromLeft": "Distance depuis la gauche",
+ "minWords": "Mots min. par ligne"
+ },
"layout": {
- "webcamBlurIntensity": "Intensité du flou",
+ "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
"bgModes": {
"transparent": "Détouré",
- "none": "Original",
"blur": "Flouté",
- "custom": "Personnalisé"
+ "custom": "Personnalisé",
+ "none": "Original"
},
- "selectPreset": "Choisir un préréglage",
- "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
- "reactiveWebcam": "Réduire au zoom",
+ "dualFrame": "Double cadre",
+ "webcamCropX": "Déplacement horizontal",
+ "webcamCropZoom": "Zoom du recadrage",
"shapes": {
- "circle": "Cercle",
- "square": "Carré",
"rectangle": "Rect.",
- "rounded": "Arrondi"
+ "rounded": "Arrondi",
+ "square": "Carré",
+ "circle": "Cercle"
},
- "webcamBackground": "Arrière-plan de la caméra",
+ "title": "Disposition caméra",
+ "noWebcam": "Sans webcam",
+ "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
"verticalStack": "Empilement vertical",
- "pictureInPicture": "Incrustation d'image",
- "webcamShape": "Forme de la caméra",
- "webcamCropY": "Déplacement vertical",
- "webcamSize": "Taille de la caméra",
"mirrorWebcam": "Inverser la webcam",
- "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
+ "webcamBlurIntensity": "Intensité du flou",
+ "selectPreset": "Choisir un préréglage",
+ "webcamCropY": "Déplacement vertical",
"webcamFraming": "Cadrage de la webcam",
- "noWebcam": "Sans webcam",
+ "webcamShape": "Forme de la caméra",
+ "reactiveWebcam": "Réduire au zoom",
+ "webcamSize": "Taille de la caméra",
+ "pictureInPicture": "Incrustation d'image",
+ "preset": "Préréglage",
"reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
- "webcamCropZoom": "Zoom du recadrage",
- "dualFrame": "Double cadre",
- "webcamCropX": "Déplacement horizontal",
- "title": "Disposition caméra",
- "preset": "Préréglage"
+ "webcamBackground": "Arrière-plan de la caméra"
+ },
+ "audioTrack": {
+ "slipHint": "Alt + glisser pour déplacer l’audio à l’intérieur",
+ "defaultLabel": "Piste audio",
+ "importFailed": "Impossible d’ajouter l’audio",
+ "loop": "Boucle",
+ "fadeIn": "Fondu d'entrée",
+ "add": "Ajouter une piste audio",
+ "fadeOut": "Fondu de sortie",
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour déplacer l’audio à l’intérieur.",
+ "mute": "Muet",
+ "remove": "Supprimer la piste"
+ },
+ "annotation": {
+ "blurTypeBlur": "Gaussien",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
+ "typeText": "Texte",
+ "clearBackground": "Supprimer l'arrière-plan",
+ "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "blurShapeOval": "Ovale",
+ "blurColor": "Couleur du flou",
+ "arrowColor": "Couleur de la flèche",
+ "blurColorBlack": "Noir",
+ "active": "Actif",
+ "imageUploadSuccess": "Image téléversée avec succès !",
+ "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+ "blurShapeFreehand": "Main levée",
+ "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
+ "blurTypeMosaic": "Mosaïque",
+ "deleteAnnotation": "Supprimer l'annotation",
+ "blurColorWhite": "Blanc",
+ "invalidImageType": "Type de fichier invalide",
+ "colorPalette": "Palette de couleurs",
+ "none": "Aucun",
+ "shortcutsAndTips": "Raccourcis & Astuces",
+ "color": "Couleur",
+ "blurIntensity": "Intensité du flou",
+ "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "typeArrow": "Flèche",
+ "title": "Paramètres d'annotation",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "size": "Taille",
+ "background": "Arrière-plan",
+ "typeBlur": "Flou",
+ "textPlaceholder": "Saisissez votre texte...",
+ "textColor": "Couleur du texte",
+ "typeImage": "Image",
+ "textContent": "Contenu du texte",
+ "blurShapeRectangle": "Rectangle",
+ "arrowDirection": "Direction de la flèche",
+ "blurType": "Type de flou",
+ "defaultText": "Bonjour",
+ "blurShape": "Forme du flou",
+ "customFonts": "Polices personnalisées",
+ "type": "Type",
+ "fontStyle": "Style de police",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "colorWheel": "Roue chromatique",
+ "uploadImage": "Téléverser une image",
+ "selectStyle": "Choisir un style"
},
"crop": {
- "done": "Terminer",
- "ratio": "Ratio",
- "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
- "title": "Recadrage",
- "free": "Libre",
"lockAspectRatio": "Verrouiller le ratio",
"unlockAspectRatio": "Déverrouiller le ratio",
- "cropVideo": "Recadrer la vidéo"
- },
- "zoom": {
- "position": {
- "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Position du focus"
- },
- "threeD": {
- "preset": {
- "right": "Droite",
- "iso": "Iso",
- "left": "Gauche"
- },
- "none": "Aucune",
- "title": "Rotation 3D"
- },
- "deleteZoom": "Supprimer le zoom",
- "customScale": "Zoom personnalisé",
- "selectRegion": "Sélectionnez une région de zoom à ajuster",
- "focusMode": {
- "manual": "Manuel",
- "autoDescription": "La caméra suit la position du curseur enregistré",
- "lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
- "title": "Mode focus",
- "auto": "Auto"
- },
- "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "level": "Niveau de zoom"
+ "ratio": "Ratio",
+ "title": "Recadrage",
+ "done": "Terminer",
+ "cropVideo": "Recadrer la vidéo",
+ "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
+ "free": "Libre"
},
"background": {
- "color": "Couleur",
+ "colorWheel": "Roue chromatique",
"colorLabel": "Couleur {{color}}",
- "gradient": "Dégradé",
- "imageReadFailed": "Impossible de lire ce fichier image.",
- "gradientLabel": "Dégradé {{index}}",
- "custom": "Personnalisé",
"customWallpaper": "Fond personnalisé",
- "presets": "Préréglages",
"image": "Image",
- "colorPalette": "Palette de couleurs",
+ "uploadCustom": "Téléverser une image",
+ "gradientLabel": "Dégradé {{index}}",
+ "presets": "Préréglages",
"unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
+ "title": "Arrière-plan",
+ "gradient": "Dégradé",
"help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
"imageLabel": "Fond {{index}}",
- "title": "Arrière-plan",
- "uploadCustom": "Téléverser une image",
- "colorWheel": "Roue chromatique"
- },
- "cursor": {
- "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
- "clickBounce": "Rebond au clic",
- "clipToBounds": "Rogner au canevas",
- "title": "Curseur",
- "size": "Taille",
- "themeDefault": "Par défaut",
- "smoothing": "Lissage",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
- "motionBlur": "Flou de mouvement",
- "theme": "Style du curseur",
- "show": "Afficher le curseur"
+ "colorPalette": "Palette de couleurs",
+ "custom": "Personnalisé",
+ "imageReadFailed": "Impossible de lire ce fichier image.",
+ "color": "Couleur"
},
- "captions": {
- "text": "Texte",
- "showBackground": "Afficher le fond",
- "minWords": "Mots min. par ligne",
- "translateFailed": "La traduction a échoué.",
- "distanceFromTop": "Distance depuis le haut",
- "fontSize": "Taille",
- "distanceFromRight": "Distance depuis la droite",
- "bold": "Gras",
- "textColor": "Couleur du texte",
- "original": "Original (transcription)",
- "translating": "Traduction…",
- "language": "Langue",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
- "alignLeft": "Gauche",
- "distanceFromBottom": "Distance depuis le bas",
- "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "show": "Afficher les sous-titres",
- "background": "Fond",
- "backgroundOpacity": "Opacité",
- "removeLegacyAnnotations": "Supprimer les anciennes annotations",
- "anchorTop": "Haut",
- "translate": "Traduire",
- "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
- "alignCenter": "Centre",
- "alignRight": "Droite",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
- "displayLanguage": "Affichage",
- "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
- "distanceFromLeft": "Distance depuis la gauche",
- "maxWords": "Mots max. par ligne",
- "anchorBottom": "Bas",
- "position": "Position",
- "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
- "deleteTranslation": "Supprimer cette traduction",
- "backgroundColor": "Couleur du fond",
- "font": "Police",
- "lineLength": "Longueur des lignes",
- "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
- "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas."
+ "gifSettings": {
+ "frameRate": "Fréquence d'images GIF",
+ "size": "Taille du GIF",
+ "loop": "GIF en boucle"
},
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "Résolution d'export"
+ "customFont": {
+ "dialogTitle": "Ajouter une police Google",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "Échec de l'ajout de la police",
+ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
+ "errorEmptyName": "Veuillez saisir un nom de police",
+ "addingButton": "Ajout en cours...",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
+ "namePlaceholder": "Ma police personnalisée",
+ "addButton": "Ajouter la police",
+ "urlLabel": "URL d'import Google Fonts",
+ "successMessage": "Police « {{fontName}} » ajoutée avec succès",
+ "nameLabel": "Nom d'affichage",
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import"
},
"transcript": {
"transcribing": "Transcription…",
- "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
+ "revertWord": "Rétablir « {{original}} »",
"laneVoiceover": "Voix off",
- "blankedWord": "vidé",
- "noTranscript": "Aucune transcription pour l'instant",
+ "editWord": "Modifier « {{word}} »",
"noAudio": "Ce média n'a pas de piste audio",
- "revertWord": "Rétablir « {{original}} »",
- "silence": "[silence {{duration}} s]",
- "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
- "transcribeNow": "Transcrire maintenant",
- "restoreWord": "Restaurer « {{word}} »",
"clipLabel": "Clip {{index}}",
+ "laneRecording": "Enregistrement",
"title": "Transcription actuelle",
+ "removeInserted": "Supprimer « {{word}} »",
+ "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
"insertAria": "Nouveau mot",
- "laneRecording": "Enregistrement",
- "trimSilence": "Couper le silence ({{duration}} s)",
- "insertedWord": "Ajouté par vous — aucun son derrière",
- "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
"whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
- "removeInserted": "Supprimer « {{word}} »",
- "editorAria": "Transcription de {{filename}}",
- "correctedWord": "Corrigé — la transcription disait « {{original}} »",
+ "laneLabel": "Lire la transcription depuis",
"editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
- "editWord": "Modifier « {{word}} »",
"noClips": "Aucun clip pour l'instant",
- "laneLabel": "Lire la transcription depuis",
- "restoreSilence": "Restaurer le silence ({{duration}} s)"
+ "trimSilence": "Couper le silence ({{duration}} s)",
+ "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
+ "transcribeNow": "Transcrire maintenant",
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
+ "restoreSilence": "Restaurer le silence ({{duration}} s)",
+ "noTranscript": "Aucune transcription pour l'instant",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "restoreWord": "Restaurer « {{word}} »",
+ "blankedWord": "vidé",
+ "editorAria": "Transcription de {{filename}}",
+ "silence": "[silence {{duration}} s]"
},
- "customFont": {
- "addingButton": "Ajout en cours...",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
- "addButton": "Ajouter la police",
- "dialogTitle": "Ajouter une police Google",
- "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
- "nameLabel": "Nom d'affichage",
- "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
- "urlLabel": "URL d'import Google Fonts",
- "errorEmptyName": "Veuillez saisir un nom de police",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
- "namePlaceholder": "Ma police personnalisée",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Échec de l'ajout de la police",
- "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
- "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
- "successMessage": "Police « {{fontName}} » ajoutée avec succès"
+ "audio": {
+ "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
+ "title": "Audio",
+ "reset": "Réinitialiser l’audio",
+ "outputGain": "Niveau de sortie"
},
- "annotation": {
- "arrowColor": "Couleur de la flèche",
- "colorWheel": "Roue chromatique",
- "blurType": "Type de flou",
- "active": "Actif",
- "deleteAnnotation": "Supprimer l'annotation",
- "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
- "background": "Arrière-plan",
- "imageUploadSuccess": "Image téléversée avec succès !",
- "blurColor": "Couleur du flou",
- "blurTypeBlur": "Gaussien",
- "textColor": "Couleur du texte",
- "blurColorWhite": "Blanc",
- "title": "Paramètres d'annotation",
- "type": "Type",
- "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
- "typeImage": "Image",
- "textContent": "Contenu du texte",
- "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
- "typeText": "Texte",
- "blurIntensity": "Intensité du flou",
- "none": "Aucun",
- "mosaicBlockSize": "Taille des blocs de mosaique",
- "textPlaceholder": "Saisissez votre texte...",
- "typeArrow": "Flèche",
- "color": "Couleur",
- "blurColorBlack": "Noir",
- "size": "Taille",
- "invalidImageType": "Type de fichier invalide",
- "blurShapeFreehand": "Main levée",
- "shortcutsAndTips": "Raccourcis & Astuces",
- "uploadImage": "Téléverser une image",
- "blurTypeMosaic": "Mosaïque",
- "selectStyle": "Choisir un style",
- "defaultText": "Bonjour",
- "blurShapeRectangle": "Rectangle",
- "colorPalette": "Palette de couleurs",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
- "clearBackground": "Supprimer l'arrière-plan",
- "customFonts": "Polices personnalisées",
- "typeBlur": "Flou",
- "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
- "fontStyle": "Style de police",
- "blurShape": "Forme du flou",
- "arrowDirection": "Direction de la flèche",
- "blurShapeOval": "Ovale"
+ "textAnimation": {
+ "pop": "Apparition",
+ "fade": "Fondu",
+ "pulse": "Pulsation",
+ "typewriter": "Machine à écrire",
+ "selectAnimation": "Sélectionner une animation",
+ "slideLeft": "Glisser à gauche",
+ "none": "Aucune",
+ "rise": "Monter",
+ "title": "Animation de texte"
+ },
+ "zoom": {
+ "deleteZoom": "Supprimer le zoom",
+ "threeD": {
+ "preset": {
+ "iso": "Iso",
+ "left": "Gauche",
+ "right": "Droite"
+ },
+ "none": "Aucune",
+ "title": "Rotation 3D"
+ },
+ "position": {
+ "x": "X (%)",
+ "y": "Y (%)",
+ "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
+ "title": "Position du focus"
+ },
+ "focusMode": {
+ "lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
+ "manual": "Manuel",
+ "autoDescription": "La caméra suit la position du curseur enregistré",
+ "title": "Mode focus",
+ "auto": "Auto"
+ },
+ "level": "Niveau de zoom",
+ "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
+ "selectRegion": "Sélectionnez une région de zoom à ajuster",
+ "customScale": "Zoom personnalisé"
},
"speed": {
- "customPlaybackSpeed": "Vitesse de lecture personnalisée",
- "deleteRegion": "Supprimer la région de vitesse",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
"maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
"playbackSpeed": "Vitesse de lecture",
- "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté."
+ "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
+ "deleteRegion": "Supprimer la région de vitesse",
+ "customPlaybackSpeed": "Vitesse de lecture personnalisée"
},
- "textAnimation": {
- "selectAnimation": "Sélectionner une animation",
- "pulse": "Pulsation",
- "rise": "Monter",
- "none": "Aucune",
- "slideLeft": "Glisser à gauche",
- "title": "Animation de texte",
- "fade": "Fondu",
- "pop": "Apparition",
- "typewriter": "Machine à écrire"
+ "language": {
+ "title": "Langue"
},
- "effects": {
- "motion": "Mouvement",
- "title": "Composition",
- "format": "Format",
- "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
- "fitClipFew": "{{count}} clips",
- "motionBlur": "Flou de mouvement",
- "fitClipMany": "{{count}} clips",
- "frame": "Cadre",
- "padding": "Marge",
- "roundness": "Arrondi",
- "off": "désactivé",
- "blurBg": "Flou arrière-plan",
- "shadow": "Ombre",
- "on": "activé",
- "fitClipOne": "{{count}} clip",
- "formatOriginal": "Original",
- "fitClip": "Ajuster"
+ "exportQuality": {
+ "medium": "1080p",
+ "low": "720p",
+ "title": "Résolution d'export",
+ "high": "Source"
},
"exportFormat": {
- "gifDescription": "Image animée pour le partage",
"mp4": "MP4",
+ "gifDescription": "Image animée pour le partage",
"mp4Video": "Vidéo MP4",
- "gif": "GIF",
+ "mp4Description": "Fichier vidéo haute qualité",
"gifAnimation": "Animation GIF",
- "mp4Description": "Fichier vidéo haute qualité"
+ "gif": "GIF"
+ },
+ "trim": {
+ "deleteRegion": "Supprimer la région de coupe"
},
"imageUpload": {
- "failedToUpload": "Échec du téléversement de l'image",
"uploadSuccess": "Image personnalisée téléversée avec succès !",
- "errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
"invalidFileType": "Type de fichier invalide",
- "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG."
+ "errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
+ "failedToUpload": "Échec du téléversement de l'image"
+ },
+ "cursor": {
+ "title": "Curseur",
+ "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
+ "motionBlur": "Flou de mouvement",
+ "clipToBounds": "Rogner au canevas",
+ "smoothing": "Lissage",
+ "size": "Taille",
+ "theme": "Style du curseur",
+ "themeDefault": "Par défaut",
+ "clickBounce": "Rebond au clic",
+ "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
+ "show": "Afficher le curseur"
},
"facets": {
"captions": "Sous-titres",
"transcript": "Transcription"
},
- "audio": {
- "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
- "reset": "Réinitialiser l’audio",
- "title": "Audio",
- "outputGain": "Niveau de sortie"
- },
- "language": {
- "title": "Langue"
- },
- "audioTrack": {
- "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
- "importFailed": "Impossible d’ajouter l’audio",
- "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur",
- "fadeOut": "Fondu de sortie",
- "defaultLabel": "Piste audio",
- "mute": "Muet",
- "remove": "Supprimer la piste",
- "add": "Ajouter une piste audio",
- "loop": "Boucle",
- "fadeIn": "Fondu d'entrée"
+ "effects": {
+ "title": "Composition",
+ "formatOriginal": "Original",
+ "motionBlur": "Flou de mouvement",
+ "format": "Format",
+ "padding": "Marge",
+ "fitClipOne": "{{count}} clip",
+ "fitClipFew": "{{count}} clips",
+ "frame": "Cadre",
+ "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
+ "on": "activé",
+ "blurBg": "Flou arrière-plan",
+ "fitClip": "Ajuster",
+ "motion": "Mouvement",
+ "shadow": "Ombre",
+ "fitClipMany": "{{count}} clips",
+ "roundness": "Arrondi",
+ "off": "désactivé"
},
"project": {
- "load": "Charger un projet",
"save": "Enregistrer le projet",
- "new": "Nouveau projet"
+ "new": "Nouveau projet",
+ "load": "Charger un projet"
+ },
+ "support": {
+ "saveDiagnostics": "Enregistrer les diagnostics",
+ "reportBug": "Signaler un bug",
+ "starOnGithub": "Étoile sur GitHub"
},
"panes": {
"help": "Aide"
},
- "trim": {
- "deleteRegion": "Supprimer la région de coupe"
- },
"export": {
"videoButton": "Exporter la vidéo",
"gifButton": "Exporter le GIF",
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
- },
- "support": {
- "starOnGithub": "Étoile sur GitHub",
- "saveDiagnostics": "Enregistrer les diagnostics",
- "reportBug": "Signaler un bug"
- },
- "gifSettings": {
- "size": "Taille du GIF",
- "frameRate": "Fréquence d'images GIF",
- "loop": "GIF en boucle"
}
}
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 1de5717e1..c0832e41a 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,354 +1,354 @@
{
+ "captions": {
+ "alignCenter": "가운데",
+ "distanceFromBottom": "아래에서의 거리",
+ "alignRight": "오른쪽",
+ "bold": "굵게",
+ "original": "원본 (전사)",
+ "text": "텍스트",
+ "distanceFromRight": "오른쪽에서의 거리",
+ "backgroundOpacity": "불투명도",
+ "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
+ "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
+ "translating": "번역 중…",
+ "maxWords": "줄당 최대 단어 수",
+ "anchorTop": "위",
+ "language": "언어",
+ "deleteTranslation": "이 번역 삭제",
+ "alignLeft": "왼쪽",
+ "showBackground": "배경 표시",
+ "displayLanguage": "표시",
+ "position": "위치",
+ "background": "배경",
+ "distanceFromTop": "위에서의 거리",
+ "show": "자막 표시",
+ "translateFailed": "번역에 실패했습니다.",
+ "fontSize": "크기",
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "font": "글꼴",
+ "anchorBottom": "아래",
+ "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
+ "backgroundColor": "배경 색",
+ "lineLength": "줄 길이",
+ "removeLegacyAnnotations": "이전 자막 주석 제거",
+ "textColor": "글자 색",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
+ "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "translate": "번역",
+ "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "minWords": "줄당 최소 단어 수"
+ },
"layout": {
- "webcamBlurIntensity": "블러 강도",
+ "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
"bgModes": {
"transparent": "누끼",
- "none": "원본",
"blur": "블러",
- "custom": "사용자 지정"
+ "custom": "사용자 지정",
+ "none": "원본"
},
- "selectPreset": "프리셋 선택",
- "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
- "reactiveWebcam": "확대 시 축소",
+ "dualFrame": "듀얼 프레임",
+ "webcamCropX": "가로 이동",
+ "webcamCropZoom": "자르기 확대",
"shapes": {
- "circle": "원형",
- "square": "정사각형",
"rectangle": "직사각형",
- "rounded": "둥근 모서리"
+ "rounded": "둥근 모서리",
+ "square": "정사각형",
+ "circle": "원형"
},
- "webcamBackground": "카메라 배경",
+ "title": "카메라 레이아웃",
+ "noWebcam": "웹캠 없음",
+ "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
"verticalStack": "세로 배치",
- "pictureInPicture": "화면 속 화면",
- "webcamShape": "카메라 모양",
- "webcamCropY": "세로 이동",
- "webcamSize": "웹캠 크기",
"mirrorWebcam": "웹캠 미러링",
- "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
+ "webcamBlurIntensity": "블러 강도",
+ "selectPreset": "프리셋 선택",
+ "webcamCropY": "세로 이동",
"webcamFraming": "웹캠 구도",
- "noWebcam": "웹캠 없음",
+ "webcamShape": "카메라 모양",
+ "reactiveWebcam": "확대 시 축소",
+ "webcamSize": "웹캠 크기",
+ "pictureInPicture": "화면 속 화면",
+ "preset": "프리셋",
"reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
- "webcamCropZoom": "자르기 확대",
- "dualFrame": "듀얼 프레임",
- "webcamCropX": "가로 이동",
- "title": "카메라 레이아웃",
- "preset": "프리셋"
+ "webcamBackground": "카메라 배경"
+ },
+ "audioTrack": {
+ "slipHint": "Alt를 누른 채 드래그하면 안의 오디오가 이동합니다",
+ "defaultLabel": "오디오 트랙",
+ "importFailed": "오디오를 추가할 수 없습니다",
+ "loop": "반복",
+ "fadeIn": "페이드 인",
+ "add": "오디오 트랙 추가",
+ "fadeOut": "페이드 아웃",
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "mute": "음소거",
+ "remove": "트랙 삭제"
+ },
+ "annotation": {
+ "blurTypeBlur": "가우시안",
+ "mosaicBlockSize": "모자이크 블록 크기",
+ "typeText": "텍스트",
+ "clearBackground": "배경 지우기",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "blurShapeOval": "타원",
+ "blurColor": "블러 색상",
+ "arrowColor": "화살표 색상",
+ "blurColorBlack": "검정",
+ "active": "활성",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
+ "blurShapeFreehand": "자유 곡선",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "blurTypeMosaic": "모자이크",
+ "deleteAnnotation": "주석 삭제",
+ "blurColorWhite": "흰색",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "colorPalette": "색상 팔레트",
+ "none": "없음",
+ "shortcutsAndTips": "단축키 및 팁",
+ "color": "색상",
+ "blurIntensity": "블러 강도",
+ "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "typeArrow": "화살표",
+ "title": "주석 설정",
+ "strokeWidth": "선 두께: {{width}}px",
+ "size": "크기",
+ "background": "배경",
+ "typeBlur": "블러",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "textColor": "텍스트 색상",
+ "typeImage": "이미지",
+ "textContent": "텍스트 내용",
+ "blurShapeRectangle": "사각형",
+ "arrowDirection": "화살표 방향",
+ "blurType": "블러 종류",
+ "defaultText": "안녕하세요",
+ "blurShape": "블러 모양",
+ "customFonts": "커스텀 폰트",
+ "type": "유형",
+ "fontStyle": "폰트 스타일",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "colorWheel": "색상 휠",
+ "uploadImage": "이미지 업로드",
+ "selectStyle": "스타일 선택"
},
"crop": {
- "done": "완료",
- "ratio": "비율",
- "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
- "title": "자르기",
- "free": "자유",
"lockAspectRatio": "화면 비율 고정",
"unlockAspectRatio": "화면 비율 해제",
- "cropVideo": "비디오 자르기"
- },
- "zoom": {
- "position": {
- "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "포커스 위치"
- },
- "threeD": {
- "preset": {
- "right": "오른쪽",
- "iso": "Iso",
- "left": "왼쪽"
- },
- "none": "없음",
- "title": "3D 회전"
- },
- "deleteZoom": "줌 삭제",
- "customScale": "커스텀 줌",
- "selectRegion": "조정할 줌 구간을 선택하세요",
- "focusMode": {
- "manual": "수동",
- "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
- "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
- "title": "포커스 모드",
- "auto": "자동"
- },
- "previewHold": "누르고 있으면 줌 효과 미리보기",
- "level": "줌 레벨"
+ "ratio": "비율",
+ "title": "자르기",
+ "done": "완료",
+ "cropVideo": "비디오 자르기",
+ "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
+ "free": "자유"
},
"background": {
- "color": "색상",
+ "colorWheel": "색상 휠",
"colorLabel": "색상 {{color}}",
- "gradient": "그라디언트",
- "imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "gradientLabel": "그라디언트 {{index}}",
- "custom": "사용자 지정",
"customWallpaper": "사용자 배경",
- "presets": "프리셋",
"image": "이미지",
- "colorPalette": "색상 팔레트",
+ "uploadCustom": "직접 업로드",
+ "gradientLabel": "그라디언트 {{index}}",
+ "presets": "프리셋",
"unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
+ "title": "배경",
+ "gradient": "그라디언트",
"help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
"imageLabel": "배경 {{index}}",
- "title": "배경",
- "uploadCustom": "직접 업로드",
- "colorWheel": "색상 휠"
- },
- "cursor": {
- "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
- "clickBounce": "클릭 바운스",
- "clipToBounds": "캔버스에 맞춰 자르기",
- "title": "커서",
- "size": "크기",
- "themeDefault": "기본",
- "smoothing": "부드러움",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
- "motionBlur": "모션 블러",
- "theme": "커서 스타일",
- "show": "커서 표시"
+ "colorPalette": "색상 팔레트",
+ "custom": "사용자 지정",
+ "imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
+ "color": "색상"
},
- "captions": {
- "text": "텍스트",
- "showBackground": "배경 표시",
- "minWords": "줄당 최소 단어 수",
- "translateFailed": "번역에 실패했습니다.",
- "distanceFromTop": "위에서의 거리",
- "fontSize": "크기",
- "distanceFromRight": "오른쪽에서의 거리",
- "bold": "굵게",
- "textColor": "글자 색",
- "original": "원본 (전사)",
- "translating": "번역 중…",
- "language": "언어",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
- "alignLeft": "왼쪽",
- "distanceFromBottom": "아래에서의 거리",
- "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "show": "자막 표시",
- "background": "배경",
- "backgroundOpacity": "불투명도",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "anchorTop": "위",
- "translate": "번역",
- "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
- "alignCenter": "가운데",
- "alignRight": "오른쪽",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
- "displayLanguage": "표시",
- "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
- "distanceFromLeft": "왼쪽에서의 거리",
- "maxWords": "줄당 최대 단어 수",
- "anchorBottom": "아래",
- "position": "위치",
- "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
- "deleteTranslation": "이 번역 삭제",
- "backgroundColor": "배경 색",
- "font": "글꼴",
- "lineLength": "줄 길이",
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다."
+ "gifSettings": {
+ "frameRate": "GIF 프레임 속도",
+ "size": "GIF 크기",
+ "loop": "GIF 반복"
},
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "내보내기 해상도"
+ "customFont": {
+ "dialogTitle": "Google 폰트 추가",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "failedToAdd": "폰트 추가에 실패했습니다",
+ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
+ "addingButton": "추가 중...",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
+ "namePlaceholder": "내 커스텀 폰트",
+ "addButton": "폰트 추가",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
+ "nameLabel": "표시 이름",
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사"
},
"transcript": {
"transcribing": "전사 중…",
- "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "revertWord": "\"{{original}}\"(으)로 되돌리기",
"laneVoiceover": "내레이션",
- "blankedWord": "비움",
- "noTranscript": "아직 전사가 없습니다",
+ "editWord": "\"{{word}}\" 편집",
"noAudio": "이 미디어에는 오디오 트랙이 없습니다",
- "revertWord": "\"{{original}}\"(으)로 되돌리기",
- "silence": "[무음 {{duration}}초]",
- "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "transcribeNow": "지금 전사하기",
- "restoreWord": "\"{{word}}\" 복원",
"clipLabel": "클립 {{index}}",
+ "laneRecording": "녹화",
"title": "현재 전사",
+ "removeInserted": "\"{{word}}\" 삭제",
+ "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
"insertAria": "새 단어",
- "laneRecording": "녹화",
- "trimSilence": "무음 자르기 ({{duration}}초)",
- "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
- "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
"whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
- "removeInserted": "\"{{word}}\" 삭제",
- "editorAria": "{{filename}}의 전사",
- "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
+ "laneLabel": "전사본을 읽어올 소스",
"editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
- "editWord": "\"{{word}}\" 편집",
"noClips": "아직 클립이 없습니다",
- "laneLabel": "전사본을 읽어올 소스",
- "restoreSilence": "무음 복원 ({{duration}}초)"
+ "trimSilence": "무음 자르기 ({{duration}}초)",
+ "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
+ "transcribeNow": "지금 전사하기",
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "restoreSilence": "무음 복원 ({{duration}}초)",
+ "noTranscript": "아직 전사가 없습니다",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "restoreWord": "\"{{word}}\" 복원",
+ "blankedWord": "비움",
+ "editorAria": "{{filename}}의 전사",
+ "silence": "[무음 {{duration}}초]"
},
- "customFont": {
- "addingButton": "추가 중...",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
- "addButton": "폰트 추가",
- "dialogTitle": "Google 폰트 추가",
- "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
- "nameLabel": "표시 이름",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
- "urlLabel": "Google Fonts 가져오기 URL",
- "errorEmptyName": "폰트 이름을 입력해 주세요",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
- "namePlaceholder": "내 커스텀 폰트",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "폰트 추가에 실패했습니다",
- "nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
- "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
- "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다"
+ "audio": {
+ "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
+ "title": "오디오",
+ "reset": "오디오 재설정",
+ "outputGain": "출력 레벨"
},
- "annotation": {
- "arrowColor": "화살표 색상",
- "colorWheel": "색상 휠",
- "blurType": "블러 종류",
- "active": "활성",
- "deleteAnnotation": "주석 삭제",
- "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "strokeWidth": "선 두께: {{width}}px",
- "background": "배경",
- "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
- "blurColor": "블러 색상",
- "blurTypeBlur": "가우시안",
- "textColor": "텍스트 색상",
- "blurColorWhite": "흰색",
- "title": "주석 설정",
- "type": "유형",
- "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
- "typeImage": "이미지",
- "textContent": "텍스트 내용",
- "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
- "typeText": "텍스트",
- "blurIntensity": "블러 강도",
+ "textAnimation": {
+ "pop": "팝",
+ "fade": "페이드",
+ "pulse": "펄스",
+ "typewriter": "타자기",
+ "selectAnimation": "애니메이션 선택",
+ "slideLeft": "왼쪽 슬라이드",
"none": "없음",
- "mosaicBlockSize": "모자이크 블록 크기",
- "textPlaceholder": "텍스트를 입력하세요...",
- "typeArrow": "화살표",
- "color": "색상",
- "blurColorBlack": "검정",
- "size": "크기",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "blurShapeFreehand": "자유 곡선",
- "shortcutsAndTips": "단축키 및 팁",
- "uploadImage": "이미지 업로드",
- "blurTypeMosaic": "모자이크",
- "selectStyle": "스타일 선택",
- "defaultText": "안녕하세요",
- "blurShapeRectangle": "사각형",
- "colorPalette": "색상 팔레트",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
- "clearBackground": "배경 지우기",
- "customFonts": "커스텀 폰트",
- "typeBlur": "블러",
- "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
- "fontStyle": "폰트 스타일",
- "blurShape": "블러 모양",
- "arrowDirection": "화살표 방향",
- "blurShapeOval": "타원"
+ "rise": "상승",
+ "title": "텍스트 애니메이션"
+ },
+ "zoom": {
+ "deleteZoom": "줌 삭제",
+ "threeD": {
+ "preset": {
+ "iso": "Iso",
+ "left": "왼쪽",
+ "right": "오른쪽"
+ },
+ "none": "없음",
+ "title": "3D 회전"
+ },
+ "position": {
+ "x": "X (%)",
+ "y": "Y (%)",
+ "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
+ "title": "포커스 위치"
+ },
+ "focusMode": {
+ "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
+ "manual": "수동",
+ "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
+ "title": "포커스 모드",
+ "auto": "자동"
+ },
+ "level": "줌 레벨",
+ "previewHold": "누르고 있으면 줌 효과 미리보기",
+ "selectRegion": "조정할 줌 구간을 선택하세요",
+ "customScale": "커스텀 줌"
},
"speed": {
- "customPlaybackSpeed": "재생 속도 직접 입력",
- "deleteRegion": "속도 구간 삭제",
"selectRegion": "조정할 속도 구간을 선택하세요",
"maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
"playbackSpeed": "재생 속도",
- "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다."
+ "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
+ "deleteRegion": "속도 구간 삭제",
+ "customPlaybackSpeed": "재생 속도 직접 입력"
},
- "textAnimation": {
- "selectAnimation": "애니메이션 선택",
- "pulse": "펄스",
- "rise": "상승",
- "none": "없음",
- "slideLeft": "왼쪽 슬라이드",
- "title": "텍스트 애니메이션",
- "fade": "페이드",
- "pop": "팝",
- "typewriter": "타자기"
+ "language": {
+ "title": "언어"
},
- "effects": {
- "motion": "모션",
- "title": "컴포지션",
- "format": "형식",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
- "fitClipFew": "{{count}}개 클립",
- "motionBlur": "모션 블러",
- "fitClipMany": "{{count}}개 클립",
- "frame": "프레임",
- "padding": "여백",
- "roundness": "모서리 둥글기",
- "off": "끄기",
- "blurBg": "배경 흐림",
- "shadow": "그림자",
- "on": "켜기",
- "fitClipOne": "{{count}}개 클립",
- "formatOriginal": "원본",
- "fitClip": "맞추기"
+ "exportQuality": {
+ "medium": "1080p",
+ "low": "720p",
+ "title": "내보내기 해상도",
+ "high": "Source"
},
"exportFormat": {
- "gifDescription": "공유용 애니메이션 이미지",
"mp4": "MP4",
+ "gifDescription": "공유용 애니메이션 이미지",
"mp4Video": "MP4 비디오",
- "gif": "GIF",
+ "mp4Description": "고화질 비디오 파일",
"gifAnimation": "GIF 애니메이션",
- "mp4Description": "고화질 비디오 파일"
+ "gif": "GIF"
+ },
+ "trim": {
+ "deleteRegion": "트림 구간 삭제"
},
"imageUpload": {
- "failedToUpload": "이미지 업로드에 실패했습니다",
"uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
- "errorReading": "파일을 읽는 중 오류가 발생했습니다.",
"invalidFileType": "지원하지 않는 파일 형식입니다",
- "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요."
+ "errorReading": "파일을 읽는 중 오류가 발생했습니다.",
+ "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
+ "failedToUpload": "이미지 업로드에 실패했습니다"
+ },
+ "cursor": {
+ "title": "커서",
+ "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
+ "motionBlur": "모션 블러",
+ "clipToBounds": "캔버스에 맞춰 자르기",
+ "smoothing": "부드러움",
+ "size": "크기",
+ "theme": "커서 스타일",
+ "themeDefault": "기본",
+ "clickBounce": "클릭 바운스",
+ "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
+ "show": "커서 표시"
},
"facets": {
"captions": "자막",
"transcript": "대본"
},
- "audio": {
- "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
- "reset": "오디오 재설정",
- "title": "오디오",
- "outputGain": "출력 레벨"
- },
- "language": {
- "title": "언어"
- },
- "audioTrack": {
- "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
- "importFailed": "오디오를 추가할 수 없습니다",
- "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다",
- "fadeOut": "페이드 아웃",
- "defaultLabel": "오디오 트랙",
- "mute": "음소거",
- "remove": "트랙 삭제",
- "add": "오디오 트랙 추가",
- "loop": "반복",
- "fadeIn": "페이드 인"
+ "effects": {
+ "title": "컴포지션",
+ "formatOriginal": "원본",
+ "motionBlur": "모션 블러",
+ "format": "형식",
+ "padding": "여백",
+ "fitClipOne": "{{count}}개 클립",
+ "fitClipFew": "{{count}}개 클립",
+ "frame": "프레임",
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "on": "켜기",
+ "blurBg": "배경 흐림",
+ "fitClip": "맞추기",
+ "motion": "모션",
+ "shadow": "그림자",
+ "fitClipMany": "{{count}}개 클립",
+ "roundness": "모서리 둥글기",
+ "off": "끄기"
},
"project": {
- "load": "프로젝트 불러오기",
"save": "프로젝트 저장",
- "new": "새 프로젝트"
+ "new": "새 프로젝트",
+ "load": "프로젝트 불러오기"
+ },
+ "support": {
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "버그 신고",
+ "starOnGithub": "GitHub에 Star 남기기"
},
"panes": {
"help": "도움말"
},
- "trim": {
- "deleteRegion": "트림 구간 삭제"
- },
"export": {
"videoButton": "비디오 내보내기",
"gifButton": "GIF 내보내기",
"chooseSaveLocation": "저장 위치 선택"
- },
- "support": {
- "starOnGithub": "GitHub에 Star 남기기",
- "saveDiagnostics": "Save Diagnostics",
- "reportBug": "버그 신고"
- },
- "gifSettings": {
- "size": "GIF 크기",
- "frameRate": "GIF 프레임 속도",
- "loop": "GIF 반복"
}
}
diff --git a/src/lib/ai-edition/document/audioTracks.test.ts b/src/lib/ai-edition/document/audioTracks.test.ts
index 67ae59a32..064771150 100644
--- a/src/lib/ai-edition/document/audioTracks.test.ts
+++ b/src/lib/ai-edition/document/audioTracks.test.ts
@@ -178,9 +178,17 @@ describe("patchAudioTrack", () => {
makeId,
);
const doc = { ...emptyDoc(), audioTracks: frags };
- const next = patchAudioTrack(doc, trackGroupId(frags[0]), { gainDb: -6, muted: true });
+ const next = patchAudioTrack(doc, trackGroupId(frags[0]), {
+ gainDb: -6,
+ muted: true,
+ // `loop` is the third payload key the patch spreads, and the one a half-applied
+ // patch would break loudest: a track looping on one fragment and not the other
+ // stops mid-take at the clip boundary.
+ loop: true,
+ });
expect(next.audioTracks.map((t) => t.gainDb)).toEqual([-6, -6]);
expect(next.audioTracks.every((t) => t.muted)).toBe(true);
+ expect(next.audioTracks.every((t) => t.loop)).toBe(true);
});
it("keeps fades on the outer edges when they are edited", () => {
@@ -337,6 +345,9 @@ describe("slipAudioOffsetMs", () => {
it("refuses an unknown duration", () => {
expect(slipAudioOffsetMs(0, 4_000, null, 5_000)).toBeNull();
expect(slipAudioOffsetMs(0, 4_000, 0, 5_000)).toBeNull();
+ // `undefined` is its own branch: an asset whose duration has never been probed
+ // carries no key at all, which is not the same shape as a stored null.
+ expect(slipAudioOffsetMs(0, 4_000, undefined, 5_000)).toBeNull();
});
it("returns whole milliseconds, which is what the schema stores", () => {
From 8fb74f9aed735fb26619a620befd1f7f9aaf7953 Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 19:33:27 +0200
Subject: [PATCH 112/113] fix: the four CodeRabbit findings that were left,
including the read capability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Generic reads no longer grant a read capability (CWE-200).**
`approveReadableMediaPath` approves any existing file with a media extension. Behind a picker
or a document load that is the point; behind `read-binary-file`, `read-file-chunk`,
`get-readable-file-info` and `get-audio-peaks` it meant the renderer could name any media
file on the machine and be handed its bytes.
Those four now SPEND an approval instead of granting one. Granting happens in exactly three
places: the recordings directory, a file the user picked, and the assets a loaded project
declares. That third one did not exist — the allow-list comment has always said "picker or
project load", and project load never approved anything; the generic auto-approval was
quietly standing in for it. `DocumentService` now announces every document it hands out,
after the relink so the paths are the ones the renderer will actually ask for.
**Cancellation reaches ffmpeg.** `cancel()` bumped an epoch the CHUNK loop reads between
chunks, so a cancel during extraction left ffmpeg decoding a file that can be hours long with
nothing to stop it. The extraction now holds an `AbortController` that `cancel()` aborts, and
clears it only if it is still its own — a cancel that starts a new run must not have its
controller cleared by the old one unwinding.
**A capped track keeps its fade-out at its real end.** `overlay_track_pcm` measured the ramps
against the decoded length and its own comment said why that was right — but
`mix_external_tracks` caps the decode window at the room left in the programme BEFORE
decoding, so the "decoded length" was already the truncated one. A track running past the end
faded out at the truncation point instead of being cut off mid-ramp. The uncapped length is
passed through now. Mutation-checked: reverting the envelope length fails the new test.
**The tooltip's ref reaches the trigger.** `Tooltip` is a `forwardRef` handing its ref to
`TooltipTrigger`, which was a plain function component — on React 18 that drops it silently.
Same `forwardRef` shape as `PopoverTrigger`, which the comment already pointed at.
---
crates/compositor/src/audio.rs | 43 +++++++++++++++---
electron/ai-edition/document-service.test.ts | 28 ++++++++++++
electron/ai-edition/document-service.ts | 23 +++++++++-
electron/ipc/handlers.ts | 48 +++++++++++++++-----
electron/stt/index.ts | 18 +++++++-
src/components/ui/tooltip.tsx | 13 ++++--
6 files changed, 149 insertions(+), 24 deletions(-)
diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs
index 6d7c8d4ec..ee90c1374 100644
--- a/crates/compositor/src/audio.rs
+++ b/crates/compositor/src/audio.rs
@@ -1424,6 +1424,11 @@ pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack])
// second 9 of a ten-second export must not buffer three hours of PCM.
let remaining_sec = (programme_len - offset) as f64 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
let trim_end = trim_end_full.min(trim_start + remaining_sec);
+ // The track's own length, before that cap. The fades belong to the track, not to
+ // whatever the programme had room for — capping first and measuring after is what
+ // made a fade-out ramp down at the truncation point instead of at the real end.
+ let full_len =
+ ((trim_end_full - trim_start).max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64) as usize;
if trim_end <= trim_start {
continue;
}
@@ -1441,6 +1446,7 @@ pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack])
gain,
track.fade_in_sec.max(0.0),
track.fade_out_sec.max(0.0),
+ full_len,
);
}
programme
@@ -1457,6 +1463,10 @@ fn overlay_track_pcm(
gain: f32,
fade_in_sec: f64,
fade_out_sec: f64,
+ // The track's length before the programme cap, in samples, or 0 when nothing capped it.
+ // `decoded` may be shorter because the decode window was capped at the room left in the
+ // programme; the ramps belong to the track, not to the room.
+ full_len: usize,
) {
let programme_len = programme.first().map(Vec::len).unwrap_or(0);
if offset >= programme_len {
@@ -1467,7 +1477,8 @@ fn overlay_track_pcm(
// programme: a track running past the end is cut off there, and a fade-out
// timed to the cut would ramp down over audio the export never reaches.
let decoded_len = decoded.iter().map(Vec::len).max().unwrap_or(0);
- let (fade_in, fade_out) = resolve_fade_samples(decoded_len, fade_in_sec, fade_out_sec);
+ let envelope_len = full_len.max(decoded_len);
+ let (fade_in, fade_out) = resolve_fade_samples(envelope_len, fade_in_sec, fade_out_sec);
for channel in 0..AUDIO_OUTPUT_CHANNELS {
let Some(source) = decoded.get(channel) else {
continue;
@@ -1475,7 +1486,7 @@ fn overlay_track_pcm(
let count = source.len().min(room);
let dst = &mut programme[channel];
for k in 0..count {
- dst[offset + k] += source[k] * gain * fade_envelope(k, decoded_len, fade_in, fade_out);
+ dst[offset + k] += source[k] * gain * fade_envelope(k, envelope_len, fade_in, fade_out);
}
}
}
@@ -1867,7 +1878,7 @@ mod tests {
fn overlay_sums_at_offset_with_gain() {
let mut programme = planar(&[0.1, 0.1, 0.1, 0.1]);
// ×2 gain, placed at sample offset 1.
- overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0, 0.0, 0.0);
+ overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0, 0.0, 0.0, 0);
assert_eq!(programme[0], vec![0.1, 0.5, 0.5, 0.1]);
assert_eq!(programme[1], vec![0.1, 0.5, 0.5, 0.1]);
}
@@ -1876,14 +1887,14 @@ mod tests {
fn overlay_truncates_a_track_that_runs_past_the_programme() {
let mut programme = planar(&[0.0, 0.0, 0.0]);
// A 4-sample track placed at offset 2 has room for only 1 sample.
- overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0, 0.0, 0.0);
+ overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0, 0.0, 0.0, 0);
assert_eq!(programme[0], vec![0.0, 0.0, 1.0]);
}
#[test]
fn overlay_past_the_end_is_a_no_op() {
let mut programme = planar(&[0.3, 0.3]);
- overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0, 0.0, 0.0);
+ overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0, 0.0, 0.0, 0);
assert_eq!(programme[0], vec![0.3, 0.3]);
}
@@ -2064,12 +2075,30 @@ mod tests {
// A 4-sample fade-in at 48 kHz is far below one sample of real time, so
// ask for the whole decoded length in seconds.
let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
- overlay_track_pcm(&mut programme, &decoded, 0, 1.0, four, 0.0);
+ overlay_track_pcm(&mut programme, &decoded, 0, 1.0, four, 0.0, 0);
assert_eq!(programme[0][0], 0.0);
assert!(programme[0][1] > 0.0 && programme[0][1] < 1.0);
assert!(programme[0][3] > programme[0][1]);
}
+ #[test]
+ fn a_capped_track_keeps_its_fade_out_at_its_real_end() {
+ // The decode window is capped at the room left in the programme, so `decoded` is
+ // SHORTER than the track. Measuring the ramp against what came back would put the
+ // fade-out at the truncation point — the export would hear a track fading out that
+ // is in fact being cut off mid-sentence.
+ let mut programme = planar(&[0.0, 0.0, 0.0, 0.0]);
+ let decoded = planar(&[1.0, 1.0, 1.0, 1.0]);
+ let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ // The track really runs eight samples; the programme had room for four.
+ overlay_track_pcm(&mut programme, &decoded, 0, 1.0, 0.0, four, 8);
+ // Nothing audible has started to ramp: the fade belongs to samples 4..8, which the
+ // programme never reaches.
+ for k in 0..4 {
+ assert_eq!(programme[0][k], 1.0, "sample {k} should be untouched");
+ }
+ }
+
#[test]
fn a_track_gain_below_the_output_bound_is_honoured() {
// The per-track gain range is the inspector's -60..+12, NOT the project
@@ -2078,7 +2107,7 @@ mod tests {
let mut programme = planar(&[0.0]);
let decoded = planar(&[1.0]);
let gain = 10.0f32.powf(-40.0 / 20.0);
- overlay_track_pcm(&mut programme, &decoded, 0, gain, 0.0, 0.0);
+ overlay_track_pcm(&mut programme, &decoded, 0, gain, 0.0, 0.0, 0);
assert!((programme[0][0] - gain).abs() < 1e-9);
assert!(programme[0][0] < 10.0f32.powf(-12.0 / 20.0));
}
diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts
index bf3d12289..5951c9090 100644
--- a/electron/ai-edition/document-service.test.ts
+++ b/electron/ai-edition/document-service.test.ts
@@ -245,6 +245,34 @@ describe("DocumentService", () => {
});
});
+ describe("onProjectRead", () => {
+ it("announces every document it hands out, after the relink", async () => {
+ // The read allow-list lives in the main process and is in memory: a picker's
+ // approval is gone by the next launch. This callback is how a project reopened
+ // tomorrow can still read the media it declares — and it must fire with the
+ // RELINKED paths, since those are the ones the renderer will ask for.
+ const seen: string[][] = [];
+ const service = new DocumentService(tempDir, mediaDir, (doc) =>
+ seen.push(doc.assets.map((a) => a.originalPath)),
+ );
+ const created = await service.createProject("P");
+ const withAsset = await service.addAsset(created.project.id, {
+ path: path.join(mediaDir, "take.mp4"),
+ label: "take.mp4",
+ });
+ seen.length = 0;
+ await service.getProject(created.project.id);
+ expect(seen).toEqual([withAsset.assets.map((a) => a.originalPath)]);
+ });
+
+ it("is optional, so a service built without it loads as it always did", async () => {
+ const created = await service.createProject("P");
+ await expect(service.getProject(created.project.id)).resolves.toMatchObject({
+ project: { id: created.project.id },
+ });
+ });
+ });
+
describe("addAsset", () => {
it("appends a video asset and sets primaryAssetId on the first add", async () => {
const doc = await service.createProject("P");
diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts
index 2fca7962c..e78a9c48e 100644
--- a/electron/ai-edition/document-service.ts
+++ b/electron/ai-edition/document-service.ts
@@ -157,9 +157,23 @@ export class DocumentService {
// `mediaRegistryDir` is where the media-links registry file lives
// (RECORDINGS_DIR in production) — see getProject. Injected for the same
// reason as `projectsRoot`: this module stays free of any `electron` import.
- constructor(projectsRoot: string, mediaRegistryDir: string) {
+ /**
+ * Called with every document this service hands out, so the process that owns the read
+ * allow-list can grant the media that document declares.
+ *
+ * Injected for the same reason as the two paths above: this module stays free of any
+ * `electron` import. Optional so the tests and the CLI construct it as they always did.
+ */
+ private readonly onProjectRead?: (document: AxcutDocument) => void;
+
+ constructor(
+ projectsRoot: string,
+ mediaRegistryDir: string,
+ onProjectRead?: (document: AxcutDocument) => void,
+ ) {
this.projectsRoot = projectsRoot;
this.mediaRegistryDir = mediaRegistryDir;
+ this.onProjectRead = onProjectRead;
}
async ensureProjectsDir(): Promise {
@@ -274,7 +288,12 @@ export class DocumentService {
// back, and it is not persisted from here: the renderer saves the document
// it was given, as it does for any other load-time repair.
const migrated = migrateRawDocumentToCurrent(JSON.parse(raw));
- return documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir));
+ const document = documentSchema.parse(
+ await relinkProjectMedia(migrated, this.mediaRegistryDir),
+ );
+ // AFTER the relink, so what is granted is the path the renderer will actually ask for.
+ this.onProjectRead?.(document);
+ return document;
}
async createProject(title: string): Promise {
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index f404daa84..fcda39e0c 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -16,6 +16,7 @@ import {
shell,
systemPreferences,
} from "electron";
+import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
import {
type NativeLinuxRecordingRequest,
portalCursorMode,
@@ -372,13 +373,37 @@ function approveReadableAudioPath(
return approveReadableMediaPath(filePath, hasAllowedImportAudioExtension, trustedDirs);
}
-// For the generic media reads that accept either kind — NOT for the pickers,
-// which must stay type-specific (see `hasAllowedImportMediaExtension`).
-function approveReadableAvPath(
- filePath?: string | null,
- trustedDirs?: string[],
-): Promise {
- return approveReadableMediaPath(filePath, hasAllowedImportMediaExtension, trustedDirs);
+/**
+ * A path a generic read may use — and NOT a way to obtain one.
+ *
+ * `approveReadableMediaPath` grants approval to any existing file with a media extension.
+ * Behind a picker or a document load that is the point; behind `read-binary-file` it meant
+ * the renderer could name any media file on the machine and have its bytes handed back,
+ * which is a capability no generic handler should carry (CWE-200).
+ *
+ * Approval is granted in exactly three places now: the recordings directory, a file the user
+ * picked, and the assets a loaded project declares (`approveDocumentMedia`). Everything else
+ * spends one.
+ */
+function readableApprovedPath(filePath?: string | null): string | null {
+ const normalizedPath = normalizeVideoSourcePath(filePath);
+ if (!normalizedPath) return null;
+ if (!isPathAllowed(normalizedPath)) return null;
+ // The extension check stays: an approval granted for a recording must not become a way
+ // to read the project file, the log, or anything else sitting beside it.
+ if (!hasAllowedImportMediaExtension(normalizedPath)) return null;
+ return normalizedPath;
+}
+
+/** Grant the media a loaded project declares. The document is the app's own file, and this
+ * is what the picker's approval decays into once the app restarts. */
+function approveDocumentMedia(document: AxcutDocument): void {
+ for (const asset of document.assets ?? []) {
+ const media = normalizeVideoSourcePath(asset.originalPath);
+ if (media && hasAllowedImportMediaExtension(media)) approveFilePath(media);
+ const camera = normalizeVideoSourcePath(asset.cameraTrack?.sourcePath);
+ if (camera && hasAllowedImportMediaExtension(camera)) approveFilePath(camera);
+ }
}
function resolveRecordingOutputPath(fileName: string): string {
@@ -3800,7 +3825,7 @@ export function registerIpcHandlers(
ipcMain.handle("read-binary-file", async (_, filePath: string) => {
try {
- const normalizedPath = await approveReadableAvPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -3830,7 +3855,7 @@ export function registerIpcHandlers(
// recording above that can never be loaded whole — see read-file-chunk).
ipcMain.handle("get-readable-file-info", async (_, filePath: string) => {
try {
- const normalizedPath = await approveReadableAvPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -3866,7 +3891,7 @@ export function registerIpcHandlers(
async (_, filePath: string, durationSec: number): Promise => {
try {
// Same approval gate as every other read of a renderer-supplied path.
- const normalizedPath = await approveReadableAvPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return { success: false, message: "File path is not approved" };
}
@@ -3890,7 +3915,7 @@ export function registerIpcHandlers(
// do (2 GiB cap) and a 16 GB machine cannot hold for multi-GB recordings.
ipcMain.handle("read-file-chunk", async (_, filePath: string, offset: number, length: number) => {
try {
- const normalizedPath = await approveReadableAvPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -4319,6 +4344,7 @@ export function registerIpcHandlers(
const aiEditionDocuments = new DocumentService(
path.join(app.getPath("userData"), "projects"),
RECORDINGS_DIR,
+ approveDocumentMedia,
);
// LlmConfigStore is single-instance for a duller reason — its constructor does
diff --git a/electron/stt/index.ts b/electron/stt/index.ts
index 1004726f5..a20f4e577 100644
--- a/electron/stt/index.ts
+++ b/electron/stt/index.ts
@@ -104,6 +104,13 @@ export class SttManager {
*/
private cancelEpoch = 0;
+ /**
+ * The extraction in flight, if any. `cancelEpoch` alone stops the CHUNK loop, which is
+ * checked between chunks — so a cancel during the decode left ffmpeg running to
+ * completion on a file that can be hours long, and the user saw nothing stop.
+ */
+ private extraction: AbortController | null = null;
+
/**
* Attach a sink for the renderer status channel; returns its detach function.
*
@@ -135,6 +142,7 @@ export class SttManager {
*/
cancel(): void {
this.cancelEpoch++;
+ this.extraction?.abort();
}
/**
@@ -243,7 +251,15 @@ export class SttManager {
if (!req.sourcePath) {
throw new Error("stt:transcribe needs either `samples` or `sourcePath`");
}
- return extractMono16kPcm(req.sourcePath);
+ const controller = new AbortController();
+ this.extraction = controller;
+ try {
+ return await extractMono16kPcm(req.sourcePath, { signal: controller.signal });
+ } finally {
+ // Only if it is still ours: a cancel that started a new run must not have its
+ // controller cleared by the old one unwinding.
+ if (this.extraction === controller) this.extraction = null;
+ }
}
async transcribe(req: SttTranscribeRequest): Promise {
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index 823b3f9b5..5ca6071ac 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -20,9 +20,16 @@ function TooltipRoot({ ...props }: React.ComponentProps;
}
-function TooltipTrigger({ ...props }: React.ComponentProps) {
- return ;
-}
+// forwardRef, like PopoverTrigger: `Tooltip` below hands its own ref here, and on React 18
+// a plain function component drops it silently — the ref resolves to null and React logs
+// "Function components cannot be given refs".
+const TooltipTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentProps
+>(({ ...props }, ref) => (
+
+));
+TooltipTrigger.displayName = "TooltipTrigger";
function TooltipContent({
className,
From 8d9411e425447da808e6741027c114c47092a82a Mon Sep 17 00:00:00 2001
From: EtienneLescot
Date: Fri, 4 Sep 2026 20:14:32 +0200
Subject: [PATCH 113/113] fix(rebase): restore what replaying 112 commits
dropped
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The branch was rebased onto main rather than merged, because the repository allows only
"Rebase and merge" — a merge commit makes that button refuse, which is exactly what it did.
Replaying 112 commits over 71 is not the same operation as merging them once, and it showed:
a handful of files came out short of the tree that was actually tested. Three transcript-pane
test fixtures lost the word-edit props, `settings.json` lost `captions.transcribe` in all
thirteen locales, and the three export pipelines and the two panes each lost a line or two.
None of it would have failed loudly — the tests that cover them are the ones whose fixtures
were trimmed.
So the tree is put back to the one that was verified, file by file, and only for the files
this branch actually owns. `.github/workflows/build-whisper-stt.yml` is left as main has it:
the branch does not own it, and there the rebase was the one that was right.
Verified rather than asserted: the working tree now differs from the merge that ran the full
suite by that one workflow file and nothing else.
---
crates/compositor/src/pipeline_linux.rs | 4 +-
crates/compositor/src/pipeline_macos.rs | 4 +-
crates/compositor/src/pipeline_windows.rs | 4 +-
src/components/ai-edition/NewEditorShell.tsx | 2 +-
src/components/ai-edition/RightPanes.tsx | 6 +-
.../TranscriptPane.captions.test.tsx | 3 +
.../ai-edition/TranscriptPane.lanes.test.tsx | 3 +
.../TranscriptPane.wordEdit.test.tsx | 1 +
.../TranscriptPane.wordInsert.test.tsx | 1 +
.../v4/V4Timeline.geometry.test.tsx | 3 +
src/i18n/locales/ar/settings.json | 591 +++++++++---------
src/i18n/locales/ar/timeline.json | 166 ++---
src/i18n/locales/en/settings.json | 591 +++++++++---------
src/i18n/locales/en/timeline.json | 166 ++---
src/i18n/locales/es/settings.json | 591 +++++++++---------
src/i18n/locales/es/timeline.json | 166 ++---
src/i18n/locales/fr/settings.json | 581 ++++++++---------
src/i18n/locales/fr/timeline.json | 166 ++---
src/i18n/locales/it/settings.json | 591 +++++++++---------
src/i18n/locales/it/timeline.json | 166 ++---
src/i18n/locales/ja-JP/settings.json | 591 +++++++++---------
src/i18n/locales/ja-JP/timeline.json | 166 ++---
src/i18n/locales/ko-KR/settings.json | 581 ++++++++---------
src/i18n/locales/ko-KR/timeline.json | 166 ++---
src/i18n/locales/pt-BR/settings.json | 591 +++++++++---------
src/i18n/locales/pt-BR/timeline.json | 166 ++---
src/i18n/locales/ru/settings.json | 591 +++++++++---------
src/i18n/locales/ru/timeline.json | 166 ++---
src/i18n/locales/tr/settings.json | 591 +++++++++---------
src/i18n/locales/tr/timeline.json | 166 ++---
src/i18n/locales/vi/settings.json | 591 +++++++++---------
src/i18n/locales/vi/timeline.json | 166 ++---
src/i18n/locales/zh-CN/settings.json | 591 +++++++++---------
src/i18n/locales/zh-CN/timeline.json | 166 ++---
src/i18n/locales/zh-TW/settings.json | 591 +++++++++---------
src/i18n/locales/zh-TW/timeline.json | 166 ++---
src/lib/ai-edition/document/transcribe.ts | 4 +-
.../engineering/rendering-performance.md | 30 +-
38 files changed, 4938 insertions(+), 4948 deletions(-)
diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs
index bc54c3843..387e29319 100644
--- a/crates/compositor/src/pipeline_linux.rs
+++ b/crates/compositor/src/pipeline_linux.rs
@@ -27,8 +27,8 @@ use std::ffi::CString;
use std::ptr;
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
- mix_external_tracks, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
+ AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::config::Cfg;
diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs
index 1b3f75798..5721c2011 100644
--- a/crates/compositor/src/pipeline_macos.rs
+++ b/crates/compositor/src/pipeline_macos.rs
@@ -30,8 +30,8 @@
//! décodeurs, symétrique.
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
- mix_external_tracks, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
+ AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::compositor::Compositor;
diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs
index 43e6fc826..8a474f388 100644
--- a/crates/compositor/src/pipeline_windows.rs
+++ b/crates/compositor/src/pipeline_windows.rs
@@ -3,8 +3,8 @@
//! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps.
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
- mix_external_tracks, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
+ AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::compositor::{Compositor, OUT_H, OUT_W};
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 508b004d5..6404c3fdb 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -34,11 +34,11 @@ import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTi
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { isGeneratedAssetId } from "@/lib/ai-edition/timeline/clip-parts";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
-import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import {
dropTrimPillsByIds,
ventilateTimelineSpanToTrims,
} from "@/lib/ai-edition/timeline/trim-mapping";
+import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import { matchesShortcut } from "@/lib/shortcuts";
import { nativeBridgeClient } from "@/native";
import type { AiEditionProjectSummary } from "@/native/contracts";
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 296b1948f..8d89fa510 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -1049,7 +1049,6 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
section,
busy,
busyLabel,
- lane,
cueWordId,
onSeek,
onTrimTimelineSpan,
@@ -1062,9 +1061,6 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
section: ClipSection;
busy: boolean;
busyLabel?: string;
- /** Which lane the block belongs to. Only the insert gesture cares: a pause is a held
- * CLIP frame, and a voiceover placement has no clip to hold. */
- lane: TranscriptLane;
cueWordId: string | null;
onSeek: (sec: number) => void;
onTrimTimelineSpan: (startSec: number, endSec: number, reason: string) => void;
@@ -1442,7 +1438,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}}
>
- {ts("transcript.transcribing")}
+ {busyLabel ?? ts("transcript.transcribing")}
) : null}
diff --git a/src/components/ai-edition/TranscriptPane.captions.test.tsx b/src/components/ai-edition/TranscriptPane.captions.test.tsx
index 2d4265803..96aae2c17 100644
--- a/src/components/ai-edition/TranscriptPane.captions.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.captions.test.tsx
@@ -70,6 +70,9 @@ function mount(transcripts: AxcutTranscript[]) {
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
+ onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
/>
,
);
diff --git a/src/components/ai-edition/TranscriptPane.lanes.test.tsx b/src/components/ai-edition/TranscriptPane.lanes.test.tsx
index 20e8e0dd2..e84868f3e 100644
--- a/src/components/ai-edition/TranscriptPane.lanes.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.lanes.test.tsx
@@ -140,6 +140,9 @@ function mount(audioTracks: AxcutAudioTrack[]) {
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
+ onSetWordText={vi.fn()}
+ onInsertWord={vi.fn()}
+ onRemoveWords={vi.fn()}
/>
,
);
diff --git a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
index 69b80687b..9e22b9a42 100644
--- a/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.wordEdit.test.tsx
@@ -57,6 +57,7 @@ function renderPane(words?: AxcutWord[], busyAssetIds: string[] = []) {
{
multiSelection: [],
clipSelection: null,
audioTracks: tracks ?? [{ ...makeTrack(), ...trackOverrides }],
+ // The lane reads these for the amber added-word marks (#540); this fixture
+ // is about audio geometry, so it has none.
+ transcripts: [],
selectedAudioTrackId: null,
selectAudioTrack,
placeAudioTrack,
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index fd910497f..3a9e392ca 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "شدة الضبابية",
- "bgModes": {
- "transparent": "تفريغ",
- "none": "الأصلي",
- "blur": "تمويه",
- "custom": "مخصص"
- },
- "selectPreset": "حدد إعدادًا مسبقًا",
- "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
- "reactiveWebcam": "تصغير عند التكبير",
- "shapes": {
- "circle": "دائرة",
- "square": "مربع",
- "rectangle": "مستطيل",
- "rounded": "زوايا مستديرة"
- },
- "webcamBackground": "خلفية الكاميرا",
- "verticalStack": "تكدس عمودي",
- "pictureInPicture": "صورة داخل صورة",
- "webcamShape": "شكل الكاميرا",
- "webcamCropY": "تحريك عمودي",
- "webcamSize": "حجم كاميرا الويب",
- "mirrorWebcam": "عكس كاميرا الويب",
- "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
- "webcamFraming": "تأطير كاميرا الويب",
- "noWebcam": "بدون كاميرا",
- "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
- "webcamCropZoom": "تكبير الاقتصاص",
- "dualFrame": "إطار مزدوج",
- "webcamCropX": "تحريك أفقي",
- "title": "تخطيط الكاميرا",
- "preset": "الإعداد المسبق"
- },
- "crop": {
- "done": "تم",
- "ratio": "النسبة",
- "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
- "title": "اقتصاص",
- "free": "حر",
- "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
- "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
- "cropVideo": "اقتصاص الفيديو"
- },
- "zoom": {
- "position": {
- "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "موضع التركيز"
- },
- "threeD": {
- "preset": {
- "right": "يمين",
- "iso": "متساوي القياس",
- "left": "يسار"
- },
- "none": "بلا",
- "title": "دوران ثلاثي الأبعاد"
- },
- "deleteZoom": "حذف التكبير",
- "customScale": "تكبير مخصص",
- "selectRegion": "حدد منطقة التكبير للتعديل",
- "focusMode": {
- "manual": "يدوي",
- "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
- "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
- "title": "وضع التركيز",
- "auto": "تلقائي"
- },
- "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
- "level": "مستوى التكبير"
- },
"background": {
- "color": "لون",
- "colorLabel": "اللون {{color}}",
- "gradient": "تدرج لوني",
- "imageReadFailed": "تعذّر قراءة ملف الصورة.",
"gradientLabel": "تدرج لوني {{index}}",
+ "uploadCustom": "رفع صورة مخصصة",
+ "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
+ "title": "الخلفية",
+ "imageLabel": "الخلفية {{index}}",
"custom": "مخصص",
+ "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
+ "gradient": "تدرج لوني",
+ "colorLabel": "اللون {{color}}",
"customWallpaper": "خلفية مخصصة",
- "presets": "إعدادات مسبقة",
- "image": "صورة",
"colorPalette": "لوحة الألوان",
- "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
- "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
- "imageLabel": "الخلفية {{index}}",
- "title": "الخلفية",
- "uploadCustom": "رفع صورة مخصصة",
+ "imageReadFailed": "تعذّر قراءة ملف الصورة.",
+ "image": "صورة",
+ "presets": "إعدادات مسبقة",
+ "color": "لون",
"colorWheel": "عجلة الألوان"
},
- "cursor": {
- "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
- "clickBounce": "ارتداد النقر",
- "clipToBounds": "القص ضمن اللوحة",
- "title": "المؤشر",
- "size": "الحجم",
- "themeDefault": "افتراضي",
- "smoothing": "التنعيم",
- "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
- "motionBlur": "ضبابية الحركة",
- "theme": "نمط المؤشر",
- "show": "إظهار المؤشر"
- },
- "captions": {
- "text": "النص",
- "showBackground": "إظهار الخلفية",
- "minWords": "أقل عدد كلمات في السطر",
- "translateFailed": "فشلت الترجمة.",
- "distanceFromTop": "المسافة من الأعلى",
- "fontSize": "الحجم",
- "distanceFromRight": "المسافة من اليمين",
- "bold": "عريض",
- "textColor": "لون النص",
- "original": "الأصل (النص المفرّغ)",
- "translating": "جارٍ الترجمة…",
- "language": "اللغة",
- "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
- "alignLeft": "يسار",
- "distanceFromBottom": "المسافة من الأسفل",
- "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
- "show": "إظهار الترجمة",
- "background": "الخلفية",
- "backgroundOpacity": "العتامة",
- "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
- "anchorTop": "أعلى",
- "translate": "ترجمة",
- "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
- "alignCenter": "توسيط",
- "alignRight": "يمين",
- "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
- "displayLanguage": "العرض",
- "noTranscript": "تُقرأ التسميات التوضيحية من نسخ الوسائط النصي. تُفعَّل بمجرد نسخ هذا الفيديو نصيًا.",
- "distanceFromLeft": "المسافة من اليسار",
- "maxWords": "أكثر عدد كلمات في السطر",
- "anchorBottom": "أسفل",
- "position": "الموضع",
- "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
- "deleteTranslation": "حذف هذه الترجمة",
- "backgroundColor": "لون الخلفية",
- "font": "الخط",
- "lineLength": "طول السطر",
- "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
- "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "دقة التصدير"
- },
- "transcript": {
- "transcribing": "جارٍ التفريغ…",
- "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
- "laneVoiceover": "التعليق الصوتي",
- "blankedWord": "مُفرَّغة",
- "noTranscript": "لا يوجد نص بعد",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
- "revertWord": "استعادة \"{{original}}\"",
- "silence": "[صمت {{duration}} ث]",
- "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
- "transcribeNow": "فرّغ النص الآن",
- "restoreWord": "استعادة \"{{word}}\"",
- "clipLabel": "المقطع {{index}}",
- "title": "النص الحالي",
- "insertAria": "كلمة جديدة",
- "laneRecording": "التسجيل",
- "trimSilence": "قص الصمت ({{duration}} ث)",
- "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
- "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
- "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
- "removeInserted": "حذف \"{{word}}\"",
- "editorAria": "نص {{filename}}",
- "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
- "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
- "editWord": "تحرير \"{{word}}\"",
- "noClips": "لا توجد مقاطع بعد",
- "laneLabel": "اقرأ النص من",
- "restoreSilence": "استعادة الصمت ({{duration}} ث)"
- },
"customFont": {
+ "namePlaceholder": "خطي المخصص",
+ "failedToAdd": "فشل في إضافة الخط",
"addingButton": "جاري الإضافة...",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
"urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
- "addButton": "إضافة خط",
- "dialogTitle": "إضافة خط Google",
- "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
- "nameLabel": "اسم العرض",
- "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
"urlLabel": "رابط استيراد خطوط Google",
+ "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
+ "nameLabel": "اسم العرض",
"errorEmptyName": "يرجى إدخال اسم الخط",
- "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
- "namePlaceholder": "خطي المخصص",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "فشل في إضافة الخط",
"nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
"errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
- "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
- "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "إضافة خط Google",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
+ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
+ "addButton": "إضافة خط"
+ },
+ "imageUpload": {
+ "invalidFileType": "نوع ملف غير صالح",
+ "failedToUpload": "فشل رفع الصورة",
+ "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
+ "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
+ "errorReading": "حدث خطأ أثناء قراءة الملف."
},
"annotation": {
- "arrowColor": "لون السهم",
- "colorWheel": "عجلة الألوان",
- "blurType": "نوع التمويه",
- "active": "نشط",
- "deleteAnnotation": "حذف الشرح",
- "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
- "strokeWidth": "عرض الخط: {{width}}px",
- "background": "الخلفية",
- "imageUploadSuccess": "تم رفع الصورة بنجاح!",
- "blurColor": "لون التمويه",
- "blurTypeBlur": "غاوسي",
- "textColor": "لون النص",
- "blurColorWhite": "أبيض",
- "title": "إعدادات الشروح",
- "type": "النوع",
- "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
- "typeImage": "صورة",
- "textContent": "محتوى النص",
"supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
- "typeText": "نص",
- "blurIntensity": "كثافة التمويه",
- "none": "بدون",
- "mosaicBlockSize": "حجم كتلة الفسيفساء",
- "textPlaceholder": "أدخل النص هنا...",
- "typeArrow": "سهم",
- "color": "لون",
- "blurColorBlack": "أسود",
+ "blurShapeRectangle": "مستطيل",
"size": "الحجم",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "clearBackground": "مسح الخلفية",
+ "colorPalette": "لوحة الألوان",
"invalidImageType": "نوع ملف غير صالح",
+ "background": "الخلفية",
+ "typeText": "نص",
+ "active": "نشط",
+ "color": "لون",
"blurShapeFreehand": "رسم حر",
- "shortcutsAndTips": "اختصارات ونصائح",
- "uploadImage": "رفع صورة",
+ "arrowDirection": "اتجاه السهم",
"blurTypeMosaic": "فسيفساء",
+ "colorWheel": "عجلة الألوان",
+ "textColor": "لون النص",
+ "title": "إعدادات الشروح",
+ "blurType": "نوع التمويه",
+ "typeBlur": "تمويه",
+ "blurIntensity": "كثافة التمويه",
"selectStyle": "حدد النمط",
- "defaultText": "مرحبا",
- "blurShapeRectangle": "مستطيل",
- "colorPalette": "لوحة الألوان",
- "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
- "clearBackground": "مسح الخلفية",
+ "textContent": "محتوى النص",
+ "typeArrow": "سهم",
+ "none": "بدون",
+ "blurColor": "لون التمويه",
"customFonts": "خطوط مخصصة",
- "typeBlur": "تمويه",
+ "imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "type": "النوع",
+ "arrowColor": "لون السهم",
+ "textPlaceholder": "أدخل النص هنا...",
+ "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
+ "blurShape": "شكل التمويه",
+ "uploadImage": "رفع صورة",
+ "blurTypeBlur": "غاوسي",
"tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
+ "shortcutsAndTips": "اختصارات ونصائح",
+ "deleteAnnotation": "حذف الشرح",
"fontStyle": "نمط الخط",
- "blurShape": "شكل التمويه",
- "arrowDirection": "اتجاه السهم",
- "blurShapeOval": "بيضاوي"
- },
- "speed": {
- "customPlaybackSpeed": "سرعة تشغيل مخصصة",
- "deleteRegion": "حذف منطقة السرعة",
- "selectRegion": "حدد منطقة السرعة للتعديل",
- "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
- "playbackSpeed": "سرعة التشغيل",
- "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير."
- },
- "textAnimation": {
- "selectAnimation": "حدد الحركة",
- "pulse": "نبض",
- "rise": "ارتفاع",
- "none": "بدون",
- "slideLeft": "انزلاق لليسار",
- "title": "تحريك النص",
- "fade": "تلاشي",
- "pop": "ظهور",
- "typewriter": "آلة كاتبة"
+ "defaultText": "مرحبا",
+ "mosaicBlockSize": "حجم كتلة الفسيفساء",
+ "blurColorBlack": "أسود",
+ "strokeWidth": "عرض الخط: {{width}}px",
+ "blurShapeOval": "بيضاوي",
+ "blurColorWhite": "أبيض",
+ "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
+ "typeImage": "صورة"
},
"effects": {
- "motion": "الحركة",
- "title": "التركيب",
- "format": "التنسيق",
- "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
"fitClipFew": "{{count}} مقاطع",
- "motionBlur": "ضبابية الحركة",
- "fitClipMany": "{{count}} مقاطع",
- "frame": "الإطار",
- "padding": "المسافة البادئة",
- "roundness": "الاستدارة",
- "off": "إيقاف",
- "blurBg": "تمويه الخلفية",
+ "title": "التركيب",
"shadow": "ظل",
+ "off": "إيقاف",
"on": "تشغيل",
+ "blurBg": "تمويه الخلفية",
+ "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
"fitClipOne": "مقطع واحد",
"formatOriginal": "الأصلي",
- "fitClip": "ملاءمة"
+ "fitClipMany": "{{count}} مقاطع",
+ "frame": "الإطار",
+ "motion": "الحركة",
+ "padding": "المسافة البادئة",
+ "format": "التنسيق",
+ "fitClip": "ملاءمة",
+ "motionBlur": "ضبابية الحركة",
+ "roundness": "الاستدارة"
+ },
+ "transcript": {
+ "laneRecording": "التسجيل",
+ "noTranscript": "لا يوجد نص بعد",
+ "title": "النص الحالي",
+ "restoreWord": "استعادة \"{{word}}\"",
+ "revertWord": "استعادة \"{{original}}\"",
+ "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
+ "restoreSilence": "استعادة الصمت ({{duration}} ث)",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "editWord": "تحرير \"{{word}}\"",
+ "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
+ "insertAria": "كلمة جديدة",
+ "editorAria": "نص {{filename}}",
+ "transcribeNow": "فرّغ النص الآن",
+ "transcribing": "جارٍ التفريغ…",
+ "trimSilence": "قص الصمت ({{duration}} ث)",
+ "removeInserted": "حذف \"{{word}}\"",
+ "laneLabel": "اقرأ النص من",
+ "noClips": "لا توجد مقاطع بعد",
+ "laneVoiceover": "التعليق الصوتي",
+ "silence": "[صمت {{duration}} ث]",
+ "clipLabel": "المقطع {{index}}",
+ "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
+ "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "blankedWord": "مُفرَّغة"
},
"exportFormat": {
- "gifDescription": "صورة متحركة للمشاركة",
"mp4": "MP4",
- "mp4Video": "فيديو MP4",
- "gif": "GIF",
+ "mp4Description": "ملف فيديو عالي الجودة",
"gifAnimation": "صورة GIF متحركة",
- "mp4Description": "ملف فيديو عالي الجودة"
+ "mp4Video": "فيديو MP4",
+ "gifDescription": "صورة متحركة للمشاركة",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "فشل رفع الصورة",
- "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
- "errorReading": "حدث خطأ أثناء قراءة الملف.",
- "invalidFileType": "نوع ملف غير صالح",
- "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG."
+ "captions": {
+ "showBackground": "إظهار الخلفية",
+ "deleteTranslation": "حذف هذه الترجمة",
+ "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
+ "backgroundOpacity": "العتامة",
+ "backgroundColor": "لون الخلفية",
+ "alignCenter": "توسيط",
+ "translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
+ "distanceFromRight": "المسافة من اليمين",
+ "language": "اللغة",
+ "text": "النص",
+ "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
+ "distanceFromTop": "المسافة من الأعلى",
+ "translateFailed": "فشلت الترجمة.",
+ "alignLeft": "يسار",
+ "distanceFromBottom": "المسافة من الأسفل",
+ "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
+ "translate": "ترجمة",
+ "position": "الموضع",
+ "fontSize": "الحجم",
+ "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
+ "noTranscript": "تُقرأ الترجمة من نص الوسائط. فرّغ نص هذا الفيديو لتفعيلها.",
+ "distanceFromLeft": "المسافة من اليسار",
+ "anchorBottom": "أسفل",
+ "transcribe": "تفريغ نص الفيديو",
+ "bold": "عريض",
+ "alignRight": "يمين",
+ "anchorTop": "أعلى",
+ "minWords": "أقل عدد كلمات في السطر",
+ "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
+ "displayLanguage": "العرض",
+ "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
+ "background": "الخلفية",
+ "lineLength": "طول السطر",
+ "original": "الأصل (النص المفرّغ)",
+ "maxWords": "أكثر عدد كلمات في السطر",
+ "font": "الخط",
+ "translating": "جارٍ الترجمة…",
+ "show": "إظهار الترجمة",
+ "textColor": "لون النص"
},
- "facets": {
- "captions": "الترجمة",
- "transcript": "النص"
+ "panes": {
+ "help": "مساعدة"
},
- "audio": {
- "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
- "reset": "إعادة ضبط الصوت",
- "title": "الصوت",
- "outputGain": "ضبط مستوى الإخراج"
+ "speed": {
+ "deleteRegion": "حذف منطقة السرعة",
+ "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
+ "selectRegion": "حدد منطقة السرعة للتعديل",
+ "playbackSpeed": "سرعة التشغيل",
+ "customPlaybackSpeed": "سرعة تشغيل مخصصة",
+ "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير."
},
- "language": {
- "title": "اللغة"
+ "gifSettings": {
+ "frameRate": "معدل إطارات GIF",
+ "loop": "تكرار GIF",
+ "size": "حجم GIF"
+ },
+ "exportQuality": {
+ "title": "دقة التصدير",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "defaultLabel": "مسار صوتي",
"importFailed": "تعذّر إضافة الصوت",
- "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
"fadeOut": "تلاشٍ للخارج",
- "defaultLabel": "مسار صوتي",
- "mute": "كتم",
+ "fadeIn": "تلاشٍ للداخل",
"remove": "حذف المسار",
- "add": "إضافة مسار صوتي",
"loop": "تكرار",
- "fadeIn": "تلاشٍ للداخل"
+ "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
+ "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "add": "إضافة مسار صوتي",
+ "mute": "كتم"
},
- "project": {
- "load": "تحميل المشروع",
- "save": "حفظ المشروع",
- "new": "مشروع جديد"
+ "layout": {
+ "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
+ "mirrorWebcam": "عكس كاميرا الويب",
+ "webcamFraming": "تأطير كاميرا الويب",
+ "shapes": {
+ "rectangle": "مستطيل",
+ "rounded": "زوايا مستديرة",
+ "circle": "دائرة",
+ "square": "مربع"
+ },
+ "selectPreset": "حدد إعدادًا مسبقًا",
+ "bgModes": {
+ "custom": "مخصص",
+ "none": "الأصلي",
+ "blur": "تمويه",
+ "transparent": "تفريغ"
+ },
+ "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
+ "webcamBlurIntensity": "شدة الضبابية",
+ "preset": "الإعداد المسبق",
+ "webcamCropZoom": "تكبير الاقتصاص",
+ "webcamSize": "حجم كاميرا الويب",
+ "dualFrame": "إطار مزدوج",
+ "webcamCropY": "تحريك عمودي",
+ "verticalStack": "تكدس عمودي",
+ "pictureInPicture": "صورة داخل صورة",
+ "webcamShape": "شكل الكاميرا",
+ "webcamCropX": "تحريك أفقي",
+ "reactiveWebcam": "تصغير عند التكبير",
+ "webcamBackground": "خلفية الكاميرا",
+ "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
+ "title": "تخطيط الكاميرا",
+ "noWebcam": "بدون كاميرا"
},
- "panes": {
- "help": "مساعدة"
+ "textAnimation": {
+ "slideLeft": "انزلاق لليسار",
+ "pulse": "نبض",
+ "typewriter": "آلة كاتبة",
+ "selectAnimation": "حدد الحركة",
+ "fade": "تلاشي",
+ "title": "تحريك النص",
+ "none": "بدون",
+ "pop": "ظهور",
+ "rise": "ارتفاع"
},
- "trim": {
- "deleteRegion": "حذف منطقة القص"
+ "facets": {
+ "transcript": "النص",
+ "captions": "الترجمة"
},
- "export": {
- "videoButton": "تصدير الفيديو",
- "gifButton": "تصدير GIF",
- "chooseSaveLocation": "اختيار موقع الحفظ"
+ "crop": {
+ "title": "اقتصاص",
+ "free": "حر",
+ "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع",
+ "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
+ "done": "تم",
+ "ratio": "النسبة",
+ "cropVideo": "اقتصاص الفيديو",
+ "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل",
+ "title": "موضع التركيز",
+ "x": "X (%)"
+ },
+ "deleteZoom": "حذف التكبير",
+ "focusMode": {
+ "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة.",
+ "auto": "تلقائي",
+ "manual": "يدوي",
+ "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
+ "title": "وضع التركيز"
+ },
+ "threeD": {
+ "preset": {
+ "left": "يسار",
+ "right": "يمين",
+ "iso": "متساوي القياس"
+ },
+ "none": "بلا",
+ "title": "دوران ثلاثي الأبعاد"
+ },
+ "level": "مستوى التكبير",
+ "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
+ "customScale": "تكبير مخصص",
+ "selectRegion": "حدد منطقة التكبير للتعديل"
+ },
+ "audio": {
+ "outputGain": "ضبط مستوى الإخراج",
+ "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
+ "reset": "إعادة ضبط الصوت",
+ "title": "الصوت"
+ },
+ "language": {
+ "title": "اللغة"
+ },
+ "project": {
+ "new": "مشروع جديد",
+ "load": "تحميل المشروع",
+ "save": "حفظ المشروع"
},
"support": {
"starOnGithub": "إعطاء نجمة على GitHub",
"saveDiagnostics": "حفظ التشخيصات",
"reportBug": "الإبلاغ عن خطأ"
},
- "gifSettings": {
- "size": "حجم GIF",
- "frameRate": "معدل إطارات GIF",
- "loop": "تكرار GIF"
+ "cursor": {
+ "smoothing": "التنعيم",
+ "clickBounce": "ارتداد النقر",
+ "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.",
+ "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
+ "size": "الحجم",
+ "title": "المؤشر",
+ "show": "إظهار المؤشر",
+ "themeDefault": "افتراضي",
+ "clipToBounds": "القص ضمن اللوحة",
+ "motionBlur": "ضبابية الحركة",
+ "theme": "نمط المؤشر"
+ },
+ "export": {
+ "gifButton": "تصدير GIF",
+ "chooseSaveLocation": "اختيار موقع الحفظ",
+ "videoButton": "تصدير الفيديو"
+ },
+ "trim": {
+ "deleteRegion": "حذف منطقة القص"
}
}
diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json
index 4ff6524eb..d4d77c4af 100644
--- a/src/i18n/locales/ar/timeline.json
+++ b/src/i18n/locales/ar/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "لا يحتوي هذا التسجيل على بيانات حركة مؤشر، أو أن التكبيرات الحالية تغطي بالفعل اللحظات المزدحمة.",
- "smartCutsNoAudio": "لا يحتوي هذا الملف على صوت",
- "automaticZoomsHint": "من حركة المؤشر المسجلة",
- "dragToReorderHint": "اسحب لإعادة الترتيب · انقر نقرًا مزدوجًا لتعديل نقطتي البداية والنهاية",
- "smartCutsNeedsTranscript": "يتطلب نصًا منسوخًا",
- "addedWord": "كلمة مضافة: \"{{word}}\" — لا صوت خلفها",
- "smartZoomsAndCuts": "قصات ذكية",
- "autoZoomFailed": "فشل التكبير التلقائي",
- "smartCutsWaiting": "جارٍ النسخ… سيكون جاهزًا بعد قليل",
- "automaticZooms": "تكبيرات تلقائية",
- "arrangeClipsHint": "اسحب المقاطع أدناه لإعادة ترتيبها أو إسقاط مقاطع جديدة",
- "comment": "تعليق",
- "addAudioTooltip": "إضافة صوت",
- "timelineTools": "أدوات المخطط الزمني",
- "deleteClip": "حذف المقطع",
- "arrangeClips": "ترتيب المقاطع",
- "editInOutPoints": "تعديل نقطتي البداية والنهاية",
- "smartZoomsAndCutsHint": "باستخدام الذكاء الاصطناعي",
- "addedAutoZoomPlural": "تمت إضافة {{count}} تكبيرات تلقائية",
- "noAutoZoomMoments": "لم يتم العثور على لحظات تكبير تلقائي",
- "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط",
- "smartCutsNoSpeech": "لم يتم اكتشاف كلام",
- "newAnnotation": "شرح",
- "importRecordingFirst": "استورد تسجيلاً أولاً",
- "addedAutoZoom": "تمت إضافة {{count}} تكبير تلقائي",
- "dropToAdd": "أفلت للإضافة إلى المخطط الزمني",
- "aiEnhanceRequested": "طُلب من وكيل الذكاء الاصطناعي قص الأوقات الميتة",
- "autoEnhance": "تحسين تلقائي"
+ "buttons": {
+ "addZoom": "إضافة تكبير (Z)",
+ "suggestZooms": "اقتراح تكبير من المؤشر",
+ "autoZoomOn": "اقتراحات التكبير التلقائي مفعّلة — انقر لإزالة التكبيرات المقترحة",
+ "autoZoomOff": "اقتراحات التكبير التلقائي معطّلة — انقر لاقتراح تكبيرات من المؤشر",
+ "autoFocusAllOn": "التركيز التلقائي مفعّل لجميع التكبيرات — انقر للتبديل إلى يدوي للجميع",
+ "autoFocusAllOff": "تفعيل التركيز التلقائي لجميع التكبيرات (الكاميرا تتبع المؤشر)",
+ "addTrim": "إضافة قص (T)",
+ "addAnnotation": "إضافة شرح (A)",
+ "addSpeed": "إضافة سرعة (S)",
+ "addCameraFullscreen": "إضافة كاميرا كاملة الشاشة (C)"
+ },
+ "hints": {
+ "pressZoom": "اضغط Z لإضافة تكبير",
+ "pressTrim": "اضغط T لإضافة قص",
+ "pressAnnotation": "اضغط A لإضافة شرح",
+ "pressAudio": "اضغط M لإضافة صوت، وV لتسجيل تعليق صوتي",
+ "pressSpeed": "اضغط S لإضافة سرعة",
+ "pressCameraFullscreen": "اضغط C لإضافة مقطع كاميرا كاملة الشاشة"
},
"labels": {
- "zoom": "تكبير",
- "cameraFullscreenItem": "كاميرا كاملة الشاشة {{index}}",
- "imageItem": "صورة",
"pan": "تحريك",
- "zoomItem": "تكبير {{index}}",
- "cameraFullscreen": "كاميرا كاملة الشاشة",
+ "zoom": "تكبير",
+ "trim": "قص",
"speed": "سرعة",
- "emptyText": "نص فارغ",
+ "zoomItem": "تكبير {{index}}",
"trimItem": "قص {{index}}",
+ "speedItem": "سرعة {{index}}",
"annotationItem": "شرح",
- "trim": "قص",
- "speedItem": "سرعة {{index}}"
+ "imageItem": "صورة",
+ "emptyText": "نص فارغ",
+ "cameraFullscreen": "كاميرا كاملة الشاشة",
+ "cameraFullscreenItem": "كاميرا كاملة الشاشة {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "لم يتم تحميل أي فيديو",
+ "dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
},
"errors": {
- "noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
+ "cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
+ "zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
+ "noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
"noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.",
- "cameraFullscreenExistsAtLocation": "يوجد بالفعل مقطع كاميرا كاملة الشاشة في هذا الموقع أو لا توجد مساحة كافية متاحة.",
"noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام",
"noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.",
- "zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
"noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر",
- "speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
- "noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
+ "noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
"noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي",
+ "noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
"cannotPlaceTrim": "لا يمكن وضع القص هنا",
- "cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
- "cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
- "zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
- "noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
"trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
- "cannotPlaceCameraFullscreen": "لا يمكن وضع الكاميرا الكاملة هنا"
+ "cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
+ "speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "cannotPlaceCameraFullscreen": "لا يمكن وضع الكاميرا الكاملة هنا",
+ "cameraFullscreenExistsAtLocation": "يوجد بالفعل مقطع كاميرا كاملة الشاشة في هذا الموقع أو لا توجد مساحة كافية متاحة."
+ },
+ "success": {
+ "addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
+ "addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
+ },
+ "toolbar": {
+ "autoEnhance": "تحسين تلقائي",
+ "automaticZooms": "تكبيرات تلقائية",
+ "automaticZoomsHint": "من حركة المؤشر المسجلة",
+ "smartZoomsAndCuts": "قصات ذكية",
+ "smartZoomsAndCutsHint": "باستخدام الذكاء الاصطناعي",
+ "comment": "تعليق",
+ "timelineTools": "أدوات المخطط الزمني",
+ "arrangeClips": "ترتيب المقاطع",
+ "arrangeClipsHint": "اسحب المقاطع أدناه لإعادة ترتيبها أو إسقاط مقاطع جديدة",
+ "newAnnotation": "شرح",
+ "dragToReorderHint": "اسحب لإعادة الترتيب · انقر نقرًا مزدوجًا لتعديل نقطتي البداية والنهاية",
+ "editInOutPoints": "تعديل نقطتي البداية والنهاية",
+ "deleteClip": "حذف المقطع",
+ "dropToAdd": "أفلت للإضافة إلى المخطط الزمني",
+ "importRecordingFirst": "استورد تسجيلاً أولاً",
+ "noAutoZoomMoments": "لم يتم العثور على لحظات تكبير تلقائي",
+ "noAutoZoomMomentsDescription": "لا يحتوي هذا التسجيل على بيانات حركة مؤشر، أو أن التكبيرات الحالية تغطي بالفعل اللحظات المزدحمة.",
+ "addedAutoZoom": "تمت إضافة {{count}} تكبير تلقائي",
+ "addedAutoZoomPlural": "تمت إضافة {{count}} تكبيرات تلقائية",
+ "autoZoomFailed": "فشل التكبير التلقائي",
+ "aiEnhanceRequested": "طُلب من وكيل الذكاء الاصطناعي قص الأوقات الميتة",
+ "smartCutsWaiting": "جارٍ النسخ… سيكون جاهزًا بعد قليل",
+ "smartCutsNeedsTranscript": "يتطلب نصًا منسوخًا",
+ "smartCutsNoAudio": "لا يحتوي هذا الملف على صوت",
+ "smartCutsNoSpeech": "لم يتم اكتشاف كلام",
+ "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط",
+ "addAudioTooltip": "إضافة صوت",
+ "addedWord": "كلمة مضافة: \"{{word}}\" — لا صوت خلفها"
},
"audio": {
- "micDenied": "تم رفض الوصول إلى الميكروفون",
+ "addVoiceover": "إضافة تعليق صوتي",
+ "addVoiceoverHint": "سجّل تعليقًا صوتيًا فوق الفيديو",
"subtitle": "ضع تعليقًا صوتيًا أو موسيقى خلفية على المخطط الزمني",
- "importFailed": "تعذر استيراد الملف الصوتي",
+ "record": "تسجيل تعليق صوتي",
+ "importFile": "استيراد ملف صوتي",
"importFileHint": "أدرج موسيقى أو ملفًا صوتيًا",
- "addVoiceoverHint": "سجّل تعليقًا صوتيًا فوق الفيديو",
- "stop": "إيقاف",
"recording": "جارٍ التسجيل",
- "saveFailed": "تعذر حفظ التسجيل",
"recordingHint": "علّق صوتيًا مع الفيديو — يعمل أثناء التسجيل",
- "importFile": "استيراد ملف صوتي",
- "record": "تسجيل تعليق صوتي",
+ "stop": "إيقاف",
+ "micDenied": "تم رفض الوصول إلى الميكروفون",
"recordingUnavailable": "التسجيل غير متاح هنا",
- "addVoiceover": "إضافة تعليق صوتي"
- },
- "success": {
- "addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
- "addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
- },
- "hints": {
- "pressAnnotation": "اضغط A لإضافة شرح",
- "pressSpeed": "اضغط S لإضافة سرعة",
- "pressTrim": "اضغط T لإضافة قص",
- "pressCameraFullscreen": "اضغط C لإضافة مقطع كاميرا كاملة الشاشة",
- "pressZoom": "اضغط Z لإضافة تكبير",
- "pressAudio": "اضغط M لإضافة صوت، وV لتسجيل تعليق صوتي"
- },
- "buttons": {
- "autoFocusAllOff": "تفعيل التركيز التلقائي لجميع التكبيرات (الكاميرا تتبع المؤشر)",
- "autoZoomOff": "اقتراحات التكبير التلقائي معطّلة — انقر لاقتراح تكبيرات من المؤشر",
- "addAnnotation": "إضافة شرح (A)",
- "suggestZooms": "اقتراح تكبير من المؤشر",
- "addSpeed": "إضافة سرعة (S)",
- "addCameraFullscreen": "إضافة كاميرا كاملة الشاشة (C)",
- "autoFocusAllOn": "التركيز التلقائي مفعّل لجميع التكبيرات — انقر للتبديل إلى يدوي للجميع",
- "autoZoomOn": "اقتراحات التكبير التلقائي مفعّلة — انقر لإزالة التكبيرات المقترحة",
- "addZoom": "إضافة تكبير (Z)",
- "addTrim": "إضافة قص (T)"
- },
- "emptyState": {
- "noVideo": "لم يتم تحميل أي فيديو",
- "dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
+ "saveFailed": "تعذر حفظ التسجيل",
+ "importFailed": "تعذر استيراد الملف الصوتي"
}
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 22c295610..7053126b8 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Blur Intensity",
- "bgModes": {
- "transparent": "Cutout",
- "none": "Original",
- "blur": "Blur",
- "custom": "Custom"
- },
- "selectPreset": "Select preset",
- "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
- "reactiveWebcam": "Shrink on Zoom",
- "shapes": {
- "circle": "Circle",
- "square": "Square",
- "rectangle": "Rect",
- "rounded": "Rounded"
- },
- "webcamBackground": "Camera Background",
- "verticalStack": "Vertical Stack",
- "pictureInPicture": "Picture in Picture",
- "webcamShape": "Camera Shape",
- "webcamCropY": "Pan vertically",
- "webcamSize": "Webcam Size",
- "mirrorWebcam": "Mirror Webcam",
- "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
- "webcamFraming": "Webcam crop",
- "noWebcam": "No Webcam",
- "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
- "webcamCropZoom": "Zoom",
- "dualFrame": "Dual Frame",
- "webcamCropX": "Pan horizontally",
- "title": "Camera layout",
- "preset": "Preset"
- },
- "crop": {
- "done": "Done",
- "ratio": "Ratio",
- "dragInstruction": "Drag on each side to adjust the crop area",
- "title": "Crop",
- "free": "Free",
- "lockAspectRatio": "Lock aspect ratio",
- "unlockAspectRatio": "Unlock aspect ratio",
- "cropVideo": "Crop Video"
- },
- "zoom": {
- "position": {
- "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Focus Position"
- },
- "threeD": {
- "preset": {
- "right": "Right",
- "iso": "Iso",
- "left": "Left"
- },
- "none": "None",
- "title": "3D Rotation"
- },
- "deleteZoom": "Delete Zoom",
- "customScale": "Custom Zoom",
- "selectRegion": "Select a zoom region to adjust",
- "focusMode": {
- "manual": "Manual",
- "autoDescription": "Camera follows the recorded cursor position",
- "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
- "title": "Focus Mode",
- "auto": "Auto"
- },
- "previewHold": "Hold to preview zoom effect",
- "level": "Zoom Level"
- },
"background": {
- "color": "Color",
- "colorLabel": "Color {{color}}",
- "gradient": "Gradient",
- "imageReadFailed": "Could not read that image file.",
"gradientLabel": "Gradient {{index}}",
+ "uploadCustom": "Upload Custom",
+ "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
+ "title": "Background",
+ "imageLabel": "Background {{index}}",
"custom": "Custom",
+ "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
+ "gradient": "Gradient",
+ "colorLabel": "Color {{color}}",
"customWallpaper": "Custom wallpaper",
- "presets": "Presets",
- "image": "Image",
"colorPalette": "Color Palette",
- "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
- "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.",
- "imageLabel": "Background {{index}}",
- "title": "Background",
- "uploadCustom": "Upload Custom",
+ "imageReadFailed": "Could not read that image file.",
+ "image": "Image",
+ "presets": "Presets",
+ "color": "Color",
"colorWheel": "Color Wheel"
},
- "cursor": {
- "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
- "clickBounce": "Click Bounce",
- "clipToBounds": "Clip to Canvas",
- "title": "Cursor",
- "size": "Size",
- "themeDefault": "Default",
- "smoothing": "Smoothing",
- "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
- "motionBlur": "Motion Blur",
- "theme": "Cursor Style",
- "show": "Show Cursor"
- },
- "captions": {
- "text": "Text",
- "showBackground": "Show background",
- "minWords": "Min words per line",
- "translateFailed": "Translation failed.",
- "distanceFromTop": "Distance from top",
- "fontSize": "Size",
- "distanceFromRight": "Distance from right",
- "bold": "Bold",
- "textColor": "Text color",
- "original": "Original (transcript)",
- "translating": "Translating…",
- "language": "Language",
- "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
- "alignLeft": "Left",
- "distanceFromBottom": "Distance from bottom",
- "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
- "show": "Show captions",
- "background": "Background",
- "backgroundOpacity": "Opacity",
- "removeLegacyAnnotations": "Remove old caption annotations",
- "anchorTop": "Top",
- "translate": "Translate",
- "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
- "alignCenter": "Center",
- "alignRight": "Right",
- "translateHint": "Translate the transcript with the configured AI provider",
- "displayLanguage": "Display",
- "noTranscript": "Captions are read from the media transcript. They turn on once this video has been transcribed.",
- "distanceFromLeft": "Distance from left",
- "maxWords": "Max words per line",
- "anchorBottom": "Bottom",
- "position": "Position",
- "anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
- "deleteTranslation": "Delete this translation",
- "backgroundColor": "Background color",
- "font": "Font",
- "lineLength": "Line length",
- "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
- "anchorHintTop": "Long captions grow downward — the top edge stays put."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "Export resolution"
- },
- "transcript": {
- "transcribing": "Transcribing…",
- "laneFeedsCaptions": "Captions are burnt from this lane.",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
- "laneVoiceover": "Voice-over",
- "blankedWord": "blanked",
- "noTranscript": "No transcript yet",
- "noAudio": "This media has no audio track",
- "revertWord": "Restore \"{{original}}\"",
- "silence": "[silence {{duration}}s]",
- "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
- "transcribeNow": "Transcribe now",
- "restoreWord": "Restore \"{{word}}\"",
- "clipLabel": "Clip {{index}}",
- "title": "Current transcription",
- "insertAria": "New word",
- "laneRecording": "Recording",
- "trimSilence": "Trim silence ({{duration}}s)",
- "insertedWord": "Added by you — no audio behind it",
- "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
- "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
- "removeInserted": "Delete \"{{word}}\"",
- "editorAria": "Transcript for {{filename}}",
- "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
- "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
- "editWord": "Edit \"{{word}}\"",
- "noClips": "No clips yet",
- "laneLabel": "Read the transcript from",
- "restoreSilence": "Restore silence ({{duration}}s)"
- },
"customFont": {
+ "namePlaceholder": "My Custom Font",
+ "failedToAdd": "Failed to add font",
"addingButton": "Adding...",
+ "errorInvalidUrl": "Please enter a valid Google Fonts URL",
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
- "addButton": "Add Font",
- "dialogTitle": "Add Google Font",
- "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
- "nameLabel": "Display Name",
- "errorTimeout": "Font took too long to load. Please check the URL and try again.",
"urlLabel": "Google Fonts Import URL",
+ "successMessage": "Font \"{{fontName}}\" added successfully",
+ "nameLabel": "Display Name",
"errorEmptyName": "Please enter a font name",
- "errorEmptyUrl": "Please enter a Google Fonts import URL",
- "namePlaceholder": "My Custom Font",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Failed to add font",
"nameHelp": "This is how the font will appear in the font selector",
+ "errorEmptyUrl": "Please enter a Google Fonts import URL",
"errorExtractFailed": "Could not extract font family from URL",
- "errorInvalidUrl": "Please enter a valid Google Fonts URL",
- "successMessage": "Font \"{{fontName}}\" added successfully"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Add Google Font",
+ "errorTimeout": "Font took too long to load. Please check the URL and try again.",
+ "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.",
+ "addButton": "Add Font"
+ },
+ "imageUpload": {
+ "invalidFileType": "Invalid file type",
+ "failedToUpload": "Failed to upload image",
+ "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
+ "uploadSuccess": "Custom image uploaded successfully!",
+ "errorReading": "There was an error reading the file."
},
"annotation": {
- "arrowColor": "Arrow Color",
- "colorWheel": "Color Wheel",
- "blurType": "Blur Type",
- "active": "Active",
- "deleteAnnotation": "Delete Annotation",
- "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
- "strokeWidth": "Stroke Width: {{width}}px",
- "background": "Background",
- "imageUploadSuccess": "Image uploaded successfully!",
- "blurColor": "Blur Color",
- "blurTypeBlur": "Gaussian",
- "textColor": "Text Color",
- "blurColorWhite": "White",
- "title": "Annotation Settings",
- "type": "Type",
- "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
- "typeImage": "Image",
- "textContent": "Text Content",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
- "typeText": "Text",
- "blurIntensity": "Blur Intensity",
- "none": "None",
- "mosaicBlockSize": "Mosaic Block Size",
- "textPlaceholder": "Enter your text...",
- "typeArrow": "Arrow",
- "color": "Color",
- "blurColorBlack": "Black",
+ "blurShapeRectangle": "Rectangle",
"size": "Size",
+ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
+ "clearBackground": "Clear Background",
+ "colorPalette": "Color Palette",
"invalidImageType": "Invalid file type",
+ "background": "Background",
+ "typeText": "Text",
+ "active": "Active",
+ "color": "Color",
"blurShapeFreehand": "Freehand",
- "shortcutsAndTips": "Shortcuts & Tips",
- "uploadImage": "Upload Image",
+ "arrowDirection": "Arrow Direction",
"blurTypeMosaic": "Mosaic",
+ "colorWheel": "Color Wheel",
+ "textColor": "Text Color",
+ "title": "Annotation Settings",
+ "blurType": "Blur Type",
+ "typeBlur": "Blur",
+ "blurIntensity": "Blur Intensity",
"selectStyle": "Select style",
- "defaultText": "Hello",
- "blurShapeRectangle": "Rectangle",
- "colorPalette": "Color Palette",
- "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
- "clearBackground": "Clear Background",
+ "textContent": "Text Content",
+ "typeArrow": "Arrow",
+ "none": "None",
+ "blurColor": "Blur Color",
"customFonts": "Custom Fonts",
- "typeBlur": "Blur",
+ "imageUploadSuccess": "Image uploaded successfully!",
+ "type": "Type",
+ "arrowColor": "Arrow Color",
+ "textPlaceholder": "Enter your text...",
+ "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
+ "blurShape": "Blur Shape",
+ "uploadImage": "Upload Image",
+ "blurTypeBlur": "Gaussian",
"tipTabCycle": "Use Tab to cycle through overlapping items.",
+ "shortcutsAndTips": "Shortcuts & Tips",
+ "deleteAnnotation": "Delete Annotation",
"fontStyle": "Font Style",
- "blurShape": "Blur Shape",
- "arrowDirection": "Arrow Direction",
- "blurShapeOval": "Oval"
- },
- "speed": {
- "customPlaybackSpeed": "Custom Playback Speed",
- "deleteRegion": "Delete Speed Region",
- "selectRegion": "Select a speed region to adjust",
- "maxSpeedError": "Speed can't go higher than {{max}}×",
- "playbackSpeed": "Playback Speed",
- "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected."
- },
- "textAnimation": {
- "selectAnimation": "Select animation",
- "pulse": "Pulse",
- "rise": "Rise",
- "none": "None",
- "slideLeft": "Slide Left",
- "title": "Text Animation",
- "fade": "Fade",
- "pop": "Pop",
- "typewriter": "Typewriter"
+ "defaultText": "Hello",
+ "mosaicBlockSize": "Mosaic Block Size",
+ "blurColorBlack": "Black",
+ "strokeWidth": "Stroke Width: {{width}}px",
+ "blurShapeOval": "Oval",
+ "blurColorWhite": "White",
+ "tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
+ "typeImage": "Image"
},
"effects": {
- "motion": "Motion",
- "title": "Composition",
- "format": "Format",
- "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
"fitClipFew": "{{count}} clips",
- "motionBlur": "Motion Blur",
- "fitClipMany": "{{count}} clips",
- "frame": "Frame",
- "padding": "Padding",
- "roundness": "Roundness",
- "off": "off",
- "blurBg": "Blur BG",
+ "title": "Composition",
"shadow": "Shadow",
+ "off": "off",
"on": "on",
+ "blurBg": "Blur BG",
+ "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video.",
"fitClipOne": "{{count}} clip",
"formatOriginal": "Original",
- "fitClip": "Fit"
+ "fitClipMany": "{{count}} clips",
+ "frame": "Frame",
+ "motion": "Motion",
+ "padding": "Padding",
+ "format": "Format",
+ "fitClip": "Fit",
+ "motionBlur": "Motion Blur",
+ "roundness": "Roundness"
+ },
+ "transcript": {
+ "laneRecording": "Recording",
+ "noTranscript": "No transcript yet",
+ "title": "Current transcription",
+ "restoreWord": "Restore \"{{word}}\"",
+ "revertWord": "Restore \"{{original}}\"",
+ "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
+ "restoreSilence": "Restore silence ({{duration}}s)",
+ "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to cut it out of the film (red). Double-click a word to correct its text. Hover a marked word to undo it.",
+ "editWord": "Edit \"{{word}}\"",
+ "laneFeedsCaptions": "Captions are burnt from this lane.",
+ "insertedWord": "Added by you — no audio behind it",
+ "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
+ "insertAria": "New word",
+ "editorAria": "Transcript for {{filename}}",
+ "transcribeNow": "Transcribe now",
+ "transcribing": "Transcribing…",
+ "trimSilence": "Trim silence ({{duration}}s)",
+ "removeInserted": "Delete \"{{word}}\"",
+ "laneLabel": "Read the transcript from",
+ "noClips": "No clips yet",
+ "laneVoiceover": "Voice-over",
+ "silence": "[silence {{duration}}s]",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
+ "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
+ "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
+ "noAudio": "This media has no audio track",
+ "blankedWord": "blanked"
},
"exportFormat": {
- "gifDescription": "Animated image for sharing",
"mp4": "MP4",
- "mp4Video": "MP4 Video",
- "gif": "GIF",
+ "mp4Description": "High quality video file",
"gifAnimation": "GIF Animation",
- "mp4Description": "High quality video file"
+ "mp4Video": "MP4 Video",
+ "gifDescription": "Animated image for sharing",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Failed to upload image",
- "uploadSuccess": "Custom image uploaded successfully!",
- "errorReading": "There was an error reading the file.",
- "invalidFileType": "Invalid file type",
- "jpgOnly": "Please upload a JPG, JPEG, or PNG image file."
+ "captions": {
+ "showBackground": "Show background",
+ "deleteTranslation": "Delete this translation",
+ "legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
+ "backgroundOpacity": "Opacity",
+ "backgroundColor": "Background color",
+ "alignCenter": "Center",
+ "translationIsNonDestructive": "Translations are stored beside the transcript, never in it — the original text and its timings stay untouched.",
+ "distanceFromRight": "Distance from right",
+ "language": "Language",
+ "text": "Text",
+ "anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
+ "distanceFromTop": "Distance from top",
+ "translateFailed": "Translation failed.",
+ "alignLeft": "Left",
+ "distanceFromBottom": "Distance from bottom",
+ "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
+ "translate": "Translate",
+ "position": "Position",
+ "fontSize": "Size",
+ "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
+ "noTranscript": "Captions are read from the media transcript. Transcribe this video to turn them on.",
+ "distanceFromLeft": "Distance from left",
+ "anchorBottom": "Bottom",
+ "transcribe": "Transcribe video",
+ "bold": "Bold",
+ "alignRight": "Right",
+ "anchorTop": "Top",
+ "minWords": "Min words per line",
+ "translateHint": "Translate the transcript with the configured AI provider",
+ "anchorHintTop": "Long captions grow downward — the top edge stays put.",
+ "displayLanguage": "Display",
+ "removeLegacyAnnotations": "Remove old caption annotations",
+ "background": "Background",
+ "lineLength": "Line length",
+ "original": "Original (transcript)",
+ "maxWords": "Max words per line",
+ "font": "Font",
+ "translating": "Translating…",
+ "show": "Show captions",
+ "textColor": "Text color"
},
- "facets": {
- "captions": "Captions",
- "transcript": "Transcript"
+ "panes": {
+ "help": "Help"
},
- "audio": {
- "help": "Adjust the audio output level. It applies identically in the preview and the export.",
- "reset": "Reset audio",
- "title": "Audio",
- "outputGain": "Output level"
+ "speed": {
+ "deleteRegion": "Delete Speed Region",
+ "maxSpeedError": "Speed can't go higher than {{max}}×",
+ "selectRegion": "Select a speed region to adjust",
+ "playbackSpeed": "Playback Speed",
+ "customPlaybackSpeed": "Custom Playback Speed",
+ "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected."
},
- "language": {
- "title": "Language"
+ "gifSettings": {
+ "frameRate": "GIF Frame Rate",
+ "loop": "Loop GIF",
+ "size": "GIF Size"
+ },
+ "exportQuality": {
+ "title": "Export resolution",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "defaultLabel": "Audio track",
"importFailed": "Could not add audio",
- "slipHint": "Alt-drag to slide the audio inside it",
"fadeOut": "Fade out",
- "defaultLabel": "Audio track",
- "mute": "Mute",
+ "fadeIn": "Fade in",
"remove": "Delete track",
- "add": "Add audio track",
"loop": "Loop",
- "fadeIn": "Fade in"
+ "slipHint": "Alt-drag to slide the audio inside it",
+ "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "add": "Add audio track",
+ "mute": "Mute"
},
- "project": {
- "load": "Load Project",
- "save": "Save Project",
- "new": "New Project"
+ "layout": {
+ "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
+ "mirrorWebcam": "Mirror Webcam",
+ "webcamFraming": "Webcam crop",
+ "shapes": {
+ "rectangle": "Rect",
+ "rounded": "Rounded",
+ "circle": "Circle",
+ "square": "Square"
+ },
+ "selectPreset": "Select preset",
+ "bgModes": {
+ "custom": "Custom",
+ "none": "Original",
+ "blur": "Blur",
+ "transparent": "Cutout"
+ },
+ "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
+ "webcamBlurIntensity": "Blur Intensity",
+ "preset": "Preset",
+ "webcamCropZoom": "Zoom",
+ "webcamSize": "Webcam Size",
+ "dualFrame": "Dual Frame",
+ "webcamCropY": "Pan vertically",
+ "verticalStack": "Vertical Stack",
+ "pictureInPicture": "Picture in Picture",
+ "webcamShape": "Camera Shape",
+ "webcamCropX": "Pan horizontally",
+ "reactiveWebcam": "Shrink on Zoom",
+ "webcamBackground": "Camera Background",
+ "helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
+ "title": "Camera layout",
+ "noWebcam": "No Webcam"
},
- "panes": {
- "help": "Help"
+ "textAnimation": {
+ "slideLeft": "Slide Left",
+ "pulse": "Pulse",
+ "typewriter": "Typewriter",
+ "selectAnimation": "Select animation",
+ "fade": "Fade",
+ "title": "Text Animation",
+ "none": "None",
+ "pop": "Pop",
+ "rise": "Rise"
},
- "trim": {
- "deleteRegion": "Delete Trim Region"
+ "facets": {
+ "transcript": "Transcript",
+ "captions": "Captions"
},
- "export": {
- "videoButton": "Export Video",
- "gifButton": "Export GIF",
- "chooseSaveLocation": "Choose Save Location"
+ "crop": {
+ "title": "Crop",
+ "free": "Free",
+ "unlockAspectRatio": "Unlock aspect ratio",
+ "dragInstruction": "Drag on each side to adjust the crop area",
+ "done": "Done",
+ "ratio": "Ratio",
+ "cropVideo": "Crop Video",
+ "lockAspectRatio": "Lock aspect ratio"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost",
+ "title": "Focus Position",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Delete Zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom.",
+ "auto": "Auto",
+ "manual": "Manual",
+ "autoDescription": "Camera follows the recorded cursor position",
+ "title": "Focus Mode"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Left",
+ "right": "Right",
+ "iso": "Iso"
+ },
+ "none": "None",
+ "title": "3D Rotation"
+ },
+ "level": "Zoom Level",
+ "previewHold": "Hold to preview zoom effect",
+ "customScale": "Custom Zoom",
+ "selectRegion": "Select a zoom region to adjust"
+ },
+ "audio": {
+ "outputGain": "Output level",
+ "help": "Adjust the audio output level. It applies identically in the preview and the export.",
+ "reset": "Reset audio",
+ "title": "Audio"
+ },
+ "language": {
+ "title": "Language"
+ },
+ "project": {
+ "new": "New Project",
+ "load": "Load Project",
+ "save": "Save Project"
},
"support": {
"starOnGithub": "Star on GitHub",
"saveDiagnostics": "Save Diagnostics",
"reportBug": "Report Bug"
},
- "gifSettings": {
- "size": "GIF Size",
- "frameRate": "GIF Frame Rate",
- "loop": "Loop GIF"
+ "cursor": {
+ "smoothing": "Smoothing",
+ "clickBounce": "Click Bounce",
+ "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.",
+ "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
+ "size": "Size",
+ "title": "Cursor",
+ "show": "Show Cursor",
+ "themeDefault": "Default",
+ "clipToBounds": "Clip to Canvas",
+ "motionBlur": "Motion Blur",
+ "theme": "Cursor Style"
+ },
+ "export": {
+ "gifButton": "Export GIF",
+ "chooseSaveLocation": "Choose Save Location",
+ "videoButton": "Export Video"
+ },
+ "trim": {
+ "deleteRegion": "Delete Trim Region"
}
}
diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json
index 55b3a541e..fa8a50b7e 100644
--- a/src/i18n/locales/en/timeline.json
+++ b/src/i18n/locales/en/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "This recording has no cursor movement data, or existing zooms already cover the busy moments.",
- "smartCutsNoAudio": "This media has no audio",
- "automaticZoomsHint": "From recorded cursor movement",
- "dragToReorderHint": "Drag to reorder · double-click to edit in/out points",
- "smartCutsNeedsTranscript": "Needs a transcript",
- "addedWord": "Added word: \"{{word}}\" — no audio behind it",
- "smartZoomsAndCuts": "Smart cuts",
- "autoZoomFailed": "Auto-zoom failed",
- "smartCutsWaiting": "Transcribing… ready in a moment",
- "automaticZooms": "Automatic zooms",
- "arrangeClipsHint": "Drag clips below to reorder or drop new ones in",
- "comment": "Comment",
- "addAudioTooltip": "Add audio",
- "timelineTools": "Timeline tools",
- "deleteClip": "Delete clip",
- "arrangeClips": "Arrange clips",
- "editInOutPoints": "Edit in/out points",
- "smartZoomsAndCutsHint": "With AI",
- "addedAutoZoomPlural": "Added {{count}} automatic zooms",
- "noAutoZoomMoments": "No auto-zoom moments found",
- "smartCutsFailed": "Transcription failed — retry it from Media",
- "smartCutsNoSpeech": "No speech detected",
- "newAnnotation": "Annotation",
- "importRecordingFirst": "Import a recording first",
- "addedAutoZoom": "Added {{count}} automatic zoom",
- "dropToAdd": "Drop to add to timeline",
- "aiEnhanceRequested": "Asked the AI agent to cut the dead time",
- "autoEnhance": "Auto-enhance"
+ "buttons": {
+ "addZoom": "Add Zoom (Z)",
+ "suggestZooms": "Suggest Zooms from Cursor",
+ "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms",
+ "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor",
+ "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual",
+ "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)",
+ "addTrim": "Add Trim (T)",
+ "addAnnotation": "Add Annotation (A)",
+ "addSpeed": "Add Speed (S)",
+ "addCameraFullscreen": "Add Full Camera (C)"
+ },
+ "hints": {
+ "pressZoom": "Press Z to add zoom",
+ "pressTrim": "Press T to add trim",
+ "pressAnnotation": "Press A to add annotation",
+ "pressAudio": "Press M to add audio, V to record a voiceover",
+ "pressSpeed": "Press S to add speed",
+ "pressCameraFullscreen": "Press C to add a Full Camera segment"
},
"labels": {
- "zoom": "Zoom",
- "cameraFullscreenItem": "Full Camera {{index}}",
- "imageItem": "Image",
"pan": "Pan",
- "zoomItem": "Zoom {{index}}",
- "cameraFullscreen": "Full Camera",
+ "zoom": "Zoom",
+ "trim": "Trim",
"speed": "Speed",
- "emptyText": "Empty text",
+ "zoomItem": "Zoom {{index}}",
"trimItem": "Trim {{index}}",
+ "speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
- "trim": "Trim",
- "speedItem": "Speed {{index}}"
+ "imageItem": "Image",
+ "emptyText": "Empty text",
+ "cameraFullscreen": "Full Camera",
+ "cameraFullscreenItem": "Full Camera {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "No Video Loaded",
+ "dragAndDrop": "Drag and drop a video to start editing"
},
"errors": {
- "noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
+ "cannotPlaceZoom": "Cannot place zoom here",
+ "zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
+ "zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
+ "noCursorTelemetry": "No cursor telemetry available",
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
- "cameraFullscreenExistsAtLocation": "A Full Camera segment already exists at this location or not enough space available.",
"noUsableTelemetry": "No usable cursor telemetry",
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
- "zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
"noDwellMoments": "No clear cursor dwell moments found",
- "speedExistsAtLocation": "Speed region already exists at this location or not enough space available.",
- "noCursorTelemetry": "No cursor telemetry available",
+ "noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
"noAutoZoomSlots": "No auto-zoom slots available",
+ "noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
"cannotPlaceTrim": "Cannot place trim here",
- "cannotPlaceZoom": "Cannot place zoom here",
- "cannotPlaceSpeed": "Cannot place speed here",
- "zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
- "noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
"trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
- "cannotPlaceCameraFullscreen": "Cannot place Full Camera here"
+ "cannotPlaceSpeed": "Cannot place speed here",
+ "speedExistsAtLocation": "Speed region already exists at this location or not enough space available.",
+ "cannotPlaceCameraFullscreen": "Cannot place Full Camera here",
+ "cameraFullscreenExistsAtLocation": "A Full Camera segment already exists at this location or not enough space available."
+ },
+ "success": {
+ "addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
+ "addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
+ },
+ "toolbar": {
+ "autoEnhance": "Auto-enhance",
+ "automaticZooms": "Automatic zooms",
+ "automaticZoomsHint": "From recorded cursor movement",
+ "smartZoomsAndCuts": "Smart cuts",
+ "smartZoomsAndCutsHint": "With AI",
+ "comment": "Comment",
+ "timelineTools": "Timeline tools",
+ "arrangeClips": "Arrange clips",
+ "arrangeClipsHint": "Drag clips below to reorder or drop new ones in",
+ "newAnnotation": "Annotation",
+ "dragToReorderHint": "Drag to reorder · double-click to edit in/out points",
+ "editInOutPoints": "Edit in/out points",
+ "deleteClip": "Delete clip",
+ "dropToAdd": "Drop to add to timeline",
+ "importRecordingFirst": "Import a recording first",
+ "noAutoZoomMoments": "No auto-zoom moments found",
+ "noAutoZoomMomentsDescription": "This recording has no cursor movement data, or existing zooms already cover the busy moments.",
+ "addedAutoZoom": "Added {{count}} automatic zoom",
+ "addedAutoZoomPlural": "Added {{count}} automatic zooms",
+ "autoZoomFailed": "Auto-zoom failed",
+ "aiEnhanceRequested": "Asked the AI agent to cut the dead time",
+ "smartCutsWaiting": "Transcribing… ready in a moment",
+ "smartCutsNeedsTranscript": "Needs a transcript",
+ "smartCutsNoAudio": "This media has no audio",
+ "smartCutsNoSpeech": "No speech detected",
+ "smartCutsFailed": "Transcription failed — retry it from Media",
+ "addAudioTooltip": "Add audio",
+ "addedWord": "Added word: \"{{word}}\" — no audio behind it"
},
"audio": {
- "micDenied": "Microphone access was denied",
+ "addVoiceover": "Add Voiceover",
+ "addVoiceoverHint": "Record narration over your video",
"subtitle": "Place a voiceover or background music layer on the timeline",
- "importFailed": "Could not import the audio file",
+ "record": "Record voiceover",
+ "importFile": "Import audio file",
"importFileHint": "Bring in music or an audio file",
- "addVoiceoverHint": "Record narration over your video",
- "stop": "Stop",
"recording": "Recording",
- "saveFailed": "Could not save the recording",
"recordingHint": "Narrate along with the video — it plays while you record",
- "importFile": "Import audio file",
- "record": "Record voiceover",
+ "stop": "Stop",
+ "micDenied": "Microphone access was denied",
"recordingUnavailable": "Recording is not available here",
- "addVoiceover": "Add Voiceover"
- },
- "success": {
- "addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
- "addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
- },
- "hints": {
- "pressAnnotation": "Press A to add annotation",
- "pressSpeed": "Press S to add speed",
- "pressTrim": "Press T to add trim",
- "pressCameraFullscreen": "Press C to add a Full Camera segment",
- "pressZoom": "Press Z to add zoom",
- "pressAudio": "Press M to add audio, V to record a voiceover"
- },
- "buttons": {
- "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)",
- "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor",
- "addAnnotation": "Add Annotation (A)",
- "suggestZooms": "Suggest Zooms from Cursor",
- "addSpeed": "Add Speed (S)",
- "addCameraFullscreen": "Add Full Camera (C)",
- "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual",
- "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms",
- "addZoom": "Add Zoom (Z)",
- "addTrim": "Add Trim (T)"
- },
- "emptyState": {
- "noVideo": "No Video Loaded",
- "dragAndDrop": "Drag and drop a video to start editing"
+ "saveFailed": "Could not save the recording",
+ "importFailed": "Could not import the audio file"
}
}
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index f7b0a897f..84419849c 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Intensidad del desenfoque",
- "bgModes": {
- "transparent": "Recortado",
- "none": "Original",
- "blur": "Desenfocado",
- "custom": "Personalizado"
- },
- "selectPreset": "Seleccionar predefinido",
- "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
- "reactiveWebcam": "Reducir al ampliar",
- "shapes": {
- "circle": "Círculo",
- "square": "Cuadrado",
- "rectangle": "Rect.",
- "rounded": "Redondeado"
- },
- "webcamBackground": "Fondo de la cámara",
- "verticalStack": "Apilado vertical",
- "pictureInPicture": "Imagen en imagen",
- "webcamShape": "Forma de cámara",
- "webcamCropY": "Desplazamiento vertical",
- "webcamSize": "Tamaño de cámara",
- "mirrorWebcam": "Reflejar cámara",
- "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
- "webcamFraming": "Encuadre de cámara",
- "noWebcam": "Sin cámara",
- "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
- "webcamCropZoom": "Zoom de recorte",
- "dualFrame": "Marco dual",
- "webcamCropX": "Desplazamiento horizontal",
- "title": "Disposición de cámara",
- "preset": "Predefinido"
- },
- "crop": {
- "done": "Listo",
- "ratio": "Proporción",
- "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
- "title": "Recortar",
- "free": "Libre",
- "lockAspectRatio": "Bloquear relación de aspecto",
- "unlockAspectRatio": "Desbloquear relación de aspecto",
- "cropVideo": "Recortar video"
- },
- "zoom": {
- "position": {
- "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Posición de enfoque"
- },
- "threeD": {
- "preset": {
- "right": "Derecha",
- "iso": "Iso",
- "left": "Izquierda"
- },
- "none": "Ninguna",
- "title": "Rotación 3D"
- },
- "deleteZoom": "Eliminar zoom",
- "customScale": "Zoom personalizado",
- "selectRegion": "Selecciona una región de zoom para ajustar",
- "focusMode": {
- "manual": "Manual",
- "autoDescription": "La cámara sigue la posición del cursor grabado",
- "lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
- "title": "Modo de enfoque",
- "auto": "Auto"
- },
- "previewHold": "Mantener para previsualizar el efecto de zoom",
- "level": "Nivel de zoom"
- },
"background": {
- "color": "Color",
- "colorLabel": "Color {{color}}",
- "gradient": "Degradado",
- "imageReadFailed": "No se pudo leer ese archivo de imagen.",
"gradientLabel": "Degradado {{index}}",
+ "uploadCustom": "Subir personalizado",
+ "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
+ "title": "Fondo",
+ "imageLabel": "Fondo {{index}}",
"custom": "Personalizado",
+ "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
+ "gradient": "Degradado",
+ "colorLabel": "Color {{color}}",
"customWallpaper": "Fondo personalizado",
- "presets": "Ajustes preestablecidos",
- "image": "Imagen",
"colorPalette": "Paleta de colores",
- "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
- "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.",
- "imageLabel": "Fondo {{index}}",
- "title": "Fondo",
- "uploadCustom": "Subir personalizado",
+ "imageReadFailed": "No se pudo leer ese archivo de imagen.",
+ "image": "Imagen",
+ "presets": "Ajustes preestablecidos",
+ "color": "Color",
"colorWheel": "Rueda de colores"
},
- "cursor": {
- "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
- "clickBounce": "Rebote al clic",
- "clipToBounds": "Recortar al lienzo",
- "title": "Cursor",
- "size": "Tamaño",
- "themeDefault": "Predeterminado",
- "smoothing": "Suavizado",
- "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
- "motionBlur": "Desenfoque de movimiento",
- "theme": "Estilo del cursor",
- "show": "Mostrar cursor"
- },
- "captions": {
- "text": "Texto",
- "showBackground": "Mostrar fondo",
- "minWords": "Mín. palabras por línea",
- "translateFailed": "La traducción ha fallado.",
- "distanceFromTop": "Distancia desde arriba",
- "fontSize": "Tamaño",
- "distanceFromRight": "Distancia desde la derecha",
- "bold": "Negrita",
- "textColor": "Color del texto",
- "original": "Original (transcripción)",
- "translating": "Traduciendo…",
- "language": "Idioma",
- "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
- "alignLeft": "Izquierda",
- "distanceFromBottom": "Distancia desde abajo",
- "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
- "show": "Mostrar subtítulos",
- "background": "Fondo",
- "backgroundOpacity": "Opacidad",
- "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
- "anchorTop": "Arriba",
- "translate": "Traducir",
- "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
- "alignCenter": "Centro",
- "alignRight": "Derecha",
- "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
- "displayLanguage": "Visualización",
- "noTranscript": "Los subtítulos se leen de la transcripción del medio. Se activan una vez transcrito el vídeo.",
- "distanceFromLeft": "Distancia desde la izquierda",
- "maxWords": "Máx. palabras por línea",
- "anchorBottom": "Abajo",
- "position": "Posición",
- "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
- "deleteTranslation": "Eliminar esta traducción",
- "backgroundColor": "Color del fondo",
- "font": "Fuente",
- "lineLength": "Longitud de línea",
- "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
- "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "Resolución de exportación"
- },
- "transcript": {
- "transcribing": "Transcribiendo…",
- "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
- "laneVoiceover": "Voz en off",
- "blankedWord": "vaciada",
- "noTranscript": "Aún no hay transcripción",
- "noAudio": "Este medio no tiene pista de audio",
- "revertWord": "Restaurar «{{original}}»",
- "silence": "[silencio {{duration}} s]",
- "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
- "transcribeNow": "Transcribir ahora",
- "restoreWord": "Restaurar «{{word}}»",
- "clipLabel": "Clip {{index}}",
- "title": "Transcripción actual",
- "insertAria": "Palabra nueva",
- "laneRecording": "Grabación",
- "trimSilence": "Recortar silencio ({{duration}} s)",
- "insertedWord": "Añadida por ti: no hay audio detrás",
- "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
- "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
- "removeInserted": "Eliminar «{{word}}»",
- "editorAria": "Transcripción de {{filename}}",
- "correctedWord": "Corregida: la transcripción decía «{{original}}»",
- "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
- "editWord": "Editar «{{word}}»",
- "noClips": "Aún no hay clips",
- "laneLabel": "Leer la transcripción desde",
- "restoreSilence": "Restaurar silencio ({{duration}} s)"
- },
"customFont": {
+ "namePlaceholder": "Mi fuente personalizada",
+ "failedToAdd": "Error al agregar la fuente",
"addingButton": "Agregando...",
+ "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
- "addButton": "Agregar fuente",
- "dialogTitle": "Agregar fuente de Google",
- "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
- "nameLabel": "Nombre para mostrar",
- "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
"urlLabel": "URL de importación de Google Fonts",
+ "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
+ "nameLabel": "Nombre para mostrar",
"errorEmptyName": "Por favor ingresa un nombre de fuente",
- "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
- "namePlaceholder": "Mi fuente personalizada",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Error al agregar la fuente",
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
+ "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
"errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
- "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
- "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Agregar fuente de Google",
+ "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
+ "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.",
+ "addButton": "Agregar fuente"
+ },
+ "imageUpload": {
+ "invalidFileType": "Tipo de archivo no válido",
+ "failedToUpload": "Error al subir la imagen",
+ "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
+ "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
+ "errorReading": "Hubo un error al leer el archivo."
},
"annotation": {
- "arrowColor": "Color de la flecha",
- "colorWheel": "Rueda de colores",
- "blurType": "Tipo de desenfoque",
- "active": "Activo",
- "deleteAnnotation": "Eliminar anotación",
- "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
- "strokeWidth": "Grosor del trazo: {{width}}px",
- "background": "Fondo",
- "imageUploadSuccess": "¡Imagen subida exitosamente!",
- "blurColor": "Color del desenfoque",
- "blurTypeBlur": "Gaussiano",
- "textColor": "Color de texto",
- "blurColorWhite": "Blanco",
- "title": "Configuración de anotaciones",
- "type": "Tipo",
- "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
- "typeImage": "Imagen",
- "textContent": "Contenido de texto",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
- "typeText": "Texto",
- "blurIntensity": "Intensidad del desenfoque",
- "none": "Ninguno",
- "mosaicBlockSize": "Tamano del bloque mosaico",
- "textPlaceholder": "Escribe tu texto...",
- "typeArrow": "Flecha",
- "color": "Color",
- "blurColorBlack": "Negro",
+ "blurShapeRectangle": "Rectángulo",
"size": "Tamaño",
+ "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
+ "clearBackground": "Quitar fondo",
+ "colorPalette": "Paleta de colores",
"invalidImageType": "Tipo de archivo no válido",
+ "background": "Fondo",
+ "typeText": "Texto",
+ "active": "Activo",
+ "color": "Color",
"blurShapeFreehand": "Mano alzada",
- "shortcutsAndTips": "Atajos y consejos",
- "uploadImage": "Subir imagen",
+ "arrowDirection": "Dirección de la flecha",
"blurTypeMosaic": "Mosaico",
+ "colorWheel": "Rueda de colores",
+ "textColor": "Color de texto",
+ "title": "Configuración de anotaciones",
+ "blurType": "Tipo de desenfoque",
+ "typeBlur": "Desenfoque",
+ "blurIntensity": "Intensidad del desenfoque",
"selectStyle": "Seleccionar estilo",
- "defaultText": "Hola",
- "blurShapeRectangle": "Rectángulo",
- "colorPalette": "Paleta de colores",
- "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
- "clearBackground": "Quitar fondo",
+ "textContent": "Contenido de texto",
+ "typeArrow": "Flecha",
+ "none": "Ninguno",
+ "blurColor": "Color del desenfoque",
"customFonts": "Fuentes personalizadas",
- "typeBlur": "Desenfoque",
+ "imageUploadSuccess": "¡Imagen subida exitosamente!",
+ "type": "Tipo",
+ "arrowColor": "Color de la flecha",
+ "textPlaceholder": "Escribe tu texto...",
+ "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
+ "blurShape": "Forma del desenfoque",
+ "uploadImage": "Subir imagen",
+ "blurTypeBlur": "Gaussiano",
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
+ "shortcutsAndTips": "Atajos y consejos",
+ "deleteAnnotation": "Eliminar anotación",
"fontStyle": "Estilo de fuente",
- "blurShape": "Forma del desenfoque",
- "arrowDirection": "Dirección de la flecha",
- "blurShapeOval": "Óvalo"
- },
- "speed": {
- "customPlaybackSpeed": "Velocidad personalizada",
- "deleteRegion": "Eliminar región de velocidad",
- "selectRegion": "Selecciona una región de velocidad para ajustar",
- "maxSpeedError": "La velocidad no puede superar {{max}}×",
- "playbackSpeed": "Velocidad de reproducción",
- "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada."
- },
- "textAnimation": {
- "selectAnimation": "Seleccionar animación",
- "pulse": "Pulso",
- "rise": "Ascender",
- "none": "Ninguna",
- "slideLeft": "Deslizar izquierda",
- "title": "Animación de texto",
- "fade": "Desvanecimiento",
- "pop": "Aparecer",
- "typewriter": "Máquina de escribir"
+ "defaultText": "Hola",
+ "mosaicBlockSize": "Tamano del bloque mosaico",
+ "blurColorBlack": "Negro",
+ "strokeWidth": "Grosor del trazo: {{width}}px",
+ "blurShapeOval": "Óvalo",
+ "blurColorWhite": "Blanco",
+ "tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
+ "typeImage": "Imagen"
},
"effects": {
- "motion": "Movimiento",
- "title": "Composición",
- "format": "Formato",
- "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
"fitClipFew": "{{count}} clips",
- "motionBlur": "Desenfoque de movimiento",
- "fitClipMany": "{{count}} clips",
- "frame": "Marco",
- "padding": "Relleno",
- "roundness": "Redondez",
- "off": "desactivado",
- "blurBg": "Desenfocar fondo",
+ "title": "Composición",
"shadow": "Sombra",
+ "off": "desactivado",
"on": "activado",
+ "blurBg": "Desenfocar fondo",
+ "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo.",
"fitClipOne": "{{count}} clip",
"formatOriginal": "Original",
- "fitClip": "Ajustar"
+ "fitClipMany": "{{count}} clips",
+ "frame": "Marco",
+ "motion": "Movimiento",
+ "padding": "Relleno",
+ "format": "Formato",
+ "fitClip": "Ajustar",
+ "motionBlur": "Desenfoque de movimiento",
+ "roundness": "Redondez"
+ },
+ "transcript": {
+ "laneRecording": "Grabación",
+ "noTranscript": "Aún no hay transcripción",
+ "title": "Transcripción actual",
+ "restoreWord": "Restaurar «{{word}}»",
+ "revertWord": "Restaurar «{{original}}»",
+ "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
+ "restoreSilence": "Restaurar silencio ({{duration}} s)",
+ "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la corta del vídeo (en rojo). Haz doble clic en una palabra para corregir su texto. Pasa el cursor sobre una palabra marcada para deshacer.",
+ "editWord": "Editar «{{word}}»",
+ "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
+ "insertAria": "Palabra nueva",
+ "editorAria": "Transcripción de {{filename}}",
+ "transcribeNow": "Transcribir ahora",
+ "transcribing": "Transcribiendo…",
+ "trimSilence": "Recortar silencio ({{duration}} s)",
+ "removeInserted": "Eliminar «{{word}}»",
+ "laneLabel": "Leer la transcripción desde",
+ "noClips": "Aún no hay clips",
+ "laneVoiceover": "Voz en off",
+ "silence": "[silencio {{duration}} s]",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corregida: la transcripción decía «{{original}}»",
+ "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
+ "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
+ "noAudio": "Este medio no tiene pista de audio",
+ "blankedWord": "vaciada"
},
"exportFormat": {
- "gifDescription": "Imagen animada para compartir",
"mp4": "MP4",
- "mp4Video": "Video MP4",
- "gif": "GIF",
+ "mp4Description": "Archivo de video de alta calidad",
"gifAnimation": "Animación GIF",
- "mp4Description": "Archivo de video de alta calidad"
+ "mp4Video": "Video MP4",
+ "gifDescription": "Imagen animada para compartir",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Error al subir la imagen",
- "uploadSuccess": "¡Imagen personalizada subida exitosamente!",
- "errorReading": "Hubo un error al leer el archivo.",
- "invalidFileType": "Tipo de archivo no válido",
- "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG."
+ "captions": {
+ "showBackground": "Mostrar fondo",
+ "deleteTranslation": "Eliminar esta traducción",
+ "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.",
+ "backgroundOpacity": "Opacidad",
+ "backgroundColor": "Color del fondo",
+ "alignCenter": "Centro",
+ "translationIsNonDestructive": "Las traducciones se guardan junto a la transcripción, nunca dentro: el texto original y sus tiempos quedan intactos.",
+ "distanceFromRight": "Distancia desde la derecha",
+ "language": "Idioma",
+ "text": "Texto",
+ "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.",
+ "distanceFromTop": "Distancia desde arriba",
+ "translateFailed": "La traducción ha fallado.",
+ "alignLeft": "Izquierda",
+ "distanceFromBottom": "Distancia desde abajo",
+ "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
+ "translate": "Traducir",
+ "position": "Posición",
+ "fontSize": "Tamaño",
+ "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
+ "noTranscript": "Los subtítulos se leen de la transcripción del recurso. Transcribe este vídeo para activarlos.",
+ "distanceFromLeft": "Distancia desde la izquierda",
+ "anchorBottom": "Abajo",
+ "transcribe": "Transcribir vídeo",
+ "bold": "Negrita",
+ "alignRight": "Derecha",
+ "anchorTop": "Arriba",
+ "minWords": "Mín. palabras por línea",
+ "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
+ "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.",
+ "displayLanguage": "Visualización",
+ "removeLegacyAnnotations": "Eliminar anotaciones de subtítulos antiguas",
+ "background": "Fondo",
+ "lineLength": "Longitud de línea",
+ "original": "Original (transcripción)",
+ "maxWords": "Máx. palabras por línea",
+ "font": "Fuente",
+ "translating": "Traduciendo…",
+ "show": "Mostrar subtítulos",
+ "textColor": "Color del texto"
},
- "facets": {
- "captions": "Subtítulos",
- "transcript": "Transcripción"
+ "panes": {
+ "help": "Ayuda"
},
- "audio": {
- "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
- "reset": "Restablecer audio",
- "title": "Audio",
- "outputGain": "Ajuste de salida"
+ "speed": {
+ "deleteRegion": "Eliminar región de velocidad",
+ "maxSpeedError": "La velocidad no puede superar {{max}}×",
+ "selectRegion": "Selecciona una región de velocidad para ajustar",
+ "playbackSpeed": "Velocidad de reproducción",
+ "customPlaybackSpeed": "Velocidad personalizada",
+ "previewFrameSteppingHint": "Por encima de {{native}}×, la vista previa avanza fotograma a fotograma y sin sonido. La exportación no se ve afectada."
},
- "language": {
- "title": "Idioma"
+ "gifSettings": {
+ "frameRate": "Velocidad de cuadros del GIF",
+ "loop": "Repetir GIF",
+ "size": "Tamaño del GIF"
+ },
+ "exportQuality": {
+ "title": "Resolución de exportación",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "defaultLabel": "Pista de audio",
"importFailed": "No se pudo añadir el audio",
- "slipHint": "Alt + arrastrar para desplazar el audio dentro",
"fadeOut": "Desvanecido",
- "defaultLabel": "Pista de audio",
- "mute": "Silenciar",
+ "fadeIn": "Aparición",
"remove": "Eliminar pista",
- "add": "Añadir pista de audio",
"loop": "Bucle",
- "fadeIn": "Aparición"
+ "slipHint": "Alt + arrastrar para desplazar el audio dentro",
+ "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "add": "Añadir pista de audio",
+ "mute": "Silenciar"
},
- "project": {
- "load": "Cargar proyecto",
- "save": "Guardar proyecto",
- "new": "Nuevo proyecto"
+ "layout": {
+ "help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
+ "mirrorWebcam": "Reflejar cámara",
+ "webcamFraming": "Encuadre de cámara",
+ "shapes": {
+ "rectangle": "Rect.",
+ "rounded": "Redondeado",
+ "circle": "Círculo",
+ "square": "Cuadrado"
+ },
+ "selectPreset": "Seleccionar predefinido",
+ "bgModes": {
+ "custom": "Personalizado",
+ "none": "Original",
+ "blur": "Desenfocado",
+ "transparent": "Recortado"
+ },
+ "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
+ "webcamBlurIntensity": "Intensidad del desenfoque",
+ "preset": "Predefinido",
+ "webcamCropZoom": "Zoom de recorte",
+ "webcamSize": "Tamaño de cámara",
+ "dualFrame": "Marco dual",
+ "webcamCropY": "Desplazamiento vertical",
+ "verticalStack": "Apilado vertical",
+ "pictureInPicture": "Imagen en imagen",
+ "webcamShape": "Forma de cámara",
+ "webcamCropX": "Desplazamiento horizontal",
+ "reactiveWebcam": "Reducir al ampliar",
+ "webcamBackground": "Fondo de la cámara",
+ "helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
+ "title": "Disposición de cámara",
+ "noWebcam": "Sin cámara"
},
- "panes": {
- "help": "Ayuda"
+ "textAnimation": {
+ "slideLeft": "Deslizar izquierda",
+ "pulse": "Pulso",
+ "typewriter": "Máquina de escribir",
+ "selectAnimation": "Seleccionar animación",
+ "fade": "Desvanecimiento",
+ "title": "Animación de texto",
+ "none": "Ninguna",
+ "pop": "Aparecer",
+ "rise": "Ascender"
},
- "trim": {
- "deleteRegion": "Eliminar región de recorte"
+ "facets": {
+ "transcript": "Transcripción",
+ "captions": "Subtítulos"
},
- "export": {
- "videoButton": "Exportar video",
- "gifButton": "Exportar GIF",
- "chooseSaveLocation": "Elegir ubicación de guardado"
+ "crop": {
+ "title": "Recortar",
+ "free": "Libre",
+ "unlockAspectRatio": "Desbloquear relación de aspecto",
+ "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
+ "done": "Listo",
+ "ratio": "Proporción",
+ "cropVideo": "Recortar video",
+ "lockAspectRatio": "Bloquear relación de aspecto"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior",
+ "title": "Posición de enfoque",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Eliminar zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controlado por el interruptor global de enfoque automático en la línea de tiempo. Desactívalo para configurar el modo de enfoque por cada zoom.",
+ "auto": "Auto",
+ "manual": "Manual",
+ "autoDescription": "La cámara sigue la posición del cursor grabado",
+ "title": "Modo de enfoque"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Izquierda",
+ "right": "Derecha",
+ "iso": "Iso"
+ },
+ "none": "Ninguna",
+ "title": "Rotación 3D"
+ },
+ "level": "Nivel de zoom",
+ "previewHold": "Mantener para previsualizar el efecto de zoom",
+ "customScale": "Zoom personalizado",
+ "selectRegion": "Selecciona una región de zoom para ajustar"
+ },
+ "audio": {
+ "outputGain": "Ajuste de salida",
+ "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación.",
+ "reset": "Restablecer audio",
+ "title": "Audio"
+ },
+ "language": {
+ "title": "Idioma"
+ },
+ "project": {
+ "new": "Nuevo proyecto",
+ "load": "Cargar proyecto",
+ "save": "Guardar proyecto"
},
"support": {
"starOnGithub": "Dar estrella en GitHub",
"saveDiagnostics": "Guardar diagnósticos",
"reportBug": "Reportar error"
},
- "gifSettings": {
- "size": "Tamaño del GIF",
- "frameRate": "Velocidad de cuadros del GIF",
- "loop": "Repetir GIF"
+ "cursor": {
+ "smoothing": "Suavizado",
+ "clickBounce": "Rebote al clic",
+ "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.",
+ "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.",
+ "size": "Tamaño",
+ "title": "Cursor",
+ "show": "Mostrar cursor",
+ "themeDefault": "Predeterminado",
+ "clipToBounds": "Recortar al lienzo",
+ "motionBlur": "Desenfoque de movimiento",
+ "theme": "Estilo del cursor"
+ },
+ "export": {
+ "gifButton": "Exportar GIF",
+ "chooseSaveLocation": "Elegir ubicación de guardado",
+ "videoButton": "Exportar video"
+ },
+ "trim": {
+ "deleteRegion": "Eliminar región de recorte"
}
}
diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json
index 5b7298398..045f66809 100644
--- a/src/i18n/locales/es/timeline.json
+++ b/src/i18n/locales/es/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "Esta grabación no tiene datos de movimiento del cursor, o los zooms existentes ya cubren los momentos con actividad.",
- "smartCutsNoAudio": "Este medio no tiene audio",
- "automaticZoomsHint": "Del movimiento del cursor grabado",
- "dragToReorderHint": "Arrastra para reordenar · doble clic para editar los puntos de entrada/salida",
- "smartCutsNeedsTranscript": "Requiere una transcripción",
- "addedWord": "Palabra añadida: «{{word}}» — sin audio detrás",
- "smartZoomsAndCuts": "Cortes inteligentes",
- "autoZoomFailed": "Error en el zoom automático",
- "smartCutsWaiting": "Transcribiendo… disponible en un momento",
- "automaticZooms": "Zooms automáticos",
- "arrangeClipsHint": "Arrastra los clips de abajo para reordenarlos o suelta otros nuevos",
- "comment": "Comentario",
- "addAudioTooltip": "Añadir audio",
- "timelineTools": "Herramientas de la línea de tiempo",
- "deleteClip": "Eliminar clip",
- "arrangeClips": "Organizar clips",
- "editInOutPoints": "Editar puntos de entrada/salida",
- "smartZoomsAndCutsHint": "Con IA",
- "addedAutoZoomPlural": "Se añadieron {{count}} zooms automáticos",
- "noAutoZoomMoments": "No se encontraron momentos para zoom automático",
- "smartCutsFailed": "La transcripción falló: reinténtala desde Medios",
- "smartCutsNoSpeech": "No se detectó voz",
- "newAnnotation": "Anotación",
- "importRecordingFirst": "Importa una grabación primero",
- "addedAutoZoom": "Se añadió {{count}} zoom automático",
- "dropToAdd": "Suelta para añadir a la línea de tiempo",
- "aiEnhanceRequested": "Se pidió al agente de IA que corte los tiempos muertos",
- "autoEnhance": "Mejora automática"
+ "buttons": {
+ "addZoom": "Agregar zoom (Z)",
+ "suggestZooms": "Sugerir zooms desde el cursor",
+ "autoZoomOn": "Sugerencias de zoom automático activadas — haz clic para quitar los zooms sugeridos",
+ "autoZoomOff": "Sugerencias de zoom automático desactivadas — haz clic para sugerir zooms desde el cursor",
+ "autoFocusAllOn": "Enfoque automático activado para todos los zooms — haz clic para pasar todos a manual",
+ "autoFocusAllOff": "Activar enfoque automático para todos los zooms (la cámara sigue el cursor)",
+ "addTrim": "Agregar recorte (T)",
+ "addAnnotation": "Agregar anotación (A)",
+ "addSpeed": "Agregar velocidad (S)",
+ "addCameraFullscreen": "Agregar cámara a pantalla completa (C)"
+ },
+ "hints": {
+ "pressZoom": "Presiona Z para agregar zoom",
+ "pressTrim": "Presiona T para agregar recorte",
+ "pressAnnotation": "Presiona A para agregar anotación",
+ "pressAudio": "Pulsa M para añadir audio, V para grabar una voz en off",
+ "pressSpeed": "Presiona S para agregar velocidad",
+ "pressCameraFullscreen": "Presiona C para agregar un segmento de cámara a pantalla completa"
},
"labels": {
- "zoom": "Zoom",
- "cameraFullscreenItem": "Cámara a pantalla completa {{index}}",
- "imageItem": "Imagen",
"pan": "Desplazar",
- "zoomItem": "Zoom {{index}}",
- "cameraFullscreen": "Cámara a pantalla completa",
+ "zoom": "Zoom",
+ "trim": "Recortar",
"speed": "Velocidad",
- "emptyText": "Texto vacío",
+ "zoomItem": "Zoom {{index}}",
"trimItem": "Recorte {{index}}",
+ "speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
- "trim": "Recortar",
- "speedItem": "Velocidad {{index}}"
+ "imageItem": "Imagen",
+ "emptyText": "Texto vacío",
+ "cameraFullscreen": "Cámara a pantalla completa",
+ "cameraFullscreenItem": "Cámara a pantalla completa {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "No hay video cargado",
+ "dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
},
"errors": {
- "noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
+ "cannotPlaceZoom": "No se puede colocar el zoom aquí",
+ "zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
+ "zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
+ "noCursorTelemetry": "No hay telemetría de cursor disponible",
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
- "cameraFullscreenExistsAtLocation": "Ya existe un segmento de cámara a pantalla completa en esta ubicación o no hay suficiente espacio disponible.",
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
- "zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
- "speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible.",
- "noCursorTelemetry": "No hay telemetría de cursor disponible",
+ "noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
+ "noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
- "cannotPlaceZoom": "No se puede colocar el zoom aquí",
- "cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
- "zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
- "noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
"trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
- "cannotPlaceCameraFullscreen": "No se puede colocar la cámara a pantalla completa aquí"
+ "cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
+ "speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible.",
+ "cannotPlaceCameraFullscreen": "No se puede colocar la cámara a pantalla completa aquí",
+ "cameraFullscreenExistsAtLocation": "Ya existe un segmento de cámara a pantalla completa en esta ubicación o no hay suficiente espacio disponible."
+ },
+ "success": {
+ "addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
+ "addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
+ },
+ "toolbar": {
+ "autoEnhance": "Mejora automática",
+ "automaticZooms": "Zooms automáticos",
+ "automaticZoomsHint": "Del movimiento del cursor grabado",
+ "smartZoomsAndCuts": "Cortes inteligentes",
+ "smartZoomsAndCutsHint": "Con IA",
+ "comment": "Comentario",
+ "timelineTools": "Herramientas de la línea de tiempo",
+ "arrangeClips": "Organizar clips",
+ "arrangeClipsHint": "Arrastra los clips de abajo para reordenarlos o suelta otros nuevos",
+ "newAnnotation": "Anotación",
+ "dragToReorderHint": "Arrastra para reordenar · doble clic para editar los puntos de entrada/salida",
+ "editInOutPoints": "Editar puntos de entrada/salida",
+ "deleteClip": "Eliminar clip",
+ "dropToAdd": "Suelta para añadir a la línea de tiempo",
+ "importRecordingFirst": "Importa una grabación primero",
+ "noAutoZoomMoments": "No se encontraron momentos para zoom automático",
+ "noAutoZoomMomentsDescription": "Esta grabación no tiene datos de movimiento del cursor, o los zooms existentes ya cubren los momentos con actividad.",
+ "addedAutoZoom": "Se añadió {{count}} zoom automático",
+ "addedAutoZoomPlural": "Se añadieron {{count}} zooms automáticos",
+ "autoZoomFailed": "Error en el zoom automático",
+ "aiEnhanceRequested": "Se pidió al agente de IA que corte los tiempos muertos",
+ "smartCutsWaiting": "Transcribiendo… disponible en un momento",
+ "smartCutsNeedsTranscript": "Requiere una transcripción",
+ "smartCutsNoAudio": "Este medio no tiene audio",
+ "smartCutsNoSpeech": "No se detectó voz",
+ "smartCutsFailed": "La transcripción falló: reinténtala desde Medios",
+ "addAudioTooltip": "Añadir audio",
+ "addedWord": "Palabra añadida: «{{word}}» — sin audio detrás"
},
"audio": {
- "micDenied": "Se denegó el acceso al micrófono",
+ "addVoiceover": "Añadir voz en off",
+ "addVoiceoverHint": "Graba una narración sobre tu vídeo",
"subtitle": "Coloca una capa de voz en off o de música de fondo en la línea de tiempo",
- "importFailed": "No se pudo importar el archivo de audio",
+ "record": "Grabar voz en off",
+ "importFile": "Importar archivo de audio",
"importFileHint": "Importa música o un archivo de audio",
- "addVoiceoverHint": "Graba una narración sobre tu vídeo",
- "stop": "Detener",
"recording": "Grabando",
- "saveFailed": "No se pudo guardar la grabación",
"recordingHint": "Narra junto al vídeo: se reproduce mientras grabas",
- "importFile": "Importar archivo de audio",
- "record": "Grabar voz en off",
+ "stop": "Detener",
+ "micDenied": "Se denegó el acceso al micrófono",
"recordingUnavailable": "La grabación no está disponible aquí",
- "addVoiceover": "Añadir voz en off"
- },
- "success": {
- "addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
- "addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
- },
- "hints": {
- "pressAnnotation": "Presiona A para agregar anotación",
- "pressSpeed": "Presiona S para agregar velocidad",
- "pressTrim": "Presiona T para agregar recorte",
- "pressCameraFullscreen": "Presiona C para agregar un segmento de cámara a pantalla completa",
- "pressZoom": "Presiona Z para agregar zoom",
- "pressAudio": "Pulsa M para añadir audio, V para grabar una voz en off"
- },
- "buttons": {
- "autoFocusAllOff": "Activar enfoque automático para todos los zooms (la cámara sigue el cursor)",
- "autoZoomOff": "Sugerencias de zoom automático desactivadas — haz clic para sugerir zooms desde el cursor",
- "addAnnotation": "Agregar anotación (A)",
- "suggestZooms": "Sugerir zooms desde el cursor",
- "addSpeed": "Agregar velocidad (S)",
- "addCameraFullscreen": "Agregar cámara a pantalla completa (C)",
- "autoFocusAllOn": "Enfoque automático activado para todos los zooms — haz clic para pasar todos a manual",
- "autoZoomOn": "Sugerencias de zoom automático activadas — haz clic para quitar los zooms sugeridos",
- "addZoom": "Agregar zoom (Z)",
- "addTrim": "Agregar recorte (T)"
- },
- "emptyState": {
- "noVideo": "No hay video cargado",
- "dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
+ "saveFailed": "No se pudo guardar la grabación",
+ "importFailed": "No se pudo importar el archivo de audio"
}
}
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 40d0a84fa..1c8bf2764 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,354 +1,355 @@
{
- "captions": {
- "alignCenter": "Centre",
- "distanceFromBottom": "Distance depuis le bas",
- "alignRight": "Droite",
- "bold": "Gras",
- "original": "Original (transcription)",
- "text": "Texte",
- "distanceFromRight": "Distance depuis la droite",
- "backgroundOpacity": "Opacité",
- "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
- "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "noTranscript": "Les sous-titres sont lus dans la transcription du média. Ils s’activent une fois la vidéo transcrite.",
- "translating": "Traduction…",
- "maxWords": "Mots max. par ligne",
- "anchorTop": "Haut",
- "language": "Langue",
- "deleteTranslation": "Supprimer cette traduction",
- "alignLeft": "Gauche",
- "showBackground": "Afficher le fond",
- "displayLanguage": "Affichage",
- "position": "Position",
- "background": "Fond",
- "distanceFromTop": "Distance depuis le haut",
- "show": "Afficher les sous-titres",
- "translateFailed": "La traduction a échoué.",
- "fontSize": "Taille",
- "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
- "font": "Police",
- "anchorBottom": "Bas",
- "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
- "backgroundColor": "Couleur du fond",
- "lineLength": "Longueur des lignes",
- "removeLegacyAnnotations": "Supprimer les anciennes annotations",
- "textColor": "Couleur du texte",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
- "translate": "Traduire",
- "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
- "distanceFromLeft": "Distance depuis la gauche",
- "minWords": "Mots min. par ligne"
- },
- "layout": {
- "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
- "bgModes": {
- "transparent": "Détouré",
- "blur": "Flouté",
- "custom": "Personnalisé",
- "none": "Original"
- },
- "dualFrame": "Double cadre",
- "webcamCropX": "Déplacement horizontal",
- "webcamCropZoom": "Zoom du recadrage",
- "shapes": {
- "rectangle": "Rect.",
- "rounded": "Arrondi",
- "square": "Carré",
- "circle": "Cercle"
- },
- "title": "Disposition caméra",
- "noWebcam": "Sans webcam",
- "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
- "verticalStack": "Empilement vertical",
- "mirrorWebcam": "Inverser la webcam",
- "webcamBlurIntensity": "Intensité du flou",
- "selectPreset": "Choisir un préréglage",
- "webcamCropY": "Déplacement vertical",
- "webcamFraming": "Cadrage de la webcam",
- "webcamShape": "Forme de la caméra",
- "reactiveWebcam": "Réduire au zoom",
- "webcamSize": "Taille de la caméra",
- "pictureInPicture": "Incrustation d'image",
- "preset": "Préréglage",
- "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
- "webcamBackground": "Arrière-plan de la caméra"
- },
- "audioTrack": {
- "slipHint": "Alt + glisser pour déplacer l’audio à l’intérieur",
- "defaultLabel": "Piste audio",
- "importFailed": "Impossible d’ajouter l’audio",
- "loop": "Boucle",
- "fadeIn": "Fondu d'entrée",
- "add": "Ajouter une piste audio",
- "fadeOut": "Fondu de sortie",
- "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour déplacer l’audio à l’intérieur.",
- "mute": "Muet",
- "remove": "Supprimer la piste"
- },
- "annotation": {
- "blurTypeBlur": "Gaussien",
- "mosaicBlockSize": "Taille des blocs de mosaique",
- "typeText": "Texte",
- "clearBackground": "Supprimer l'arrière-plan",
- "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
- "blurShapeOval": "Ovale",
- "blurColor": "Couleur du flou",
- "arrowColor": "Couleur de la flèche",
- "blurColorBlack": "Noir",
- "active": "Actif",
- "imageUploadSuccess": "Image téléversée avec succès !",
- "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "blurShapeFreehand": "Main levée",
- "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
- "blurTypeMosaic": "Mosaïque",
- "deleteAnnotation": "Supprimer l'annotation",
- "blurColorWhite": "Blanc",
- "invalidImageType": "Type de fichier invalide",
- "colorPalette": "Palette de couleurs",
- "none": "Aucun",
- "shortcutsAndTips": "Raccourcis & Astuces",
- "color": "Couleur",
- "blurIntensity": "Intensité du flou",
- "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
- "typeArrow": "Flèche",
- "title": "Paramètres d'annotation",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
- "size": "Taille",
- "background": "Arrière-plan",
- "typeBlur": "Flou",
- "textPlaceholder": "Saisissez votre texte...",
- "textColor": "Couleur du texte",
- "typeImage": "Image",
- "textContent": "Contenu du texte",
- "blurShapeRectangle": "Rectangle",
- "arrowDirection": "Direction de la flèche",
- "blurType": "Type de flou",
- "defaultText": "Bonjour",
- "blurShape": "Forme du flou",
- "customFonts": "Polices personnalisées",
- "type": "Type",
- "fontStyle": "Style de police",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
- "colorWheel": "Roue chromatique",
- "uploadImage": "Téléverser une image",
- "selectStyle": "Choisir un style"
- },
- "crop": {
- "lockAspectRatio": "Verrouiller le ratio",
- "unlockAspectRatio": "Déverrouiller le ratio",
- "ratio": "Ratio",
- "title": "Recadrage",
- "done": "Terminer",
- "cropVideo": "Recadrer la vidéo",
- "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
- "free": "Libre"
- },
"background": {
- "colorWheel": "Roue chromatique",
- "colorLabel": "Couleur {{color}}",
- "customWallpaper": "Fond personnalisé",
- "image": "Image",
- "uploadCustom": "Téléverser une image",
"gradientLabel": "Dégradé {{index}}",
- "presets": "Préréglages",
+ "uploadCustom": "Téléverser une image",
"unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
"title": "Arrière-plan",
- "gradient": "Dégradé",
- "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
"imageLabel": "Fond {{index}}",
- "colorPalette": "Palette de couleurs",
"custom": "Personnalisé",
+ "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.",
+ "gradient": "Dégradé",
+ "colorLabel": "Couleur {{color}}",
+ "customWallpaper": "Fond personnalisé",
+ "colorPalette": "Palette de couleurs",
"imageReadFailed": "Impossible de lire ce fichier image.",
- "color": "Couleur"
- },
- "gifSettings": {
- "frameRate": "Fréquence d'images GIF",
- "size": "Taille du GIF",
- "loop": "GIF en boucle"
+ "image": "Image",
+ "presets": "Préréglages",
+ "color": "Couleur",
+ "colorWheel": "Roue chromatique"
},
"customFont": {
- "dialogTitle": "Ajouter une police Google",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "namePlaceholder": "Ma police personnalisée",
"failedToAdd": "Échec de l'ajout de la police",
- "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
- "errorEmptyName": "Veuillez saisir un nom de police",
"addingButton": "Ajout en cours...",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
"errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
- "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
- "namePlaceholder": "Ma police personnalisée",
- "addButton": "Ajouter la police",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
"urlLabel": "URL d'import Google Fonts",
"successMessage": "Police « {{fontName}} » ajoutée avec succès",
"nameLabel": "Nom d'affichage",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "errorEmptyName": "Veuillez saisir un nom de police",
"nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import"
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Ajouter une police Google",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
+ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte.",
+ "addButton": "Ajouter la police"
+ },
+ "imageUpload": {
+ "invalidFileType": "Type de fichier invalide",
+ "failedToUpload": "Échec du téléversement de l'image",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
+ "uploadSuccess": "Image personnalisée téléversée avec succès !",
+ "errorReading": "Une erreur s'est produite lors de la lecture du fichier."
+ },
+ "annotation": {
+ "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Rectangle",
+ "size": "Taille",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "clearBackground": "Supprimer l'arrière-plan",
+ "colorPalette": "Palette de couleurs",
+ "invalidImageType": "Type de fichier invalide",
+ "background": "Arrière-plan",
+ "typeText": "Texte",
+ "active": "Actif",
+ "color": "Couleur",
+ "blurShapeFreehand": "Main levée",
+ "arrowDirection": "Direction de la flèche",
+ "blurTypeMosaic": "Mosaïque",
+ "colorWheel": "Roue chromatique",
+ "textColor": "Couleur du texte",
+ "title": "Paramètres d'annotation",
+ "blurType": "Type de flou",
+ "typeBlur": "Flou",
+ "blurIntensity": "Intensité du flou",
+ "selectStyle": "Choisir un style",
+ "textContent": "Contenu du texte",
+ "typeArrow": "Flèche",
+ "none": "Aucun",
+ "blurColor": "Couleur du flou",
+ "customFonts": "Polices personnalisées",
+ "imageUploadSuccess": "Image téléversée avec succès !",
+ "type": "Type",
+ "arrowColor": "Couleur de la flèche",
+ "textPlaceholder": "Saisissez votre texte...",
+ "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "blurShape": "Forme du flou",
+ "uploadImage": "Téléverser une image",
+ "blurTypeBlur": "Gaussien",
+ "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
+ "shortcutsAndTips": "Raccourcis & Astuces",
+ "deleteAnnotation": "Supprimer l'annotation",
+ "fontStyle": "Style de police",
+ "defaultText": "Bonjour",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
+ "blurColorBlack": "Noir",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "blurShapeOval": "Ovale",
+ "blurColorWhite": "Blanc",
+ "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+ "typeImage": "Image"
+ },
+ "effects": {
+ "fitClipFew": "{{count}} clips",
+ "title": "Composition",
+ "shadow": "Ombre",
+ "off": "désactivé",
+ "on": "activé",
+ "blurBg": "Flou arrière-plan",
+ "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
+ "fitClipOne": "{{count}} clip",
+ "formatOriginal": "Original",
+ "fitClipMany": "{{count}} clips",
+ "frame": "Cadre",
+ "motion": "Mouvement",
+ "padding": "Marge",
+ "format": "Format",
+ "fitClip": "Ajuster",
+ "motionBlur": "Flou de mouvement",
+ "roundness": "Arrondi"
},
"transcript": {
- "transcribing": "Transcription…",
- "revertWord": "Rétablir « {{original}} »",
- "laneVoiceover": "Voix off",
- "editWord": "Modifier « {{word}} »",
- "noAudio": "Ce média n'a pas de piste audio",
- "clipLabel": "Clip {{index}}",
"laneRecording": "Enregistrement",
+ "noTranscript": "Aucune transcription pour l'instant",
"title": "Transcription actuelle",
- "removeInserted": "Supprimer « {{word}} »",
+ "restoreWord": "Restaurer « {{word}} »",
+ "revertWord": "Rétablir « {{original}} »",
+ "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
+ "restoreSilence": "Restaurer le silence ({{duration}} s)",
+ "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
+ "editWord": "Modifier « {{word}} »",
"laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
- "insertAria": "Nouveau mot",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
"whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
+ "insertAria": "Nouveau mot",
+ "editorAria": "Transcription de {{filename}}",
+ "transcribeNow": "Transcrire maintenant",
+ "transcribing": "Transcription…",
+ "trimSilence": "Couper le silence ({{duration}} s)",
+ "removeInserted": "Supprimer « {{word}} »",
"laneLabel": "Lire la transcription depuis",
- "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
"noClips": "Aucun clip pour l'instant",
- "trimSilence": "Couper le silence ({{duration}} s)",
+ "laneVoiceover": "Voix off",
+ "silence": "[silence {{duration}} s]",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
"noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
- "transcribeNow": "Transcrire maintenant",
"editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le coupe du film (en rouge). Double-cliquez sur un mot pour corriger son texte. Survolez un mot marqué pour annuler.",
- "restoreSilence": "Restaurer le silence ({{duration}} s)",
- "noTranscript": "Aucune transcription pour l'instant",
- "correctedWord": "Corrigé — la transcription disait « {{original}} »",
- "insertedWord": "Ajouté par vous — aucun son derrière",
- "restoreWord": "Restaurer « {{word}} »",
- "blankedWord": "vidé",
- "editorAria": "Transcription de {{filename}}",
- "silence": "[silence {{duration}} s]"
+ "noAudio": "Ce média n'a pas de piste audio",
+ "blankedWord": "vidé"
},
- "audio": {
- "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
- "title": "Audio",
- "reset": "Réinitialiser l’audio",
- "outputGain": "Niveau de sortie"
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "Fichier vidéo haute qualité",
+ "gifAnimation": "Animation GIF",
+ "mp4Video": "Vidéo MP4",
+ "gifDescription": "Image animée pour le partage",
+ "gif": "GIF"
+ },
+ "captions": {
+ "showBackground": "Afficher le fond",
+ "deleteTranslation": "Supprimer cette traduction",
+ "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.",
+ "backgroundOpacity": "Opacité",
+ "backgroundColor": "Couleur du fond",
+ "alignCenter": "Centre",
+ "translationIsNonDestructive": "Les traductions sont stockées à côté de la transcription, jamais dedans — le texte original et ses timings restent intacts.",
+ "distanceFromRight": "Distance depuis la droite",
+ "language": "Langue",
+ "text": "Texte",
+ "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.",
+ "distanceFromTop": "Distance depuis le haut",
+ "translateFailed": "La traduction a échoué.",
+ "alignLeft": "Gauche",
+ "distanceFromBottom": "Distance depuis le bas",
+ "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
+ "translate": "Traduire",
+ "position": "Position",
+ "fontSize": "Taille",
+ "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
+ "noTranscript": "Les sous-titres sont issus de la transcription du média. Transcrivez cette vidéo pour les activer.",
+ "distanceFromLeft": "Distance depuis la gauche",
+ "anchorBottom": "Bas",
+ "transcribe": "Transcrire la vidéo",
+ "bold": "Gras",
+ "alignRight": "Droite",
+ "anchorTop": "Haut",
+ "minWords": "Mots min. par ligne",
+ "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
+ "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.",
+ "displayLanguage": "Affichage",
+ "removeLegacyAnnotations": "Supprimer les anciennes annotations",
+ "background": "Fond",
+ "lineLength": "Longueur des lignes",
+ "original": "Original (transcription)",
+ "maxWords": "Mots max. par ligne",
+ "font": "Police",
+ "translating": "Traduction…",
+ "show": "Afficher les sous-titres",
+ "textColor": "Couleur du texte"
+ },
+ "panes": {
+ "help": "Aide"
+ },
+ "speed": {
+ "deleteRegion": "Supprimer la région de vitesse",
+ "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
+ "selectRegion": "Sélectionnez une région de vitesse à ajuster",
+ "playbackSpeed": "Vitesse de lecture",
+ "customPlaybackSpeed": "Vitesse de lecture personnalisée",
+ "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté."
+ },
+ "gifSettings": {
+ "frameRate": "Fréquence d'images GIF",
+ "loop": "GIF en boucle",
+ "size": "Taille du GIF"
+ },
+ "exportQuality": {
+ "title": "Résolution d'export",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
+ },
+ "audioTrack": {
+ "defaultLabel": "Piste audio",
+ "importFailed": "Impossible d’ajouter l’audio",
+ "fadeOut": "Fondu de sortie",
+ "fadeIn": "Fondu d'entrée",
+ "remove": "Supprimer la piste",
+ "loop": "Boucle",
+ "slipHint": "Alt + glisser pour déplacer l’audio à l’intérieur",
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour déplacer l’audio à l’intérieur.",
+ "add": "Ajouter une piste audio",
+ "mute": "Muet"
+ },
+ "layout": {
+ "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
+ "mirrorWebcam": "Inverser la webcam",
+ "webcamFraming": "Cadrage de la webcam",
+ "shapes": {
+ "rectangle": "Rect.",
+ "rounded": "Arrondi",
+ "circle": "Cercle",
+ "square": "Carré"
+ },
+ "selectPreset": "Choisir un préréglage",
+ "bgModes": {
+ "custom": "Personnalisé",
+ "none": "Original",
+ "blur": "Flouté",
+ "transparent": "Détouré"
+ },
+ "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
+ "webcamBlurIntensity": "Intensité du flou",
+ "preset": "Préréglage",
+ "webcamCropZoom": "Zoom du recadrage",
+ "webcamSize": "Taille de la caméra",
+ "dualFrame": "Double cadre",
+ "webcamCropY": "Déplacement vertical",
+ "verticalStack": "Empilement vertical",
+ "pictureInPicture": "Incrustation d'image",
+ "webcamShape": "Forme de la caméra",
+ "webcamCropX": "Déplacement horizontal",
+ "reactiveWebcam": "Réduire au zoom",
+ "webcamBackground": "Arrière-plan de la caméra",
+ "helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
+ "title": "Disposition caméra",
+ "noWebcam": "Sans webcam"
},
"textAnimation": {
- "pop": "Apparition",
- "fade": "Fondu",
+ "slideLeft": "Glisser à gauche",
"pulse": "Pulsation",
"typewriter": "Machine à écrire",
"selectAnimation": "Sélectionner une animation",
- "slideLeft": "Glisser à gauche",
+ "fade": "Fondu",
+ "title": "Animation de texte",
"none": "Aucune",
- "rise": "Monter",
- "title": "Animation de texte"
+ "pop": "Apparition",
+ "rise": "Monter"
+ },
+ "facets": {
+ "transcript": "Transcription",
+ "captions": "Sous-titres"
+ },
+ "crop": {
+ "title": "Recadrage",
+ "free": "Libre",
+ "unlockAspectRatio": "Déverrouiller le ratio",
+ "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
+ "done": "Terminer",
+ "ratio": "Ratio",
+ "cropVideo": "Recadrer la vidéo",
+ "lockAspectRatio": "Verrouiller le ratio"
},
"zoom": {
- "deleteZoom": "Supprimer le zoom",
- "threeD": {
- "preset": {
- "iso": "Iso",
- "left": "Gauche",
- "right": "Droite"
- },
- "none": "Aucune",
- "title": "Rotation 3D"
- },
"position": {
- "x": "X (%)",
"y": "Y (%)",
"hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
- "title": "Position du focus"
+ "title": "Position du focus",
+ "x": "X (%)"
},
+ "deleteZoom": "Supprimer le zoom",
"focusMode": {
"lockedDisclaimer": "Contrôlé par le bouton global de mise au point automatique dans la timeline. Désactivez-le pour régler le mode de mise au point par zoom.",
+ "auto": "Auto",
"manual": "Manuel",
"autoDescription": "La caméra suit la position du curseur enregistré",
- "title": "Mode focus",
- "auto": "Auto"
+ "title": "Mode focus"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Gauche",
+ "right": "Droite",
+ "iso": "Iso"
+ },
+ "none": "Aucune",
+ "title": "Rotation 3D"
},
"level": "Niveau de zoom",
"previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "selectRegion": "Sélectionnez une région de zoom à ajuster",
- "customScale": "Zoom personnalisé"
+ "customScale": "Zoom personnalisé",
+ "selectRegion": "Sélectionnez une région de zoom à ajuster"
},
- "speed": {
- "selectRegion": "Sélectionnez une région de vitesse à ajuster",
- "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
- "playbackSpeed": "Vitesse de lecture",
- "previewFrameSteppingHint": "Au-delà de {{native}}×, l'aperçu avance image par image et sans son. L'export n'est pas affecté.",
- "deleteRegion": "Supprimer la région de vitesse",
- "customPlaybackSpeed": "Vitesse de lecture personnalisée"
+ "audio": {
+ "outputGain": "Niveau de sortie",
+ "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export.",
+ "reset": "Réinitialiser l’audio",
+ "title": "Audio"
},
"language": {
"title": "Langue"
},
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "title": "Résolution d'export",
- "high": "Source"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gifDescription": "Image animée pour le partage",
- "mp4Video": "Vidéo MP4",
- "mp4Description": "Fichier vidéo haute qualité",
- "gifAnimation": "Animation GIF",
- "gif": "GIF"
- },
- "trim": {
- "deleteRegion": "Supprimer la région de coupe"
+ "project": {
+ "new": "Nouveau projet",
+ "load": "Charger un projet",
+ "save": "Enregistrer le projet"
},
- "imageUpload": {
- "uploadSuccess": "Image personnalisée téléversée avec succès !",
- "invalidFileType": "Type de fichier invalide",
- "errorReading": "Une erreur s'est produite lors de la lecture du fichier.",
- "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
- "failedToUpload": "Échec du téléversement de l'image"
+ "support": {
+ "starOnGithub": "Étoile sur GitHub",
+ "saveDiagnostics": "Enregistrer les diagnostics",
+ "reportBug": "Signaler un bug"
},
"cursor": {
- "title": "Curseur",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
- "motionBlur": "Flou de mouvement",
- "clipToBounds": "Rogner au canevas",
"smoothing": "Lissage",
- "size": "Taille",
- "theme": "Style du curseur",
- "themeDefault": "Par défaut",
"clickBounce": "Rebond au clic",
+ "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.",
"clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.",
- "show": "Afficher le curseur"
- },
- "facets": {
- "captions": "Sous-titres",
- "transcript": "Transcription"
- },
- "effects": {
- "title": "Composition",
- "formatOriginal": "Original",
+ "size": "Taille",
+ "title": "Curseur",
+ "show": "Afficher le curseur",
+ "themeDefault": "Par défaut",
+ "clipToBounds": "Rogner au canevas",
"motionBlur": "Flou de mouvement",
- "format": "Format",
- "padding": "Marge",
- "fitClipOne": "{{count}} clip",
- "fitClipFew": "{{count}} clips",
- "frame": "Cadre",
- "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo.",
- "on": "activé",
- "blurBg": "Flou arrière-plan",
- "fitClip": "Ajuster",
- "motion": "Mouvement",
- "shadow": "Ombre",
- "fitClipMany": "{{count}} clips",
- "roundness": "Arrondi",
- "off": "désactivé"
- },
- "project": {
- "save": "Enregistrer le projet",
- "new": "Nouveau projet",
- "load": "Charger un projet"
- },
- "support": {
- "saveDiagnostics": "Enregistrer les diagnostics",
- "reportBug": "Signaler un bug",
- "starOnGithub": "Étoile sur GitHub"
- },
- "panes": {
- "help": "Aide"
+ "theme": "Style du curseur"
},
"export": {
- "videoButton": "Exporter la vidéo",
"gifButton": "Exporter le GIF",
- "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
+ "chooseSaveLocation": "Choisir l'emplacement d'enregistrement",
+ "videoButton": "Exporter la vidéo"
+ },
+ "trim": {
+ "deleteRegion": "Supprimer la région de coupe"
}
}
diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json
index de5b2d06f..cfaf9662a 100644
--- a/src/i18n/locales/fr/timeline.json
+++ b/src/i18n/locales/fr/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "Cet enregistrement n'a pas de données de mouvement du curseur, ou les zooms existants couvrent déjà les moments importants.",
- "smartCutsNoAudio": "Ce média n'a pas d'audio",
- "automaticZoomsHint": "Basé sur le mouvement du curseur enregistré",
- "dragToReorderHint": "Glissez pour réorganiser · double-cliquez pour modifier les points d'entrée/sortie",
- "smartCutsNeedsTranscript": "Nécessite une transcription",
- "addedWord": "Mot ajouté : « {{word}} » — aucun son derrière",
- "smartZoomsAndCuts": "Coupes intelligentes",
- "autoZoomFailed": "Échec du zoom automatique",
- "smartCutsWaiting": "Transcription en cours… disponible dans un instant",
- "automaticZooms": "Zooms automatiques",
- "arrangeClipsHint": "Glissez les clips ci-dessous pour les réorganiser ou en déposer de nouveaux",
- "comment": "Commentaire",
- "addAudioTooltip": "Ajouter un audio",
- "timelineTools": "Outils de la timeline",
- "deleteClip": "Supprimer le clip",
- "arrangeClips": "Organiser les clips",
- "editInOutPoints": "Modifier les points d'entrée/sortie",
- "smartZoomsAndCutsHint": "Avec l'IA",
- "addedAutoZoomPlural": "{{count}} zooms automatiques ajoutés",
- "noAutoZoomMoments": "Aucun moment de zoom automatique trouvé",
- "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias",
- "smartCutsNoSpeech": "Aucune parole détectée",
- "newAnnotation": "Annotation",
- "importRecordingFirst": "Importez d'abord un enregistrement",
- "addedAutoZoom": "{{count}} zoom automatique ajouté",
- "dropToAdd": "Déposez pour ajouter à la timeline",
- "aiEnhanceRequested": "Demandé à l'agent IA de couper les temps morts",
- "autoEnhance": "Amélioration auto"
+ "buttons": {
+ "addZoom": "Ajouter un zoom (Z)",
+ "suggestZooms": "Suggérer des zooms depuis le curseur",
+ "autoZoomOn": "Suggestions de zoom automatique activées — cliquez pour retirer les zooms suggérés",
+ "autoZoomOff": "Suggestions de zoom automatique désactivées — cliquez pour suggérer des zooms depuis le curseur",
+ "autoFocusAllOn": "Mise au point automatique activée pour tous les zooms — cliquez pour tout passer en manuel",
+ "autoFocusAllOff": "Activer la mise au point automatique pour tous les zooms (la caméra suit le curseur)",
+ "addTrim": "Ajouter une coupe (T)",
+ "addAnnotation": "Ajouter une annotation (A)",
+ "addSpeed": "Ajouter une vitesse (S)",
+ "addCameraFullscreen": "Ajouter Caméra plein écran (C)"
+ },
+ "hints": {
+ "pressZoom": "Appuyez sur Z pour ajouter un zoom",
+ "pressTrim": "Appuyez sur T pour ajouter une coupe",
+ "pressAnnotation": "Appuyez sur A pour ajouter une annotation",
+ "pressAudio": "Appuyez sur M pour ajouter un audio, V pour enregistrer une voix off",
+ "pressSpeed": "Appuyez sur S pour ajouter une vitesse",
+ "pressCameraFullscreen": "Appuyez sur C pour ajouter un segment Caméra plein écran"
},
"labels": {
- "zoom": "Zoom",
- "cameraFullscreenItem": "Caméra plein écran {{index}}",
- "imageItem": "Image",
"pan": "Panoramique",
- "zoomItem": "Zoom {{index}}",
- "cameraFullscreen": "Caméra plein écran",
+ "zoom": "Zoom",
+ "trim": "Couper",
"speed": "Vitesse",
- "emptyText": "Texte vide",
+ "zoomItem": "Zoom {{index}}",
"trimItem": "Coupe {{index}}",
+ "speedItem": "Vitesse {{index}}",
"annotationItem": "Annotation",
- "trim": "Couper",
- "speedItem": "Vitesse {{index}}"
+ "imageItem": "Image",
+ "emptyText": "Texte vide",
+ "cameraFullscreen": "Caméra plein écran",
+ "cameraFullscreenItem": "Caméra plein écran {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Aucune vidéo chargée",
+ "dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
},
"errors": {
- "noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
+ "cannotPlaceZoom": "Impossible de placer le zoom ici",
+ "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
+ "zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
+ "noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.",
- "cameraFullscreenExistsAtLocation": "Un segment Caméra plein écran existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
- "zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
- "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
- "noCursorTelemetry": "Aucune télémétrie de curseur disponible",
+ "noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
+ "noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
- "cannotPlaceZoom": "Impossible de placer le zoom ici",
- "cannotPlaceSpeed": "Impossible de placer la vitesse ici",
- "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
- "noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
- "cannotPlaceCameraFullscreen": "Impossible de placer la caméra plein écran ici"
+ "cannotPlaceSpeed": "Impossible de placer la vitesse ici",
+ "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
+ "cannotPlaceCameraFullscreen": "Impossible de placer la caméra plein écran ici",
+ "cameraFullscreenExistsAtLocation": "Un segment Caméra plein écran existe déjà à cet emplacement ou l'espace disponible est insuffisant."
+ },
+ "success": {
+ "addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
+ "addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
+ },
+ "toolbar": {
+ "autoEnhance": "Amélioration auto",
+ "automaticZooms": "Zooms automatiques",
+ "automaticZoomsHint": "Basé sur le mouvement du curseur enregistré",
+ "smartZoomsAndCuts": "Coupes intelligentes",
+ "smartZoomsAndCutsHint": "Avec l'IA",
+ "comment": "Commentaire",
+ "timelineTools": "Outils de la timeline",
+ "arrangeClips": "Organiser les clips",
+ "arrangeClipsHint": "Glissez les clips ci-dessous pour les réorganiser ou en déposer de nouveaux",
+ "newAnnotation": "Annotation",
+ "dragToReorderHint": "Glissez pour réorganiser · double-cliquez pour modifier les points d'entrée/sortie",
+ "editInOutPoints": "Modifier les points d'entrée/sortie",
+ "deleteClip": "Supprimer le clip",
+ "dropToAdd": "Déposez pour ajouter à la timeline",
+ "importRecordingFirst": "Importez d'abord un enregistrement",
+ "noAutoZoomMoments": "Aucun moment de zoom automatique trouvé",
+ "noAutoZoomMomentsDescription": "Cet enregistrement n'a pas de données de mouvement du curseur, ou les zooms existants couvrent déjà les moments importants.",
+ "addedAutoZoom": "{{count}} zoom automatique ajouté",
+ "addedAutoZoomPlural": "{{count}} zooms automatiques ajoutés",
+ "autoZoomFailed": "Échec du zoom automatique",
+ "aiEnhanceRequested": "Demandé à l'agent IA de couper les temps morts",
+ "smartCutsWaiting": "Transcription en cours… disponible dans un instant",
+ "smartCutsNeedsTranscript": "Nécessite une transcription",
+ "smartCutsNoAudio": "Ce média n'a pas d'audio",
+ "smartCutsNoSpeech": "Aucune parole détectée",
+ "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias",
+ "addAudioTooltip": "Ajouter un audio",
+ "addedWord": "Mot ajouté : « {{word}} » — aucun son derrière"
},
"audio": {
- "micDenied": "L'accès au micro a été refusé",
+ "addVoiceover": "Ajouter une voix off",
+ "addVoiceoverHint": "Enregistrez une narration par-dessus votre vidéo",
"subtitle": "Placez une couche de voix off ou de musique de fond sur la timeline",
- "importFailed": "Impossible d'importer le fichier audio",
+ "record": "Enregistrer une voix off",
+ "importFile": "Importer un fichier audio",
"importFileHint": "Importez une musique ou un fichier audio",
- "addVoiceoverHint": "Enregistrez une narration par-dessus votre vidéo",
- "stop": "Arrêter",
"recording": "Enregistrement",
- "saveFailed": "Impossible d'enregistrer la capture",
"recordingHint": "Commentez en même temps que la vidéo — elle joue pendant l'enregistrement",
- "importFile": "Importer un fichier audio",
- "record": "Enregistrer une voix off",
+ "stop": "Arrêter",
+ "micDenied": "L'accès au micro a été refusé",
"recordingUnavailable": "L'enregistrement n'est pas disponible ici",
- "addVoiceover": "Ajouter une voix off"
- },
- "success": {
- "addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
- "addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
- },
- "hints": {
- "pressAnnotation": "Appuyez sur A pour ajouter une annotation",
- "pressSpeed": "Appuyez sur S pour ajouter une vitesse",
- "pressTrim": "Appuyez sur T pour ajouter une coupe",
- "pressCameraFullscreen": "Appuyez sur C pour ajouter un segment Caméra plein écran",
- "pressZoom": "Appuyez sur Z pour ajouter un zoom",
- "pressAudio": "Appuyez sur M pour ajouter un audio, V pour enregistrer une voix off"
- },
- "buttons": {
- "autoFocusAllOff": "Activer la mise au point automatique pour tous les zooms (la caméra suit le curseur)",
- "autoZoomOff": "Suggestions de zoom automatique désactivées — cliquez pour suggérer des zooms depuis le curseur",
- "addAnnotation": "Ajouter une annotation (A)",
- "suggestZooms": "Suggérer des zooms depuis le curseur",
- "addSpeed": "Ajouter une vitesse (S)",
- "addCameraFullscreen": "Ajouter Caméra plein écran (C)",
- "autoFocusAllOn": "Mise au point automatique activée pour tous les zooms — cliquez pour tout passer en manuel",
- "autoZoomOn": "Suggestions de zoom automatique activées — cliquez pour retirer les zooms suggérés",
- "addZoom": "Ajouter un zoom (Z)",
- "addTrim": "Ajouter une coupe (T)"
- },
- "emptyState": {
- "noVideo": "Aucune vidéo chargée",
- "dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
+ "saveFailed": "Impossible d'enregistrer la capture",
+ "importFailed": "Impossible d'importer le fichier audio"
}
}
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 9b9ba6763..2e5ac7381 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Intensità sfocatura",
- "bgModes": {
- "transparent": "Scontornato",
- "none": "Originale",
- "blur": "Sfocato",
- "custom": "Personalizzato"
- },
- "selectPreset": "Seleziona predefinito",
- "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
- "reactiveWebcam": "Riduci con lo zoom",
- "shapes": {
- "circle": "Cerchio",
- "square": "Quadrato",
- "rectangle": "Rett.",
- "rounded": "Arrotondato"
- },
- "webcamBackground": "Sfondo della fotocamera",
- "verticalStack": "Pila verticale",
- "pictureInPicture": "Immagine nell'immagine",
- "webcamShape": "Forma fotocamera",
- "webcamCropY": "Spostamento verticale",
- "webcamSize": "Dimensione webcam",
- "mirrorWebcam": "Specchia webcam",
- "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
- "webcamFraming": "Inquadratura webcam",
- "noWebcam": "Nessuna webcam",
- "reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
- "webcamCropZoom": "Zoom ritaglio",
- "dualFrame": "Doppio frame",
- "webcamCropX": "Spostamento orizzontale",
- "title": "Disposizione camera",
- "preset": "Predefinito"
- },
- "crop": {
- "done": "Fatto",
- "ratio": "Proporzioni",
- "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
- "title": "Ritaglia",
- "free": "Libero",
- "lockAspectRatio": "Blocca proporzioni",
- "unlockAspectRatio": "Sblocca proporzioni",
- "cropVideo": "Ritaglia video"
- },
- "zoom": {
- "position": {
- "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Posizione messa a fuoco"
- },
- "threeD": {
- "preset": {
- "right": "Destra",
- "iso": "Iso",
- "left": "Sinistra"
- },
- "none": "Nessuna",
- "title": "Rotazione 3D"
- },
- "deleteZoom": "Elimina zoom",
- "customScale": "Zoom personalizzato",
- "selectRegion": "Seleziona una regione zoom da regolare",
- "focusMode": {
- "manual": "Manuale",
- "autoDescription": "La fotocamera segue la posizione del cursore registrato",
- "lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
- "title": "Modalità messa a fuoco",
- "auto": "Automatico"
- },
- "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
- "level": "Livello zoom"
- },
"background": {
- "color": "Colore",
- "colorLabel": "Colore {{color}}",
- "gradient": "Sfumatura",
- "imageReadFailed": "Impossibile leggere quel file immagine.",
"gradientLabel": "Sfumatura {{index}}",
+ "uploadCustom": "Carica personalizzato",
+ "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
+ "title": "Sfondo",
+ "imageLabel": "Sfondo {{index}}",
"custom": "Personalizzato",
+ "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
+ "gradient": "Sfumatura",
+ "colorLabel": "Colore {{color}}",
"customWallpaper": "Sfondo personalizzato",
- "presets": "Predefiniti",
- "image": "Immagine",
"colorPalette": "Tavolozza dei colori",
- "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
- "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.",
- "imageLabel": "Sfondo {{index}}",
- "title": "Sfondo",
- "uploadCustom": "Carica personalizzato",
+ "imageReadFailed": "Impossibile leggere quel file immagine.",
+ "image": "Immagine",
+ "presets": "Predefiniti",
+ "color": "Colore",
"colorWheel": "Ruota dei colori"
},
- "cursor": {
- "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
- "clickBounce": "Rimbalzo clic",
- "clipToBounds": "Ritaglia al canvas",
- "title": "Cursore",
- "size": "Dimensione",
- "themeDefault": "Predefinito",
- "smoothing": "Smussatura",
- "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
- "motionBlur": "Sfocatura movimento",
- "theme": "Stile del cursore",
- "show": "Mostra cursore"
- },
- "captions": {
- "text": "Testo",
- "showBackground": "Mostra sfondo",
- "minWords": "Parole min. per riga",
- "translateFailed": "Traduzione non riuscita.",
- "distanceFromTop": "Distanza dall'alto",
- "fontSize": "Dimensione",
- "distanceFromRight": "Distanza da destra",
- "bold": "Grassetto",
- "textColor": "Colore del testo",
- "original": "Originale (trascrizione)",
- "translating": "Traduzione…",
- "language": "Lingua",
- "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
- "alignLeft": "Sinistra",
- "distanceFromBottom": "Distanza dal basso",
- "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
- "show": "Mostra sottotitoli",
- "background": "Sfondo",
- "backgroundOpacity": "Opacità",
- "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
- "anchorTop": "Alto",
- "translate": "Traduci",
- "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
- "alignCenter": "Centro",
- "alignRight": "Destra",
- "translateHint": "Traduci la trascrizione con il provider IA configurato",
- "displayLanguage": "Visualizzazione",
- "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Si attivano una volta trascritto il video.",
- "distanceFromLeft": "Distanza da sinistra",
- "maxWords": "Parole max. per riga",
- "anchorBottom": "Basso",
- "position": "Posizione",
- "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
- "deleteTranslation": "Elimina questa traduzione",
- "backgroundColor": "Colore dello sfondo",
- "font": "Carattere",
- "lineLength": "Lunghezza riga",
- "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
- "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Originale",
- "title": "Risoluzione esportazione"
- },
- "transcript": {
- "transcribing": "Trascrizione…",
- "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
- "laneVoiceover": "Voce fuori campo",
- "blankedWord": "svuotata",
- "noTranscript": "Ancora nessuna trascrizione",
- "noAudio": "Questo contenuto non ha una traccia audio",
- "revertWord": "Ripristina «{{original}}»",
- "silence": "[silenzio {{duration}} s]",
- "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
- "transcribeNow": "Trascrivi ora",
- "restoreWord": "Ripristina «{{word}}»",
- "clipLabel": "Clip {{index}}",
- "title": "Trascrizione corrente",
- "insertAria": "Nuova parola",
- "laneRecording": "Registrazione",
- "trimSilence": "Taglia silenzio ({{duration}} s)",
- "insertedWord": "Aggiunta da te — nessun audio dietro",
- "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
- "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
- "removeInserted": "Elimina «{{word}}»",
- "editorAria": "Trascrizione di {{filename}}",
- "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
- "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
- "editWord": "Modifica «{{word}}»",
- "noClips": "Ancora nessun clip",
- "laneLabel": "Leggi la trascrizione da",
- "restoreSilence": "Ripristina silenzio ({{duration}} s)"
- },
"customFont": {
+ "namePlaceholder": "Il mio font personalizzato",
+ "failedToAdd": "Impossibile aggiungere il font",
"addingButton": "Aggiunta in corso...",
+ "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
"urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
- "addButton": "Aggiungi font",
- "dialogTitle": "Aggiungi font Google",
- "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
- "nameLabel": "Nome visualizzato",
- "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
"urlLabel": "URL importazione Google Fonts",
+ "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
+ "nameLabel": "Nome visualizzato",
"errorEmptyName": "Inserisci un nome per il font",
- "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
- "namePlaceholder": "Il mio font personalizzato",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Impossibile aggiungere il font",
"nameHelp": "Così apparirà il font nel selettore",
+ "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
"errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
- "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
- "successMessage": "Font \"{{fontName}}\" aggiunto con successo"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Aggiungi font Google",
+ "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
+ "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto.",
+ "addButton": "Aggiungi font"
+ },
+ "imageUpload": {
+ "invalidFileType": "Tipo di file non valido",
+ "failedToUpload": "Impossibile caricare l'immagine",
+ "jpgOnly": "Carica un file immagine JPG o JPEG.",
+ "uploadSuccess": "Immagine personalizzata caricata con successo!",
+ "errorReading": "Si è verificato un errore durante la lettura del file."
},
"annotation": {
- "arrowColor": "Colore freccia",
- "colorWheel": "Ruota dei colori",
- "blurType": "Tipo sfocatura",
- "active": "Attivo",
- "deleteAnnotation": "Elimina annotazione",
- "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
- "strokeWidth": "Larghezza tratto: {{width}}px",
- "background": "Sfondo",
- "imageUploadSuccess": "Immagine caricata con successo!",
- "blurColor": "Colore sfocatura",
- "blurTypeBlur": "Gaussiano",
- "textColor": "Colore testo",
- "blurColorWhite": "Bianco",
- "title": "Impostazioni annotazione",
- "type": "Tipo",
- "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
- "typeImage": "Immagine",
- "textContent": "Contenuto testo",
"supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
- "typeText": "Testo",
- "blurIntensity": "Intensità sfocatura",
- "none": "Nessuno",
- "mosaicBlockSize": "Dimensione blocco mosaico",
- "textPlaceholder": "Inserisci il tuo testo...",
- "typeArrow": "Freccia",
- "color": "Colore",
- "blurColorBlack": "Nero",
+ "blurShapeRectangle": "Rettangolo",
"size": "Dimensione",
+ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
+ "clearBackground": "Rimuovi sfondo",
+ "colorPalette": "Tavolozza dei colori",
"invalidImageType": "Tipo di file non valido",
+ "background": "Sfondo",
+ "typeText": "Testo",
+ "active": "Attivo",
+ "color": "Colore",
"blurShapeFreehand": "A mano libera",
- "shortcutsAndTips": "Scorciatoie e suggerimenti",
- "uploadImage": "Carica immagine",
+ "arrowDirection": "Direzione freccia",
"blurTypeMosaic": "Mosaico",
+ "colorWheel": "Ruota dei colori",
+ "textColor": "Colore testo",
+ "title": "Impostazioni annotazione",
+ "blurType": "Tipo sfocatura",
+ "typeBlur": "Sfocatura",
+ "blurIntensity": "Intensità sfocatura",
"selectStyle": "Seleziona stile",
- "defaultText": "Ciao",
- "blurShapeRectangle": "Rettangolo",
- "colorPalette": "Tavolozza dei colori",
- "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
- "clearBackground": "Rimuovi sfondo",
+ "textContent": "Contenuto testo",
+ "typeArrow": "Freccia",
+ "none": "Nessuno",
+ "blurColor": "Colore sfocatura",
"customFonts": "Caratteri personalizzati",
- "typeBlur": "Sfocatura",
+ "imageUploadSuccess": "Immagine caricata con successo!",
+ "type": "Tipo",
+ "arrowColor": "Colore freccia",
+ "textPlaceholder": "Inserisci il tuo testo...",
+ "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
+ "blurShape": "Forma sfocatura",
+ "uploadImage": "Carica immagine",
+ "blurTypeBlur": "Gaussiano",
"tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
+ "shortcutsAndTips": "Scorciatoie e suggerimenti",
+ "deleteAnnotation": "Elimina annotazione",
"fontStyle": "Stile carattere",
- "blurShape": "Forma sfocatura",
- "arrowDirection": "Direzione freccia",
- "blurShapeOval": "Ovale"
- },
- "speed": {
- "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
- "deleteRegion": "Elimina regione velocità",
- "selectRegion": "Seleziona una regione velocità da regolare",
- "maxSpeedError": "La velocità non può superare {{max}}×",
- "playbackSpeed": "Velocità di riproduzione",
- "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata."
- },
- "textAnimation": {
- "selectAnimation": "Seleziona animazione",
- "pulse": "Pulsazione",
- "rise": "Ascesa",
- "none": "Nessuna",
- "slideLeft": "Scivola a sinistra",
- "title": "Animazione testo",
- "fade": "Dissolvenza",
- "pop": "Apparizione",
- "typewriter": "Macchina da scrivere"
+ "defaultText": "Ciao",
+ "mosaicBlockSize": "Dimensione blocco mosaico",
+ "blurColorBlack": "Nero",
+ "strokeWidth": "Larghezza tratto: {{width}}px",
+ "blurShapeOval": "Ovale",
+ "blurColorWhite": "Bianco",
+ "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
+ "typeImage": "Immagine"
},
"effects": {
- "motion": "Movimento",
- "title": "Composizione",
- "format": "Formato",
- "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
"fitClipFew": "{{count}} clip",
- "motionBlur": "Sfocatura movimento",
- "fitClipMany": "{{count}} clip",
- "frame": "Cornice",
- "padding": "Spaziatura",
- "roundness": "Arrotondamento",
- "off": "spento",
- "blurBg": "Sfuma sfondo",
+ "title": "Composizione",
"shadow": "Ombra",
+ "off": "spento",
"on": "acceso",
+ "blurBg": "Sfuma sfondo",
+ "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video.",
"fitClipOne": "{{count}} clip",
"formatOriginal": "Originale",
- "fitClip": "Adatta"
+ "fitClipMany": "{{count}} clip",
+ "frame": "Cornice",
+ "motion": "Movimento",
+ "padding": "Spaziatura",
+ "format": "Formato",
+ "fitClip": "Adatta",
+ "motionBlur": "Sfocatura movimento",
+ "roundness": "Arrotondamento"
+ },
+ "transcript": {
+ "laneRecording": "Registrazione",
+ "noTranscript": "Ancora nessuna trascrizione",
+ "title": "Trascrizione corrente",
+ "restoreWord": "Ripristina «{{word}}»",
+ "revertWord": "Ripristina «{{original}}»",
+ "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
+ "restoreSilence": "Ripristina silenzio ({{duration}} s)",
+ "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la taglia dal video (in rosso). Fai doppio clic su una parola per correggerne il testo. Passa sopra una parola contrassegnata per annullare.",
+ "editWord": "Modifica «{{word}}»",
+ "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
+ "insertAria": "Nuova parola",
+ "editorAria": "Trascrizione di {{filename}}",
+ "transcribeNow": "Trascrivi ora",
+ "transcribing": "Trascrizione…",
+ "trimSilence": "Taglia silenzio ({{duration}} s)",
+ "removeInserted": "Elimina «{{word}}»",
+ "laneLabel": "Leggi la trascrizione da",
+ "noClips": "Ancora nessun clip",
+ "laneVoiceover": "Voce fuori campo",
+ "silence": "[silenzio {{duration}} s]",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
+ "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
+ "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
+ "noAudio": "Questo contenuto non ha una traccia audio",
+ "blankedWord": "svuotata"
},
"exportFormat": {
- "gifDescription": "Immagine animata per la condivisione",
"mp4": "MP4",
- "mp4Video": "Video MP4",
- "gif": "GIF",
+ "mp4Description": "File video di alta qualità",
"gifAnimation": "Animazione GIF",
- "mp4Description": "File video di alta qualità"
+ "mp4Video": "Video MP4",
+ "gifDescription": "Immagine animata per la condivisione",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Impossibile caricare l'immagine",
- "uploadSuccess": "Immagine personalizzata caricata con successo!",
- "errorReading": "Si è verificato un errore durante la lettura del file.",
- "invalidFileType": "Tipo di file non valido",
- "jpgOnly": "Carica un file immagine JPG o JPEG."
+ "captions": {
+ "showBackground": "Mostra sfondo",
+ "deleteTranslation": "Elimina questa traduzione",
+ "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.",
+ "backgroundOpacity": "Opacità",
+ "backgroundColor": "Colore dello sfondo",
+ "alignCenter": "Centro",
+ "translationIsNonDestructive": "Le traduzioni vengono salvate accanto alla trascrizione, mai al suo interno: il testo originale e i suoi tempi restano intatti.",
+ "distanceFromRight": "Distanza da destra",
+ "language": "Lingua",
+ "text": "Testo",
+ "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.",
+ "distanceFromTop": "Distanza dall'alto",
+ "translateFailed": "Traduzione non riuscita.",
+ "alignLeft": "Sinistra",
+ "distanceFromBottom": "Distanza dal basso",
+ "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
+ "translate": "Traduci",
+ "position": "Posizione",
+ "fontSize": "Dimensione",
+ "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
+ "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Trascrivi questo video per attivarli.",
+ "distanceFromLeft": "Distanza da sinistra",
+ "anchorBottom": "Basso",
+ "transcribe": "Trascrivi video",
+ "bold": "Grassetto",
+ "alignRight": "Destra",
+ "anchorTop": "Alto",
+ "minWords": "Parole min. per riga",
+ "translateHint": "Traduci la trascrizione con il provider IA configurato",
+ "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.",
+ "displayLanguage": "Visualizzazione",
+ "removeLegacyAnnotations": "Rimuovi le vecchie annotazioni dei sottotitoli",
+ "background": "Sfondo",
+ "lineLength": "Lunghezza riga",
+ "original": "Originale (trascrizione)",
+ "maxWords": "Parole max. per riga",
+ "font": "Carattere",
+ "translating": "Traduzione…",
+ "show": "Mostra sottotitoli",
+ "textColor": "Colore del testo"
},
- "facets": {
- "captions": "Sottotitoli",
- "transcript": "Trascrizione"
+ "panes": {
+ "help": "Aiuto"
},
- "audio": {
- "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
- "reset": "Reimposta audio",
- "title": "Audio",
- "outputGain": "Livello di uscita"
+ "speed": {
+ "deleteRegion": "Elimina regione velocità",
+ "maxSpeedError": "La velocità non può superare {{max}}×",
+ "selectRegion": "Seleziona una regione velocità da regolare",
+ "playbackSpeed": "Velocità di riproduzione",
+ "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
+ "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata."
},
- "language": {
- "title": "Lingua"
+ "gifSettings": {
+ "frameRate": "Frequenza fotogrammi GIF",
+ "loop": "GIF in loop",
+ "size": "Dimensione GIF"
+ },
+ "exportQuality": {
+ "title": "Risoluzione esportazione",
+ "low": "720p",
+ "high": "Originale",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "defaultLabel": "Traccia audio",
"importFailed": "Impossibile aggiungere l’audio",
- "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
"fadeOut": "Dissolvenza in uscita",
- "defaultLabel": "Traccia audio",
- "mute": "Muto",
+ "fadeIn": "Dissolvenza in entrata",
"remove": "Elimina traccia",
- "add": "Aggiungi traccia audio",
"loop": "Ripeti",
- "fadeIn": "Dissolvenza in entrata"
+ "slipHint": "Alt + trascina per far scorrere l’audio all’interno",
+ "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "add": "Aggiungi traccia audio",
+ "mute": "Muto"
},
- "project": {
- "load": "Carica progetto",
- "save": "Salva progetto",
- "new": "Nuovo progetto"
+ "layout": {
+ "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
+ "mirrorWebcam": "Specchia webcam",
+ "webcamFraming": "Inquadratura webcam",
+ "shapes": {
+ "rectangle": "Rett.",
+ "rounded": "Arrotondato",
+ "circle": "Cerchio",
+ "square": "Quadrato"
+ },
+ "selectPreset": "Seleziona predefinito",
+ "bgModes": {
+ "custom": "Personalizzato",
+ "none": "Originale",
+ "blur": "Sfocato",
+ "transparent": "Scontornato"
+ },
+ "reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
+ "webcamBlurIntensity": "Intensità sfocatura",
+ "preset": "Predefinito",
+ "webcamCropZoom": "Zoom ritaglio",
+ "webcamSize": "Dimensione webcam",
+ "dualFrame": "Doppio frame",
+ "webcamCropY": "Spostamento verticale",
+ "verticalStack": "Pila verticale",
+ "pictureInPicture": "Immagine nell'immagine",
+ "webcamShape": "Forma fotocamera",
+ "webcamCropX": "Spostamento orizzontale",
+ "reactiveWebcam": "Riduci con lo zoom",
+ "webcamBackground": "Sfondo della fotocamera",
+ "helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
+ "title": "Disposizione camera",
+ "noWebcam": "Nessuna webcam"
},
- "panes": {
- "help": "Aiuto"
+ "textAnimation": {
+ "slideLeft": "Scivola a sinistra",
+ "pulse": "Pulsazione",
+ "typewriter": "Macchina da scrivere",
+ "selectAnimation": "Seleziona animazione",
+ "fade": "Dissolvenza",
+ "title": "Animazione testo",
+ "none": "Nessuna",
+ "pop": "Apparizione",
+ "rise": "Ascesa"
},
- "trim": {
- "deleteRegion": "Elimina regione taglio"
+ "facets": {
+ "transcript": "Trascrizione",
+ "captions": "Sottotitoli"
},
- "export": {
- "videoButton": "Esporta video",
- "gifButton": "Esporta GIF",
- "chooseSaveLocation": "Scegli posizione di salvataggio"
+ "crop": {
+ "title": "Ritaglia",
+ "free": "Libero",
+ "unlockAspectRatio": "Sblocca proporzioni",
+ "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
+ "done": "Fatto",
+ "ratio": "Proporzioni",
+ "cropVideo": "Ritaglia video",
+ "lockAspectRatio": "Blocca proporzioni"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso",
+ "title": "Posizione messa a fuoco",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Elimina zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controllato dall'interruttore globale di messa a fuoco automatica nella timeline. Disattivalo per impostare la modalità di messa a fuoco per ogni zoom.",
+ "auto": "Automatico",
+ "manual": "Manuale",
+ "autoDescription": "La fotocamera segue la posizione del cursore registrato",
+ "title": "Modalità messa a fuoco"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Sinistra",
+ "right": "Destra",
+ "iso": "Iso"
+ },
+ "none": "Nessuna",
+ "title": "Rotazione 3D"
+ },
+ "level": "Livello zoom",
+ "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
+ "customScale": "Zoom personalizzato",
+ "selectRegion": "Seleziona una regione zoom da regolare"
+ },
+ "audio": {
+ "outputGain": "Livello di uscita",
+ "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione.",
+ "reset": "Reimposta audio",
+ "title": "Audio"
+ },
+ "language": {
+ "title": "Lingua"
+ },
+ "project": {
+ "new": "Nuovo progetto",
+ "load": "Carica progetto",
+ "save": "Salva progetto"
},
"support": {
"starOnGithub": "Metti stella su GitHub",
"saveDiagnostics": "Salva dati diagnostici",
"reportBug": "Segnala bug"
},
- "gifSettings": {
- "size": "Dimensione GIF",
- "frameRate": "Frequenza fotogrammi GIF",
- "loop": "GIF in loop"
+ "cursor": {
+ "smoothing": "Smussatura",
+ "clickBounce": "Rimbalzo clic",
+ "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.",
+ "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.",
+ "size": "Dimensione",
+ "title": "Cursore",
+ "show": "Mostra cursore",
+ "themeDefault": "Predefinito",
+ "clipToBounds": "Ritaglia al canvas",
+ "motionBlur": "Sfocatura movimento",
+ "theme": "Stile del cursore"
+ },
+ "export": {
+ "gifButton": "Esporta GIF",
+ "chooseSaveLocation": "Scegli posizione di salvataggio",
+ "videoButton": "Esporta video"
+ },
+ "trim": {
+ "deleteRegion": "Elimina regione taglio"
}
}
diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json
index 2eb958dd9..f0431fceb 100644
--- a/src/i18n/locales/it/timeline.json
+++ b/src/i18n/locales/it/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "Questa registrazione non ha dati sul movimento del cursore, oppure gli zoom esistenti coprono già i momenti principali.",
- "smartCutsNoAudio": "Questo contenuto non ha audio",
- "automaticZoomsHint": "Dal movimento del cursore registrato",
- "dragToReorderHint": "Trascina per riordinare · doppio clic per modificare i punti di entrata/uscita",
- "smartCutsNeedsTranscript": "Richiede una trascrizione",
- "addedWord": "Parola aggiunta: «{{word}}» — nessun audio dietro",
- "smartZoomsAndCuts": "Tagli intelligenti",
- "autoZoomFailed": "Zoom automatico non riuscito",
- "smartCutsWaiting": "Trascrizione in corso… disponibile a breve",
- "automaticZooms": "Zoom automatici",
- "arrangeClipsHint": "Trascina le clip qui sotto per riordinarle o rilasciane di nuove",
- "comment": "Commento",
- "addAudioTooltip": "Aggiungi audio",
- "timelineTools": "Strumenti della timeline",
- "deleteClip": "Elimina clip",
- "arrangeClips": "Organizza clip",
- "editInOutPoints": "Modifica punti di entrata/uscita",
- "smartZoomsAndCutsHint": "Con l'IA",
- "addedAutoZoomPlural": "Aggiunti {{count}} zoom automatici",
- "noAutoZoomMoments": "Nessun momento per lo zoom automatico trovato",
- "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali",
- "smartCutsNoSpeech": "Nessun parlato rilevato",
- "newAnnotation": "Annotazione",
- "importRecordingFirst": "Importa prima una registrazione",
- "addedAutoZoom": "Aggiunto {{count}} zoom automatico",
- "dropToAdd": "Rilascia per aggiungere alla timeline",
- "aiEnhanceRequested": "Chiesto all'agente IA di tagliare i tempi morti",
- "autoEnhance": "Miglioramento automatico"
+ "buttons": {
+ "addZoom": "Aggiungi zoom (Z)",
+ "suggestZooms": "Suggerisci zoom dal cursore",
+ "autoZoomOn": "Suggerimenti di zoom automatico attivi — clicca per rimuovere gli zoom suggeriti",
+ "autoZoomOff": "Suggerimenti di zoom automatico disattivi — clicca per suggerire zoom dal cursore",
+ "autoFocusAllOn": "Messa a fuoco automatica attiva per tutti gli zoom — clicca per passare tutti a manuale",
+ "autoFocusAllOff": "Attiva la messa a fuoco automatica per tutti gli zoom (la fotocamera segue il cursore)",
+ "addTrim": "Aggiungi taglio (T)",
+ "addAnnotation": "Aggiungi annotazione (A)",
+ "addSpeed": "Aggiungi velocità (S)",
+ "addCameraFullscreen": "Aggiungi Camera a schermo intero (C)"
+ },
+ "hints": {
+ "pressZoom": "Premi Z per aggiungere zoom",
+ "pressTrim": "Premi T per aggiungere taglio",
+ "pressAnnotation": "Premi A per aggiungere annotazione",
+ "pressAudio": "Premi M per aggiungere audio, V per registrare una voce fuori campo",
+ "pressSpeed": "Premi S per aggiungere velocità",
+ "pressCameraFullscreen": "Premi C per aggiungere un segmento Camera a schermo intero"
},
"labels": {
- "zoom": "Zoom",
- "cameraFullscreenItem": "Camera a schermo intero {{index}}",
- "imageItem": "Immagine",
"pan": "Panoramica",
- "zoomItem": "Zoom {{index}}",
- "cameraFullscreen": "Camera a schermo intero",
+ "zoom": "Zoom",
+ "trim": "Taglio",
"speed": "Velocità",
- "emptyText": "Testo vuoto",
+ "zoomItem": "Zoom {{index}}",
"trimItem": "Taglio {{index}}",
+ "speedItem": "Velocità {{index}}",
"annotationItem": "Annotazione",
- "trim": "Taglio",
- "speedItem": "Velocità {{index}}"
+ "imageItem": "Immagine",
+ "emptyText": "Testo vuoto",
+ "cameraFullscreen": "Camera a schermo intero",
+ "cameraFullscreenItem": "Camera a schermo intero {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Nessun video caricato",
+ "dragAndDrop": "Trascina e rilascia un video per iniziare a modificare"
},
"errors": {
- "noAutoZoomSlotsDescription": "I punti di sosta rilevati si sovrappongono alle regioni zoom esistenti.",
+ "cannotPlaceZoom": "Impossibile posizionare lo zoom qui",
+ "zoomExistsAtLocation": "Lo zoom esiste già in questa posizione o non c'è spazio sufficiente.",
+ "zoomSuggestionUnavailable": "Gestore suggerimenti zoom non disponibile",
+ "noCursorTelemetry": "Nessuna telemetria del cursore disponibile",
"noCursorTelemetryDescription": "Registra prima uno screencast per generare suggerimenti basati sul cursore.",
- "cameraFullscreenExistsAtLocation": "Un segmento Camera a schermo intero esiste già in questa posizione o non c'è spazio sufficiente.",
"noUsableTelemetry": "Nessuna telemetria del cursore utilizzabile",
"noUsableTelemetryDescription": "La registrazione non include dati sufficienti sul movimento del cursore.",
- "zoomSuggestionUnavailable": "Gestore suggerimenti zoom non disponibile",
"noDwellMoments": "Nessun momento di sosta del cursore trovato",
- "speedExistsAtLocation": "La regione velocità esiste già in questa posizione o non c'è spazio sufficiente.",
- "noCursorTelemetry": "Nessuna telemetria del cursore disponibile",
+ "noDwellMomentsDescription": "Prova una registrazione con pause del cursore più lente sulle azioni importanti.",
"noAutoZoomSlots": "Nessuno slot di zoom automatico disponibile",
+ "noAutoZoomSlotsDescription": "I punti di sosta rilevati si sovrappongono alle regioni zoom esistenti.",
"cannotPlaceTrim": "Impossibile posizionare il taglio qui",
- "cannotPlaceZoom": "Impossibile posizionare lo zoom qui",
- "cannotPlaceSpeed": "Impossibile posizionare la velocità qui",
- "zoomExistsAtLocation": "Lo zoom esiste già in questa posizione o non c'è spazio sufficiente.",
- "noDwellMomentsDescription": "Prova una registrazione con pause del cursore più lente sulle azioni importanti.",
"trimExistsAtLocation": "Il taglio esiste già in questa posizione o non c'è spazio sufficiente.",
- "cannotPlaceCameraFullscreen": "Impossibile posizionare la Camera a schermo intero qui"
+ "cannotPlaceSpeed": "Impossibile posizionare la velocità qui",
+ "speedExistsAtLocation": "La regione velocità esiste già in questa posizione o non c'è spazio sufficiente.",
+ "cannotPlaceCameraFullscreen": "Impossibile posizionare la Camera a schermo intero qui",
+ "cameraFullscreenExistsAtLocation": "Un segmento Camera a schermo intero esiste già in questa posizione o non c'è spazio sufficiente."
+ },
+ "success": {
+ "addedZoomSuggestions": "Aggiunto {{count}} suggerimento zoom basato sul cursore",
+ "addedZoomSuggestionsPlural": "Aggiunti {{count}} suggerimenti zoom basati sul cursore"
+ },
+ "toolbar": {
+ "autoEnhance": "Miglioramento automatico",
+ "automaticZooms": "Zoom automatici",
+ "automaticZoomsHint": "Dal movimento del cursore registrato",
+ "smartZoomsAndCuts": "Tagli intelligenti",
+ "smartZoomsAndCutsHint": "Con l'IA",
+ "comment": "Commento",
+ "timelineTools": "Strumenti della timeline",
+ "arrangeClips": "Organizza clip",
+ "arrangeClipsHint": "Trascina le clip qui sotto per riordinarle o rilasciane di nuove",
+ "newAnnotation": "Annotazione",
+ "dragToReorderHint": "Trascina per riordinare · doppio clic per modificare i punti di entrata/uscita",
+ "editInOutPoints": "Modifica punti di entrata/uscita",
+ "deleteClip": "Elimina clip",
+ "dropToAdd": "Rilascia per aggiungere alla timeline",
+ "importRecordingFirst": "Importa prima una registrazione",
+ "noAutoZoomMoments": "Nessun momento per lo zoom automatico trovato",
+ "noAutoZoomMomentsDescription": "Questa registrazione non ha dati sul movimento del cursore, oppure gli zoom esistenti coprono già i momenti principali.",
+ "addedAutoZoom": "Aggiunto {{count}} zoom automatico",
+ "addedAutoZoomPlural": "Aggiunti {{count}} zoom automatici",
+ "autoZoomFailed": "Zoom automatico non riuscito",
+ "aiEnhanceRequested": "Chiesto all'agente IA di tagliare i tempi morti",
+ "smartCutsWaiting": "Trascrizione in corso… disponibile a breve",
+ "smartCutsNeedsTranscript": "Richiede una trascrizione",
+ "smartCutsNoAudio": "Questo contenuto non ha audio",
+ "smartCutsNoSpeech": "Nessun parlato rilevato",
+ "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali",
+ "addAudioTooltip": "Aggiungi audio",
+ "addedWord": "Parola aggiunta: «{{word}}» — nessun audio dietro"
},
"audio": {
- "micDenied": "Accesso al microfono negato",
+ "addVoiceover": "Aggiungi voce fuori campo",
+ "addVoiceoverHint": "Registra una narrazione sopra il video",
"subtitle": "Posiziona un livello di voce fuori campo o di musica di sottofondo sulla timeline",
- "importFailed": "Impossibile importare il file audio",
+ "record": "Registra voce fuori campo",
+ "importFile": "Importa file audio",
"importFileHint": "Importa musica o un file audio",
- "addVoiceoverHint": "Registra una narrazione sopra il video",
- "stop": "Ferma",
"recording": "Registrazione",
- "saveFailed": "Impossibile salvare la registrazione",
"recordingHint": "Racconta insieme al video: continua a riprodursi mentre registri",
- "importFile": "Importa file audio",
- "record": "Registra voce fuori campo",
+ "stop": "Ferma",
+ "micDenied": "Accesso al microfono negato",
"recordingUnavailable": "La registrazione non è disponibile qui",
- "addVoiceover": "Aggiungi voce fuori campo"
- },
- "success": {
- "addedZoomSuggestions": "Aggiunto {{count}} suggerimento zoom basato sul cursore",
- "addedZoomSuggestionsPlural": "Aggiunti {{count}} suggerimenti zoom basati sul cursore"
- },
- "hints": {
- "pressAnnotation": "Premi A per aggiungere annotazione",
- "pressSpeed": "Premi S per aggiungere velocità",
- "pressTrim": "Premi T per aggiungere taglio",
- "pressCameraFullscreen": "Premi C per aggiungere un segmento Camera a schermo intero",
- "pressZoom": "Premi Z per aggiungere zoom",
- "pressAudio": "Premi M per aggiungere audio, V per registrare una voce fuori campo"
- },
- "buttons": {
- "autoFocusAllOff": "Attiva la messa a fuoco automatica per tutti gli zoom (la fotocamera segue il cursore)",
- "autoZoomOff": "Suggerimenti di zoom automatico disattivi — clicca per suggerire zoom dal cursore",
- "addAnnotation": "Aggiungi annotazione (A)",
- "suggestZooms": "Suggerisci zoom dal cursore",
- "addSpeed": "Aggiungi velocità (S)",
- "addCameraFullscreen": "Aggiungi Camera a schermo intero (C)",
- "autoFocusAllOn": "Messa a fuoco automatica attiva per tutti gli zoom — clicca per passare tutti a manuale",
- "autoZoomOn": "Suggerimenti di zoom automatico attivi — clicca per rimuovere gli zoom suggeriti",
- "addZoom": "Aggiungi zoom (Z)",
- "addTrim": "Aggiungi taglio (T)"
- },
- "emptyState": {
- "noVideo": "Nessun video caricato",
- "dragAndDrop": "Trascina e rilascia un video per iniziare a modificare"
+ "saveFailed": "Impossibile salvare la registrazione",
+ "importFailed": "Impossibile importare il file audio"
}
}
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index e9cba9377..6c746cdfa 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "ぼかしの強さ",
- "bgModes": {
- "transparent": "切り抜き",
- "none": "オリジナル",
- "blur": "ぼかし",
- "custom": "カスタム"
- },
- "selectPreset": "プリセットを選択",
- "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
- "reactiveWebcam": "ズーム時に縮小",
- "shapes": {
- "circle": "円",
- "square": "正方形",
- "rectangle": "長方形",
- "rounded": "角丸"
- },
- "webcamBackground": "カメラ背景",
- "verticalStack": "縦並び",
- "pictureInPicture": "ピクチャーインピクチャ",
- "webcamShape": "カメラの形状",
- "webcamCropY": "垂直方向に移動",
- "webcamSize": "カメラのサイズ",
- "mirrorWebcam": "Webカメラを反転",
- "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
- "webcamFraming": "ウェブカメラの構図",
- "noWebcam": "Webカメラなし",
- "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
- "webcamCropZoom": "クロップのズーム",
- "dualFrame": "デュアルフレーム",
- "webcamCropX": "水平方向に移動",
- "title": "カメラレイアウト",
- "preset": "プリセット"
- },
- "crop": {
- "done": "完了",
- "ratio": "比率",
- "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
- "title": "クロップ",
- "free": "自由",
- "lockAspectRatio": "アスペクト比を固定",
- "unlockAspectRatio": "アスペクト比の固定を解除",
- "cropVideo": "動画をクロップ"
- },
- "zoom": {
- "position": {
- "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "フォーカス位置"
- },
- "threeD": {
- "preset": {
- "right": "右",
- "iso": "Iso",
- "left": "左"
- },
- "none": "なし",
- "title": "3D回転"
- },
- "deleteZoom": "ズームを削除",
- "customScale": "カスタムズーム",
- "selectRegion": "ズーム範囲を選択して調整",
- "focusMode": {
- "manual": "手動",
- "autoDescription": "表示範囲が録画中のカーソル位置に追従します",
- "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
- "title": "フォーカスモード",
- "auto": "自動"
- },
- "previewHold": "押している間ズーム効果をプレビュー",
- "level": "ズーム倍率"
- },
"background": {
- "color": "色",
- "colorLabel": "色 {{color}}",
- "gradient": "グラデーション",
- "imageReadFailed": "この画像ファイルを読み込めませんでした。",
"gradientLabel": "グラデーション {{index}}",
+ "uploadCustom": "カスタム画像を読み込む",
+ "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
+ "title": "背景",
+ "imageLabel": "背景 {{index}}",
"custom": "カスタム",
+ "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
+ "gradient": "グラデーション",
+ "colorLabel": "色 {{color}}",
"customWallpaper": "カスタム壁紙",
- "presets": "プリセット",
- "image": "画像",
"colorPalette": "カラーパレット",
- "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
- "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
- "imageLabel": "背景 {{index}}",
- "title": "背景",
- "uploadCustom": "カスタム画像を読み込む",
+ "imageReadFailed": "この画像ファイルを読み込めませんでした。",
+ "image": "画像",
+ "presets": "プリセット",
+ "color": "色",
"colorWheel": "カラーホイール"
},
- "cursor": {
- "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
- "clickBounce": "クリックバウンス",
- "clipToBounds": "キャンバスにクリップ",
- "title": "カーソル",
- "size": "サイズ",
- "themeDefault": "デフォルト",
- "smoothing": "スムージング",
- "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
- "motionBlur": "モーションブラー",
- "theme": "カーソルのスタイル",
- "show": "カーソルを表示"
- },
- "captions": {
- "text": "テキスト",
- "showBackground": "背景を表示",
- "minWords": "1 行の最小単語数",
- "translateFailed": "翻訳に失敗しました。",
- "distanceFromTop": "上端からの距離",
- "fontSize": "サイズ",
- "distanceFromRight": "右端からの距離",
- "bold": "太字",
- "textColor": "文字色",
- "original": "オリジナル(文字起こし)",
- "translating": "翻訳中…",
- "language": "言語",
- "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
- "alignLeft": "左",
- "distanceFromBottom": "下端からの距離",
- "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
- "show": "字幕を表示",
- "background": "背景",
- "backgroundOpacity": "不透明度",
- "removeLegacyAnnotations": "古い字幕の注釈を削除",
- "anchorTop": "上",
- "translate": "翻訳",
- "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
- "alignCenter": "中央",
- "alignRight": "右",
- "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
- "displayLanguage": "表示",
- "noTranscript": "字幕はメディアの文字起こしから読み込まれます。この動画が文字起こしされると有効になります。",
- "distanceFromLeft": "左端からの距離",
- "maxWords": "1 行の最大単語数",
- "anchorBottom": "下",
- "position": "位置",
- "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
- "deleteTranslation": "この翻訳を削除",
- "backgroundColor": "背景色",
- "font": "フォント",
- "lineLength": "行の長さ",
- "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
- "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。"
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "書き出し解像度"
- },
- "transcript": {
- "transcribing": "文字起こし中…",
- "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
- "laneVoiceover": "ナレーション",
- "blankedWord": "空欄",
- "noTranscript": "文字起こしがまだありません",
- "noAudio": "このメディアには音声トラックがありません",
- "revertWord": "「{{original}}」に戻す",
- "silence": "[無音 {{duration}} 秒]",
- "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
- "transcribeNow": "今すぐ文字起こし",
- "restoreWord": "「{{word}}」を元に戻す",
- "clipLabel": "クリップ {{index}}",
- "title": "現在の文字起こし",
- "insertAria": "新しい単語",
- "laneRecording": "録画",
- "trimSilence": "無音をトリム({{duration}} 秒)",
- "insertedWord": "あなたが追加した単語 — 音声はありません",
- "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
- "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
- "removeInserted": "「{{word}}」を削除",
- "editorAria": "{{filename}} の文字起こし",
- "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
- "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
- "editWord": "「{{word}}」を編集",
- "noClips": "クリップがまだありません",
- "laneLabel": "文字起こしの読み込み元",
- "restoreSilence": "無音を元に戻す({{duration}} 秒)"
- },
"customFont": {
+ "namePlaceholder": "マイカスタムフォント",
+ "failedToAdd": "フォントの追加に失敗しました",
"addingButton": "追加中...",
+ "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
"urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
- "addButton": "フォントを追加",
- "dialogTitle": "Googleフォントを追加",
- "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
- "nameLabel": "表示名",
- "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
"urlLabel": "GoogleフォントのインポートURL",
+ "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
+ "nameLabel": "表示名",
"errorEmptyName": "フォント名を入力してください",
- "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
- "namePlaceholder": "マイカスタムフォント",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "フォントの追加に失敗しました",
"nameHelp": "フォントセレクターに表示される名前です",
+ "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
"errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
- "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
- "successMessage": "フォント \"{{fontName}}\" が正常に追加されました"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Googleフォントを追加",
+ "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
+ "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。",
+ "addButton": "フォントを追加"
+ },
+ "imageUpload": {
+ "invalidFileType": "無効なファイル形式",
+ "failedToUpload": "画像の読み込みに失敗しました",
+ "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "uploadSuccess": "カスタム画像を読み込みました。",
+ "errorReading": "ファイルの読み取り中にエラーが発生しました。"
},
"annotation": {
- "arrowColor": "矢印の色",
- "colorWheel": "カラーホイール",
- "blurType": "ぼかしの種類",
- "active": "アクティブ",
- "deleteAnnotation": "注釈を削除",
- "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
- "strokeWidth": "線の太さ: {{width}}px",
- "background": "背景",
- "imageUploadSuccess": "画像を読み込みました。",
- "blurColor": "ぼかしの色",
- "blurTypeBlur": "ガウス",
- "textColor": "文字色",
- "blurColorWhite": "白",
- "title": "注釈設定",
- "type": "種類",
- "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "typeImage": "画像",
- "textContent": "テキスト内容",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
- "typeText": "テキスト",
- "blurIntensity": "ぼかしの強さ",
- "none": "なし",
- "mosaicBlockSize": "モザイクブロックのサイズ",
- "textPlaceholder": "テキストを入力してください...",
- "typeArrow": "矢印",
- "color": "色",
- "blurColorBlack": "黒",
+ "blurShapeRectangle": "長方形",
"size": "サイズ",
+ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
+ "clearBackground": "背景をクリア",
+ "colorPalette": "カラーパレット",
"invalidImageType": "無効なファイル形式",
+ "background": "背景",
+ "typeText": "テキスト",
+ "active": "アクティブ",
+ "color": "色",
"blurShapeFreehand": "自由形状",
- "shortcutsAndTips": "ショートカットとヒント",
- "uploadImage": "画像を読み込む",
+ "arrowDirection": "矢印の方向",
"blurTypeMosaic": "モザイク",
+ "colorWheel": "カラーホイール",
+ "textColor": "文字色",
+ "title": "注釈設定",
+ "blurType": "ぼかしの種類",
+ "typeBlur": "ぼかし",
+ "blurIntensity": "ぼかしの強さ",
"selectStyle": "スタイルを選択",
- "defaultText": "こんにちは",
- "blurShapeRectangle": "長方形",
- "colorPalette": "カラーパレット",
- "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
- "clearBackground": "背景をクリア",
+ "textContent": "テキスト内容",
+ "typeArrow": "矢印",
+ "none": "なし",
+ "blurColor": "ぼかしの色",
"customFonts": "カスタムフォント",
- "typeBlur": "ぼかし",
+ "imageUploadSuccess": "画像を読み込みました。",
+ "type": "種類",
+ "arrowColor": "矢印の色",
+ "textPlaceholder": "テキストを入力してください...",
+ "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "blurShape": "ぼかしの形状",
+ "uploadImage": "画像を読み込む",
+ "blurTypeBlur": "ガウス",
"tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
+ "shortcutsAndTips": "ショートカットとヒント",
+ "deleteAnnotation": "注釈を削除",
"fontStyle": "フォントスタイル",
- "blurShape": "ぼかしの形状",
- "arrowDirection": "矢印の方向",
- "blurShapeOval": "楕円"
- },
- "speed": {
- "customPlaybackSpeed": "カスタム再生速度",
- "deleteRegion": "再生速度の範囲を削除",
- "selectRegion": "再生速度の範囲を選択して調整",
- "maxSpeedError": "速度は{{max}}×を超えることはできません",
- "playbackSpeed": "再生速度",
- "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。"
- },
- "textAnimation": {
- "selectAnimation": "アニメーションを選択",
- "pulse": "パルス",
- "rise": "上昇",
- "none": "なし",
- "slideLeft": "左へスライド",
- "title": "テキストアニメーション",
- "fade": "フェード",
- "pop": "ポップ",
- "typewriter": "タイプライター"
+ "defaultText": "こんにちは",
+ "mosaicBlockSize": "モザイクブロックのサイズ",
+ "blurColorBlack": "黒",
+ "strokeWidth": "線の太さ: {{width}}px",
+ "blurShapeOval": "楕円",
+ "blurColorWhite": "白",
+ "tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
+ "typeImage": "画像"
},
"effects": {
- "motion": "モーション",
- "title": "コンポジション",
- "format": "フォーマット",
- "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
"fitClipFew": "{{count}} クリップ",
- "motionBlur": "モーションブラー",
- "fitClipMany": "{{count}} クリップ",
- "frame": "フレーム",
- "padding": "余白",
- "roundness": "丸み",
- "off": "オフ",
- "blurBg": "背景をぼかす",
+ "title": "コンポジション",
"shadow": "影",
+ "off": "オフ",
"on": "オン",
+ "blurBg": "背景をぼかす",
+ "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
"fitClipOne": "{{count}} クリップ",
"formatOriginal": "元のサイズ",
- "fitClip": "合わせる"
+ "fitClipMany": "{{count}} クリップ",
+ "frame": "フレーム",
+ "motion": "モーション",
+ "padding": "余白",
+ "format": "フォーマット",
+ "fitClip": "合わせる",
+ "motionBlur": "モーションブラー",
+ "roundness": "丸み"
+ },
+ "transcript": {
+ "laneRecording": "録画",
+ "noTranscript": "文字起こしがまだありません",
+ "title": "現在の文字起こし",
+ "restoreWord": "「{{word}}」を元に戻す",
+ "revertWord": "「{{original}}」に戻す",
+ "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
+ "restoreSilence": "無音を元に戻す({{duration}} 秒)",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "editWord": "「{{word}}」を編集",
+ "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
+ "insertAria": "新しい単語",
+ "editorAria": "{{filename}} の文字起こし",
+ "transcribeNow": "今すぐ文字起こし",
+ "transcribing": "文字起こし中…",
+ "trimSilence": "無音をトリム({{duration}} 秒)",
+ "removeInserted": "「{{word}}」を削除",
+ "laneLabel": "文字起こしの読み込み元",
+ "noClips": "クリップがまだありません",
+ "laneVoiceover": "ナレーション",
+ "silence": "[無音 {{duration}} 秒]",
+ "clipLabel": "クリップ {{index}}",
+ "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
+ "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "noAudio": "このメディアには音声トラックがありません",
+ "blankedWord": "空欄"
},
"exportFormat": {
- "gifDescription": "共有用のアニメーション画像",
"mp4": "MP4",
- "mp4Video": "MP4 動画",
- "gif": "GIF",
+ "mp4Description": "高品質の動画ファイル",
"gifAnimation": "GIF アニメーション",
- "mp4Description": "高品質の動画ファイル"
+ "mp4Video": "MP4 動画",
+ "gifDescription": "共有用のアニメーション画像",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "画像の読み込みに失敗しました",
- "uploadSuccess": "カスタム画像を読み込みました。",
- "errorReading": "ファイルの読み取り中にエラーが発生しました。",
- "invalidFileType": "無効なファイル形式",
- "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。"
+ "captions": {
+ "showBackground": "背景を表示",
+ "deleteTranslation": "この翻訳を削除",
+ "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
+ "backgroundOpacity": "不透明度",
+ "backgroundColor": "背景色",
+ "alignCenter": "中央",
+ "translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
+ "distanceFromRight": "右端からの距離",
+ "language": "言語",
+ "text": "テキスト",
+ "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
+ "distanceFromTop": "上端からの距離",
+ "translateFailed": "翻訳に失敗しました。",
+ "alignLeft": "左",
+ "distanceFromBottom": "下端からの距離",
+ "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
+ "translate": "翻訳",
+ "position": "位置",
+ "fontSize": "サイズ",
+ "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
+ "noTranscript": "字幕はメディアの文字起こしから読み込まれます。有効にするにはこの動画を文字起こししてください。",
+ "distanceFromLeft": "左端からの距離",
+ "anchorBottom": "下",
+ "transcribe": "動画を文字起こし",
+ "bold": "太字",
+ "alignRight": "右",
+ "anchorTop": "上",
+ "minWords": "1 行の最小単語数",
+ "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
+ "displayLanguage": "表示",
+ "removeLegacyAnnotations": "古い字幕の注釈を削除",
+ "background": "背景",
+ "lineLength": "行の長さ",
+ "original": "オリジナル(文字起こし)",
+ "maxWords": "1 行の最大単語数",
+ "font": "フォント",
+ "translating": "翻訳中…",
+ "show": "字幕を表示",
+ "textColor": "文字色"
},
- "facets": {
- "captions": "字幕",
- "transcript": "文字起こし"
+ "panes": {
+ "help": "ヘルプ"
},
- "audio": {
- "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
- "reset": "オーディオをリセット",
- "title": "オーディオ",
- "outputGain": "出力レベル"
+ "speed": {
+ "deleteRegion": "再生速度の範囲を削除",
+ "maxSpeedError": "速度は{{max}}×を超えることはできません",
+ "selectRegion": "再生速度の範囲を選択して調整",
+ "playbackSpeed": "再生速度",
+ "customPlaybackSpeed": "カスタム再生速度",
+ "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。"
},
- "language": {
- "title": "言語"
+ "gifSettings": {
+ "frameRate": "GIF フレームレート",
+ "loop": "GIF をループする",
+ "size": "GIF サイズ"
+ },
+ "exportQuality": {
+ "title": "書き出し解像度",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "defaultLabel": "オーディオトラック",
"importFailed": "オーディオを追加できませんでした",
- "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
"fadeOut": "フェードアウト",
- "defaultLabel": "オーディオトラック",
- "mute": "ミュート",
+ "fadeIn": "フェードイン",
"remove": "トラックを削除",
- "add": "オーディオトラックを追加",
"loop": "ループ",
- "fadeIn": "フェードイン"
+ "slipHint": "Alt を押しながらドラッグで中の音声をずらす",
+ "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "add": "オーディオトラックを追加",
+ "mute": "ミュート"
},
- "project": {
- "load": "プロジェクトを読み込む",
- "save": "プロジェクトを保存",
- "new": "新規プロジェクト"
+ "layout": {
+ "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
+ "mirrorWebcam": "Webカメラを反転",
+ "webcamFraming": "ウェブカメラの構図",
+ "shapes": {
+ "rectangle": "長方形",
+ "rounded": "角丸",
+ "circle": "円",
+ "square": "正方形"
+ },
+ "selectPreset": "プリセットを選択",
+ "bgModes": {
+ "custom": "カスタム",
+ "none": "オリジナル",
+ "blur": "ぼかし",
+ "transparent": "切り抜き"
+ },
+ "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
+ "webcamBlurIntensity": "ぼかしの強さ",
+ "preset": "プリセット",
+ "webcamCropZoom": "クロップのズーム",
+ "webcamSize": "カメラのサイズ",
+ "dualFrame": "デュアルフレーム",
+ "webcamCropY": "垂直方向に移動",
+ "verticalStack": "縦並び",
+ "pictureInPicture": "ピクチャーインピクチャ",
+ "webcamShape": "カメラの形状",
+ "webcamCropX": "水平方向に移動",
+ "reactiveWebcam": "ズーム時に縮小",
+ "webcamBackground": "カメラ背景",
+ "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
+ "title": "カメラレイアウト",
+ "noWebcam": "Webカメラなし"
},
- "panes": {
- "help": "ヘルプ"
+ "textAnimation": {
+ "slideLeft": "左へスライド",
+ "pulse": "パルス",
+ "typewriter": "タイプライター",
+ "selectAnimation": "アニメーションを選択",
+ "fade": "フェード",
+ "title": "テキストアニメーション",
+ "none": "なし",
+ "pop": "ポップ",
+ "rise": "上昇"
},
- "trim": {
- "deleteRegion": "トリム範囲を削除"
+ "facets": {
+ "transcript": "文字起こし",
+ "captions": "字幕"
},
- "export": {
- "videoButton": "動画をエクスポート",
- "gifButton": "GIF をエクスポート",
- "chooseSaveLocation": "保存場所を選択"
+ "crop": {
+ "title": "クロップ",
+ "free": "自由",
+ "unlockAspectRatio": "アスペクト比の固定を解除",
+ "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
+ "done": "完了",
+ "ratio": "比率",
+ "cropVideo": "動画をクロップ",
+ "lockAspectRatio": "アスペクト比を固定"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 左端 / 上端、100 = 右端 / 下端",
+ "title": "フォーカス位置",
+ "x": "X (%)"
+ },
+ "deleteZoom": "ズームを削除",
+ "focusMode": {
+ "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。",
+ "auto": "自動",
+ "manual": "手動",
+ "autoDescription": "表示範囲が録画中のカーソル位置に追従します",
+ "title": "フォーカスモード"
+ },
+ "threeD": {
+ "preset": {
+ "left": "左",
+ "right": "右",
+ "iso": "Iso"
+ },
+ "none": "なし",
+ "title": "3D回転"
+ },
+ "level": "ズーム倍率",
+ "previewHold": "押している間ズーム効果をプレビュー",
+ "customScale": "カスタムズーム",
+ "selectRegion": "ズーム範囲を選択して調整"
+ },
+ "audio": {
+ "outputGain": "出力レベル",
+ "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
+ "reset": "オーディオをリセット",
+ "title": "オーディオ"
+ },
+ "language": {
+ "title": "言語"
+ },
+ "project": {
+ "new": "新規プロジェクト",
+ "load": "プロジェクトを読み込む",
+ "save": "プロジェクトを保存"
},
"support": {
"starOnGithub": "GitHub でスターを付ける",
"saveDiagnostics": "診断情報を保存",
"reportBug": "バグを報告"
},
- "gifSettings": {
- "size": "GIF サイズ",
- "frameRate": "GIF フレームレート",
- "loop": "GIF をループする"
+ "cursor": {
+ "smoothing": "スムージング",
+ "clickBounce": "クリックバウンス",
+ "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。",
+ "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
+ "size": "サイズ",
+ "title": "カーソル",
+ "show": "カーソルを表示",
+ "themeDefault": "デフォルト",
+ "clipToBounds": "キャンバスにクリップ",
+ "motionBlur": "モーションブラー",
+ "theme": "カーソルのスタイル"
+ },
+ "export": {
+ "gifButton": "GIF をエクスポート",
+ "chooseSaveLocation": "保存場所を選択",
+ "videoButton": "動画をエクスポート"
+ },
+ "trim": {
+ "deleteRegion": "トリム範囲を削除"
}
}
diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json
index f87c88b7f..8c8b0c76d 100644
--- a/src/i18n/locales/ja-JP/timeline.json
+++ b/src/i18n/locales/ja-JP/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "この録画にはカーソルの動きのデータがないか、既存のズームがすでに重要な瞬間をカバーしています。",
- "smartCutsNoAudio": "このメディアには音声がありません",
- "automaticZoomsHint": "記録されたカーソルの動きから",
- "dragToReorderHint": "ドラッグして並べ替え・ダブルクリックでイン/アウトポイントを編集",
- "smartCutsNeedsTranscript": "文字起こしが必要です",
- "addedWord": "追加した単語:「{{word}}」— 音声はありません",
- "smartZoomsAndCuts": "スマートカット",
- "autoZoomFailed": "自動ズームに失敗しました",
- "smartCutsWaiting": "文字起こし中… まもなく使えます",
- "automaticZooms": "自動ズーム",
- "arrangeClipsHint": "下のクリップをドラッグして並べ替えるか、新しいクリップをドロップします",
- "comment": "コメント",
- "addAudioTooltip": "音声を追加",
- "timelineTools": "タイムラインツール",
- "deleteClip": "クリップを削除",
- "arrangeClips": "クリップを配置",
- "editInOutPoints": "イン/アウトポイントを編集",
- "smartZoomsAndCutsHint": "AIを使用",
- "addedAutoZoomPlural": "自動ズームを {{count}} 件追加しました",
- "noAutoZoomMoments": "自動ズームの瞬間が見つかりません",
- "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください",
- "smartCutsNoSpeech": "音声が検出されませんでした",
- "newAnnotation": "注釈",
- "importRecordingFirst": "先に録画をインポートしてください",
- "addedAutoZoom": "自動ズームを {{count}} 件追加しました",
- "dropToAdd": "ドロップしてタイムラインに追加",
- "aiEnhanceRequested": "AIエージェントに無音部分のカットを依頼しました",
- "autoEnhance": "自動強化"
+ "buttons": {
+ "addZoom": "ズームを追加 (Z)",
+ "suggestZooms": "カーソル位置からズームを提案",
+ "autoZoomOn": "自動ズーム提案がオン — クリックすると提案されたズームを削除します",
+ "autoZoomOff": "自動ズーム提案がオフ — クリックするとカーソル位置からズームを提案します",
+ "autoFocusAllOn": "すべてのズームでオートフォーカスがオン — クリックするとすべて手動に切り替わります",
+ "autoFocusAllOff": "すべてのズームでオートフォーカスをオンにする(カメラがカーソルに追従)",
+ "addTrim": "トリムを追加 (T)",
+ "addAnnotation": "注釈を追加 (A)",
+ "addSpeed": "再生速度を追加 (S)",
+ "addCameraFullscreen": "フルスクリーンカメラを追加 (C)"
+ },
+ "hints": {
+ "pressZoom": "Zキーを押してズームを追加",
+ "pressTrim": "Tキーを押してトリムを追加",
+ "pressAnnotation": "Aキーを押して注釈を追加",
+ "pressAudio": "M キーで音声を追加、V キーでナレーションを録音",
+ "pressSpeed": "Sキーを押して再生速度を追加",
+ "pressCameraFullscreen": "Cキーを押してフルスクリーンカメラのセグメントを追加"
},
"labels": {
- "zoom": "ズーム",
- "cameraFullscreenItem": "フルスクリーンカメラ {{index}}",
- "imageItem": "画像",
"pan": "移動",
- "zoomItem": "ズーム {{index}}",
- "cameraFullscreen": "フルスクリーンカメラ",
+ "zoom": "ズーム",
+ "trim": "トリム",
"speed": "再生速度",
- "emptyText": "空のテキスト",
+ "zoomItem": "ズーム {{index}}",
"trimItem": "トリム {{index}}",
+ "speedItem": "再生速度 {{index}}",
"annotationItem": "注釈",
- "trim": "トリム",
- "speedItem": "再生速度 {{index}}"
+ "imageItem": "画像",
+ "emptyText": "空のテキスト",
+ "cameraFullscreen": "フルスクリーンカメラ",
+ "cameraFullscreenItem": "フルスクリーンカメラ {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "ビデオが読み込まれていません",
+ "dragAndDrop": "ビデオをドラッグアンドドロップして編集を開始してください"
},
"errors": {
- "noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
+ "cannotPlaceZoom": "ここにズームを配置できません",
+ "zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
+ "zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
+ "noCursorTelemetry": "カーソルの動きが記録されていません",
"noCursorTelemetryDescription": "まず画面録画を行い、カーソルに基づく提案を生成してください。",
- "cameraFullscreenExistsAtLocation": "この場所にはすでにフルスクリーンカメラのセグメントが存在するか、十分なスペースがありません。",
"noUsableTelemetry": "使用可能なカーソルの動きデータがありません",
"noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。",
- "zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
"noDwellMoments": "カーソルが静止したポイントが見つかりません",
- "speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。",
- "noCursorTelemetry": "カーソルの動きが記録されていません",
+ "noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
"noAutoZoomSlots": "自動ズームを適用できる箇所がありません",
+ "noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
"cannotPlaceTrim": "ここにトリムを配置できません",
- "cannotPlaceZoom": "ここにズームを配置できません",
- "cannotPlaceSpeed": "ここに再生速度を配置できません",
- "zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
- "noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
"trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。",
- "cannotPlaceCameraFullscreen": "ここにフルスクリーンカメラを配置できません"
+ "cannotPlaceSpeed": "ここに再生速度を配置できません",
+ "speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。",
+ "cannotPlaceCameraFullscreen": "ここにフルスクリーンカメラを配置できません",
+ "cameraFullscreenExistsAtLocation": "この場所にはすでにフルスクリーンカメラのセグメントが存在するか、十分なスペースがありません。"
+ },
+ "success": {
+ "addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
+ "addedZoomSuggestionsPlural": "カーソルに基づくズーム提案を {{count}} 件追加しました"
+ },
+ "toolbar": {
+ "autoEnhance": "自動強化",
+ "automaticZooms": "自動ズーム",
+ "automaticZoomsHint": "記録されたカーソルの動きから",
+ "smartZoomsAndCuts": "スマートカット",
+ "smartZoomsAndCutsHint": "AIを使用",
+ "comment": "コメント",
+ "timelineTools": "タイムラインツール",
+ "arrangeClips": "クリップを配置",
+ "arrangeClipsHint": "下のクリップをドラッグして並べ替えるか、新しいクリップをドロップします",
+ "newAnnotation": "注釈",
+ "dragToReorderHint": "ドラッグして並べ替え・ダブルクリックでイン/アウトポイントを編集",
+ "editInOutPoints": "イン/アウトポイントを編集",
+ "deleteClip": "クリップを削除",
+ "dropToAdd": "ドロップしてタイムラインに追加",
+ "importRecordingFirst": "先に録画をインポートしてください",
+ "noAutoZoomMoments": "自動ズームの瞬間が見つかりません",
+ "noAutoZoomMomentsDescription": "この録画にはカーソルの動きのデータがないか、既存のズームがすでに重要な瞬間をカバーしています。",
+ "addedAutoZoom": "自動ズームを {{count}} 件追加しました",
+ "addedAutoZoomPlural": "自動ズームを {{count}} 件追加しました",
+ "autoZoomFailed": "自動ズームに失敗しました",
+ "aiEnhanceRequested": "AIエージェントに無音部分のカットを依頼しました",
+ "smartCutsWaiting": "文字起こし中… まもなく使えます",
+ "smartCutsNeedsTranscript": "文字起こしが必要です",
+ "smartCutsNoAudio": "このメディアには音声がありません",
+ "smartCutsNoSpeech": "音声が検出されませんでした",
+ "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください",
+ "addAudioTooltip": "音声を追加",
+ "addedWord": "追加した単語:「{{word}}」— 音声はありません"
},
"audio": {
- "micDenied": "マイクへのアクセスが拒否されました",
+ "addVoiceover": "ナレーションを追加",
+ "addVoiceoverHint": "動画にナレーションを録音",
"subtitle": "タイムラインにナレーションまたは BGM のレイヤーを配置します",
- "importFailed": "音声ファイルを読み込めませんでした",
+ "record": "ナレーションを録音",
+ "importFile": "音声ファイルを読み込む",
"importFileHint": "音楽やオーディオファイルを読み込む",
- "addVoiceoverHint": "動画にナレーションを録音",
- "stop": "停止",
"recording": "録音中",
- "saveFailed": "録音を保存できませんでした",
"recordingHint": "動画に合わせて話してください — 録音中も再生されます",
- "importFile": "音声ファイルを読み込む",
- "record": "ナレーションを録音",
+ "stop": "停止",
+ "micDenied": "マイクへのアクセスが拒否されました",
"recordingUnavailable": "ここでは録音できません",
- "addVoiceover": "ナレーションを追加"
- },
- "success": {
- "addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
- "addedZoomSuggestionsPlural": "カーソルに基づくズーム提案を {{count}} 件追加しました"
- },
- "hints": {
- "pressAnnotation": "Aキーを押して注釈を追加",
- "pressSpeed": "Sキーを押して再生速度を追加",
- "pressTrim": "Tキーを押してトリムを追加",
- "pressCameraFullscreen": "Cキーを押してフルスクリーンカメラのセグメントを追加",
- "pressZoom": "Zキーを押してズームを追加",
- "pressAudio": "M キーで音声を追加、V キーでナレーションを録音"
- },
- "buttons": {
- "autoFocusAllOff": "すべてのズームでオートフォーカスをオンにする(カメラがカーソルに追従)",
- "autoZoomOff": "自動ズーム提案がオフ — クリックするとカーソル位置からズームを提案します",
- "addAnnotation": "注釈を追加 (A)",
- "suggestZooms": "カーソル位置からズームを提案",
- "addSpeed": "再生速度を追加 (S)",
- "addCameraFullscreen": "フルスクリーンカメラを追加 (C)",
- "autoFocusAllOn": "すべてのズームでオートフォーカスがオン — クリックするとすべて手動に切り替わります",
- "autoZoomOn": "自動ズーム提案がオン — クリックすると提案されたズームを削除します",
- "addZoom": "ズームを追加 (Z)",
- "addTrim": "トリムを追加 (T)"
- },
- "emptyState": {
- "noVideo": "ビデオが読み込まれていません",
- "dragAndDrop": "ビデオをドラッグアンドドロップして編集を開始してください"
+ "saveFailed": "録音を保存できませんでした",
+ "importFailed": "音声ファイルを読み込めませんでした"
}
}
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index c0832e41a..66cbbd245 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,354 +1,355 @@
{
- "captions": {
- "alignCenter": "가운데",
- "distanceFromBottom": "아래에서의 거리",
- "alignRight": "오른쪽",
- "bold": "굵게",
- "original": "원본 (전사)",
- "text": "텍스트",
- "distanceFromRight": "오른쪽에서의 거리",
- "backgroundOpacity": "불투명도",
- "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
- "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "noTranscript": "자막은 미디어 전사본에서 읽어옵니다. 이 영상이 전사되면 켜집니다.",
- "translating": "번역 중…",
- "maxWords": "줄당 최대 단어 수",
- "anchorTop": "위",
- "language": "언어",
- "deleteTranslation": "이 번역 삭제",
- "alignLeft": "왼쪽",
- "showBackground": "배경 표시",
- "displayLanguage": "표시",
- "position": "위치",
- "background": "배경",
- "distanceFromTop": "위에서의 거리",
- "show": "자막 표시",
- "translateFailed": "번역에 실패했습니다.",
- "fontSize": "크기",
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "font": "글꼴",
- "anchorBottom": "아래",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
- "backgroundColor": "배경 색",
- "lineLength": "줄 길이",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "textColor": "글자 색",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
- "translate": "번역",
- "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
- "distanceFromLeft": "왼쪽에서의 거리",
- "minWords": "줄당 최소 단어 수"
- },
- "layout": {
- "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
- "bgModes": {
- "transparent": "누끼",
- "blur": "블러",
- "custom": "사용자 지정",
- "none": "원본"
- },
- "dualFrame": "듀얼 프레임",
- "webcamCropX": "가로 이동",
- "webcamCropZoom": "자르기 확대",
- "shapes": {
- "rectangle": "직사각형",
- "rounded": "둥근 모서리",
- "square": "정사각형",
- "circle": "원형"
- },
- "title": "카메라 레이아웃",
- "noWebcam": "웹캠 없음",
- "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
- "verticalStack": "세로 배치",
- "mirrorWebcam": "웹캠 미러링",
- "webcamBlurIntensity": "블러 강도",
- "selectPreset": "프리셋 선택",
- "webcamCropY": "세로 이동",
- "webcamFraming": "웹캠 구도",
- "webcamShape": "카메라 모양",
- "reactiveWebcam": "확대 시 축소",
- "webcamSize": "웹캠 크기",
- "pictureInPicture": "화면 속 화면",
- "preset": "프리셋",
- "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
- "webcamBackground": "카메라 배경"
- },
- "audioTrack": {
- "slipHint": "Alt를 누른 채 드래그하면 안의 오디오가 이동합니다",
- "defaultLabel": "오디오 트랙",
- "importFailed": "오디오를 추가할 수 없습니다",
- "loop": "반복",
- "fadeIn": "페이드 인",
- "add": "오디오 트랙 추가",
- "fadeOut": "페이드 아웃",
- "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt를 누른 채 드래그하면 안의 오디오가 이동합니다.",
- "mute": "음소거",
- "remove": "트랙 삭제"
- },
- "annotation": {
- "blurTypeBlur": "가우시안",
- "mosaicBlockSize": "모자이크 블록 크기",
- "typeText": "텍스트",
- "clearBackground": "배경 지우기",
- "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
- "blurShapeOval": "타원",
- "blurColor": "블러 색상",
- "arrowColor": "화살표 색상",
- "blurColorBlack": "검정",
- "active": "활성",
- "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
- "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "blurShapeFreehand": "자유 곡선",
- "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
- "blurTypeMosaic": "모자이크",
- "deleteAnnotation": "주석 삭제",
- "blurColorWhite": "흰색",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "colorPalette": "색상 팔레트",
- "none": "없음",
- "shortcutsAndTips": "단축키 및 팁",
- "color": "색상",
- "blurIntensity": "블러 강도",
- "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
- "typeArrow": "화살표",
- "title": "주석 설정",
- "strokeWidth": "선 두께: {{width}}px",
- "size": "크기",
- "background": "배경",
- "typeBlur": "블러",
- "textPlaceholder": "텍스트를 입력하세요...",
- "textColor": "텍스트 색상",
- "typeImage": "이미지",
- "textContent": "텍스트 내용",
- "blurShapeRectangle": "사각형",
- "arrowDirection": "화살표 방향",
- "blurType": "블러 종류",
- "defaultText": "안녕하세요",
- "blurShape": "블러 모양",
- "customFonts": "커스텀 폰트",
- "type": "유형",
- "fontStyle": "폰트 스타일",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
- "colorWheel": "색상 휠",
- "uploadImage": "이미지 업로드",
- "selectStyle": "스타일 선택"
- },
- "crop": {
- "lockAspectRatio": "화면 비율 고정",
- "unlockAspectRatio": "화면 비율 해제",
- "ratio": "비율",
- "title": "자르기",
- "done": "완료",
- "cropVideo": "비디오 자르기",
- "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
- "free": "자유"
- },
"background": {
- "colorWheel": "색상 휠",
- "colorLabel": "색상 {{color}}",
- "customWallpaper": "사용자 배경",
- "image": "이미지",
- "uploadCustom": "직접 업로드",
"gradientLabel": "그라디언트 {{index}}",
- "presets": "프리셋",
+ "uploadCustom": "직접 업로드",
"unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
"title": "배경",
- "gradient": "그라디언트",
- "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
"imageLabel": "배경 {{index}}",
- "colorPalette": "색상 팔레트",
"custom": "사용자 지정",
+ "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
+ "gradient": "그라디언트",
+ "colorLabel": "색상 {{color}}",
+ "customWallpaper": "사용자 배경",
+ "colorPalette": "색상 팔레트",
"imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "color": "색상"
- },
- "gifSettings": {
- "frameRate": "GIF 프레임 속도",
- "size": "GIF 크기",
- "loop": "GIF 반복"
+ "image": "이미지",
+ "presets": "프리셋",
+ "color": "색상",
+ "colorWheel": "색상 휠"
},
"customFont": {
- "dialogTitle": "Google 폰트 추가",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "namePlaceholder": "내 커스텀 폰트",
"failedToAdd": "폰트 추가에 실패했습니다",
- "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
- "errorEmptyName": "폰트 이름을 입력해 주세요",
"addingButton": "추가 중...",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
"errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
- "namePlaceholder": "내 커스텀 폰트",
- "addButton": "폰트 추가",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
"urlLabel": "Google Fonts 가져오기 URL",
"successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
"nameLabel": "표시 이름",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
"nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사"
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Google 폰트 추가",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
+ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.",
+ "addButton": "폰트 추가"
+ },
+ "imageUpload": {
+ "invalidFileType": "지원하지 않는 파일 형식입니다",
+ "failedToUpload": "이미지 업로드에 실패했습니다",
+ "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
+ "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
+ "errorReading": "파일을 읽는 중 오류가 발생했습니다."
+ },
+ "annotation": {
+ "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "사각형",
+ "size": "크기",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "clearBackground": "배경 지우기",
+ "colorPalette": "색상 팔레트",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "background": "배경",
+ "typeText": "텍스트",
+ "active": "활성",
+ "color": "색상",
+ "blurShapeFreehand": "자유 곡선",
+ "arrowDirection": "화살표 방향",
+ "blurTypeMosaic": "모자이크",
+ "colorWheel": "색상 휠",
+ "textColor": "텍스트 색상",
+ "title": "주석 설정",
+ "blurType": "블러 종류",
+ "typeBlur": "블러",
+ "blurIntensity": "블러 강도",
+ "selectStyle": "스타일 선택",
+ "textContent": "텍스트 내용",
+ "typeArrow": "화살표",
+ "none": "없음",
+ "blurColor": "블러 색상",
+ "customFonts": "커스텀 폰트",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "type": "유형",
+ "arrowColor": "화살표 색상",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "blurShape": "블러 모양",
+ "uploadImage": "이미지 업로드",
+ "blurTypeBlur": "가우시안",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "shortcutsAndTips": "단축키 및 팁",
+ "deleteAnnotation": "주석 삭제",
+ "fontStyle": "폰트 스타일",
+ "defaultText": "안녕하세요",
+ "mosaicBlockSize": "모자이크 블록 크기",
+ "blurColorBlack": "검정",
+ "strokeWidth": "선 두께: {{width}}px",
+ "blurShapeOval": "타원",
+ "blurColorWhite": "흰색",
+ "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
+ "typeImage": "이미지"
+ },
+ "effects": {
+ "fitClipFew": "{{count}}개 클립",
+ "title": "컴포지션",
+ "shadow": "그림자",
+ "off": "끄기",
+ "on": "켜기",
+ "blurBg": "배경 흐림",
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "fitClipOne": "{{count}}개 클립",
+ "formatOriginal": "원본",
+ "fitClipMany": "{{count}}개 클립",
+ "frame": "프레임",
+ "motion": "모션",
+ "padding": "여백",
+ "format": "형식",
+ "fitClip": "맞추기",
+ "motionBlur": "모션 블러",
+ "roundness": "모서리 둥글기"
},
"transcript": {
- "transcribing": "전사 중…",
- "revertWord": "\"{{original}}\"(으)로 되돌리기",
- "laneVoiceover": "내레이션",
- "editWord": "\"{{word}}\" 편집",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
- "clipLabel": "클립 {{index}}",
"laneRecording": "녹화",
+ "noTranscript": "아직 전사가 없습니다",
"title": "현재 전사",
- "removeInserted": "\"{{word}}\" 삭제",
+ "restoreWord": "\"{{word}}\" 복원",
+ "revertWord": "\"{{original}}\"(으)로 되돌리기",
+ "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
+ "restoreSilence": "무음 복원 ({{duration}}초)",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "editWord": "\"{{word}}\" 편집",
"laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
- "insertAria": "새 단어",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
"whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
+ "insertAria": "새 단어",
+ "editorAria": "{{filename}}의 전사",
+ "transcribeNow": "지금 전사하기",
+ "transcribing": "전사 중…",
+ "trimSilence": "무음 자르기 ({{duration}}초)",
+ "removeInserted": "\"{{word}}\" 삭제",
"laneLabel": "전사본을 읽어올 소스",
- "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
"noClips": "아직 클립이 없습니다",
- "trimSilence": "무음 자르기 ({{duration}}초)",
+ "laneVoiceover": "내레이션",
+ "silence": "[무음 {{duration}}초]",
+ "clipLabel": "클립 {{index}}",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
"noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "transcribeNow": "지금 전사하기",
"editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
- "restoreSilence": "무음 복원 ({{duration}}초)",
- "noTranscript": "아직 전사가 없습니다",
- "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
- "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
- "restoreWord": "\"{{word}}\" 복원",
- "blankedWord": "비움",
- "editorAria": "{{filename}}의 전사",
- "silence": "[무음 {{duration}}초]"
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "blankedWord": "비움"
},
- "audio": {
- "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
- "title": "오디오",
- "reset": "오디오 재설정",
- "outputGain": "출력 레벨"
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "고화질 비디오 파일",
+ "gifAnimation": "GIF 애니메이션",
+ "mp4Video": "MP4 비디오",
+ "gifDescription": "공유용 애니메이션 이미지",
+ "gif": "GIF"
+ },
+ "captions": {
+ "showBackground": "배경 표시",
+ "deleteTranslation": "이 번역 삭제",
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "backgroundOpacity": "불투명도",
+ "backgroundColor": "배경 색",
+ "alignCenter": "가운데",
+ "translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "distanceFromRight": "오른쪽에서의 거리",
+ "language": "언어",
+ "text": "텍스트",
+ "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
+ "distanceFromTop": "위에서의 거리",
+ "translateFailed": "번역에 실패했습니다.",
+ "alignLeft": "왼쪽",
+ "distanceFromBottom": "아래에서의 거리",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
+ "translate": "번역",
+ "position": "위치",
+ "fontSize": "크기",
+ "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
+ "noTranscript": "자막은 미디어 전사에서 읽어옵니다. 켜려면 이 동영상을 전사하세요.",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "anchorBottom": "아래",
+ "transcribe": "동영상 전사하기",
+ "bold": "굵게",
+ "alignRight": "오른쪽",
+ "anchorTop": "위",
+ "minWords": "줄당 최소 단어 수",
+ "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
+ "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
+ "displayLanguage": "표시",
+ "removeLegacyAnnotations": "이전 자막 주석 제거",
+ "background": "배경",
+ "lineLength": "줄 길이",
+ "original": "원본 (전사)",
+ "maxWords": "줄당 최대 단어 수",
+ "font": "글꼴",
+ "translating": "번역 중…",
+ "show": "자막 표시",
+ "textColor": "글자 색"
+ },
+ "panes": {
+ "help": "도움말"
+ },
+ "speed": {
+ "deleteRegion": "속도 구간 삭제",
+ "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
+ "selectRegion": "조정할 속도 구간을 선택하세요",
+ "playbackSpeed": "재생 속도",
+ "customPlaybackSpeed": "재생 속도 직접 입력",
+ "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다."
+ },
+ "gifSettings": {
+ "frameRate": "GIF 프레임 속도",
+ "loop": "GIF 반복",
+ "size": "GIF 크기"
+ },
+ "exportQuality": {
+ "title": "내보내기 해상도",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
+ },
+ "audioTrack": {
+ "defaultLabel": "오디오 트랙",
+ "importFailed": "오디오를 추가할 수 없습니다",
+ "fadeOut": "페이드 아웃",
+ "fadeIn": "페이드 인",
+ "remove": "트랙 삭제",
+ "loop": "반복",
+ "slipHint": "Alt를 누른 채 드래그하면 안의 오디오가 이동합니다",
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "add": "오디오 트랙 추가",
+ "mute": "음소거"
+ },
+ "layout": {
+ "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
+ "mirrorWebcam": "웹캠 미러링",
+ "webcamFraming": "웹캠 구도",
+ "shapes": {
+ "rectangle": "직사각형",
+ "rounded": "둥근 모서리",
+ "circle": "원형",
+ "square": "정사각형"
+ },
+ "selectPreset": "프리셋 선택",
+ "bgModes": {
+ "custom": "사용자 지정",
+ "none": "원본",
+ "blur": "블러",
+ "transparent": "누끼"
+ },
+ "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
+ "webcamBlurIntensity": "블러 강도",
+ "preset": "프리셋",
+ "webcamCropZoom": "자르기 확대",
+ "webcamSize": "웹캠 크기",
+ "dualFrame": "듀얼 프레임",
+ "webcamCropY": "세로 이동",
+ "verticalStack": "세로 배치",
+ "pictureInPicture": "화면 속 화면",
+ "webcamShape": "카메라 모양",
+ "webcamCropX": "가로 이동",
+ "reactiveWebcam": "확대 시 축소",
+ "webcamBackground": "카메라 배경",
+ "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
+ "title": "카메라 레이아웃",
+ "noWebcam": "웹캠 없음"
},
"textAnimation": {
- "pop": "팝",
- "fade": "페이드",
+ "slideLeft": "왼쪽 슬라이드",
"pulse": "펄스",
"typewriter": "타자기",
"selectAnimation": "애니메이션 선택",
- "slideLeft": "왼쪽 슬라이드",
+ "fade": "페이드",
+ "title": "텍스트 애니메이션",
"none": "없음",
- "rise": "상승",
- "title": "텍스트 애니메이션"
+ "pop": "팝",
+ "rise": "상승"
+ },
+ "facets": {
+ "transcript": "대본",
+ "captions": "자막"
+ },
+ "crop": {
+ "title": "자르기",
+ "free": "자유",
+ "unlockAspectRatio": "화면 비율 해제",
+ "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
+ "done": "완료",
+ "ratio": "비율",
+ "cropVideo": "비디오 자르기",
+ "lockAspectRatio": "화면 비율 고정"
},
"zoom": {
- "deleteZoom": "줌 삭제",
- "threeD": {
- "preset": {
- "iso": "Iso",
- "left": "왼쪽",
- "right": "오른쪽"
- },
- "none": "없음",
- "title": "3D 회전"
- },
"position": {
- "x": "X (%)",
"y": "Y (%)",
"hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽",
- "title": "포커스 위치"
+ "title": "포커스 위치",
+ "x": "X (%)"
},
+ "deleteZoom": "줌 삭제",
"focusMode": {
"lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요.",
+ "auto": "자동",
"manual": "수동",
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
- "title": "포커스 모드",
- "auto": "자동"
+ "title": "포커스 모드"
+ },
+ "threeD": {
+ "preset": {
+ "left": "왼쪽",
+ "right": "오른쪽",
+ "iso": "Iso"
+ },
+ "none": "없음",
+ "title": "3D 회전"
},
"level": "줌 레벨",
"previewHold": "누르고 있으면 줌 효과 미리보기",
- "selectRegion": "조정할 줌 구간을 선택하세요",
- "customScale": "커스텀 줌"
+ "customScale": "커스텀 줌",
+ "selectRegion": "조정할 줌 구간을 선택하세요"
},
- "speed": {
- "selectRegion": "조정할 속도 구간을 선택하세요",
- "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
- "playbackSpeed": "재생 속도",
- "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다.",
- "deleteRegion": "속도 구간 삭제",
- "customPlaybackSpeed": "재생 속도 직접 입력"
+ "audio": {
+ "outputGain": "출력 레벨",
+ "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
+ "reset": "오디오 재설정",
+ "title": "오디오"
},
"language": {
"title": "언어"
},
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "title": "내보내기 해상도",
- "high": "Source"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gifDescription": "공유용 애니메이션 이미지",
- "mp4Video": "MP4 비디오",
- "mp4Description": "고화질 비디오 파일",
- "gifAnimation": "GIF 애니메이션",
- "gif": "GIF"
- },
- "trim": {
- "deleteRegion": "트림 구간 삭제"
+ "project": {
+ "new": "새 프로젝트",
+ "load": "프로젝트 불러오기",
+ "save": "프로젝트 저장"
},
- "imageUpload": {
- "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
- "invalidFileType": "지원하지 않는 파일 형식입니다",
- "errorReading": "파일을 읽는 중 오류가 발생했습니다.",
- "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
- "failedToUpload": "이미지 업로드에 실패했습니다"
+ "support": {
+ "starOnGithub": "GitHub에 Star 남기기",
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "버그 신고"
},
"cursor": {
- "title": "커서",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
- "motionBlur": "모션 블러",
- "clipToBounds": "캔버스에 맞춰 자르기",
"smoothing": "부드러움",
- "size": "크기",
- "theme": "커서 스타일",
- "themeDefault": "기본",
"clickBounce": "클릭 바운스",
+ "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.",
"clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
- "show": "커서 표시"
- },
- "facets": {
- "captions": "자막",
- "transcript": "대본"
- },
- "effects": {
- "title": "컴포지션",
- "formatOriginal": "원본",
+ "size": "크기",
+ "title": "커서",
+ "show": "커서 표시",
+ "themeDefault": "기본",
+ "clipToBounds": "캔버스에 맞춰 자르기",
"motionBlur": "모션 블러",
- "format": "형식",
- "padding": "여백",
- "fitClipOne": "{{count}}개 클립",
- "fitClipFew": "{{count}}개 클립",
- "frame": "프레임",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
- "on": "켜기",
- "blurBg": "배경 흐림",
- "fitClip": "맞추기",
- "motion": "모션",
- "shadow": "그림자",
- "fitClipMany": "{{count}}개 클립",
- "roundness": "모서리 둥글기",
- "off": "끄기"
- },
- "project": {
- "save": "프로젝트 저장",
- "new": "새 프로젝트",
- "load": "프로젝트 불러오기"
- },
- "support": {
- "saveDiagnostics": "Save Diagnostics",
- "reportBug": "버그 신고",
- "starOnGithub": "GitHub에 Star 남기기"
- },
- "panes": {
- "help": "도움말"
+ "theme": "커서 스타일"
},
"export": {
- "videoButton": "비디오 내보내기",
"gifButton": "GIF 내보내기",
- "chooseSaveLocation": "저장 위치 선택"
+ "chooseSaveLocation": "저장 위치 선택",
+ "videoButton": "비디오 내보내기"
+ },
+ "trim": {
+ "deleteRegion": "트림 구간 삭제"
}
}
diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json
index 4abe0c760..8d1ec2e88 100644
--- a/src/i18n/locales/ko-KR/timeline.json
+++ b/src/i18n/locales/ko-KR/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "이 녹화에는 커서 이동 데이터가 없거나 기존 줌이 이미 주요 순간을 다루고 있습니다.",
- "smartCutsNoAudio": "이 미디어에는 오디오가 없습니다",
- "automaticZoomsHint": "녹화된 커서 움직임 기반",
- "dragToReorderHint": "드래그하여 순서 변경 · 더블클릭하여 시작/종료 지점 편집",
- "smartCutsNeedsTranscript": "받아쓰기가 필요합니다",
- "addedWord": "추가한 단어: \"{{word}}\" — 뒤에 오디오가 없습니다",
- "smartZoomsAndCuts": "스마트 컷",
- "autoZoomFailed": "자동 줌 실패",
- "smartCutsWaiting": "받아쓰는 중… 곧 사용할 수 있습니다",
- "automaticZooms": "자동 줌",
- "arrangeClipsHint": "아래 클립을 드래그하여 순서를 바꾸거나 새 클립을 놓으세요",
- "comment": "코멘트",
- "addAudioTooltip": "오디오 추가",
- "timelineTools": "타임라인 도구",
- "deleteClip": "클립 삭제",
- "arrangeClips": "클립 정리",
- "editInOutPoints": "시작/종료 지점 편집",
- "smartZoomsAndCutsHint": "AI 사용",
- "addedAutoZoomPlural": "자동 줌 {{count}}개가 추가되었습니다",
- "noAutoZoomMoments": "자동 줌 순간을 찾을 수 없음",
- "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요",
- "smartCutsNoSpeech": "음성이 감지되지 않음",
- "newAnnotation": "주석",
- "importRecordingFirst": "먼저 녹화 파일을 가져오세요",
- "addedAutoZoom": "자동 줌 {{count}}개가 추가되었습니다",
- "dropToAdd": "타임라인에 추가하려면 놓으세요",
- "aiEnhanceRequested": "AI 에이전트에 빈 구간 컷을 요청했습니다",
- "autoEnhance": "자동 향상"
+ "buttons": {
+ "addZoom": "줌 추가 (Z)",
+ "suggestZooms": "커서 기반 줌 제안",
+ "autoZoomOn": "자동 줌 제안 켜짐 — 클릭하면 제안된 줌을 제거합니다",
+ "autoZoomOff": "자동 줌 제안 꺼짐 — 클릭하면 커서 기반으로 줌을 제안합니다",
+ "autoFocusAllOn": "모든 줌에 자동 초점 켜짐 — 클릭하면 모두 수동으로 전환합니다",
+ "autoFocusAllOff": "모든 줌에 자동 초점 켜기 (카메라가 커서를 따라갑니다)",
+ "addTrim": "트림 추가 (T)",
+ "addAnnotation": "주석 추가 (A)",
+ "addSpeed": "속도 추가 (S)",
+ "addCameraFullscreen": "전체 화면 카메라 추가 (C)"
+ },
+ "hints": {
+ "pressZoom": "Z를 눌러 줌 추가",
+ "pressTrim": "T를 눌러 트림 추가",
+ "pressAnnotation": "A를 눌러 주석 추가",
+ "pressAudio": "M 키로 오디오 추가, V 키로 보이스오버 녹음",
+ "pressSpeed": "S를 눌러 속도 추가",
+ "pressCameraFullscreen": "C를 눌러 전체 화면 카메라 구간을 추가하세요"
},
"labels": {
- "zoom": "줌",
- "cameraFullscreenItem": "전체 화면 카메라 {{index}}",
- "imageItem": "이미지",
"pan": "이동",
- "zoomItem": "줌 {{index}}",
- "cameraFullscreen": "전체 화면 카메라",
+ "zoom": "줌",
+ "trim": "트림",
"speed": "속도",
- "emptyText": "빈 텍스트",
+ "zoomItem": "줌 {{index}}",
"trimItem": "트림 {{index}}",
+ "speedItem": "속도 {{index}}",
"annotationItem": "주석",
- "trim": "트림",
- "speedItem": "속도 {{index}}"
+ "imageItem": "이미지",
+ "emptyText": "빈 텍스트",
+ "cameraFullscreen": "전체 화면 카메라",
+ "cameraFullscreenItem": "전체 화면 카메라 {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "불러온 비디오 없음",
+ "dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
},
"errors": {
- "noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
+ "cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
+ "zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
+ "zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
+ "noCursorTelemetry": "커서 데이터가 없습니다",
"noCursorTelemetryDescription": "커서 기반 제안을 생성하려면 먼저 화면을 녹화해 주세요.",
- "cameraFullscreenExistsAtLocation": "이 위치에 이미 전체 화면 카메라 구간이 있거나 공간이 부족합니다.",
"noUsableTelemetry": "사용 가능한 커서 데이터가 없습니다",
"noUsableTelemetryDescription": "녹화에 충분한 커서 이동 데이터가 포함되어 있지 않습니다.",
- "zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
"noDwellMoments": "명확한 커서 정지 구간을 찾을 수 없습니다",
- "speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다.",
- "noCursorTelemetry": "커서 데이터가 없습니다",
+ "noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
"noAutoZoomSlots": "자동 줌 슬롯이 없습니다",
+ "noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
"cannotPlaceTrim": "이 위치에 트림을 추가할 수 없습니다",
- "cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
- "cannotPlaceSpeed": "이 위치에 속도를 추가할 수 없습니다",
- "zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
- "noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
"trimExistsAtLocation": "이 위치에 이미 트림이 있거나 공간이 부족합니다.",
- "cannotPlaceCameraFullscreen": "이 위치에 전체 화면 카메라를 추가할 수 없습니다"
+ "cannotPlaceSpeed": "이 위치에 속도를 추가할 수 없습니다",
+ "speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다.",
+ "cannotPlaceCameraFullscreen": "이 위치에 전체 화면 카메라를 추가할 수 없습니다",
+ "cameraFullscreenExistsAtLocation": "이 위치에 이미 전체 화면 카메라 구간이 있거나 공간이 부족합니다."
+ },
+ "success": {
+ "addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
+ "addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
+ },
+ "toolbar": {
+ "autoEnhance": "자동 향상",
+ "automaticZooms": "자동 줌",
+ "automaticZoomsHint": "녹화된 커서 움직임 기반",
+ "smartZoomsAndCuts": "스마트 컷",
+ "smartZoomsAndCutsHint": "AI 사용",
+ "comment": "코멘트",
+ "timelineTools": "타임라인 도구",
+ "arrangeClips": "클립 정리",
+ "arrangeClipsHint": "아래 클립을 드래그하여 순서를 바꾸거나 새 클립을 놓으세요",
+ "newAnnotation": "주석",
+ "dragToReorderHint": "드래그하여 순서 변경 · 더블클릭하여 시작/종료 지점 편집",
+ "editInOutPoints": "시작/종료 지점 편집",
+ "deleteClip": "클립 삭제",
+ "dropToAdd": "타임라인에 추가하려면 놓으세요",
+ "importRecordingFirst": "먼저 녹화 파일을 가져오세요",
+ "noAutoZoomMoments": "자동 줌 순간을 찾을 수 없음",
+ "noAutoZoomMomentsDescription": "이 녹화에는 커서 이동 데이터가 없거나 기존 줌이 이미 주요 순간을 다루고 있습니다.",
+ "addedAutoZoom": "자동 줌 {{count}}개가 추가되었습니다",
+ "addedAutoZoomPlural": "자동 줌 {{count}}개가 추가되었습니다",
+ "autoZoomFailed": "자동 줌 실패",
+ "aiEnhanceRequested": "AI 에이전트에 빈 구간 컷을 요청했습니다",
+ "smartCutsWaiting": "받아쓰는 중… 곧 사용할 수 있습니다",
+ "smartCutsNeedsTranscript": "받아쓰기가 필요합니다",
+ "smartCutsNoAudio": "이 미디어에는 오디오가 없습니다",
+ "smartCutsNoSpeech": "음성이 감지되지 않음",
+ "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요",
+ "addAudioTooltip": "오디오 추가",
+ "addedWord": "추가한 단어: \"{{word}}\" — 뒤에 오디오가 없습니다"
},
"audio": {
- "micDenied": "마이크 접근이 거부되었습니다",
+ "addVoiceover": "내레이션 추가",
+ "addVoiceoverHint": "영상 위에 내레이션을 녹음",
"subtitle": "타임라인에 내레이션 또는 배경 음악 레이어를 배치합니다",
- "importFailed": "오디오 파일을 가져오지 못했습니다",
+ "record": "내레이션 녹음",
+ "importFile": "오디오 파일 가져오기",
"importFileHint": "음악이나 오디오 파일 가져오기",
- "addVoiceoverHint": "영상 위에 내레이션을 녹음",
- "stop": "중지",
"recording": "녹음 중",
- "saveFailed": "녹음을 저장하지 못했습니다",
"recordingHint": "영상에 맞춰 말하세요 — 녹음하는 동안 재생됩니다",
- "importFile": "오디오 파일 가져오기",
- "record": "내레이션 녹음",
+ "stop": "중지",
+ "micDenied": "마이크 접근이 거부되었습니다",
"recordingUnavailable": "여기에서는 녹음할 수 없습니다",
- "addVoiceover": "내레이션 추가"
- },
- "success": {
- "addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
- "addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
- },
- "hints": {
- "pressAnnotation": "A를 눌러 주석 추가",
- "pressSpeed": "S를 눌러 속도 추가",
- "pressTrim": "T를 눌러 트림 추가",
- "pressCameraFullscreen": "C를 눌러 전체 화면 카메라 구간을 추가하세요",
- "pressZoom": "Z를 눌러 줌 추가",
- "pressAudio": "M 키로 오디오 추가, V 키로 보이스오버 녹음"
- },
- "buttons": {
- "autoFocusAllOff": "모든 줌에 자동 초점 켜기 (카메라가 커서를 따라갑니다)",
- "autoZoomOff": "자동 줌 제안 꺼짐 — 클릭하면 커서 기반으로 줌을 제안합니다",
- "addAnnotation": "주석 추가 (A)",
- "suggestZooms": "커서 기반 줌 제안",
- "addSpeed": "속도 추가 (S)",
- "addCameraFullscreen": "전체 화면 카메라 추가 (C)",
- "autoFocusAllOn": "모든 줌에 자동 초점 켜짐 — 클릭하면 모두 수동으로 전환합니다",
- "autoZoomOn": "자동 줌 제안 켜짐 — 클릭하면 제안된 줌을 제거합니다",
- "addZoom": "줌 추가 (Z)",
- "addTrim": "트림 추가 (T)"
- },
- "emptyState": {
- "noVideo": "불러온 비디오 없음",
- "dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
+ "saveFailed": "녹음을 저장하지 못했습니다",
+ "importFailed": "오디오 파일을 가져오지 못했습니다"
}
}
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 1f464ac21..b70b4d05e 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Intensidade do desfoque",
- "bgModes": {
- "transparent": "Recorte",
- "none": "Original",
- "blur": "Desfocado",
- "custom": "Personalizado"
- },
- "selectPreset": "Selecionar predefinição",
- "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
- "reactiveWebcam": "Encolher ao ampliar",
- "shapes": {
- "circle": "Círculo",
- "square": "Quadrado",
- "rectangle": "Ret.",
- "rounded": "Arredondado"
- },
- "webcamBackground": "Plano de fundo da câmera",
- "verticalStack": "Empilhamento Vertical",
- "pictureInPicture": "Picture in Picture",
- "webcamShape": "Formato da Câmera",
- "webcamCropY": "Deslocamento vertical",
- "webcamSize": "Tamanho da Webcam",
- "mirrorWebcam": "Espelhar Webcam",
- "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
- "webcamFraming": "Enquadramento da webcam",
- "noWebcam": "Sem Webcam",
- "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
- "webcamCropZoom": "Zoom do recorte",
- "dualFrame": "Quadro Duplo",
- "webcamCropX": "Deslocamento horizontal",
- "title": "Layout da câmera",
- "preset": "Predefinição"
- },
- "crop": {
- "done": "Concluir",
- "ratio": "Proporção",
- "dragInstruction": "Arraste cada lado para ajustar a área de corte",
- "title": "Cortar",
- "free": "Livre",
- "lockAspectRatio": "Bloquear proporção",
- "unlockAspectRatio": "Desbloquear proporção",
- "cropVideo": "Cortar Vídeo"
- },
- "zoom": {
- "position": {
- "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Posição do Foco"
- },
- "threeD": {
- "preset": {
- "right": "Direita",
- "iso": "Iso",
- "left": "Esquerda"
- },
- "none": "Nenhuma",
- "title": "Rotação 3D"
- },
- "deleteZoom": "Excluir Zoom",
- "customScale": "Zoom Personalizado",
- "selectRegion": "Selecione uma região de zoom para ajustar",
- "focusMode": {
- "manual": "Manual",
- "autoDescription": "A câmera segue a posição do cursor gravado",
- "lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
- "title": "Modo de Foco",
- "auto": "Automático"
- },
- "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
- "level": "Nível de Zoom"
- },
"background": {
- "color": "Cor",
- "colorLabel": "Cor {{color}}",
- "gradient": "Gradiente",
- "imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
"gradientLabel": "Gradiente {{index}}",
+ "uploadCustom": "Enviar Personalizada",
+ "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
+ "title": "Fundo",
+ "imageLabel": "Fundo {{index}}",
"custom": "Personalizado",
+ "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
+ "gradient": "Gradiente",
+ "colorLabel": "Cor {{color}}",
"customWallpaper": "Papel de parede personalizado",
- "presets": "Predefinições",
- "image": "Imagem",
"colorPalette": "Paleta de Cores",
- "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
- "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.",
- "imageLabel": "Fundo {{index}}",
- "title": "Fundo",
- "uploadCustom": "Enviar Personalizada",
+ "imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
+ "image": "Imagem",
+ "presets": "Predefinições",
+ "color": "Cor",
"colorWheel": "Roda de Cores"
},
- "cursor": {
- "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
- "clickBounce": "Rebote ao clicar",
- "clipToBounds": "Recortar à tela",
- "title": "Cursor",
- "size": "Tamanho",
- "themeDefault": "Padrão",
- "smoothing": "Suavização",
- "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
- "motionBlur": "Desfoque de movimento",
- "theme": "Estilo do cursor",
- "show": "Mostrar cursor"
- },
- "captions": {
- "text": "Texto",
- "showBackground": "Mostrar fundo",
- "minWords": "Mín. de palavras por linha",
- "translateFailed": "A tradução falhou.",
- "distanceFromTop": "Distância do topo",
- "fontSize": "Tamanho",
- "distanceFromRight": "Distância da direita",
- "bold": "Negrito",
- "textColor": "Cor do texto",
- "original": "Original (transcrição)",
- "translating": "Traduzindo…",
- "language": "Idioma",
- "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
- "alignLeft": "Esquerda",
- "distanceFromBottom": "Distância da base",
- "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
- "show": "Mostrar legendas",
- "background": "Fundo",
- "backgroundOpacity": "Opacidade",
- "removeLegacyAnnotations": "Remover anotações de legenda antigas",
- "anchorTop": "Topo",
- "translate": "Traduzir",
- "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
- "alignCenter": "Centro",
- "alignRight": "Direita",
- "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
- "displayLanguage": "Exibição",
- "noTranscript": "As legendas são lidas da transcrição da mídia. Elas são ativadas assim que o vídeo for transcrito.",
- "distanceFromLeft": "Distância da esquerda",
- "maxWords": "Máx. de palavras por linha",
- "anchorBottom": "Base",
- "position": "Posição",
- "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
- "deleteTranslation": "Excluir esta tradução",
- "backgroundColor": "Cor do fundo",
- "font": "Fonte",
- "lineLength": "Comprimento da linha",
- "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
- "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move."
- },
- "exportQuality": {
- "medium": "Média",
- "low": "Baixa",
- "high": "Alta",
- "title": "Qualidade de Exportação"
- },
- "transcript": {
- "transcribing": "Transcrevendo…",
- "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
- "laneVoiceover": "Narração",
- "blankedWord": "apagada",
- "noTranscript": "Nenhuma transcrição ainda",
- "noAudio": "Esta mídia não tem faixa de áudio",
- "revertWord": "Restaurar \"{{original}}\"",
- "silence": "[silêncio {{duration}} s]",
- "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
- "transcribeNow": "Transcrever agora",
- "restoreWord": "Restaurar \"{{word}}\"",
- "clipLabel": "Clipe {{index}}",
- "title": "Transcrição atual",
- "insertAria": "Nova palavra",
- "laneRecording": "Gravação",
- "trimSilence": "Cortar silêncio ({{duration}} s)",
- "insertedWord": "Adicionada por você — sem áudio por trás",
- "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
- "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
- "removeInserted": "Excluir \"{{word}}\"",
- "editorAria": "Transcrição de {{filename}}",
- "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
- "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
- "editWord": "Editar \"{{word}}\"",
- "noClips": "Nenhum clipe ainda",
- "laneLabel": "Ler a transcrição de",
- "restoreSilence": "Restaurar silêncio ({{duration}} s)"
- },
"customFont": {
+ "namePlaceholder": "Minha Fonte Personalizada",
+ "failedToAdd": "Falha ao adicionar fonte",
"addingButton": "Adicionando...",
+ "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
"urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
- "addButton": "Adicionar Fonte",
- "dialogTitle": "Adicionar Google Font",
- "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
- "nameLabel": "Nome de Exibição",
- "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
"urlLabel": "URL de Importação do Google Fonts",
+ "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
+ "nameLabel": "Nome de Exibição",
"errorEmptyName": "Por favor, insira um nome para a fonte",
- "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
- "namePlaceholder": "Minha Fonte Personalizada",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Falha ao adicionar fonte",
"nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
+ "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
"errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
- "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
- "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Adicionar Google Font",
+ "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
+ "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta.",
+ "addButton": "Adicionar Fonte"
+ },
+ "imageUpload": {
+ "invalidFileType": "Tipo de arquivo inválido",
+ "failedToUpload": "Falha ao enviar imagem",
+ "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
+ "uploadSuccess": "Imagem personalizada enviada com sucesso!",
+ "errorReading": "Ocorreu um erro ao ler o arquivo."
},
"annotation": {
- "arrowColor": "Cor da Seta",
- "colorWheel": "Roda de Cores",
- "blurType": "Tipo de Desfoque",
- "active": "Ativo",
- "deleteAnnotation": "Excluir Anotação",
- "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
- "strokeWidth": "Largura do Traço: {{width}}px",
- "background": "Fundo",
- "imageUploadSuccess": "Imagem enviada com sucesso!",
- "blurColor": "Cor do Desfoque",
- "blurTypeBlur": "Gaussiano",
- "textColor": "Cor do Texto",
- "blurColorWhite": "Branco",
- "title": "Configurações de Anotação",
- "type": "Tipo",
- "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
- "typeImage": "Imagem",
- "textContent": "Conteúdo do Texto",
"supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
- "typeText": "Texto",
- "blurIntensity": "Intensidade do Desfoque",
- "none": "Nenhum",
- "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
- "textPlaceholder": "Digite seu texto...",
- "typeArrow": "Seta",
- "color": "Cor",
- "blurColorBlack": "Preto",
+ "blurShapeRectangle": "Retângulo",
"size": "Tamanho",
+ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
+ "clearBackground": "Limpar Fundo",
+ "colorPalette": "Paleta de Cores",
"invalidImageType": "Tipo de imagem inválido",
+ "background": "Fundo",
+ "typeText": "Texto",
+ "active": "Ativo",
+ "color": "Cor",
"blurShapeFreehand": "Mão Livre",
- "shortcutsAndTips": "Atalhos e Dicas",
- "uploadImage": "Enviar Imagem",
+ "arrowDirection": "Direção da Seta",
"blurTypeMosaic": "Mosaico",
+ "colorWheel": "Roda de Cores",
+ "textColor": "Cor do Texto",
+ "title": "Configurações de Anotação",
+ "blurType": "Tipo de Desfoque",
+ "typeBlur": "Desfoque",
+ "blurIntensity": "Intensidade do Desfoque",
"selectStyle": "Selecionar estilo",
- "defaultText": "Olá",
- "blurShapeRectangle": "Retângulo",
- "colorPalette": "Paleta de Cores",
- "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
- "clearBackground": "Limpar Fundo",
+ "textContent": "Conteúdo do Texto",
+ "typeArrow": "Seta",
+ "none": "Nenhum",
+ "blurColor": "Cor do Desfoque",
"customFonts": "Fontes Personalizadas",
- "typeBlur": "Desfoque",
+ "imageUploadSuccess": "Imagem enviada com sucesso!",
+ "type": "Tipo",
+ "arrowColor": "Cor da Seta",
+ "textPlaceholder": "Digite seu texto...",
+ "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
+ "blurShape": "Formato do Desfoque",
+ "uploadImage": "Enviar Imagem",
+ "blurTypeBlur": "Gaussiano",
"tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
+ "shortcutsAndTips": "Atalhos e Dicas",
+ "deleteAnnotation": "Excluir Anotação",
"fontStyle": "Estilo da Fonte",
- "blurShape": "Formato do Desfoque",
- "arrowDirection": "Direção da Seta",
- "blurShapeOval": "Oval"
- },
- "speed": {
- "customPlaybackSpeed": "Velocidade Personalizada",
- "deleteRegion": "Excluir Região de Velocidade",
- "selectRegion": "Selecione uma região de velocidade para ajustar",
- "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
- "playbackSpeed": "Velocidade de Reprodução",
- "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada."
- },
- "textAnimation": {
- "selectAnimation": "Selecionar animação",
- "pulse": "Pulsar",
- "rise": "Subir",
- "none": "Nenhuma",
- "slideLeft": "Deslizar à Esquerda",
- "title": "Animação de Texto",
- "fade": "Esmaecer",
- "pop": "Aparecer",
- "typewriter": "Máquina de Escrever"
+ "defaultText": "Olá",
+ "mosaicBlockSize": "Tamanho do Bloco do Mosaico",
+ "blurColorBlack": "Preto",
+ "strokeWidth": "Largura do Traço: {{width}}px",
+ "blurShapeOval": "Oval",
+ "blurColorWhite": "Branco",
+ "tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
+ "typeImage": "Imagem"
},
"effects": {
- "motion": "Movimento",
- "title": "Composição",
- "format": "Formato",
- "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
"fitClipFew": "{{count}} clipes",
- "motionBlur": "Desfoque de Movimento",
- "fitClipMany": "{{count}} clipes",
- "frame": "Moldura",
- "padding": "Espaçamento",
- "roundness": "Arredondamento",
- "off": "desativado",
- "blurBg": "Desfocar Fundo",
+ "title": "Composição",
"shadow": "Sombra",
+ "off": "desativado",
"on": "ativado",
+ "blurBg": "Desfocar Fundo",
+ "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo.",
"fitClipOne": "{{count}} clipe",
"formatOriginal": "Original",
- "fitClip": "Ajustar"
+ "fitClipMany": "{{count}} clipes",
+ "frame": "Moldura",
+ "motion": "Movimento",
+ "padding": "Espaçamento",
+ "format": "Formato",
+ "fitClip": "Ajustar",
+ "motionBlur": "Desfoque de Movimento",
+ "roundness": "Arredondamento"
+ },
+ "transcript": {
+ "laneRecording": "Gravação",
+ "noTranscript": "Nenhuma transcrição ainda",
+ "title": "Transcrição atual",
+ "restoreWord": "Restaurar \"{{word}}\"",
+ "revertWord": "Restaurar \"{{original}}\"",
+ "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
+ "restoreSilence": "Restaurar silêncio ({{duration}} s)",
+ "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção a corta do vídeo (em vermelho). Clique duas vezes em uma palavra para corrigir o texto. Passe o mouse sobre uma palavra marcada para desfazer.",
+ "editWord": "Editar \"{{word}}\"",
+ "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
+ "insertAria": "Nova palavra",
+ "editorAria": "Transcrição de {{filename}}",
+ "transcribeNow": "Transcrever agora",
+ "transcribing": "Transcrevendo…",
+ "trimSilence": "Cortar silêncio ({{duration}} s)",
+ "removeInserted": "Excluir \"{{word}}\"",
+ "laneLabel": "Ler a transcrição de",
+ "noClips": "Nenhum clipe ainda",
+ "laneVoiceover": "Narração",
+ "silence": "[silêncio {{duration}} s]",
+ "clipLabel": "Clipe {{index}}",
+ "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
+ "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
+ "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
+ "noAudio": "Esta mídia não tem faixa de áudio",
+ "blankedWord": "apagada"
},
"exportFormat": {
- "gifDescription": "Imagem animada para compartilhamento",
"mp4": "MP4",
- "mp4Video": "Vídeo MP4",
- "gif": "GIF",
+ "mp4Description": "Arquivo de vídeo de alta qualidade",
"gifAnimation": "Animação GIF",
- "mp4Description": "Arquivo de vídeo de alta qualidade"
+ "mp4Video": "Vídeo MP4",
+ "gifDescription": "Imagem animada para compartilhamento",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Falha ao enviar imagem",
- "uploadSuccess": "Imagem personalizada enviada com sucesso!",
- "errorReading": "Ocorreu um erro ao ler o arquivo.",
- "invalidFileType": "Tipo de arquivo inválido",
- "jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG."
+ "captions": {
+ "showBackground": "Mostrar fundo",
+ "deleteTranslation": "Excluir esta tradução",
+ "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.",
+ "backgroundOpacity": "Opacidade",
+ "backgroundColor": "Cor do fundo",
+ "alignCenter": "Centro",
+ "translationIsNonDestructive": "As traduções são guardadas ao lado da transcrição, nunca dentro dela — o texto original e seus tempos permanecem intactos.",
+ "distanceFromRight": "Distância da direita",
+ "language": "Idioma",
+ "text": "Texto",
+ "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.",
+ "distanceFromTop": "Distância do topo",
+ "translateFailed": "A tradução falhou.",
+ "alignLeft": "Esquerda",
+ "distanceFromBottom": "Distância da base",
+ "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
+ "translate": "Traduzir",
+ "position": "Posição",
+ "fontSize": "Tamanho",
+ "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
+ "noTranscript": "As legendas são lidas da transcrição da mídia. Transcreva este vídeo para ativá-las.",
+ "distanceFromLeft": "Distância da esquerda",
+ "anchorBottom": "Base",
+ "transcribe": "Transcrever vídeo",
+ "bold": "Negrito",
+ "alignRight": "Direita",
+ "anchorTop": "Topo",
+ "minWords": "Mín. de palavras por linha",
+ "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
+ "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.",
+ "displayLanguage": "Exibição",
+ "removeLegacyAnnotations": "Remover anotações de legenda antigas",
+ "background": "Fundo",
+ "lineLength": "Comprimento da linha",
+ "original": "Original (transcrição)",
+ "maxWords": "Máx. de palavras por linha",
+ "font": "Fonte",
+ "translating": "Traduzindo…",
+ "show": "Mostrar legendas",
+ "textColor": "Cor do texto"
},
- "facets": {
- "captions": "Legendas",
- "transcript": "Transcrição"
+ "panes": {
+ "help": "Ajuda"
},
- "audio": {
- "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
- "reset": "Redefinir áudio",
- "title": "Áudio",
- "outputGain": "Nível de saída"
+ "speed": {
+ "deleteRegion": "Excluir Região de Velocidade",
+ "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
+ "selectRegion": "Selecione uma região de velocidade para ajustar",
+ "playbackSpeed": "Velocidade de Reprodução",
+ "customPlaybackSpeed": "Velocidade Personalizada",
+ "previewFrameSteppingHint": "Acima de {{native}}×, a prévia avança quadro a quadro e sem som. A exportação não é afetada."
},
- "language": {
- "title": "Idioma"
+ "gifSettings": {
+ "frameRate": "Taxa de Quadros do GIF",
+ "loop": "Loop no GIF",
+ "size": "Tamanho do GIF"
+ },
+ "exportQuality": {
+ "title": "Qualidade de Exportação",
+ "low": "Baixa",
+ "high": "Alta",
+ "medium": "Média"
},
"audioTrack": {
- "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "defaultLabel": "Faixa de áudio",
"importFailed": "Não foi possível adicionar o áudio",
- "slipHint": "Alt + arrastar para deslizar o áudio dentro",
"fadeOut": "Fade out",
- "defaultLabel": "Faixa de áudio",
- "mute": "Silenciar",
+ "fadeIn": "Fade in",
"remove": "Excluir faixa",
- "add": "Adicionar faixa de áudio",
"loop": "Repetir",
- "fadeIn": "Fade in"
+ "slipHint": "Alt + arrastar para deslizar o áudio dentro",
+ "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "add": "Adicionar faixa de áudio",
+ "mute": "Silenciar"
},
- "project": {
- "load": "Carregar Projeto",
- "save": "Salvar Projeto",
- "new": "Novo Projeto"
+ "layout": {
+ "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
+ "mirrorWebcam": "Espelhar Webcam",
+ "webcamFraming": "Enquadramento da webcam",
+ "shapes": {
+ "rectangle": "Ret.",
+ "rounded": "Arredondado",
+ "circle": "Círculo",
+ "square": "Quadrado"
+ },
+ "selectPreset": "Selecionar predefinição",
+ "bgModes": {
+ "custom": "Personalizado",
+ "none": "Original",
+ "blur": "Desfocado",
+ "transparent": "Recorte"
+ },
+ "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
+ "webcamBlurIntensity": "Intensidade do desfoque",
+ "preset": "Predefinição",
+ "webcamCropZoom": "Zoom do recorte",
+ "webcamSize": "Tamanho da Webcam",
+ "dualFrame": "Quadro Duplo",
+ "webcamCropY": "Deslocamento vertical",
+ "verticalStack": "Empilhamento Vertical",
+ "pictureInPicture": "Picture in Picture",
+ "webcamShape": "Formato da Câmera",
+ "webcamCropX": "Deslocamento horizontal",
+ "reactiveWebcam": "Encolher ao ampliar",
+ "webcamBackground": "Plano de fundo da câmera",
+ "helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
+ "title": "Layout da câmera",
+ "noWebcam": "Sem Webcam"
},
- "panes": {
- "help": "Ajuda"
+ "textAnimation": {
+ "slideLeft": "Deslizar à Esquerda",
+ "pulse": "Pulsar",
+ "typewriter": "Máquina de Escrever",
+ "selectAnimation": "Selecionar animação",
+ "fade": "Esmaecer",
+ "title": "Animação de Texto",
+ "none": "Nenhuma",
+ "pop": "Aparecer",
+ "rise": "Subir"
},
- "trim": {
- "deleteRegion": "Excluir Região de Recorte"
+ "facets": {
+ "transcript": "Transcrição",
+ "captions": "Legendas"
},
- "export": {
- "videoButton": "Exportar Vídeo",
- "gifButton": "Exportar GIF",
- "chooseSaveLocation": "Escolher Local para Salvar"
+ "crop": {
+ "title": "Cortar",
+ "free": "Livre",
+ "unlockAspectRatio": "Desbloquear proporção",
+ "dragInstruction": "Arraste cada lado para ajustar a área de corte",
+ "done": "Concluir",
+ "ratio": "Proporção",
+ "cropVideo": "Cortar Vídeo",
+ "lockAspectRatio": "Bloquear proporção"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior",
+ "title": "Posição do Foco",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Excluir Zoom",
+ "focusMode": {
+ "lockedDisclaimer": "Controlado pelo interruptor global de Foco Automático na linha do tempo. Desative-o para definir o modo de foco por zoom.",
+ "auto": "Automático",
+ "manual": "Manual",
+ "autoDescription": "A câmera segue a posição do cursor gravado",
+ "title": "Modo de Foco"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Esquerda",
+ "right": "Direita",
+ "iso": "Iso"
+ },
+ "none": "Nenhuma",
+ "title": "Rotação 3D"
+ },
+ "level": "Nível de Zoom",
+ "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
+ "customScale": "Zoom Personalizado",
+ "selectRegion": "Selecione uma região de zoom para ajustar"
+ },
+ "audio": {
+ "outputGain": "Nível de saída",
+ "help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação.",
+ "reset": "Redefinir áudio",
+ "title": "Áudio"
+ },
+ "language": {
+ "title": "Idioma"
+ },
+ "project": {
+ "new": "Novo Projeto",
+ "load": "Carregar Projeto",
+ "save": "Salvar Projeto"
},
"support": {
"starOnGithub": "Dar Estrela no GitHub",
"saveDiagnostics": "Salvar Diagnósticos",
"reportBug": "Relatar Bug"
},
- "gifSettings": {
- "size": "Tamanho do GIF",
- "frameRate": "Taxa de Quadros do GIF",
- "loop": "Loop no GIF"
+ "cursor": {
+ "smoothing": "Suavização",
+ "clickBounce": "Rebote ao clicar",
+ "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.",
+ "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.",
+ "size": "Tamanho",
+ "title": "Cursor",
+ "show": "Mostrar cursor",
+ "themeDefault": "Padrão",
+ "clipToBounds": "Recortar à tela",
+ "motionBlur": "Desfoque de movimento",
+ "theme": "Estilo do cursor"
+ },
+ "export": {
+ "gifButton": "Exportar GIF",
+ "chooseSaveLocation": "Escolher Local para Salvar",
+ "videoButton": "Exportar Vídeo"
+ },
+ "trim": {
+ "deleteRegion": "Excluir Região de Recorte"
}
}
diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json
index 96630e222..2c0669cbb 100644
--- a/src/i18n/locales/pt-BR/timeline.json
+++ b/src/i18n/locales/pt-BR/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "Esta gravação não tem dados de movimento do cursor, ou os zooms existentes já cobrem os momentos de destaque.",
- "smartCutsNoAudio": "Esta mídia não tem áudio",
- "automaticZoomsHint": "Do movimento do cursor gravado",
- "dragToReorderHint": "Arraste para reordenar · clique duas vezes para editar os pontos de entrada/saída",
- "smartCutsNeedsTranscript": "Requer uma transcrição",
- "addedWord": "Palavra adicionada: \"{{word}}\" — sem áudio por trás",
- "smartZoomsAndCuts": "Cortes inteligentes",
- "autoZoomFailed": "Falha no zoom automático",
- "smartCutsWaiting": "Transcrevendo… disponível em instantes",
- "automaticZooms": "Zooms automáticos",
- "arrangeClipsHint": "Arraste os clipes abaixo para reordená-los ou solte novos",
- "comment": "Comentário",
- "addAudioTooltip": "Adicionar áudio",
- "timelineTools": "Ferramentas da linha do tempo",
- "deleteClip": "Excluir clipe",
- "arrangeClips": "Organizar clipes",
- "editInOutPoints": "Editar pontos de entrada/saída",
- "smartZoomsAndCutsHint": "Com IA",
- "addedAutoZoomPlural": "{{count}} zooms automáticos adicionados",
- "noAutoZoomMoments": "Nenhum momento de zoom automático encontrado",
- "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia",
- "smartCutsNoSpeech": "Nenhuma fala detectada",
- "newAnnotation": "Anotação",
- "importRecordingFirst": "Importe uma gravação primeiro",
- "addedAutoZoom": "{{count}} zoom automático adicionado",
- "dropToAdd": "Solte para adicionar à linha do tempo",
- "aiEnhanceRequested": "Pedido ao agente de IA para cortar os tempos mortos",
- "autoEnhance": "Melhoria automática"
+ "buttons": {
+ "addZoom": "Adicionar Zoom (Z)",
+ "suggestZooms": "Sugerir Zooms a partir do Cursor",
+ "autoZoomOn": "Sugestões de zoom automático ativadas — clique para remover os zooms sugeridos",
+ "autoZoomOff": "Sugestões de zoom automático desativadas — clique para sugerir zooms a partir do cursor",
+ "autoFocusAllOn": "Foco automático ativado para todos os zooms — clique para mudar todos para manual",
+ "autoFocusAllOff": "Ativar foco automático para todos os zooms (a câmera segue o cursor)",
+ "addTrim": "Adicionar Recorte (T)",
+ "addAnnotation": "Adicionar Anotação (A)",
+ "addSpeed": "Adicionar Velocidade (S)",
+ "addCameraFullscreen": "Adicionar Câmera em Tela Cheia (C)"
+ },
+ "hints": {
+ "pressZoom": "Pressione Z para adicionar zoom",
+ "pressTrim": "Pressione T para adicionar recorte",
+ "pressAnnotation": "Pressione A para adicionar anotação",
+ "pressAudio": "Pressione M para adicionar áudio, V para gravar uma narração",
+ "pressSpeed": "Pressione S para adicionar velocidade",
+ "pressCameraFullscreen": "Pressione C para adicionar um segmento de Câmera em Tela Cheia"
},
"labels": {
- "zoom": "Zoom",
- "cameraFullscreenItem": "Câmera em Tela Cheia {{index}}",
- "imageItem": "Imagem",
"pan": "Mover",
- "zoomItem": "Zoom {{index}}",
- "cameraFullscreen": "Câmera em Tela Cheia",
+ "zoom": "Zoom",
+ "trim": "Recorte",
"speed": "Velocidade",
- "emptyText": "Texto vazio",
+ "zoomItem": "Zoom {{index}}",
"trimItem": "Recorte {{index}}",
+ "speedItem": "Velocidade {{index}}",
"annotationItem": "Anotação",
- "trim": "Recorte",
- "speedItem": "Velocidade {{index}}"
+ "imageItem": "Imagem",
+ "emptyText": "Texto vazio",
+ "cameraFullscreen": "Câmera em Tela Cheia",
+ "cameraFullscreenItem": "Câmera em Tela Cheia {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Nenhum Vídeo Carregado",
+ "dragAndDrop": "Arraste e solte um vídeo para começar a editar"
},
"errors": {
- "noAutoZoomSlotsDescription": "Pontos de parada detectados sobrepõem regiões de zoom existentes.",
+ "cannotPlaceZoom": "Não é possível colocar zoom aqui",
+ "zoomExistsAtLocation": "Já existe um zoom neste local ou não há espaço suficiente disponível.",
+ "zoomSuggestionUnavailable": "Sugestão de zoom não disponível",
+ "noCursorTelemetry": "Nenhuma telemetria de cursor disponível",
"noCursorTelemetryDescription": "Grave um screencast primeiro para gerar sugestões baseadas no cursor.",
- "cameraFullscreenExistsAtLocation": "Já existe um segmento de Câmera em Tela Cheia neste local ou não há espaço suficiente disponível.",
"noUsableTelemetry": "Nenhuma telemetria de cursor utilizável",
"noUsableTelemetryDescription": "A gravação não inclui dados suficientes de movimento do cursor.",
- "zoomSuggestionUnavailable": "Sugestão de zoom não disponível",
"noDwellMoments": "Nenhum momento claro de parada do cursor encontrado",
- "speedExistsAtLocation": "Já existe uma região de velocidade neste local ou não há espaço suficiente disponível.",
- "noCursorTelemetry": "Nenhuma telemetria de cursor disponível",
+ "noDwellMomentsDescription": "Tente uma gravação com pausas mais lentas do cursor em ações importantes.",
"noAutoZoomSlots": "Nenhum slot de zoom automático disponível",
+ "noAutoZoomSlotsDescription": "Pontos de parada detectados sobrepõem regiões de zoom existentes.",
"cannotPlaceTrim": "Não é possível colocar recorte aqui",
- "cannotPlaceZoom": "Não é possível colocar zoom aqui",
- "cannotPlaceSpeed": "Não é possível colocar velocidade aqui",
- "zoomExistsAtLocation": "Já existe um zoom neste local ou não há espaço suficiente disponível.",
- "noDwellMomentsDescription": "Tente uma gravação com pausas mais lentas do cursor em ações importantes.",
"trimExistsAtLocation": "Já existe um recorte neste local ou não há espaço suficiente disponível.",
- "cannotPlaceCameraFullscreen": "Não é possível colocar Câmera em Tela Cheia aqui"
+ "cannotPlaceSpeed": "Não é possível colocar velocidade aqui",
+ "speedExistsAtLocation": "Já existe uma região de velocidade neste local ou não há espaço suficiente disponível.",
+ "cannotPlaceCameraFullscreen": "Não é possível colocar Câmera em Tela Cheia aqui",
+ "cameraFullscreenExistsAtLocation": "Já existe um segmento de Câmera em Tela Cheia neste local ou não há espaço suficiente disponível."
+ },
+ "success": {
+ "addedZoomSuggestions": "Adicionada {{count}} sugestão de zoom baseada no cursor",
+ "addedZoomSuggestionsPlural": "Adicionadas {{count}} sugestões de zoom baseadas no cursor"
+ },
+ "toolbar": {
+ "autoEnhance": "Melhoria automática",
+ "automaticZooms": "Zooms automáticos",
+ "automaticZoomsHint": "Do movimento do cursor gravado",
+ "smartZoomsAndCuts": "Cortes inteligentes",
+ "smartZoomsAndCutsHint": "Com IA",
+ "comment": "Comentário",
+ "timelineTools": "Ferramentas da linha do tempo",
+ "arrangeClips": "Organizar clipes",
+ "arrangeClipsHint": "Arraste os clipes abaixo para reordená-los ou solte novos",
+ "newAnnotation": "Anotação",
+ "dragToReorderHint": "Arraste para reordenar · clique duas vezes para editar os pontos de entrada/saída",
+ "editInOutPoints": "Editar pontos de entrada/saída",
+ "deleteClip": "Excluir clipe",
+ "dropToAdd": "Solte para adicionar à linha do tempo",
+ "importRecordingFirst": "Importe uma gravação primeiro",
+ "noAutoZoomMoments": "Nenhum momento de zoom automático encontrado",
+ "noAutoZoomMomentsDescription": "Esta gravação não tem dados de movimento do cursor, ou os zooms existentes já cobrem os momentos de destaque.",
+ "addedAutoZoom": "{{count}} zoom automático adicionado",
+ "addedAutoZoomPlural": "{{count}} zooms automáticos adicionados",
+ "autoZoomFailed": "Falha no zoom automático",
+ "aiEnhanceRequested": "Pedido ao agente de IA para cortar os tempos mortos",
+ "smartCutsWaiting": "Transcrevendo… disponível em instantes",
+ "smartCutsNeedsTranscript": "Requer uma transcrição",
+ "smartCutsNoAudio": "Esta mídia não tem áudio",
+ "smartCutsNoSpeech": "Nenhuma fala detectada",
+ "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia",
+ "addAudioTooltip": "Adicionar áudio",
+ "addedWord": "Palavra adicionada: \"{{word}}\" — sem áudio por trás"
},
"audio": {
- "micDenied": "Acesso ao microfone negado",
+ "addVoiceover": "Adicionar narração",
+ "addVoiceoverHint": "Grave uma narração sobre o seu vídeo",
"subtitle": "Coloque uma camada de narração ou de música de fundo na linha do tempo",
- "importFailed": "Não foi possível importar o arquivo de áudio",
+ "record": "Gravar narração",
+ "importFile": "Importar arquivo de áudio",
"importFileHint": "Importe música ou um arquivo de áudio",
- "addVoiceoverHint": "Grave uma narração sobre o seu vídeo",
- "stop": "Parar",
"recording": "Gravando",
- "saveFailed": "Não foi possível salvar a gravação",
"recordingHint": "Narre junto com o vídeo — ele continua tocando enquanto você grava",
- "importFile": "Importar arquivo de áudio",
- "record": "Gravar narração",
+ "stop": "Parar",
+ "micDenied": "Acesso ao microfone negado",
"recordingUnavailable": "A gravação não está disponível aqui",
- "addVoiceover": "Adicionar narração"
- },
- "success": {
- "addedZoomSuggestions": "Adicionada {{count}} sugestão de zoom baseada no cursor",
- "addedZoomSuggestionsPlural": "Adicionadas {{count}} sugestões de zoom baseadas no cursor"
- },
- "hints": {
- "pressAnnotation": "Pressione A para adicionar anotação",
- "pressSpeed": "Pressione S para adicionar velocidade",
- "pressTrim": "Pressione T para adicionar recorte",
- "pressCameraFullscreen": "Pressione C para adicionar um segmento de Câmera em Tela Cheia",
- "pressZoom": "Pressione Z para adicionar zoom",
- "pressAudio": "Pressione M para adicionar áudio, V para gravar uma narração"
- },
- "buttons": {
- "autoFocusAllOff": "Ativar foco automático para todos os zooms (a câmera segue o cursor)",
- "autoZoomOff": "Sugestões de zoom automático desativadas — clique para sugerir zooms a partir do cursor",
- "addAnnotation": "Adicionar Anotação (A)",
- "suggestZooms": "Sugerir Zooms a partir do Cursor",
- "addSpeed": "Adicionar Velocidade (S)",
- "addCameraFullscreen": "Adicionar Câmera em Tela Cheia (C)",
- "autoFocusAllOn": "Foco automático ativado para todos os zooms — clique para mudar todos para manual",
- "autoZoomOn": "Sugestões de zoom automático ativadas — clique para remover os zooms sugeridos",
- "addZoom": "Adicionar Zoom (Z)",
- "addTrim": "Adicionar Recorte (T)"
- },
- "emptyState": {
- "noVideo": "Nenhum Vídeo Carregado",
- "dragAndDrop": "Arraste e solte um vídeo para começar a editar"
+ "saveFailed": "Não foi possível salvar a gravação",
+ "importFailed": "Não foi possível importar o arquivo de áudio"
}
}
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index c3199653f..c390819f5 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Интенсивность размытия",
- "bgModes": {
- "transparent": "Вырезка",
- "none": "Оригинал",
- "blur": "Размытие",
- "custom": "Пользовательский"
- },
- "selectPreset": "Выбрать пресет",
- "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
- "reactiveWebcam": "Уменьшать при зуме",
- "shapes": {
- "circle": "Круг",
- "square": "Квадрат",
- "rectangle": "Прямоуг.",
- "rounded": "Скруглённый"
- },
- "webcamBackground": "Фон камеры",
- "verticalStack": "Вертикальный стек",
- "pictureInPicture": "Картинка в картинке",
- "webcamShape": "Форма камеры",
- "webcamCropY": "Смещение по вертикали",
- "webcamSize": "Размер веб-камеры",
- "mirrorWebcam": "Зеркалить веб-камеру",
- "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
- "webcamFraming": "Кадрирование веб-камеры",
- "noWebcam": "Без веб-камеры",
- "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
- "webcamCropZoom": "Масштаб обрезки",
- "dualFrame": "Двойной кадр",
- "webcamCropX": "Смещение по горизонтали",
- "title": "Расположение камеры",
- "preset": "Пресет"
- },
- "crop": {
- "done": "Готово",
- "ratio": "Соотношение сторон",
- "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
- "title": "Обрезка",
- "free": "Свободно",
- "lockAspectRatio": "Заблокировать соотношение сторон",
- "unlockAspectRatio": "Разблокировать соотношение сторон",
- "cropVideo": "Обрезать видео"
- },
- "zoom": {
- "position": {
- "hint": "0 = край слева / сверху, 100 = край справа / снизу",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Положение фокуса"
- },
- "threeD": {
- "preset": {
- "right": "Справа",
- "iso": "Изометрия",
- "left": "Слева"
- },
- "none": "Нет",
- "title": "3D вращение"
- },
- "deleteZoom": "Удалить масштабирование",
- "customScale": "Пользовательский масштаб",
- "selectRegion": "Выберите область масштабирования для настройки",
- "focusMode": {
- "manual": "Ручной",
- "autoDescription": "Камера следует за записанной позицией курсора",
- "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
- "title": "Режим фокуса",
- "auto": "Авто"
- },
- "previewHold": "Удерживайте для предпросмотра эффекта зума",
- "level": "Уровень масштабирования"
- },
"background": {
- "color": "Цвет",
- "colorLabel": "Цвет {{color}}",
- "gradient": "Градиент",
- "imageReadFailed": "Не удалось прочитать этот файл изображения.",
"gradientLabel": "Градиент {{index}}",
+ "uploadCustom": "Загрузить свой",
+ "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
+ "title": "Фон",
+ "imageLabel": "Фон {{index}}",
"custom": "Свой",
+ "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
+ "gradient": "Градиент",
+ "colorLabel": "Цвет {{color}}",
"customWallpaper": "Свои обои",
- "presets": "Пресеты",
- "image": "Изображение",
"colorPalette": "Палитра цветов",
- "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
- "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
- "imageLabel": "Фон {{index}}",
- "title": "Фон",
- "uploadCustom": "Загрузить свой",
+ "imageReadFailed": "Не удалось прочитать этот файл изображения.",
+ "image": "Изображение",
+ "presets": "Пресеты",
+ "color": "Цвет",
"colorWheel": "Цветовой круг"
},
- "cursor": {
- "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
- "clickBounce": "Отскок при клике",
- "clipToBounds": "Обрезать по холсту",
- "title": "Курсор",
- "size": "Размер",
- "themeDefault": "По умолчанию",
- "smoothing": "Сглаживание",
- "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
- "motionBlur": "Размытие движения",
- "theme": "Стиль курсора",
- "show": "Показывать курсор"
- },
- "captions": {
- "text": "Текст",
- "showBackground": "Показывать фон",
- "minWords": "Мин. слов в строке",
- "translateFailed": "Не удалось перевести.",
- "distanceFromTop": "Отступ сверху",
- "fontSize": "Размер",
- "distanceFromRight": "Отступ справа",
- "bold": "Полужирный",
- "textColor": "Цвет текста",
- "original": "Оригинал (расшифровка)",
- "translating": "Перевод…",
- "language": "Язык",
- "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
- "alignLeft": "Слева",
- "distanceFromBottom": "Отступ снизу",
- "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
- "show": "Показывать субтитры",
- "background": "Фон",
- "backgroundOpacity": "Непрозрачность",
- "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
- "anchorTop": "Сверху",
- "translate": "Перевести",
- "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
- "alignCenter": "По центру",
- "alignRight": "Справа",
- "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
- "displayLanguage": "Отображение",
- "noTranscript": "Субтитры берутся из расшифровки медиафайла. Они включаются, как только видео расшифровано.",
- "distanceFromLeft": "Отступ слева",
- "maxWords": "Макс. слов в строке",
- "anchorBottom": "Снизу",
- "position": "Положение",
- "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
- "deleteTranslation": "Удалить этот перевод",
- "backgroundColor": "Цвет фона",
- "font": "Шрифт",
- "lineLength": "Длина строки",
- "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
- "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "Разрешение экспорта"
- },
- "transcript": {
- "transcribing": "Расшифровка…",
- "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
- "laneVoiceover": "Закадровый голос",
- "blankedWord": "очищено",
- "noTranscript": "Расшифровки пока нет",
- "noAudio": "В этом медиафайле нет аудиодорожки",
- "revertWord": "Вернуть «{{original}}»",
- "silence": "[тишина {{duration}} с]",
- "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
- "transcribeNow": "Расшифровать сейчас",
- "restoreWord": "Вернуть «{{word}}»",
- "clipLabel": "Клип {{index}}",
- "title": "Текущая расшифровка",
- "insertAria": "Новое слово",
- "laneRecording": "Запись",
- "trimSilence": "Вырезать тишину ({{duration}} с)",
- "insertedWord": "Добавлено вами — за ним нет звука",
- "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
- "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
- "removeInserted": "Удалить «{{word}}»",
- "editorAria": "Расшифровка «{{filename}}»",
- "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
- "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
- "editWord": "Изменить «{{word}}»",
- "noClips": "Клипов пока нет",
- "laneLabel": "Читать расшифровку из",
- "restoreSilence": "Вернуть тишину ({{duration}} с)"
- },
"customFont": {
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "failedToAdd": "Не удалось добавить шрифт",
"addingButton": "Добавление...",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
"urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
- "addButton": "Добавить шрифт",
- "dialogTitle": "Добавить шрифт Google",
- "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
- "nameLabel": "Отображаемое имя",
- "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
"urlLabel": "URL импорта Google Fonts",
+ "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
+ "nameLabel": "Отображаемое имя",
"errorEmptyName": "Пожалуйста, введите имя шрифта",
- "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
- "namePlaceholder": "Мой пользовательский шрифт",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Не удалось добавить шрифт",
"nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
"errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
- "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
- "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Добавить шрифт Google",
+ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
+ "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts.",
+ "addButton": "Добавить шрифт"
+ },
+ "imageUpload": {
+ "invalidFileType": "Неверный тип файла",
+ "failedToUpload": "Не удалось загрузить изображение",
+ "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
+ "uploadSuccess": "Пользовательское изображение успешно загружено!",
+ "errorReading": "Произошла ошибка при чтении файла."
},
"annotation": {
- "arrowColor": "Цвет стрелки",
- "colorWheel": "Цветовой круг",
- "blurType": "Тип размытия",
- "active": "Активно",
- "deleteAnnotation": "Удалить аннотацию",
- "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
- "strokeWidth": "Толщина линии: {{width}}px",
- "background": "Фон",
- "imageUploadSuccess": "Изображение успешно загружено!",
- "blurColor": "Цвет размытия",
- "blurTypeBlur": "Гауссово",
- "textColor": "Цвет текста",
- "blurColorWhite": "Белый",
- "title": "Настройки аннотаций",
- "type": "Тип",
- "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
- "typeImage": "Изображение",
- "textContent": "Содержание текста",
"supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
- "typeText": "Текст",
- "blurIntensity": "Интенсивность размытия",
- "none": "Нет",
- "mosaicBlockSize": "Размер блока мозаики",
- "textPlaceholder": "Введите ваш текст...",
- "typeArrow": "Стрелка",
- "color": "Цвет",
- "blurColorBlack": "Чёрный",
+ "blurShapeRectangle": "Прямоугольник",
"size": "Размер",
+ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
+ "clearBackground": "Очистить фон",
+ "colorPalette": "Палитра цветов",
"invalidImageType": "Неверный тип файла",
+ "background": "Фон",
+ "typeText": "Текст",
+ "active": "Активно",
+ "color": "Цвет",
"blurShapeFreehand": "От руки",
- "shortcutsAndTips": "Горячие клавиши и советы",
- "uploadImage": "Загрузить изображение",
+ "arrowDirection": "Направление стрелки",
"blurTypeMosaic": "Мозаика",
+ "colorWheel": "Цветовой круг",
+ "textColor": "Цвет текста",
+ "title": "Настройки аннотаций",
+ "blurType": "Тип размытия",
+ "typeBlur": "Размытие",
+ "blurIntensity": "Интенсивность размытия",
"selectStyle": "Выбрать стиль",
- "defaultText": "Привет",
- "blurShapeRectangle": "Прямоугольник",
- "colorPalette": "Палитра цветов",
- "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
- "clearBackground": "Очистить фон",
+ "textContent": "Содержание текста",
+ "typeArrow": "Стрелка",
+ "none": "Нет",
+ "blurColor": "Цвет размытия",
"customFonts": "Пользовательские шрифты",
- "typeBlur": "Размытие",
+ "imageUploadSuccess": "Изображение успешно загружено!",
+ "type": "Тип",
+ "arrowColor": "Цвет стрелки",
+ "textPlaceholder": "Введите ваш текст...",
+ "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
+ "blurShape": "Форма размытия",
+ "uploadImage": "Загрузить изображение",
+ "blurTypeBlur": "Гауссово",
"tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
+ "shortcutsAndTips": "Горячие клавиши и советы",
+ "deleteAnnotation": "Удалить аннотацию",
"fontStyle": "Стиль шрифта",
- "blurShape": "Форма размытия",
- "arrowDirection": "Направление стрелки",
- "blurShapeOval": "Овал"
- },
- "speed": {
- "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
- "deleteRegion": "Удалить область скорости",
- "selectRegion": "Выберите область скорости для настройки",
- "maxSpeedError": "Скорость не может быть выше {{max}}×",
- "playbackSpeed": "Скорость воспроизведения",
- "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет."
- },
- "textAnimation": {
- "selectAnimation": "Выбрать анимацию",
- "pulse": "Импульс",
- "rise": "Подъем",
- "none": "Нет",
- "slideLeft": "Скольжение влево",
- "title": "Анимация текста",
- "fade": "Затухание",
- "pop": "Всплытие",
- "typewriter": "Пишущая машинка"
+ "defaultText": "Привет",
+ "mosaicBlockSize": "Размер блока мозаики",
+ "blurColorBlack": "Чёрный",
+ "strokeWidth": "Толщина линии: {{width}}px",
+ "blurShapeOval": "Овал",
+ "blurColorWhite": "Белый",
+ "tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
+ "typeImage": "Изображение"
},
"effects": {
- "motion": "Движение",
- "title": "Композиция",
- "format": "Формат",
- "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
"fitClipFew": "{{count}} клипа",
- "motionBlur": "Размытие движения",
- "fitClipMany": "{{count}} клипов",
- "frame": "Рамка",
- "padding": "Отступ",
- "roundness": "Скругление",
- "off": "выкл",
- "blurBg": "Размытие фона",
+ "title": "Композиция",
"shadow": "Тень",
+ "off": "выкл",
"on": "вкл",
+ "blurBg": "Размытие фона",
+ "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
"fitClipOne": "{{count}} клип",
"formatOriginal": "Исходный",
- "fitClip": "Подогнать"
+ "fitClipMany": "{{count}} клипов",
+ "frame": "Рамка",
+ "motion": "Движение",
+ "padding": "Отступ",
+ "format": "Формат",
+ "fitClip": "Подогнать",
+ "motionBlur": "Размытие движения",
+ "roundness": "Скругление"
+ },
+ "transcript": {
+ "laneRecording": "Запись",
+ "noTranscript": "Расшифровки пока нет",
+ "title": "Текущая расшифровка",
+ "restoreWord": "Вернуть «{{word}}»",
+ "revertWord": "Вернуть «{{original}}»",
+ "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
+ "restoreSilence": "Вернуть тишину ({{duration}} с)",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "editWord": "Изменить «{{word}}»",
+ "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
+ "insertAria": "Новое слово",
+ "editorAria": "Расшифровка «{{filename}}»",
+ "transcribeNow": "Расшифровать сейчас",
+ "transcribing": "Расшифровка…",
+ "trimSilence": "Вырезать тишину ({{duration}} с)",
+ "removeInserted": "Удалить «{{word}}»",
+ "laneLabel": "Читать расшифровку из",
+ "noClips": "Клипов пока нет",
+ "laneVoiceover": "Закадровый голос",
+ "silence": "[тишина {{duration}} с]",
+ "clipLabel": "Клип {{index}}",
+ "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
+ "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "blankedWord": "очищено"
},
"exportFormat": {
- "gifDescription": "Анимированное изображение для обмена",
"mp4": "MP4",
- "mp4Video": "MP4 видео",
- "gif": "GIF",
+ "mp4Description": "Видеофайл высокого качества",
"gifAnimation": "GIF анимация",
- "mp4Description": "Видеофайл высокого качества"
+ "mp4Video": "MP4 видео",
+ "gifDescription": "Анимированное изображение для обмена",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Не удалось загрузить изображение",
- "uploadSuccess": "Пользовательское изображение успешно загружено!",
- "errorReading": "Произошла ошибка при чтении файла.",
- "invalidFileType": "Неверный тип файла",
- "jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG."
+ "captions": {
+ "showBackground": "Показывать фон",
+ "deleteTranslation": "Удалить этот перевод",
+ "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
+ "backgroundOpacity": "Непрозрачность",
+ "backgroundColor": "Цвет фона",
+ "alignCenter": "По центру",
+ "translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
+ "distanceFromRight": "Отступ справа",
+ "language": "Язык",
+ "text": "Текст",
+ "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
+ "distanceFromTop": "Отступ сверху",
+ "translateFailed": "Не удалось перевести.",
+ "alignLeft": "Слева",
+ "distanceFromBottom": "Отступ снизу",
+ "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
+ "translate": "Перевести",
+ "position": "Положение",
+ "fontSize": "Размер",
+ "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
+ "noTranscript": "Субтитры берутся из расшифровки медиафайла. Расшифруйте это видео, чтобы включить их.",
+ "distanceFromLeft": "Отступ слева",
+ "anchorBottom": "Снизу",
+ "transcribe": "Расшифровать видео",
+ "bold": "Полужирный",
+ "alignRight": "Справа",
+ "anchorTop": "Сверху",
+ "minWords": "Мин. слов в строке",
+ "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
+ "displayLanguage": "Отображение",
+ "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
+ "background": "Фон",
+ "lineLength": "Длина строки",
+ "original": "Оригинал (расшифровка)",
+ "maxWords": "Макс. слов в строке",
+ "font": "Шрифт",
+ "translating": "Перевод…",
+ "show": "Показывать субтитры",
+ "textColor": "Цвет текста"
},
- "facets": {
- "captions": "Субтитры",
- "transcript": "Транскрипт"
+ "panes": {
+ "help": "Справка"
},
- "audio": {
- "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
- "reset": "Сбросить аудио",
- "title": "Аудио",
- "outputGain": "Уровень выхода"
+ "speed": {
+ "deleteRegion": "Удалить область скорости",
+ "maxSpeedError": "Скорость не может быть выше {{max}}×",
+ "selectRegion": "Выберите область скорости для настройки",
+ "playbackSpeed": "Скорость воспроизведения",
+ "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
+ "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет."
},
- "language": {
- "title": "Язык"
+ "gifSettings": {
+ "frameRate": "Частота кадров GIF",
+ "loop": "Зациклить GIF",
+ "size": "Размер GIF"
+ },
+ "exportQuality": {
+ "title": "Разрешение экспорта",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "defaultLabel": "Аудиодорожка",
"importFailed": "Не удалось добавить аудио",
- "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
"fadeOut": "Затухание",
- "defaultLabel": "Аудиодорожка",
- "mute": "Без звука",
+ "fadeIn": "Нарастание",
"remove": "Удалить дорожку",
- "add": "Добавить аудиодорожку",
"loop": "Повтор",
- "fadeIn": "Нарастание"
+ "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри",
+ "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "add": "Добавить аудиодорожку",
+ "mute": "Без звука"
},
- "project": {
- "load": "Загрузить проект",
- "save": "Сохранить проект",
- "new": "Новый проект"
+ "layout": {
+ "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
+ "mirrorWebcam": "Зеркалить веб-камеру",
+ "webcamFraming": "Кадрирование веб-камеры",
+ "shapes": {
+ "rectangle": "Прямоуг.",
+ "rounded": "Скруглённый",
+ "circle": "Круг",
+ "square": "Квадрат"
+ },
+ "selectPreset": "Выбрать пресет",
+ "bgModes": {
+ "custom": "Пользовательский",
+ "none": "Оригинал",
+ "blur": "Размытие",
+ "transparent": "Вырезка"
+ },
+ "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
+ "webcamBlurIntensity": "Интенсивность размытия",
+ "preset": "Пресет",
+ "webcamCropZoom": "Масштаб обрезки",
+ "webcamSize": "Размер веб-камеры",
+ "dualFrame": "Двойной кадр",
+ "webcamCropY": "Смещение по вертикали",
+ "verticalStack": "Вертикальный стек",
+ "pictureInPicture": "Картинка в картинке",
+ "webcamShape": "Форма камеры",
+ "webcamCropX": "Смещение по горизонтали",
+ "reactiveWebcam": "Уменьшать при зуме",
+ "webcamBackground": "Фон камеры",
+ "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
+ "title": "Расположение камеры",
+ "noWebcam": "Без веб-камеры"
},
- "panes": {
- "help": "Справка"
+ "textAnimation": {
+ "slideLeft": "Скольжение влево",
+ "pulse": "Импульс",
+ "typewriter": "Пишущая машинка",
+ "selectAnimation": "Выбрать анимацию",
+ "fade": "Затухание",
+ "title": "Анимация текста",
+ "none": "Нет",
+ "pop": "Всплытие",
+ "rise": "Подъем"
},
- "trim": {
- "deleteRegion": "Удалить область обрезки"
+ "facets": {
+ "transcript": "Транскрипт",
+ "captions": "Субтитры"
},
- "export": {
- "videoButton": "Экспорт видео",
- "gifButton": "Экспорт GIF",
- "chooseSaveLocation": "Выбрать место сохранения"
+ "crop": {
+ "title": "Обрезка",
+ "free": "Свободно",
+ "unlockAspectRatio": "Разблокировать соотношение сторон",
+ "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
+ "done": "Готово",
+ "ratio": "Соотношение сторон",
+ "cropVideo": "Обрезать видео",
+ "lockAspectRatio": "Заблокировать соотношение сторон"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = край слева / сверху, 100 = край справа / снизу",
+ "title": "Положение фокуса",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Удалить масштабирование",
+ "focusMode": {
+ "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно.",
+ "auto": "Авто",
+ "manual": "Ручной",
+ "autoDescription": "Камера следует за записанной позицией курсора",
+ "title": "Режим фокуса"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Слева",
+ "right": "Справа",
+ "iso": "Изометрия"
+ },
+ "none": "Нет",
+ "title": "3D вращение"
+ },
+ "level": "Уровень масштабирования",
+ "previewHold": "Удерживайте для предпросмотра эффекта зума",
+ "customScale": "Пользовательский масштаб",
+ "selectRegion": "Выберите область масштабирования для настройки"
+ },
+ "audio": {
+ "outputGain": "Уровень выхода",
+ "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
+ "reset": "Сбросить аудио",
+ "title": "Аудио"
+ },
+ "language": {
+ "title": "Язык"
+ },
+ "project": {
+ "new": "Новый проект",
+ "load": "Загрузить проект",
+ "save": "Сохранить проект"
},
"support": {
"starOnGithub": "Звезда на GitHub",
"saveDiagnostics": "Сохранить диагностику",
"reportBug": "Сообщить об ошибке"
},
- "gifSettings": {
- "size": "Размер GIF",
- "frameRate": "Частота кадров GIF",
- "loop": "Зациклить GIF"
+ "cursor": {
+ "smoothing": "Сглаживание",
+ "clickBounce": "Отскок при клике",
+ "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.",
+ "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
+ "size": "Размер",
+ "title": "Курсор",
+ "show": "Показывать курсор",
+ "themeDefault": "По умолчанию",
+ "clipToBounds": "Обрезать по холсту",
+ "motionBlur": "Размытие движения",
+ "theme": "Стиль курсора"
+ },
+ "export": {
+ "gifButton": "Экспорт GIF",
+ "chooseSaveLocation": "Выбрать место сохранения",
+ "videoButton": "Экспорт видео"
+ },
+ "trim": {
+ "deleteRegion": "Удалить область обрезки"
}
}
diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json
index 1f715065f..e6281aaa8 100644
--- a/src/i18n/locales/ru/timeline.json
+++ b/src/i18n/locales/ru/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "В этой записи нет данных о движении курсора, либо существующие зумы уже покрывают активные моменты.",
- "smartCutsNoAudio": "В этом медиафайле нет звука",
- "automaticZoomsHint": "На основе записанного движения курсора",
- "dragToReorderHint": "Перетащите для изменения порядка · дважды щёлкните для редактирования точек входа/выхода",
- "smartCutsNeedsTranscript": "Нужна расшифровка",
- "addedWord": "Добавленное слово: «{{word}}» — за ним нет звука",
- "smartZoomsAndCuts": "Умные вырезки",
- "autoZoomFailed": "Не удалось выполнить автозум",
- "smartCutsWaiting": "Идёт расшифровка… скоро будет готово",
- "automaticZooms": "Автоматические зумы",
- "arrangeClipsHint": "Перетащите клипы ниже, чтобы изменить порядок, или добавьте новые",
- "comment": "Комментарий",
- "addAudioTooltip": "Добавить аудио",
- "timelineTools": "Инструменты таймлайна",
- "deleteClip": "Удалить клип",
- "arrangeClips": "Упорядочить клипы",
- "editInOutPoints": "Редактировать точки входа/выхода",
- "smartZoomsAndCutsHint": "С помощью ИИ",
- "addedAutoZoomPlural": "Добавлено {{count}} автоматических зумов",
- "noAutoZoomMoments": "Моменты для автозума не найдены",
- "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»",
- "smartCutsNoSpeech": "Речь не обнаружена",
- "newAnnotation": "Аннотация",
- "importRecordingFirst": "Сначала импортируйте запись",
- "addedAutoZoom": "Добавлен {{count}} автоматический зум",
- "dropToAdd": "Отпустите, чтобы добавить на таймлайн",
- "aiEnhanceRequested": "Агенту ИИ поручено вырезать паузы",
- "autoEnhance": "Авто-улучшение"
+ "buttons": {
+ "addZoom": "Добавить масштабирование (Z)",
+ "suggestZooms": "Предложить масштабирование на основе курсора",
+ "autoZoomOn": "Автоматические предложения масштабирования включены — нажмите, чтобы убрать предложенные зумы",
+ "autoZoomOff": "Автоматические предложения масштабирования выключены — нажмите, чтобы предложить зумы по курсору",
+ "autoFocusAllOn": "Автофокус включён для всех зумов — нажмите, чтобы переключить все в ручной режим",
+ "autoFocusAllOff": "Включить автофокус для всех зумов (камера следует за курсором)",
+ "addTrim": "Добавить обрезку (T)",
+ "addAnnotation": "Добавить аннотацию (A)",
+ "addSpeed": "Изменить скорость (S)",
+ "addCameraFullscreen": "Добавить камеру на весь экран (C)"
+ },
+ "hints": {
+ "pressZoom": "Нажмите Z для добавления масштабирования",
+ "pressTrim": "Нажмите T для добавления обрезки",
+ "pressAnnotation": "Нажмите A для добавления аннотации",
+ "pressAudio": "Нажмите M, чтобы добавить аудио, V — чтобы записать закадровый голос",
+ "pressSpeed": "Нажмите S для изменения скорости",
+ "pressCameraFullscreen": "Нажмите C, чтобы добавить сегмент камеры на весь экран"
},
"labels": {
- "zoom": "Масштабирование",
- "cameraFullscreenItem": "Камера на весь экран {{index}}",
- "imageItem": "Изображение",
"pan": "Панорамирование",
- "zoomItem": "Масштабирование {{index}}",
- "cameraFullscreen": "Камера на весь экран",
+ "zoom": "Масштабирование",
+ "trim": "Обрезка",
"speed": "Скорость воспроизведения",
- "emptyText": "Пустой текст",
+ "zoomItem": "Масштабирование {{index}}",
"trimItem": "Обрезка {{index}}",
+ "speedItem": "Скорость воспроизведения {{index}}",
"annotationItem": "Аннотация",
- "trim": "Обрезка",
- "speedItem": "Скорость воспроизведения {{index}}"
+ "imageItem": "Изображение",
+ "emptyText": "Пустой текст",
+ "cameraFullscreen": "Камера на весь экран",
+ "cameraFullscreenItem": "Камера на весь экран {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Видео не загружено",
+ "dragAndDrop": "Перетащите видео для начала редактирования"
},
"errors": {
- "noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
+ "cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
+ "zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
+ "zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
+ "noCursorTelemetry": "Нет данных телеметрии курсора",
"noCursorTelemetryDescription": "Сначала запишите screencast для генерации предложений на основе курсора.",
- "cameraFullscreenExistsAtLocation": "Сегмент камеры на весь экран уже существует в этом месте или недостаточно свободного места.",
"noUsableTelemetry": "Нет пригодной телеметрии курсора",
"noUsableTelemetryDescription": "Запись не содержит достаточно данных о движении курсора.",
- "zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
"noDwellMoments": "Не найдено чётких моментов задержки курсора",
- "speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места.",
- "noCursorTelemetry": "Нет данных телеметрии курсора",
+ "noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
"noAutoZoomSlots": "Нет доступных слотов авто-масштабирования",
+ "noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
"cannotPlaceTrim": "Невозможно разместить обрезку здесь",
- "cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
- "cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
- "zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
- "noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
"trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
- "cannotPlaceCameraFullscreen": "Невозможно разместить камеру на весь экран здесь"
+ "cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
+ "speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места.",
+ "cannotPlaceCameraFullscreen": "Невозможно разместить камеру на весь экран здесь",
+ "cameraFullscreenExistsAtLocation": "Сегмент камеры на весь экран уже существует в этом месте или недостаточно свободного места."
+ },
+ "success": {
+ "addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
+ "addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
+ },
+ "toolbar": {
+ "autoEnhance": "Авто-улучшение",
+ "automaticZooms": "Автоматические зумы",
+ "automaticZoomsHint": "На основе записанного движения курсора",
+ "smartZoomsAndCuts": "Умные вырезки",
+ "smartZoomsAndCutsHint": "С помощью ИИ",
+ "comment": "Комментарий",
+ "timelineTools": "Инструменты таймлайна",
+ "arrangeClips": "Упорядочить клипы",
+ "arrangeClipsHint": "Перетащите клипы ниже, чтобы изменить порядок, или добавьте новые",
+ "newAnnotation": "Аннотация",
+ "dragToReorderHint": "Перетащите для изменения порядка · дважды щёлкните для редактирования точек входа/выхода",
+ "editInOutPoints": "Редактировать точки входа/выхода",
+ "deleteClip": "Удалить клип",
+ "dropToAdd": "Отпустите, чтобы добавить на таймлайн",
+ "importRecordingFirst": "Сначала импортируйте запись",
+ "noAutoZoomMoments": "Моменты для автозума не найдены",
+ "noAutoZoomMomentsDescription": "В этой записи нет данных о движении курсора, либо существующие зумы уже покрывают активные моменты.",
+ "addedAutoZoom": "Добавлен {{count}} автоматический зум",
+ "addedAutoZoomPlural": "Добавлено {{count}} автоматических зумов",
+ "autoZoomFailed": "Не удалось выполнить автозум",
+ "aiEnhanceRequested": "Агенту ИИ поручено вырезать паузы",
+ "smartCutsWaiting": "Идёт расшифровка… скоро будет готово",
+ "smartCutsNeedsTranscript": "Нужна расшифровка",
+ "smartCutsNoAudio": "В этом медиафайле нет звука",
+ "smartCutsNoSpeech": "Речь не обнаружена",
+ "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»",
+ "addAudioTooltip": "Добавить аудио",
+ "addedWord": "Добавленное слово: «{{word}}» — за ним нет звука"
},
"audio": {
- "micDenied": "Доступ к микрофону запрещён",
+ "addVoiceover": "Добавить озвучку",
+ "addVoiceoverHint": "Запишите закадровый голос поверх видео",
"subtitle": "Разместите слой озвучки или фоновой музыки на таймлайне",
- "importFailed": "Не удалось импортировать аудиофайл",
+ "record": "Записать озвучку",
+ "importFile": "Импортировать аудиофайл",
"importFileHint": "Импортируйте музыку или аудиофайл",
- "addVoiceoverHint": "Запишите закадровый голос поверх видео",
- "stop": "Остановить",
"recording": "Запись",
- "saveFailed": "Не удалось сохранить запись",
"recordingHint": "Говорите под видео — оно продолжает играть во время записи",
- "importFile": "Импортировать аудиофайл",
- "record": "Записать озвучку",
+ "stop": "Остановить",
+ "micDenied": "Доступ к микрофону запрещён",
"recordingUnavailable": "Запись здесь недоступна",
- "addVoiceover": "Добавить озвучку"
- },
- "success": {
- "addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
- "addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
- },
- "hints": {
- "pressAnnotation": "Нажмите A для добавления аннотации",
- "pressSpeed": "Нажмите S для изменения скорости",
- "pressTrim": "Нажмите T для добавления обрезки",
- "pressCameraFullscreen": "Нажмите C, чтобы добавить сегмент камеры на весь экран",
- "pressZoom": "Нажмите Z для добавления масштабирования",
- "pressAudio": "Нажмите M, чтобы добавить аудио, V — чтобы записать закадровый голос"
- },
- "buttons": {
- "autoFocusAllOff": "Включить автофокус для всех зумов (камера следует за курсором)",
- "autoZoomOff": "Автоматические предложения масштабирования выключены — нажмите, чтобы предложить зумы по курсору",
- "addAnnotation": "Добавить аннотацию (A)",
- "suggestZooms": "Предложить масштабирование на основе курсора",
- "addSpeed": "Изменить скорость (S)",
- "addCameraFullscreen": "Добавить камеру на весь экран (C)",
- "autoFocusAllOn": "Автофокус включён для всех зумов — нажмите, чтобы переключить все в ручной режим",
- "autoZoomOn": "Автоматические предложения масштабирования включены — нажмите, чтобы убрать предложенные зумы",
- "addZoom": "Добавить масштабирование (Z)",
- "addTrim": "Добавить обрезку (T)"
- },
- "emptyState": {
- "noVideo": "Видео не загружено",
- "dragAndDrop": "Перетащите видео для начала редактирования"
+ "saveFailed": "Не удалось сохранить запись",
+ "importFailed": "Не удалось импортировать аудиофайл"
}
}
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index ec4916edc..6309560f7 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
- "bgModes": {
- "transparent": "Kesme",
- "none": "Orijinal",
- "blur": "Bulanık",
- "custom": "Özel"
- },
- "selectPreset": "Ön ayar seçin",
- "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
- "reactiveWebcam": "Yakınlaştırınca küçült",
- "shapes": {
- "circle": "Daire",
- "square": "Kare",
- "rectangle": "Dikdörtgen",
- "rounded": "Yuvarlatılmış"
- },
- "webcamBackground": "Kamera Arka Planı",
- "verticalStack": "Dikey Yığın",
- "pictureInPicture": "Resim İçinde Resim",
- "webcamShape": "Kamera Şekli",
- "webcamCropY": "Dikey kaydırma",
- "webcamSize": "Webcam Boyutu",
- "mirrorWebcam": "Web kamerasını aynala",
- "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
- "webcamFraming": "Webcam kadrajı",
- "noWebcam": "Web kamerası yok",
- "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
- "webcamCropZoom": "Kırpma yakınlaştırması",
- "dualFrame": "Çift Kare",
- "webcamCropX": "Yatay kaydırma",
- "title": "Kamera düzeni",
- "preset": "Ön Ayar"
- },
- "crop": {
- "done": "Tamam",
- "ratio": "Oran",
- "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
- "title": "Kırpma",
- "free": "Serbest",
- "lockAspectRatio": "En boy oranını kilitle",
- "unlockAspectRatio": "En boy oranının kilidini aç",
- "cropVideo": "Videoyu Kırp"
- },
- "zoom": {
- "position": {
- "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Odak Konumu"
- },
- "threeD": {
- "preset": {
- "right": "Sağ",
- "iso": "Iso",
- "left": "Sol"
- },
- "none": "Yok",
- "title": "3D Döndürme"
- },
- "deleteZoom": "Yakınlaştırmayı Sil",
- "customScale": "Özel Yakınlaştırma",
- "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
- "focusMode": {
- "manual": "Manuel",
- "autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
- "lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
- "title": "Odak Modu",
- "auto": "Otomatik"
- },
- "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
- "level": "Yakınlaştırma Seviyesi"
- },
"background": {
- "color": "Renk",
- "colorLabel": "Renk {{color}}",
- "gradient": "Gradyan",
- "imageReadFailed": "Bu görsel dosyası okunamadı.",
"gradientLabel": "Gradyan {{index}}",
+ "uploadCustom": "Özel Yükle",
+ "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
+ "title": "Arka Plan",
+ "imageLabel": "Arka plan {{index}}",
"custom": "Özel",
+ "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
+ "gradient": "Gradyan",
+ "colorLabel": "Renk {{color}}",
"customWallpaper": "Özel duvar kâğıdı",
- "presets": "Ön ayarlar",
- "image": "Görüntü",
"colorPalette": "Renk paleti",
- "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
- "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.",
- "imageLabel": "Arka plan {{index}}",
- "title": "Arka Plan",
- "uploadCustom": "Özel Yükle",
+ "imageReadFailed": "Bu görsel dosyası okunamadı.",
+ "image": "Görüntü",
+ "presets": "Ön ayarlar",
+ "color": "Renk",
"colorWheel": "Renk çarkı"
},
- "cursor": {
- "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
- "clickBounce": "Tıklama Sıçraması",
- "clipToBounds": "Tuvale Kırp",
- "title": "İmleç",
- "size": "Boyut",
- "themeDefault": "Varsayılan",
- "smoothing": "Yumuşatma",
- "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
- "motionBlur": "Hareket Bulanıklığı",
- "theme": "İmleç Stili",
- "show": "İmleci Göster"
- },
- "captions": {
- "text": "Metin",
- "showBackground": "Arka planı göster",
- "minWords": "Satır başına en az kelime",
- "translateFailed": "Çeviri başarısız oldu.",
- "distanceFromTop": "Üstten uzaklık",
- "fontSize": "Boyut",
- "distanceFromRight": "Sağdan uzaklık",
- "bold": "Kalın",
- "textColor": "Metin rengi",
- "original": "Özgün (döküm)",
- "translating": "Çevriliyor…",
- "language": "Dil",
- "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
- "alignLeft": "Sol",
- "distanceFromBottom": "Alttan uzaklık",
- "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
- "show": "Altyazıları göster",
- "background": "Arka plan",
- "backgroundOpacity": "Saydamlık",
- "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
- "anchorTop": "Üst",
- "translate": "Çevir",
- "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
- "alignCenter": "Orta",
- "alignRight": "Sağ",
- "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
- "displayLanguage": "Görüntüleme",
- "noTranscript": "Altyazılar medyanın deşifresinden okunur. Video deşifre edildiğinde etkinleşirler.",
- "distanceFromLeft": "Soldan uzaklık",
- "maxWords": "Satır başına en çok kelime",
- "anchorBottom": "Alt",
- "position": "Konum",
- "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
- "deleteTranslation": "Bu çeviriyi sil",
- "backgroundColor": "Arka plan rengi",
- "font": "Yazı tipi",
- "lineLength": "Satır uzunluğu",
- "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
- "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "Dışa aktarma çözünürlüğü"
- },
- "transcript": {
- "transcribing": "Döküm çıkarılıyor…",
- "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
- "laneVoiceover": "Dış ses",
- "blankedWord": "boşaltıldı",
- "noTranscript": "Henüz döküm yok",
- "noAudio": "Bu medyada ses parçası yok",
- "revertWord": "\"{{original}}\" haline getir",
- "silence": "[sessizlik {{duration}} sn]",
- "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
- "transcribeNow": "Şimdi dökümünü çıkar",
- "restoreWord": "\"{{word}}\" kelimesini geri al",
- "clipLabel": "Klip {{index}}",
- "title": "Geçerli döküm",
- "insertAria": "Yeni kelime",
- "laneRecording": "Kayıt",
- "trimSilence": "Sessizliği kırp ({{duration}} sn)",
- "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
- "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
- "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
- "removeInserted": "\"{{word}}\" kelimesini sil",
- "editorAria": "{{filename}} dökümü",
- "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
- "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
- "editWord": "\"{{word}}\" kelimesini düzenle",
- "noClips": "Henüz klip yok",
- "laneLabel": "Deşifreyi şuradan oku",
- "restoreSilence": "Sessizliği geri al ({{duration}} sn)"
- },
"customFont": {
+ "namePlaceholder": "Özel Yazı Tipim",
+ "failedToAdd": "Yazı tipi eklenemedi",
"addingButton": "Ekleniyor...",
+ "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
"urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
- "addButton": "Yazı Tipi Ekle",
- "dialogTitle": "Google Yazı Tipi Ekle",
- "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
- "nameLabel": "Görünen Ad",
- "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
"urlLabel": "Google Fonts İçe Aktarım URL'si",
+ "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
+ "nameLabel": "Görünen Ad",
"errorEmptyName": "Lütfen bir yazı tipi adı girin",
- "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
- "namePlaceholder": "Özel Yazı Tipim",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Yazı tipi eklenemedi",
"nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
+ "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
"errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
- "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
- "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Google Yazı Tipi Ekle",
+ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
+ "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin.",
+ "addButton": "Yazı Tipi Ekle"
+ },
+ "imageUpload": {
+ "invalidFileType": "Geçersiz dosya türü",
+ "failedToUpload": "Görüntü yüklenemedi",
+ "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.",
+ "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
+ "errorReading": "Dosya okunurken bir hata oluştu."
},
"annotation": {
- "arrowColor": "Ok Rengi",
- "colorWheel": "Renk çarkı",
- "blurType": "Bulanıklık Türü",
- "active": "Aktif",
- "deleteAnnotation": "Açıklamayı Sil",
- "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
- "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
- "background": "Arka Plan",
- "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
- "blurColor": "Bulanıklık Rengi",
- "blurTypeBlur": "Gauss",
- "textColor": "Metin Rengi",
- "blurColorWhite": "Beyaz",
- "title": "Açıklama Ayarları",
- "type": "Tür",
- "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
- "typeImage": "Görüntü",
- "textContent": "Metin İçeriği",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
- "typeText": "Metin",
- "blurIntensity": "Bulanıklık Yoğunluğu",
- "none": "Yok",
- "mosaicBlockSize": "Mozaik Blok Boyutu",
- "textPlaceholder": "Metninizi girin...",
- "typeArrow": "Ok",
- "color": "Renk",
- "blurColorBlack": "Siyah",
+ "blurShapeRectangle": "Dikdörtgen",
"size": "Boyut",
+ "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
+ "clearBackground": "Arka Planı Temizle",
+ "colorPalette": "Renk paleti",
"invalidImageType": "Geçersiz dosya türü",
+ "background": "Arka Plan",
+ "typeText": "Metin",
+ "active": "Aktif",
+ "color": "Renk",
"blurShapeFreehand": "Serbest",
- "shortcutsAndTips": "Kısayollar ve İpuçları",
- "uploadImage": "Görüntü Yükle",
+ "arrowDirection": "Ok Yönü",
"blurTypeMosaic": "Mozaik",
+ "colorWheel": "Renk çarkı",
+ "textColor": "Metin Rengi",
+ "title": "Açıklama Ayarları",
+ "blurType": "Bulanıklık Türü",
+ "typeBlur": "Bulanık",
+ "blurIntensity": "Bulanıklık Yoğunluğu",
"selectStyle": "Stil seçin",
- "defaultText": "Merhaba",
- "blurShapeRectangle": "Dikdörtgen",
- "colorPalette": "Renk paleti",
- "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
- "clearBackground": "Arka Planı Temizle",
+ "textContent": "Metin İçeriği",
+ "typeArrow": "Ok",
+ "none": "Yok",
+ "blurColor": "Bulanıklık Rengi",
"customFonts": "Özel Yazı Tipleri",
- "typeBlur": "Bulanık",
+ "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
+ "type": "Tür",
+ "arrowColor": "Ok Rengi",
+ "textPlaceholder": "Metninizi girin...",
+ "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
+ "blurShape": "Bulanık Şekli",
+ "uploadImage": "Görüntü Yükle",
+ "blurTypeBlur": "Gauss",
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
+ "deleteAnnotation": "Açıklamayı Sil",
"fontStyle": "Yazı Tipi Stili",
- "blurShape": "Bulanık Şekli",
- "arrowDirection": "Ok Yönü",
- "blurShapeOval": "Oval"
- },
- "speed": {
- "customPlaybackSpeed": "Özel Oynatma Hızı",
- "deleteRegion": "Hız Bölgesini Sil",
- "selectRegion": "Ayarlamak için bir hız bölgesi seçin",
- "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
- "playbackSpeed": "Oynatma Hızı",
- "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez."
- },
- "textAnimation": {
- "selectAnimation": "Animasyon seçin",
- "pulse": "Nabız",
- "rise": "Yükselme",
- "none": "Yok",
- "slideLeft": "Sola Kaydırma",
- "title": "Metin Animasyonu",
- "fade": "Belirme",
- "pop": "Fırlama",
- "typewriter": "Daktilo"
+ "defaultText": "Merhaba",
+ "mosaicBlockSize": "Mozaik Blok Boyutu",
+ "blurColorBlack": "Siyah",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "blurShapeOval": "Oval",
+ "blurColorWhite": "Beyaz",
+ "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "typeImage": "Görüntü"
},
"effects": {
- "motion": "Hareket",
- "title": "Kompozisyon",
- "format": "Biçim",
- "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
"fitClipFew": "{{count}} klip",
- "motionBlur": "Hareket Bulanıklığı",
- "fitClipMany": "{{count}} klip",
- "frame": "Çerçeve",
- "padding": "Dolgu",
- "roundness": "Yuvarlaklık",
- "off": "kapalı",
- "blurBg": "Arka Planı Bulanıklaştır",
+ "title": "Kompozisyon",
"shadow": "Gölge",
+ "off": "kapalı",
"on": "açık",
+ "blurBg": "Arka Planı Bulanıklaştır",
+ "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk.",
"fitClipOne": "{{count}} klip",
"formatOriginal": "Orijinal",
- "fitClip": "Sığdır"
+ "fitClipMany": "{{count}} klip",
+ "frame": "Çerçeve",
+ "motion": "Hareket",
+ "padding": "Dolgu",
+ "format": "Biçim",
+ "fitClip": "Sığdır",
+ "motionBlur": "Hareket Bulanıklığı",
+ "roundness": "Yuvarlaklık"
+ },
+ "transcript": {
+ "laneRecording": "Kayıt",
+ "noTranscript": "Henüz döküm yok",
+ "title": "Geçerli döküm",
+ "restoreWord": "\"{{word}}\" kelimesini geri al",
+ "revertWord": "\"{{original}}\" haline getir",
+ "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
+ "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
+ "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşu onu videodan keser (kırmızı). Bir kelimeye çift tıklayarak metnini düzeltebilirsiniz. İşaretli bir kelimenin üzerine gelerek geri alabilirsiniz.",
+ "editWord": "\"{{word}}\" kelimesini düzenle",
+ "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
+ "insertAria": "Yeni kelime",
+ "editorAria": "{{filename}} dökümü",
+ "transcribeNow": "Şimdi dökümünü çıkar",
+ "transcribing": "Döküm çıkarılıyor…",
+ "trimSilence": "Sessizliği kırp ({{duration}} sn)",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
+ "laneLabel": "Deşifreyi şuradan oku",
+ "noClips": "Henüz klip yok",
+ "laneVoiceover": "Dış ses",
+ "silence": "[sessizlik {{duration}} sn]",
+ "clipLabel": "Klip {{index}}",
+ "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
+ "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
+ "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
+ "noAudio": "Bu medyada ses parçası yok",
+ "blankedWord": "boşaltıldı"
},
"exportFormat": {
- "gifDescription": "Paylaşım için hareketli görüntü",
"mp4": "MP4",
- "mp4Video": "MP4 Video",
- "gif": "GIF",
+ "mp4Description": "Yüksek kaliteli video dosyası",
"gifAnimation": "GIF Animasyon",
- "mp4Description": "Yüksek kaliteli video dosyası"
+ "mp4Video": "MP4 Video",
+ "gifDescription": "Paylaşım için hareketli görüntü",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Görüntü yüklenemedi",
- "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
- "errorReading": "Dosya okunurken bir hata oluştu.",
- "invalidFileType": "Geçersiz dosya türü",
- "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin."
+ "captions": {
+ "showBackground": "Arka planı göster",
+ "deleteTranslation": "Bu çeviriyi sil",
+ "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.",
+ "backgroundOpacity": "Saydamlık",
+ "backgroundColor": "Arka plan rengi",
+ "alignCenter": "Orta",
+ "translationIsNonDestructive": "Çeviriler dökümün içine değil yanına kaydedilir — özgün metin ve zamanlamaları olduğu gibi kalır.",
+ "distanceFromRight": "Sağdan uzaklık",
+ "language": "Dil",
+ "text": "Metin",
+ "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.",
+ "distanceFromTop": "Üstten uzaklık",
+ "translateFailed": "Çeviri başarısız oldu.",
+ "alignLeft": "Sol",
+ "distanceFromBottom": "Alttan uzaklık",
+ "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
+ "translate": "Çevir",
+ "position": "Konum",
+ "fontSize": "Boyut",
+ "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
+ "noTranscript": "Altyazılar medyanın dökümünden okunur. Açmak için bu videonun dökümünü çıkarın.",
+ "distanceFromLeft": "Soldan uzaklık",
+ "anchorBottom": "Alt",
+ "transcribe": "Videonun dökümünü çıkar",
+ "bold": "Kalın",
+ "alignRight": "Sağ",
+ "anchorTop": "Üst",
+ "minWords": "Satır başına en az kelime",
+ "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
+ "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.",
+ "displayLanguage": "Görüntüleme",
+ "removeLegacyAnnotations": "Eski altyazı açıklamalarını kaldır",
+ "background": "Arka plan",
+ "lineLength": "Satır uzunluğu",
+ "original": "Özgün (döküm)",
+ "maxWords": "Satır başına en çok kelime",
+ "font": "Yazı tipi",
+ "translating": "Çevriliyor…",
+ "show": "Altyazıları göster",
+ "textColor": "Metin rengi"
},
- "facets": {
- "captions": "Altyazılar",
- "transcript": "Metin Dökümü"
+ "panes": {
+ "help": "Yardım"
},
- "audio": {
- "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
- "reset": "Sesi sıfırla",
- "title": "Ses",
- "outputGain": "Çıkış seviyesi"
+ "speed": {
+ "deleteRegion": "Hız Bölgesini Sil",
+ "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
+ "selectRegion": "Ayarlamak için bir hız bölgesi seçin",
+ "playbackSpeed": "Oynatma Hızı",
+ "customPlaybackSpeed": "Özel Oynatma Hızı",
+ "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez."
},
- "language": {
- "title": "Dil"
+ "gifSettings": {
+ "frameRate": "GIF Kare Hızı",
+ "loop": "GIF Döngüsü",
+ "size": "GIF Boyutu"
+ },
+ "exportQuality": {
+ "title": "Dışa aktarma çözünürlüğü",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "defaultLabel": "Ses parçası",
"importFailed": "Ses eklenemedi",
- "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
"fadeOut": "Kararma",
- "defaultLabel": "Ses parçası",
- "mute": "Sessiz",
+ "fadeIn": "Açılma",
"remove": "Parçayı sil",
- "add": "Ses parçası ekle",
"loop": "Döngü",
- "fadeIn": "Açılma"
+ "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin",
+ "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "add": "Ses parçası ekle",
+ "mute": "Sessiz"
},
- "project": {
- "load": "Proje Yükle",
- "save": "Projeyi Kaydet",
- "new": "Yeni Proje"
+ "layout": {
+ "help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
+ "mirrorWebcam": "Web kamerasını aynala",
+ "webcamFraming": "Webcam kadrajı",
+ "shapes": {
+ "rectangle": "Dikdörtgen",
+ "rounded": "Yuvarlatılmış",
+ "circle": "Daire",
+ "square": "Kare"
+ },
+ "selectPreset": "Ön ayar seçin",
+ "bgModes": {
+ "custom": "Özel",
+ "none": "Orijinal",
+ "blur": "Bulanık",
+ "transparent": "Kesme"
+ },
+ "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
+ "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
+ "preset": "Ön Ayar",
+ "webcamCropZoom": "Kırpma yakınlaştırması",
+ "webcamSize": "Webcam Boyutu",
+ "dualFrame": "Çift Kare",
+ "webcamCropY": "Dikey kaydırma",
+ "verticalStack": "Dikey Yığın",
+ "pictureInPicture": "Resim İçinde Resim",
+ "webcamShape": "Kamera Şekli",
+ "webcamCropX": "Yatay kaydırma",
+ "reactiveWebcam": "Yakınlaştırınca küçült",
+ "webcamBackground": "Kamera Arka Planı",
+ "helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
+ "title": "Kamera düzeni",
+ "noWebcam": "Web kamerası yok"
},
- "panes": {
- "help": "Yardım"
+ "textAnimation": {
+ "slideLeft": "Sola Kaydırma",
+ "pulse": "Nabız",
+ "typewriter": "Daktilo",
+ "selectAnimation": "Animasyon seçin",
+ "fade": "Belirme",
+ "title": "Metin Animasyonu",
+ "none": "Yok",
+ "pop": "Fırlama",
+ "rise": "Yükselme"
},
- "trim": {
- "deleteRegion": "Kırpma Bölgesini Sil"
+ "facets": {
+ "transcript": "Metin Dökümü",
+ "captions": "Altyazılar"
},
- "export": {
- "videoButton": "Videoyu Dışa Aktar",
- "gifButton": "GIF Olarak Dışa Aktar",
- "chooseSaveLocation": "Kayıt Konumu Seç"
+ "crop": {
+ "title": "Kırpma",
+ "free": "Serbest",
+ "unlockAspectRatio": "En boy oranının kilidini aç",
+ "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
+ "done": "Tamam",
+ "ratio": "Oran",
+ "cropVideo": "Videoyu Kırp",
+ "lockAspectRatio": "En boy oranını kilitle"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = en sol / en üst, 100 = en sağ / en alt",
+ "title": "Odak Konumu",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Yakınlaştırmayı Sil",
+ "focusMode": {
+ "lockedDisclaimer": "Zaman çizelgesindeki genel Otomatik Odak anahtarı tarafından kontrol edilir. Odak modunu yakınlaştırma başına ayarlamak için kapatın.",
+ "auto": "Otomatik",
+ "manual": "Manuel",
+ "autoDescription": "Kamera kaydedilen imleç konumunu takip eder",
+ "title": "Odak Modu"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Sol",
+ "right": "Sağ",
+ "iso": "Iso"
+ },
+ "none": "Yok",
+ "title": "3D Döndürme"
+ },
+ "level": "Yakınlaştırma Seviyesi",
+ "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
+ "customScale": "Özel Yakınlaştırma",
+ "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin"
+ },
+ "audio": {
+ "outputGain": "Çıkış seviyesi",
+ "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
+ "reset": "Sesi sıfırla",
+ "title": "Ses"
+ },
+ "language": {
+ "title": "Dil"
+ },
+ "project": {
+ "new": "Yeni Proje",
+ "load": "Proje Yükle",
+ "save": "Projeyi Kaydet"
},
"support": {
"starOnGithub": "GitHub'da Yıldızla",
"saveDiagnostics": "Teşhis Verilerini Kaydet",
"reportBug": "Hata Bildir"
},
- "gifSettings": {
- "size": "GIF Boyutu",
- "frameRate": "GIF Kare Hızı",
- "loop": "GIF Döngüsü"
+ "cursor": {
+ "smoothing": "Yumuşatma",
+ "clickBounce": "Tıklama Sıçraması",
+ "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.",
+ "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.",
+ "size": "Boyut",
+ "title": "İmleç",
+ "show": "İmleci Göster",
+ "themeDefault": "Varsayılan",
+ "clipToBounds": "Tuvale Kırp",
+ "motionBlur": "Hareket Bulanıklığı",
+ "theme": "İmleç Stili"
+ },
+ "export": {
+ "gifButton": "GIF Olarak Dışa Aktar",
+ "chooseSaveLocation": "Kayıt Konumu Seç",
+ "videoButton": "Videoyu Dışa Aktar"
+ },
+ "trim": {
+ "deleteRegion": "Kırpma Bölgesini Sil"
}
}
diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json
index db20ec28e..2a40fd1f8 100644
--- a/src/i18n/locales/tr/timeline.json
+++ b/src/i18n/locales/tr/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "Bu kayıtta imleç hareket verisi yok veya mevcut yakınlaştırmalar zaten yoğun anları kapsıyor.",
- "smartCutsNoAudio": "Bu medyada ses yok",
- "automaticZoomsHint": "Kaydedilen imleç hareketinden",
- "dragToReorderHint": "Yeniden sıralamak için sürükleyin · giriş/çıkış noktalarını düzenlemek için çift tıklayın",
- "smartCutsNeedsTranscript": "Bir döküm gerekiyor",
- "addedWord": "Eklenen kelime: \"{{word}}\" — arkasında ses yok",
- "smartZoomsAndCuts": "Akıllı kırpma",
- "autoZoomFailed": "Otomatik yakınlaştırma başarısız oldu",
- "smartCutsWaiting": "Metne dökülüyor… birazdan hazır",
- "automaticZooms": "Otomatik yakınlaştırmalar",
- "arrangeClipsHint": "Yeniden sıralamak için aşağıdaki klipleri sürükleyin veya yenilerini bırakın",
- "comment": "Yorum",
- "addAudioTooltip": "Ses ekle",
- "timelineTools": "Zaman çizelgesi araçları",
- "deleteClip": "Klibi sil",
- "arrangeClips": "Klipleri düzenle",
- "editInOutPoints": "Giriş/çıkış noktalarını düzenle",
- "smartZoomsAndCutsHint": "Yapay zeka ile",
- "addedAutoZoomPlural": "{{count}} otomatik yakınlaştırma eklendi",
- "noAutoZoomMoments": "Otomatik yakınlaştırma anı bulunamadı",
- "smartCutsFailed": "Metne dökme başarısız — Medya bölümünden yeniden deneyin",
- "smartCutsNoSpeech": "Konuşma algılanmadı",
- "newAnnotation": "Açıklama",
- "importRecordingFirst": "Önce bir kayıt içe aktarın",
- "addedAutoZoom": "{{count}} otomatik yakınlaştırma eklendi",
- "dropToAdd": "Zaman çizelgesine eklemek için bırakın",
- "aiEnhanceRequested": "Yapay zeka aracısından ölü zamanları kırpması istendi",
- "autoEnhance": "Otomatik iyileştirme"
+ "buttons": {
+ "addZoom": "Yakınlaştırma Ekle (Z)",
+ "suggestZooms": "İmleçten Yakınlaştırma Öner",
+ "autoZoomOn": "Otomatik yakınlaştırma önerileri açık — önerilen yakınlaştırmaları kaldırmak için tıklayın",
+ "autoZoomOff": "Otomatik yakınlaştırma önerileri kapalı — imleçten yakınlaştırma önermek için tıklayın",
+ "autoFocusAllOn": "Tüm yakınlaştırmalarda Otomatik Odak açık — hepsini manuel yapmak için tıklayın",
+ "autoFocusAllOff": "Tüm yakınlaştırmalarda Otomatik Odağı aç (kamera imleci takip eder)",
+ "addTrim": "Kırpma Ekle (T)",
+ "addAnnotation": "Açıklama Ekle (A)",
+ "addSpeed": "Hız Ekle (S)",
+ "addCameraFullscreen": "Tam Ekran Kamera Ekle (C)"
+ },
+ "hints": {
+ "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
+ "pressTrim": "Kırpma eklemek için T tuşuna basın",
+ "pressAnnotation": "Açıklama eklemek için A tuşuna basın",
+ "pressAudio": "Ses eklemek için M, seslendirme kaydetmek için V tuşuna basın",
+ "pressSpeed": "Hız eklemek için S tuşuna basın",
+ "pressCameraFullscreen": "Tam Ekran Kamera bölümü eklemek için C tuşuna basın"
},
"labels": {
- "zoom": "Yakınlaştır",
- "cameraFullscreenItem": "Tam Ekran Kamera {{index}}",
- "imageItem": "Görüntü",
"pan": "Kaydır",
- "zoomItem": "Yakınlaştırma {{index}}",
- "cameraFullscreen": "Tam Ekran Kamera",
+ "zoom": "Yakınlaştır",
+ "trim": "Kırp",
"speed": "Hız",
- "emptyText": "Boş metin",
+ "zoomItem": "Yakınlaştırma {{index}}",
"trimItem": "Kırpma {{index}}",
+ "speedItem": "Hız {{index}}",
"annotationItem": "Açıklama",
- "trim": "Kırp",
- "speedItem": "Hız {{index}}"
+ "imageItem": "Görüntü",
+ "emptyText": "Boş metin",
+ "cameraFullscreen": "Tam Ekran Kamera",
+ "cameraFullscreenItem": "Tam Ekran Kamera {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Video Yüklenmedi",
+ "dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
},
"errors": {
- "noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
+ "cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
+ "zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
+ "zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
+ "noCursorTelemetry": "İmleç telemetrisi mevcut değil",
"noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
- "cameraFullscreenExistsAtLocation": "Bu konumda zaten bir Tam Ekran Kamera bölümü var veya yeterli alan yok.",
"noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
"noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
- "zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
"noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
- "speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok.",
- "noCursorTelemetry": "İmleç telemetrisi mevcut değil",
+ "noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
"noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
+ "noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
"cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
- "cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
- "cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
- "zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
- "noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
"trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
- "cannotPlaceCameraFullscreen": "Buraya Tam Ekran Kamera yerleştirilemiyor"
+ "cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
+ "speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok.",
+ "cannotPlaceCameraFullscreen": "Buraya Tam Ekran Kamera yerleştirilemiyor",
+ "cameraFullscreenExistsAtLocation": "Bu konumda zaten bir Tam Ekran Kamera bölümü var veya yeterli alan yok."
+ },
+ "success": {
+ "addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
+ "addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
+ },
+ "toolbar": {
+ "autoEnhance": "Otomatik iyileştirme",
+ "automaticZooms": "Otomatik yakınlaştırmalar",
+ "automaticZoomsHint": "Kaydedilen imleç hareketinden",
+ "smartZoomsAndCuts": "Akıllı kırpma",
+ "smartZoomsAndCutsHint": "Yapay zeka ile",
+ "comment": "Yorum",
+ "timelineTools": "Zaman çizelgesi araçları",
+ "arrangeClips": "Klipleri düzenle",
+ "arrangeClipsHint": "Yeniden sıralamak için aşağıdaki klipleri sürükleyin veya yenilerini bırakın",
+ "newAnnotation": "Açıklama",
+ "dragToReorderHint": "Yeniden sıralamak için sürükleyin · giriş/çıkış noktalarını düzenlemek için çift tıklayın",
+ "editInOutPoints": "Giriş/çıkış noktalarını düzenle",
+ "deleteClip": "Klibi sil",
+ "dropToAdd": "Zaman çizelgesine eklemek için bırakın",
+ "importRecordingFirst": "Önce bir kayıt içe aktarın",
+ "noAutoZoomMoments": "Otomatik yakınlaştırma anı bulunamadı",
+ "noAutoZoomMomentsDescription": "Bu kayıtta imleç hareket verisi yok veya mevcut yakınlaştırmalar zaten yoğun anları kapsıyor.",
+ "addedAutoZoom": "{{count}} otomatik yakınlaştırma eklendi",
+ "addedAutoZoomPlural": "{{count}} otomatik yakınlaştırma eklendi",
+ "autoZoomFailed": "Otomatik yakınlaştırma başarısız oldu",
+ "aiEnhanceRequested": "Yapay zeka aracısından ölü zamanları kırpması istendi",
+ "smartCutsWaiting": "Metne dökülüyor… birazdan hazır",
+ "smartCutsNeedsTranscript": "Bir döküm gerekiyor",
+ "smartCutsNoAudio": "Bu medyada ses yok",
+ "smartCutsNoSpeech": "Konuşma algılanmadı",
+ "smartCutsFailed": "Metne dökme başarısız — Medya bölümünden yeniden deneyin",
+ "addAudioTooltip": "Ses ekle",
+ "addedWord": "Eklenen kelime: \"{{word}}\" — arkasında ses yok"
},
"audio": {
- "micDenied": "Mikrofon erişimi reddedildi",
+ "addVoiceover": "Seslendirme ekle",
+ "addVoiceoverHint": "Videonuzun üzerine anlatım kaydedin",
"subtitle": "Zaman çizelgesine seslendirme veya fon müziği katmanı yerleştirin",
- "importFailed": "Ses dosyası içe aktarılamadı",
+ "record": "Seslendirme kaydet",
+ "importFile": "Ses dosyası içe aktar",
"importFileHint": "Müzik veya ses dosyası içe aktarın",
- "addVoiceoverHint": "Videonuzun üzerine anlatım kaydedin",
- "stop": "Durdur",
"recording": "Kaydediliyor",
- "saveFailed": "Kayıt kaydedilemedi",
"recordingHint": "Videoyla birlikte anlatın — kayıt sırasında oynamaya devam eder",
- "importFile": "Ses dosyası içe aktar",
- "record": "Seslendirme kaydet",
+ "stop": "Durdur",
+ "micDenied": "Mikrofon erişimi reddedildi",
"recordingUnavailable": "Burada kayıt kullanılamıyor",
- "addVoiceover": "Seslendirme ekle"
- },
- "success": {
- "addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
- "addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
- },
- "hints": {
- "pressAnnotation": "Açıklama eklemek için A tuşuna basın",
- "pressSpeed": "Hız eklemek için S tuşuna basın",
- "pressTrim": "Kırpma eklemek için T tuşuna basın",
- "pressCameraFullscreen": "Tam Ekran Kamera bölümü eklemek için C tuşuna basın",
- "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
- "pressAudio": "Ses eklemek için M, seslendirme kaydetmek için V tuşuna basın"
- },
- "buttons": {
- "autoFocusAllOff": "Tüm yakınlaştırmalarda Otomatik Odağı aç (kamera imleci takip eder)",
- "autoZoomOff": "Otomatik yakınlaştırma önerileri kapalı — imleçten yakınlaştırma önermek için tıklayın",
- "addAnnotation": "Açıklama Ekle (A)",
- "suggestZooms": "İmleçten Yakınlaştırma Öner",
- "addSpeed": "Hız Ekle (S)",
- "addCameraFullscreen": "Tam Ekran Kamera Ekle (C)",
- "autoFocusAllOn": "Tüm yakınlaştırmalarda Otomatik Odak açık — hepsini manuel yapmak için tıklayın",
- "autoZoomOn": "Otomatik yakınlaştırma önerileri açık — önerilen yakınlaştırmaları kaldırmak için tıklayın",
- "addZoom": "Yakınlaştırma Ekle (Z)",
- "addTrim": "Kırpma Ekle (T)"
- },
- "emptyState": {
- "noVideo": "Video Yüklenmedi",
- "dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
+ "saveFailed": "Kayıt kaydedilemedi",
+ "importFailed": "Ses dosyası içe aktarılamadı"
}
}
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 1cbc815bb..719f80e21 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "Độ mờ",
- "bgModes": {
- "transparent": "Tách nền",
- "none": "Gốc",
- "blur": "Làm mờ",
- "custom": "Tùy chỉnh"
- },
- "selectPreset": "Chọn cài đặt sẵn",
- "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
- "reactiveWebcam": "Thu nhỏ khi phóng to",
- "shapes": {
- "circle": "Tròn",
- "square": "Vuông",
- "rectangle": "Chữ nhật",
- "rounded": "Bo góc"
- },
- "webcamBackground": "Nền máy ảnh",
- "verticalStack": "Xếp chồng dọc",
- "pictureInPicture": "Hình trong hình",
- "webcamShape": "Hình dạng máy ảnh",
- "webcamCropY": "Dịch chuyển dọc",
- "webcamSize": "Kích thước Webcam",
- "mirrorWebcam": "Lật webcam",
- "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
- "webcamFraming": "Khung hình webcam",
- "noWebcam": "Không có webcam",
- "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
- "webcamCropZoom": "Thu phóng vùng cắt",
- "dualFrame": "Khung kép",
- "webcamCropX": "Dịch chuyển ngang",
- "title": "Bố cục camera",
- "preset": "Cài đặt sẵn"
- },
- "crop": {
- "done": "Hoàn tất",
- "ratio": "Tỷ lệ",
- "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
- "title": "Cắt xén",
- "free": "Tự do",
- "lockAspectRatio": "Khóa tỷ lệ khung hình",
- "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
- "cropVideo": "Cắt xén video"
- },
- "zoom": {
- "position": {
- "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "Vị trí tiêu điểm"
- },
- "threeD": {
- "preset": {
- "right": "Phải",
- "iso": "Đẳng phối",
- "left": "Trái"
- },
- "none": "Không",
- "title": "Xoay 3D"
- },
- "deleteZoom": "Xóa thu phóng",
- "customScale": "Thu phóng tùy chỉnh",
- "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
- "focusMode": {
- "manual": "Thủ công",
- "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
- "lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
- "title": "Chế độ lấy nét",
- "auto": "Tự động"
- },
- "previewHold": "Giữ để xem trước hiệu ứng phóng to",
- "level": "Mức độ thu phóng"
- },
"background": {
- "color": "Màu sắc",
- "colorLabel": "Màu {{color}}",
- "gradient": "Dải màu",
- "imageReadFailed": "Không thể đọc tệp ảnh này.",
"gradientLabel": "Dải màu {{index}}",
+ "uploadCustom": "Tải lên tùy chỉnh",
+ "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
+ "title": "Nền",
+ "imageLabel": "Nền {{index}}",
"custom": "Tùy chỉnh",
+ "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
+ "gradient": "Dải màu",
+ "colorLabel": "Màu {{color}}",
"customWallpaper": "Ảnh nền tùy chỉnh",
- "presets": "Có sẵn",
- "image": "Hình ảnh",
"colorPalette": "Bảng màu",
- "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
- "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.",
- "imageLabel": "Nền {{index}}",
- "title": "Nền",
- "uploadCustom": "Tải lên tùy chỉnh",
+ "imageReadFailed": "Không thể đọc tệp ảnh này.",
+ "image": "Hình ảnh",
+ "presets": "Có sẵn",
+ "color": "Màu sắc",
"colorWheel": "Vòng màu"
},
- "cursor": {
- "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
- "clickBounce": "Nảy khi nhấp",
- "clipToBounds": "Cắt theo khung",
- "title": "Con trỏ",
- "size": "Kích thước",
- "themeDefault": "Mặc định",
- "smoothing": "Làm mượt",
- "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
- "motionBlur": "Làm mờ chuyển động",
- "theme": "Kiểu con trỏ",
- "show": "Hiện con trỏ"
- },
- "captions": {
- "text": "Văn bản",
- "showBackground": "Hiện nền",
- "minWords": "Số từ tối thiểu mỗi dòng",
- "translateFailed": "Dịch thất bại.",
- "distanceFromTop": "Khoảng cách từ trên",
- "fontSize": "Cỡ chữ",
- "distanceFromRight": "Khoảng cách từ phải",
- "bold": "Đậm",
- "textColor": "Màu chữ",
- "original": "Gốc (bản chép lời)",
- "translating": "Đang dịch…",
- "language": "Ngôn ngữ",
- "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
- "alignLeft": "Trái",
- "distanceFromBottom": "Khoảng cách từ dưới",
- "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
- "show": "Hiện phụ đề",
- "background": "Nền",
- "backgroundOpacity": "Độ mờ",
- "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
- "anchorTop": "Trên",
- "translate": "Dịch",
- "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
- "alignCenter": "Giữa",
- "alignRight": "Phải",
- "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
- "displayLanguage": "Hiển thị",
- "noTranscript": "Phụ đề được đọc từ bản chép lời của media. Chúng bật lên khi video đã được chép lời.",
- "distanceFromLeft": "Khoảng cách từ trái",
- "maxWords": "Số từ tối đa mỗi dòng",
- "anchorBottom": "Dưới",
- "position": "Vị trí",
- "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
- "deleteTranslation": "Xóa bản dịch này",
- "backgroundColor": "Màu nền",
- "font": "Phông chữ",
- "lineLength": "Độ dài dòng",
- "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
- "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi."
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "Độ phân giải xuất"
- },
- "transcript": {
- "transcribing": "Đang chép lời…",
- "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
- "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
- "laneVoiceover": "Lời thuyết minh",
- "blankedWord": "đã xoá",
- "noTranscript": "Chưa có bản chép lời",
- "noAudio": "Media này không có bản âm thanh",
- "revertWord": "Khôi phục \"{{original}}\"",
- "silence": "[khoảng lặng {{duration}} giây]",
- "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
- "transcribeNow": "Chép lời ngay",
- "restoreWord": "Khôi phục \"{{word}}\"",
- "clipLabel": "Clip {{index}}",
- "title": "Bản chép lời hiện tại",
- "insertAria": "Từ mới",
- "laneRecording": "Bản ghi",
- "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
- "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
- "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
- "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
- "removeInserted": "Xoá \"{{word}}\"",
- "editorAria": "Bản chép lời của {{filename}}",
- "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
- "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
- "editWord": "Sửa \"{{word}}\"",
- "noClips": "Chưa có clip nào",
- "laneLabel": "Đọc bản chép lời từ",
- "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)"
- },
"customFont": {
+ "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
+ "failedToAdd": "Thêm phông chữ thất bại",
"addingButton": "Đang thêm...",
+ "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
"urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
- "addButton": "Thêm phông chữ",
- "dialogTitle": "Thêm Google Font",
- "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
- "nameLabel": "Tên hiển thị",
- "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
"urlLabel": "URL nhập Google Fonts",
+ "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
+ "nameLabel": "Tên hiển thị",
"errorEmptyName": "Vui lòng nhập tên phông chữ",
- "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
- "namePlaceholder": "Phông chữ tùy chỉnh của tôi",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "Thêm phông chữ thất bại",
"nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
+ "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
"errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
- "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
- "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "Thêm Google Font",
+ "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
+ "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác.",
+ "addButton": "Thêm phông chữ"
+ },
+ "imageUpload": {
+ "invalidFileType": "Loại tệp không hợp lệ",
+ "failedToUpload": "Tải lên hình ảnh thất bại",
+ "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
+ "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
+ "errorReading": "Đã xảy ra lỗi khi đọc tệp."
},
"annotation": {
- "arrowColor": "Màu mũi tên",
- "colorWheel": "Vòng màu",
- "blurType": "Loại làm mờ",
- "active": "Hoạt động",
- "deleteAnnotation": "Xóa chú thích",
- "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
- "strokeWidth": "Độ dày nét: {{width}}px",
- "background": "Nền",
- "imageUploadSuccess": "Tải lên hình ảnh thành công!",
- "blurColor": "Màu làm mờ",
- "blurTypeBlur": "Gaussian",
- "textColor": "Màu văn bản",
- "blurColorWhite": "Trắng",
- "title": "Cài đặt chú thích",
- "type": "Loại",
- "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
- "typeImage": "Hình ảnh",
- "textContent": "Nội dung văn bản",
"supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
- "typeText": "Văn bản",
- "blurIntensity": "Cường độ làm mờ",
- "none": "Không có",
- "mosaicBlockSize": "Kích thước khối khảm",
- "textPlaceholder": "Nhập văn bản của bạn...",
- "typeArrow": "Mũi tên",
- "color": "Màu sắc",
- "blurColorBlack": "Đen",
+ "blurShapeRectangle": "Chữ nhật",
"size": "Kích thước",
+ "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
+ "clearBackground": "Xóa nền",
"invalidImageType": "Loại tệp không hợp lệ",
+ "colorPalette": "Bảng màu",
+ "background": "Nền",
+ "typeText": "Văn bản",
+ "active": "Hoạt động",
+ "color": "Màu sắc",
"blurShapeFreehand": "Vẽ tự do",
- "shortcutsAndTips": "Phím tắt & Mẹo",
- "uploadImage": "Tải lên hình ảnh",
+ "arrowDirection": "Hướng mũi tên",
"blurTypeMosaic": "Khảm",
+ "colorWheel": "Vòng màu",
+ "textColor": "Màu văn bản",
+ "title": "Cài đặt chú thích",
+ "blurType": "Loại làm mờ",
+ "typeBlur": "Làm mờ",
+ "blurIntensity": "Cường độ làm mờ",
"selectStyle": "Chọn kiểu",
- "defaultText": "Xin chào",
- "blurShapeRectangle": "Chữ nhật",
- "colorPalette": "Bảng màu",
- "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
- "clearBackground": "Xóa nền",
+ "textContent": "Nội dung văn bản",
+ "typeArrow": "Mũi tên",
+ "none": "Không có",
+ "blurColor": "Màu làm mờ",
"customFonts": "Phông chữ tùy chỉnh",
- "typeBlur": "Làm mờ",
+ "imageUploadSuccess": "Tải lên hình ảnh thành công!",
+ "type": "Loại",
+ "arrowColor": "Màu mũi tên",
+ "textPlaceholder": "Nhập văn bản của bạn...",
+ "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
+ "blurShape": "Hình dạng làm mờ",
+ "uploadImage": "Tải lên hình ảnh",
+ "blurTypeBlur": "Gaussian",
"tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
+ "shortcutsAndTips": "Phím tắt & Mẹo",
+ "deleteAnnotation": "Xóa chú thích",
"fontStyle": "Kiểu phông chữ",
- "blurShape": "Hình dạng làm mờ",
- "arrowDirection": "Hướng mũi tên",
- "blurShapeOval": "Bầu dục"
- },
- "speed": {
- "customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
- "deleteRegion": "Xóa vùng tốc độ",
- "selectRegion": "Chọn vùng tốc độ để điều chỉnh",
- "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
- "playbackSpeed": "Tốc độ phát",
- "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng."
- },
- "textAnimation": {
- "selectAnimation": "Chọn hoạt ảnh",
- "pulse": "Nhấp nháy",
- "rise": "Trồi lên",
- "none": "Không có",
- "slideLeft": "Trượt sang trái",
- "title": "Hoạt ảnh văn bản",
- "fade": "Mờ dần",
- "pop": "Bật lên",
- "typewriter": "Máy đánh chữ"
+ "defaultText": "Xin chào",
+ "mosaicBlockSize": "Kích thước khối khảm",
+ "blurColorBlack": "Đen",
+ "strokeWidth": "Độ dày nét: {{width}}px",
+ "blurShapeOval": "Bầu dục",
+ "blurColorWhite": "Trắng",
+ "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
+ "typeImage": "Hình ảnh"
},
"effects": {
- "motion": "Chuyển động",
- "title": "Bố cục hình ảnh",
- "format": "Định dạng",
- "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
"fitClipFew": "{{count}} clip",
- "motionBlur": "Làm mờ chuyển động",
- "fitClipMany": "{{count}} clip",
- "frame": "Khung",
- "padding": "Phần đệm",
- "roundness": "Độ bo tròn",
- "off": "tắt",
- "blurBg": "Làm mờ nền",
+ "title": "Bố cục hình ảnh",
"shadow": "Bóng đổ",
+ "off": "tắt",
"on": "bật",
+ "blurBg": "Làm mờ nền",
+ "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video.",
"fitClipOne": "{{count}} clip",
"formatOriginal": "Gốc",
- "fitClip": "Vừa khít"
+ "fitClipMany": "{{count}} clip",
+ "frame": "Khung",
+ "motion": "Chuyển động",
+ "padding": "Phần đệm",
+ "format": "Định dạng",
+ "fitClip": "Vừa khít",
+ "motionBlur": "Làm mờ chuyển động",
+ "roundness": "Độ bo tròn"
+ },
+ "transcript": {
+ "laneRecording": "Bản ghi",
+ "noTranscript": "Chưa có bản chép lời",
+ "title": "Bản chép lời hiện tại",
+ "restoreWord": "Khôi phục \"{{word}}\"",
+ "revertWord": "Khôi phục \"{{original}}\"",
+ "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
+ "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
+ "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để cắt nó khỏi video (màu đỏ). Nhấp đúp vào một từ để sửa văn bản. Di chuột lên từ được đánh dấu để hoàn tác.",
+ "editWord": "Sửa \"{{word}}\"",
+ "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
+ "insertAria": "Từ mới",
+ "editorAria": "Bản chép lời của {{filename}}",
+ "transcribeNow": "Chép lời ngay",
+ "transcribing": "Đang chép lời…",
+ "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
+ "removeInserted": "Xoá \"{{word}}\"",
+ "laneLabel": "Đọc bản chép lời từ",
+ "noClips": "Chưa có clip nào",
+ "laneVoiceover": "Lời thuyết minh",
+ "silence": "[khoảng lặng {{duration}} giây]",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
+ "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
+ "editingHintDev": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video, gõ giữa hai từ để thêm một từ mới.",
+ "noAudio": "Media này không có bản âm thanh",
+ "blankedWord": "đã xoá"
},
"exportFormat": {
- "gifDescription": "Hình ảnh động để chia sẻ",
"mp4": "MP4",
- "mp4Video": "Video MP4",
- "gif": "GIF",
+ "mp4Description": "Tệp video chất lượng cao",
"gifAnimation": "Ảnh động GIF",
- "mp4Description": "Tệp video chất lượng cao"
+ "mp4Video": "Video MP4",
+ "gifDescription": "Hình ảnh động để chia sẻ",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "Tải lên hình ảnh thất bại",
- "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
- "errorReading": "Đã xảy ra lỗi khi đọc tệp.",
- "invalidFileType": "Loại tệp không hợp lệ",
- "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG."
+ "captions": {
+ "showBackground": "Hiện nền",
+ "deleteTranslation": "Xóa bản dịch này",
+ "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.",
+ "backgroundOpacity": "Độ mờ",
+ "backgroundColor": "Màu nền",
+ "alignCenter": "Giữa",
+ "translationIsNonDestructive": "Bản dịch được lưu bên cạnh bản chép lời, không bao giờ ghi vào trong đó — văn bản gốc và mốc thời gian giữ nguyên.",
+ "distanceFromRight": "Khoảng cách từ phải",
+ "language": "Ngôn ngữ",
+ "text": "Văn bản",
+ "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.",
+ "distanceFromTop": "Khoảng cách từ trên",
+ "translateFailed": "Dịch thất bại.",
+ "alignLeft": "Trái",
+ "distanceFromBottom": "Khoảng cách từ dưới",
+ "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.",
+ "translate": "Dịch",
+ "position": "Vị trí",
+ "fontSize": "Cỡ chữ",
+ "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.",
+ "noTranscript": "Phụ đề được lấy từ bản chép lời của media. Hãy chép lời video này để bật phụ đề.",
+ "distanceFromLeft": "Khoảng cách từ trái",
+ "anchorBottom": "Dưới",
+ "transcribe": "Chép lời video",
+ "bold": "Đậm",
+ "alignRight": "Phải",
+ "anchorTop": "Trên",
+ "minWords": "Số từ tối thiểu mỗi dòng",
+ "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
+ "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.",
+ "displayLanguage": "Hiển thị",
+ "removeLegacyAnnotations": "Xóa chú thích phụ đề cũ",
+ "background": "Nền",
+ "lineLength": "Độ dài dòng",
+ "original": "Gốc (bản chép lời)",
+ "maxWords": "Số từ tối đa mỗi dòng",
+ "font": "Phông chữ",
+ "translating": "Đang dịch…",
+ "show": "Hiện phụ đề",
+ "textColor": "Màu chữ"
},
- "facets": {
- "captions": "Phụ đề",
- "transcript": "Bản ghi lời thoại"
+ "panes": {
+ "help": "Trợ giúp"
},
- "audio": {
- "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
- "reset": "Đặt lại âm thanh",
- "title": "Âm thanh",
- "outputGain": "Mức đầu ra"
+ "speed": {
+ "deleteRegion": "Xóa vùng tốc độ",
+ "maxSpeedError": "Tốc độ không thể cao hơn {{max}}×",
+ "selectRegion": "Chọn vùng tốc độ để điều chỉnh",
+ "playbackSpeed": "Tốc độ phát",
+ "customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
+ "previewFrameSteppingHint": "Trên {{native}}×, bản xem trước tua từng khung hình và tắt tiếng. Xuất video không bị ảnh hưởng."
},
- "language": {
- "title": "Ngôn ngữ"
+ "gifSettings": {
+ "frameRate": "Tốc độ khung hình GIF",
+ "loop": "Lặp lại GIF",
+ "size": "Kích thước GIF"
+ },
+ "exportQuality": {
+ "title": "Độ phân giải xuất",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "defaultLabel": "Bản âm thanh",
"importFailed": "Không thể thêm âm thanh",
- "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
"fadeOut": "Mờ ra",
- "defaultLabel": "Bản âm thanh",
- "mute": "Tắt tiếng",
+ "fadeIn": "Mờ vào",
"remove": "Xóa bản nhạc",
- "add": "Thêm bản âm thanh",
"loop": "Lặp",
- "fadeIn": "Mờ vào"
+ "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong",
+ "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "add": "Thêm bản âm thanh",
+ "mute": "Tắt tiếng"
},
- "project": {
- "load": "Tải dự án",
- "save": "Lưu dự án",
- "new": "Dự án mới"
+ "layout": {
+ "help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
+ "mirrorWebcam": "Lật webcam",
+ "webcamFraming": "Khung hình webcam",
+ "shapes": {
+ "rectangle": "Chữ nhật",
+ "rounded": "Bo góc",
+ "circle": "Tròn",
+ "square": "Vuông"
+ },
+ "selectPreset": "Chọn cài đặt sẵn",
+ "bgModes": {
+ "custom": "Tùy chỉnh",
+ "none": "Gốc",
+ "blur": "Làm mờ",
+ "transparent": "Tách nền"
+ },
+ "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
+ "webcamBlurIntensity": "Độ mờ",
+ "preset": "Cài đặt sẵn",
+ "webcamCropZoom": "Thu phóng vùng cắt",
+ "webcamSize": "Kích thước Webcam",
+ "dualFrame": "Khung kép",
+ "webcamCropY": "Dịch chuyển dọc",
+ "verticalStack": "Xếp chồng dọc",
+ "pictureInPicture": "Hình trong hình",
+ "webcamShape": "Hình dạng máy ảnh",
+ "webcamCropX": "Dịch chuyển ngang",
+ "reactiveWebcam": "Thu nhỏ khi phóng to",
+ "webcamBackground": "Nền máy ảnh",
+ "helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
+ "title": "Bố cục camera",
+ "noWebcam": "Không có webcam"
},
- "panes": {
- "help": "Trợ giúp"
+ "textAnimation": {
+ "slideLeft": "Trượt sang trái",
+ "pulse": "Nhấp nháy",
+ "typewriter": "Máy đánh chữ",
+ "selectAnimation": "Chọn hoạt ảnh",
+ "fade": "Mờ dần",
+ "title": "Hoạt ảnh văn bản",
+ "none": "Không có",
+ "pop": "Bật lên",
+ "rise": "Trồi lên"
},
- "trim": {
- "deleteRegion": "Xóa vùng cắt"
+ "facets": {
+ "transcript": "Bản ghi lời thoại",
+ "captions": "Phụ đề"
},
- "export": {
- "videoButton": "Xuất Video",
- "gifButton": "Xuất GIF",
- "chooseSaveLocation": "Chọn vị trí lưu"
+ "crop": {
+ "title": "Cắt xén",
+ "free": "Tự do",
+ "unlockAspectRatio": "Mở khóa tỷ lệ khung hình",
+ "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
+ "done": "Hoàn tất",
+ "ratio": "Tỷ lệ",
+ "cropVideo": "Cắt xén video",
+ "lockAspectRatio": "Khóa tỷ lệ khung hình"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới",
+ "title": "Vị trí tiêu điểm",
+ "x": "X (%)"
+ },
+ "deleteZoom": "Xóa thu phóng",
+ "focusMode": {
+ "lockedDisclaimer": "Được điều khiển bởi công tắc Lấy nét tự động chung trên dòng thời gian. Tắt để đặt chế độ lấy nét riêng cho từng lần thu phóng.",
+ "auto": "Tự động",
+ "manual": "Thủ công",
+ "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi",
+ "title": "Chế độ lấy nét"
+ },
+ "threeD": {
+ "preset": {
+ "left": "Trái",
+ "right": "Phải",
+ "iso": "Đẳng phối"
+ },
+ "none": "Không",
+ "title": "Xoay 3D"
+ },
+ "level": "Mức độ thu phóng",
+ "previewHold": "Giữ để xem trước hiệu ứng phóng to",
+ "customScale": "Thu phóng tùy chỉnh",
+ "selectRegion": "Chọn vùng thu phóng để điều chỉnh"
+ },
+ "audio": {
+ "outputGain": "Mức đầu ra",
+ "help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất.",
+ "reset": "Đặt lại âm thanh",
+ "title": "Âm thanh"
+ },
+ "language": {
+ "title": "Ngôn ngữ"
+ },
+ "project": {
+ "new": "Dự án mới",
+ "load": "Tải dự án",
+ "save": "Lưu dự án"
},
"support": {
"starOnGithub": "Đánh giá sao trên GitHub",
"saveDiagnostics": "Lưu thông tin chẩn đoán",
"reportBug": "Báo cáo lỗi"
},
- "gifSettings": {
- "size": "Kích thước GIF",
- "frameRate": "Tốc độ khung hình GIF",
- "loop": "Lặp lại GIF"
+ "cursor": {
+ "smoothing": "Làm mượt",
+ "clickBounce": "Nảy khi nhấp",
+ "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.",
+ "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.",
+ "size": "Kích thước",
+ "title": "Con trỏ",
+ "show": "Hiện con trỏ",
+ "themeDefault": "Mặc định",
+ "clipToBounds": "Cắt theo khung",
+ "motionBlur": "Làm mờ chuyển động",
+ "theme": "Kiểu con trỏ"
+ },
+ "export": {
+ "gifButton": "Xuất GIF",
+ "chooseSaveLocation": "Chọn vị trí lưu",
+ "videoButton": "Xuất Video"
+ },
+ "trim": {
+ "deleteRegion": "Xóa vùng cắt"
}
}
diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json
index da7c84a40..9b15c0e39 100644
--- a/src/i18n/locales/vi/timeline.json
+++ b/src/i18n/locales/vi/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "Bản ghi này không có dữ liệu chuyển động con trỏ, hoặc các thu phóng hiện có đã bao phủ các khoảnh khắc bận rộn.",
- "smartCutsNoAudio": "Media này không có âm thanh",
- "automaticZoomsHint": "Từ chuyển động con trỏ đã ghi",
- "dragToReorderHint": "Kéo để sắp xếp lại · nhấp đúp để chỉnh sửa điểm vào/ra",
- "smartCutsNeedsTranscript": "Cần có bản phiên âm",
- "addedWord": "Từ đã thêm: \"{{word}}\" — không có âm thanh phía sau",
- "smartZoomsAndCuts": "Cắt thông minh",
- "autoZoomFailed": "Thu phóng tự động thất bại",
- "smartCutsWaiting": "Đang phiên âm… sẵn sàng trong giây lát",
- "automaticZooms": "Thu phóng tự động",
- "arrangeClipsHint": "Kéo các clip bên dưới để sắp xếp lại hoặc thả clip mới vào",
- "comment": "Bình luận",
- "addAudioTooltip": "Thêm âm thanh",
- "timelineTools": "Công cụ dòng thời gian",
- "deleteClip": "Xóa clip",
- "arrangeClips": "Sắp xếp clip",
- "editInOutPoints": "Chỉnh sửa điểm vào/ra",
- "smartZoomsAndCutsHint": "Với AI",
- "addedAutoZoomPlural": "Đã thêm {{count}} thu phóng tự động",
- "noAutoZoomMoments": "Không tìm thấy khoảnh khắc thu phóng tự động",
- "smartCutsFailed": "Phiên âm thất bại — thử lại trong Media",
- "smartCutsNoSpeech": "Không phát hiện giọng nói",
- "newAnnotation": "Chú thích",
- "importRecordingFirst": "Hãy nhập một bản ghi trước",
- "addedAutoZoom": "Đã thêm {{count}} thu phóng tự động",
- "dropToAdd": "Thả để thêm vào dòng thời gian",
- "aiEnhanceRequested": "Đã yêu cầu tác nhân AI cắt thời gian chết",
- "autoEnhance": "Tự động nâng cao"
+ "buttons": {
+ "addZoom": "Thêm Thu phóng (Z)",
+ "suggestZooms": "Đề xuất Thu phóng từ Con trỏ",
+ "autoZoomOn": "Đề xuất thu phóng tự động đang bật — nhấp để loại bỏ các thu phóng được đề xuất",
+ "autoZoomOff": "Đề xuất thu phóng tự động đang tắt — nhấp để đề xuất thu phóng từ con trỏ",
+ "autoFocusAllOn": "Lấy nét tự động đang bật cho tất cả các thu phóng — nhấp để chuyển tất cả sang thủ công",
+ "autoFocusAllOff": "Bật lấy nét tự động cho tất cả các thu phóng (máy ảnh theo dõi con trỏ)",
+ "addTrim": "Thêm Cắt (T)",
+ "addAnnotation": "Thêm Chú thích (A)",
+ "addSpeed": "Thêm Tốc độ (S)",
+ "addCameraFullscreen": "Thêm Camera Toàn màn hình (C)"
+ },
+ "hints": {
+ "pressZoom": "Nhấn Z để thêm thu phóng",
+ "pressTrim": "Nhấn T để thêm cắt",
+ "pressAnnotation": "Nhấn A để thêm chú thích",
+ "pressAudio": "Nhấn M để thêm âm thanh, V để ghi âm lời thuyết minh",
+ "pressSpeed": "Nhấn S để thêm tốc độ",
+ "pressCameraFullscreen": "Nhấn C để thêm một đoạn Camera Toàn màn hình"
},
"labels": {
- "zoom": "Thu phóng",
- "cameraFullscreenItem": "Camera Toàn màn hình {{index}}",
- "imageItem": "Hình ảnh",
"pan": "Xoay",
- "zoomItem": "Thu phóng {{index}}",
- "cameraFullscreen": "Camera Toàn màn hình",
+ "zoom": "Thu phóng",
+ "trim": "Cắt",
"speed": "Tốc độ",
- "emptyText": "Văn bản trống",
+ "zoomItem": "Thu phóng {{index}}",
"trimItem": "Cắt {{index}}",
+ "speedItem": "Tốc độ {{index}}",
"annotationItem": "Chú thích",
- "trim": "Cắt",
- "speedItem": "Tốc độ {{index}}"
+ "imageItem": "Hình ảnh",
+ "emptyText": "Văn bản trống",
+ "cameraFullscreen": "Camera Toàn màn hình",
+ "cameraFullscreenItem": "Camera Toàn màn hình {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Chưa tải video",
+ "dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa"
},
"errors": {
- "noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.",
+ "cannotPlaceZoom": "Không thể đặt thu phóng ở đây",
+ "zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.",
+ "zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng",
+ "noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ",
"noCursorTelemetryDescription": "Ghi hình màn hình trước để tạo các đề xuất dựa trên con trỏ.",
- "cameraFullscreenExistsAtLocation": "Đoạn Camera Toàn màn hình đã tồn tại ở vị trí này hoặc không có đủ không gian.",
"noUsableTelemetry": "Không có dữ liệu từ xa của con trỏ có thể sử dụng",
"noUsableTelemetryDescription": "Bản ghi không chứa đủ dữ liệu chuyển động của con trỏ.",
- "zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng",
"noDwellMoments": "Không tìm thấy khoảnh khắc dừng con trỏ rõ ràng",
- "speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian.",
- "noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ",
+ "noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.",
"noAutoZoomSlots": "Không có khe thu phóng tự động nào",
+ "noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.",
"cannotPlaceTrim": "Không thể đặt cắt ở đây",
- "cannotPlaceZoom": "Không thể đặt thu phóng ở đây",
- "cannotPlaceSpeed": "Không thể đặt tốc độ ở đây",
- "zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.",
- "noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.",
"trimExistsAtLocation": "Cắt đã tồn tại ở vị trí này hoặc không có đủ không gian.",
- "cannotPlaceCameraFullscreen": "Không thể đặt Camera Toàn màn hình ở đây"
+ "cannotPlaceSpeed": "Không thể đặt tốc độ ở đây",
+ "speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian.",
+ "cannotPlaceCameraFullscreen": "Không thể đặt Camera Toàn màn hình ở đây",
+ "cameraFullscreenExistsAtLocation": "Đoạn Camera Toàn màn hình đã tồn tại ở vị trí này hoặc không có đủ không gian."
+ },
+ "success": {
+ "addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ",
+ "addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ"
+ },
+ "toolbar": {
+ "autoEnhance": "Tự động nâng cao",
+ "automaticZooms": "Thu phóng tự động",
+ "automaticZoomsHint": "Từ chuyển động con trỏ đã ghi",
+ "smartZoomsAndCuts": "Cắt thông minh",
+ "smartZoomsAndCutsHint": "Với AI",
+ "comment": "Bình luận",
+ "timelineTools": "Công cụ dòng thời gian",
+ "arrangeClips": "Sắp xếp clip",
+ "arrangeClipsHint": "Kéo các clip bên dưới để sắp xếp lại hoặc thả clip mới vào",
+ "newAnnotation": "Chú thích",
+ "dragToReorderHint": "Kéo để sắp xếp lại · nhấp đúp để chỉnh sửa điểm vào/ra",
+ "editInOutPoints": "Chỉnh sửa điểm vào/ra",
+ "deleteClip": "Xóa clip",
+ "dropToAdd": "Thả để thêm vào dòng thời gian",
+ "importRecordingFirst": "Hãy nhập một bản ghi trước",
+ "noAutoZoomMoments": "Không tìm thấy khoảnh khắc thu phóng tự động",
+ "noAutoZoomMomentsDescription": "Bản ghi này không có dữ liệu chuyển động con trỏ, hoặc các thu phóng hiện có đã bao phủ các khoảnh khắc bận rộn.",
+ "addedAutoZoom": "Đã thêm {{count}} thu phóng tự động",
+ "addedAutoZoomPlural": "Đã thêm {{count}} thu phóng tự động",
+ "autoZoomFailed": "Thu phóng tự động thất bại",
+ "aiEnhanceRequested": "Đã yêu cầu tác nhân AI cắt thời gian chết",
+ "smartCutsWaiting": "Đang phiên âm… sẵn sàng trong giây lát",
+ "smartCutsNeedsTranscript": "Cần có bản phiên âm",
+ "smartCutsNoAudio": "Media này không có âm thanh",
+ "smartCutsNoSpeech": "Không phát hiện giọng nói",
+ "smartCutsFailed": "Phiên âm thất bại — thử lại trong Media",
+ "addAudioTooltip": "Thêm âm thanh",
+ "addedWord": "Từ đã thêm: \"{{word}}\" — không có âm thanh phía sau"
},
"audio": {
- "micDenied": "Quyền truy cập micrô bị từ chối",
+ "addVoiceover": "Thêm thuyết minh",
+ "addVoiceoverHint": "Ghi âm lời thuyết minh trên video của bạn",
"subtitle": "Đặt lớp thuyết minh hoặc nhạc nền lên dòng thời gian",
- "importFailed": "Không thể nhập tệp âm thanh",
+ "record": "Ghi âm thuyết minh",
+ "importFile": "Nhập tệp âm thanh",
"importFileHint": "Nhập nhạc hoặc tệp âm thanh",
- "addVoiceoverHint": "Ghi âm lời thuyết minh trên video của bạn",
- "stop": "Dừng",
"recording": "Đang ghi",
- "saveFailed": "Không thể lưu bản ghi",
"recordingHint": "Thuyết minh cùng video — video vẫn phát trong khi bạn ghi âm",
- "importFile": "Nhập tệp âm thanh",
- "record": "Ghi âm thuyết minh",
+ "stop": "Dừng",
+ "micDenied": "Quyền truy cập micrô bị từ chối",
"recordingUnavailable": "Không thể ghi âm ở đây",
- "addVoiceover": "Thêm thuyết minh"
- },
- "success": {
- "addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ",
- "addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ"
- },
- "hints": {
- "pressAnnotation": "Nhấn A để thêm chú thích",
- "pressSpeed": "Nhấn S để thêm tốc độ",
- "pressTrim": "Nhấn T để thêm cắt",
- "pressCameraFullscreen": "Nhấn C để thêm một đoạn Camera Toàn màn hình",
- "pressZoom": "Nhấn Z để thêm thu phóng",
- "pressAudio": "Nhấn M để thêm âm thanh, V để ghi âm lời thuyết minh"
- },
- "buttons": {
- "autoFocusAllOff": "Bật lấy nét tự động cho tất cả các thu phóng (máy ảnh theo dõi con trỏ)",
- "autoZoomOff": "Đề xuất thu phóng tự động đang tắt — nhấp để đề xuất thu phóng từ con trỏ",
- "addAnnotation": "Thêm Chú thích (A)",
- "suggestZooms": "Đề xuất Thu phóng từ Con trỏ",
- "addSpeed": "Thêm Tốc độ (S)",
- "addCameraFullscreen": "Thêm Camera Toàn màn hình (C)",
- "autoFocusAllOn": "Lấy nét tự động đang bật cho tất cả các thu phóng — nhấp để chuyển tất cả sang thủ công",
- "autoZoomOn": "Đề xuất thu phóng tự động đang bật — nhấp để loại bỏ các thu phóng được đề xuất",
- "addZoom": "Thêm Thu phóng (Z)",
- "addTrim": "Thêm Cắt (T)"
- },
- "emptyState": {
- "noVideo": "Chưa tải video",
- "dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa"
+ "saveFailed": "Không thể lưu bản ghi",
+ "importFailed": "Không thể nhập tệp âm thanh"
}
}
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index cf2c729db..fb5033f53 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "模糊强度",
- "bgModes": {
- "transparent": "抠图",
- "none": "原画",
- "blur": "模糊",
- "custom": "自定义"
- },
- "selectPreset": "选择预设",
- "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
- "reactiveWebcam": "缩放时缩小",
- "shapes": {
- "circle": "圆形",
- "square": "正方形",
- "rectangle": "矩形",
- "rounded": "圆角"
- },
- "webcamBackground": "摄像头背景",
- "verticalStack": "垂直堆叠",
- "pictureInPicture": "画中画",
- "webcamShape": "摄像头形状",
- "webcamCropY": "垂直移动",
- "webcamSize": "摄像头大小",
- "mirrorWebcam": "镜像摄像头",
- "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
- "webcamFraming": "摄像头构图",
- "noWebcam": "无摄像头",
- "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
- "webcamCropZoom": "裁剪缩放",
- "dualFrame": "双画框",
- "webcamCropX": "水平移动",
- "title": "摄像头布局",
- "preset": "预设"
- },
- "crop": {
- "done": "完成",
- "ratio": "比例",
- "dragInstruction": "拖动每一侧来调整裁剪区域",
- "title": "裁剪",
- "free": "自由",
- "lockAspectRatio": "锁定宽高比",
- "unlockAspectRatio": "解锁宽高比",
- "cropVideo": "裁剪视频"
- },
- "zoom": {
- "position": {
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "焦点位置"
- },
- "threeD": {
- "preset": {
- "right": "右",
- "iso": "Iso",
- "left": "左"
- },
- "none": "无",
- "title": "3D 旋转"
- },
- "deleteZoom": "删除缩放",
- "customScale": "自定义缩放",
- "selectRegion": "选择要调整的缩放区域",
- "focusMode": {
- "manual": "手动",
- "autoDescription": "摄像头跟随录制时的光标位置",
- "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
- "title": "对焦模式",
- "auto": "自动"
- },
- "previewHold": "按住预览放大效果",
- "level": "缩放级别"
- },
"background": {
- "color": "颜色",
- "colorLabel": "颜色 {{color}}",
- "gradient": "渐变",
- "imageReadFailed": "无法读取该图片文件。",
"gradientLabel": "渐变 {{index}}",
+ "uploadCustom": "上传自定义",
+ "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
+ "title": "背景",
+ "imageLabel": "背景 {{index}}",
"custom": "自定义",
+ "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
+ "gradient": "渐变",
+ "colorLabel": "颜色 {{color}}",
"customWallpaper": "自定义壁纸",
- "presets": "预设",
- "image": "图片",
"colorPalette": "颜色调色板",
- "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
- "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
- "imageLabel": "背景 {{index}}",
- "title": "背景",
- "uploadCustom": "上传自定义",
+ "imageReadFailed": "无法读取该图片文件。",
+ "image": "图片",
+ "presets": "预设",
+ "color": "颜色",
"colorWheel": "颜色轮"
},
- "cursor": {
- "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
- "clickBounce": "点击弹跳",
- "clipToBounds": "裁剪到画布",
- "title": "光标",
- "size": "大小",
- "themeDefault": "默认",
- "smoothing": "平滑",
- "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
- "motionBlur": "运动模糊",
- "theme": "光标样式",
- "show": "显示光标"
- },
- "captions": {
- "text": "文本",
- "showBackground": "显示背景",
- "minWords": "每行最少词数",
- "translateFailed": "翻译失败。",
- "distanceFromTop": "距顶部",
- "fontSize": "字号",
- "distanceFromRight": "距右侧",
- "bold": "粗体",
- "textColor": "文字颜色",
- "original": "原文(转录)",
- "translating": "翻译中…",
- "language": "语言",
- "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
- "alignLeft": "左对齐",
- "distanceFromBottom": "距底部",
- "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
- "show": "显示字幕",
- "background": "背景",
- "backgroundOpacity": "不透明度",
- "removeLegacyAnnotations": "移除旧的字幕批注",
- "anchorTop": "顶部",
- "translate": "翻译",
- "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
- "alignCenter": "居中",
- "alignRight": "右对齐",
- "translateHint": "使用已配置的 AI 提供方翻译转录",
- "displayLanguage": "显示",
- "noTranscript": "字幕读取自媒体转写文本。视频转写完成后即可启用。",
- "distanceFromLeft": "距左侧",
- "maxWords": "每行最多词数",
- "anchorBottom": "底部",
- "position": "位置",
- "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
- "deleteTranslation": "删除此翻译",
- "backgroundColor": "背景颜色",
- "font": "字体",
- "lineLength": "行长",
- "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
- "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。"
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "导出分辨率"
- },
- "transcript": {
- "transcribing": "转录中…",
- "laneFeedsCaptions": "字幕从这条轨道烧录。",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
- "laneVoiceover": "配音",
- "blankedWord": "已清空",
- "noTranscript": "暂无转录",
- "noAudio": "此媒体没有音频轨道",
- "revertWord": "还原为“{{original}}”",
- "silence": "[静音 {{duration}} 秒]",
- "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
- "transcribeNow": "立即转录",
- "restoreWord": "恢复“{{word}}”",
- "clipLabel": "片段 {{index}}",
- "title": "当前转录",
- "insertAria": "新词",
- "laneRecording": "录制",
- "trimSilence": "修剪静音({{duration}} 秒)",
- "insertedWord": "你添加的词 — 背后没有声音",
- "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
- "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
- "removeInserted": "删除“{{word}}”",
- "editorAria": "{{filename}} 的转录",
- "correctedWord": "已更正 — 转录原文为“{{original}}”",
- "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
- "editWord": "编辑“{{word}}”",
- "noClips": "暂无片段",
- "laneLabel": "转写文本读取自",
- "restoreSilence": "恢复静音({{duration}} 秒)"
- },
"customFont": {
+ "namePlaceholder": "我的自定义字体",
+ "failedToAdd": "添加字体失败",
"addingButton": "添加中...",
+ "errorInvalidUrl": "请输入有效的 Google Fonts URL",
"urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
- "addButton": "添加字体",
- "dialogTitle": "添加 Google 字体",
- "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
- "nameLabel": "显示名称",
- "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
"urlLabel": "Google Fonts 导入 URL",
+ "successMessage": "字体 \"{{fontName}}\" 添加成功",
+ "nameLabel": "显示名称",
"errorEmptyName": "请输入字体名称",
- "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
- "namePlaceholder": "我的自定义字体",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "添加字体失败",
"nameHelp": "这是字体在字体选择器中显示的名称",
+ "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
"errorExtractFailed": "无法从 URL 中提取字体系列",
- "errorInvalidUrl": "请输入有效的 Google Fonts URL",
- "successMessage": "字体 \"{{fontName}}\" 添加成功"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "添加 Google 字体",
+ "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
+ "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。",
+ "addButton": "添加字体"
+ },
+ "imageUpload": {
+ "invalidFileType": "无效的文件类型",
+ "failedToUpload": "上传图片失败",
+ "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。",
+ "uploadSuccess": "自定义图片上传成功!",
+ "errorReading": "读取文件时出错。"
},
"annotation": {
- "arrowColor": "箭头颜色",
- "colorWheel": "颜色轮",
- "blurType": "模糊类型",
- "active": "活动",
- "deleteAnnotation": "删除标注",
- "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
- "strokeWidth": "描边宽度:{{width}}px",
- "background": "背景",
- "imageUploadSuccess": "图片上传成功!",
- "blurColor": "模糊颜色",
- "blurTypeBlur": "高斯",
- "textColor": "文本颜色",
- "blurColorWhite": "白色",
- "title": "标注设置",
- "type": "类型",
- "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
- "typeImage": "图片",
- "textContent": "文本内容",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
- "typeText": "文本",
- "blurIntensity": "模糊强度",
- "none": "无",
- "mosaicBlockSize": "马赛克块大小",
- "textPlaceholder": "输入您的文本...",
- "typeArrow": "箭头",
- "color": "颜色",
- "blurColorBlack": "黑色",
+ "blurShapeRectangle": "矩形",
"size": "大小",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
+ "clearBackground": "清除背景",
+ "colorPalette": "颜色调色板",
"invalidImageType": "无效的文件类型",
+ "background": "背景",
+ "typeText": "文本",
+ "active": "活动",
+ "color": "颜色",
"blurShapeFreehand": "自由手绘",
- "shortcutsAndTips": "快捷键与提示",
- "uploadImage": "上传图片",
+ "arrowDirection": "箭头方向",
"blurTypeMosaic": "马赛克",
+ "colorWheel": "颜色轮",
+ "textColor": "文本颜色",
+ "title": "标注设置",
+ "blurType": "模糊类型",
+ "typeBlur": "模糊",
+ "blurIntensity": "模糊强度",
"selectStyle": "选择样式",
- "defaultText": "你好",
- "blurShapeRectangle": "矩形",
- "colorPalette": "颜色调色板",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
- "clearBackground": "清除背景",
+ "textContent": "文本内容",
+ "typeArrow": "箭头",
+ "none": "无",
+ "blurColor": "模糊颜色",
"customFonts": "自定义字体",
- "typeBlur": "模糊",
+ "imageUploadSuccess": "图片上传成功!",
+ "type": "类型",
+ "arrowColor": "箭头颜色",
+ "textPlaceholder": "输入您的文本...",
+ "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
+ "blurShape": "模糊形状",
+ "uploadImage": "上传图片",
+ "blurTypeBlur": "高斯",
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
+ "shortcutsAndTips": "快捷键与提示",
+ "deleteAnnotation": "删除标注",
"fontStyle": "字体样式",
- "blurShape": "模糊形状",
- "arrowDirection": "箭头方向",
- "blurShapeOval": "椭圆"
- },
- "speed": {
- "customPlaybackSpeed": "自定义播放速度",
- "deleteRegion": "删除速度区域",
- "selectRegion": "选择要调整的速度区域",
- "maxSpeedError": "速度不能超过 {{max}}×",
- "playbackSpeed": "播放速度",
- "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。"
- },
- "textAnimation": {
- "selectAnimation": "选择动画",
- "pulse": "脉动",
- "rise": "上升",
- "none": "无",
- "slideLeft": "向左滑动",
- "title": "文本动画",
- "fade": "淡入淡出",
- "pop": "弹出",
- "typewriter": "打字机"
+ "defaultText": "你好",
+ "mosaicBlockSize": "马赛克块大小",
+ "blurColorBlack": "黑色",
+ "strokeWidth": "描边宽度:{{width}}px",
+ "blurShapeOval": "椭圆",
+ "blurColorWhite": "白色",
+ "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+ "typeImage": "图片"
},
"effects": {
- "motion": "运动",
- "title": "画面合成",
- "format": "格式",
- "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
"fitClipFew": "{{count}} 个片段",
- "motionBlur": "运动模糊",
- "fitClipMany": "{{count}} 个片段",
- "frame": "画框",
- "padding": "内边距",
- "roundness": "圆角",
- "off": "关",
- "blurBg": "模糊背景",
+ "title": "画面合成",
"shadow": "阴影",
+ "off": "关",
"on": "开",
+ "blurBg": "模糊背景",
+ "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
"fitClipOne": "{{count}} 个片段",
"formatOriginal": "原始",
- "fitClip": "适配"
+ "fitClipMany": "{{count}} 个片段",
+ "frame": "画框",
+ "motion": "运动",
+ "padding": "内边距",
+ "format": "格式",
+ "fitClip": "适配",
+ "motionBlur": "运动模糊",
+ "roundness": "圆角"
+ },
+ "transcript": {
+ "laneRecording": "录制",
+ "noTranscript": "暂无转录",
+ "title": "当前转录",
+ "restoreWord": "恢复“{{word}}”",
+ "revertWord": "还原为“{{original}}”",
+ "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
+ "restoreSilence": "恢复静音({{duration}} 秒)",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
+ "editWord": "编辑“{{word}}”",
+ "laneFeedsCaptions": "字幕从这条轨道烧录。",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
+ "insertAria": "新词",
+ "editorAria": "{{filename}} 的转录",
+ "transcribeNow": "立即转录",
+ "transcribing": "转录中…",
+ "trimSilence": "修剪静音({{duration}} 秒)",
+ "removeInserted": "删除“{{word}}”",
+ "laneLabel": "转写文本读取自",
+ "noClips": "暂无片段",
+ "laneVoiceover": "配音",
+ "silence": "[静音 {{duration}} 秒]",
+ "clipLabel": "片段 {{index}}",
+ "correctedWord": "已更正 — 转录原文为“{{original}}”",
+ "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "noAudio": "此媒体没有音频轨道",
+ "blankedWord": "已清空"
},
"exportFormat": {
- "gifDescription": "可分享的动态图片",
"mp4": "MP4",
- "mp4Video": "MP4 视频",
- "gif": "GIF",
+ "mp4Description": "高质量视频文件",
"gifAnimation": "GIF 动画",
- "mp4Description": "高质量视频文件"
+ "mp4Video": "MP4 视频",
+ "gifDescription": "可分享的动态图片",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "上传图片失败",
- "uploadSuccess": "自定义图片上传成功!",
- "errorReading": "读取文件时出错。",
- "invalidFileType": "无效的文件类型",
- "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。"
+ "captions": {
+ "showBackground": "显示背景",
+ "deleteTranslation": "删除此翻译",
+ "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
+ "backgroundOpacity": "不透明度",
+ "backgroundColor": "背景颜色",
+ "alignCenter": "居中",
+ "translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
+ "distanceFromRight": "距右侧",
+ "language": "语言",
+ "text": "文本",
+ "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
+ "distanceFromTop": "距顶部",
+ "translateFailed": "翻译失败。",
+ "alignLeft": "左对齐",
+ "distanceFromBottom": "距底部",
+ "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
+ "translate": "翻译",
+ "position": "位置",
+ "fontSize": "字号",
+ "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
+ "noTranscript": "字幕来自媒体的转录。请先转录此视频以启用字幕。",
+ "distanceFromLeft": "距左侧",
+ "anchorBottom": "底部",
+ "transcribe": "转录视频",
+ "bold": "粗体",
+ "alignRight": "右对齐",
+ "anchorTop": "顶部",
+ "minWords": "每行最少词数",
+ "translateHint": "使用已配置的 AI 提供方翻译转录",
+ "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
+ "displayLanguage": "显示",
+ "removeLegacyAnnotations": "移除旧的字幕批注",
+ "background": "背景",
+ "lineLength": "行长",
+ "original": "原文(转录)",
+ "maxWords": "每行最多词数",
+ "font": "字体",
+ "translating": "翻译中…",
+ "show": "显示字幕",
+ "textColor": "文字颜色"
},
- "facets": {
- "captions": "字幕",
- "transcript": "转录文本"
+ "panes": {
+ "help": "帮助"
},
- "audio": {
- "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
- "reset": "重置音频",
- "title": "音频",
- "outputGain": "输出电平"
+ "speed": {
+ "deleteRegion": "删除速度区域",
+ "maxSpeedError": "速度不能超过 {{max}}×",
+ "selectRegion": "选择要调整的速度区域",
+ "playbackSpeed": "播放速度",
+ "customPlaybackSpeed": "自定义播放速度",
+ "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。"
},
- "language": {
- "title": "语言"
+ "gifSettings": {
+ "frameRate": "GIF 帧率",
+ "loop": "循环 GIF",
+ "size": "GIF 尺寸"
+ },
+ "exportQuality": {
+ "title": "导出分辨率",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "defaultLabel": "音频轨道",
"importFailed": "无法添加音频",
- "slipHint": "按住 Alt 拖动可在其中滑动音频",
"fadeOut": "淡出",
- "defaultLabel": "音频轨道",
- "mute": "静音",
+ "fadeIn": "淡入",
"remove": "删除轨道",
- "add": "添加音频轨道",
"loop": "循环",
- "fadeIn": "淡入"
+ "slipHint": "按住 Alt 拖动可在其中滑动音频",
+ "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "add": "添加音频轨道",
+ "mute": "静音"
},
- "project": {
- "load": "加载项目",
- "save": "保存项目",
- "new": "新建项目"
+ "layout": {
+ "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
+ "mirrorWebcam": "镜像摄像头",
+ "webcamFraming": "摄像头构图",
+ "shapes": {
+ "rectangle": "矩形",
+ "rounded": "圆角",
+ "circle": "圆形",
+ "square": "正方形"
+ },
+ "selectPreset": "选择预设",
+ "bgModes": {
+ "custom": "自定义",
+ "none": "原画",
+ "blur": "模糊",
+ "transparent": "抠图"
+ },
+ "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
+ "webcamBlurIntensity": "模糊强度",
+ "preset": "预设",
+ "webcamCropZoom": "裁剪缩放",
+ "webcamSize": "摄像头大小",
+ "dualFrame": "双画框",
+ "webcamCropY": "垂直移动",
+ "verticalStack": "垂直堆叠",
+ "pictureInPicture": "画中画",
+ "webcamShape": "摄像头形状",
+ "webcamCropX": "水平移动",
+ "reactiveWebcam": "缩放时缩小",
+ "webcamBackground": "摄像头背景",
+ "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
+ "title": "摄像头布局",
+ "noWebcam": "无摄像头"
},
- "panes": {
- "help": "帮助"
+ "textAnimation": {
+ "slideLeft": "向左滑动",
+ "pulse": "脉动",
+ "typewriter": "打字机",
+ "selectAnimation": "选择动画",
+ "fade": "淡入淡出",
+ "title": "文本动画",
+ "none": "无",
+ "pop": "弹出",
+ "rise": "上升"
},
- "trim": {
- "deleteRegion": "删除剪辑区域"
+ "facets": {
+ "transcript": "转录文本",
+ "captions": "字幕"
},
- "export": {
- "videoButton": "导出视频",
- "gifButton": "导出 GIF",
- "chooseSaveLocation": "选择保存位置"
+ "crop": {
+ "title": "裁剪",
+ "free": "自由",
+ "unlockAspectRatio": "解锁宽高比",
+ "dragInstruction": "拖动每一侧来调整裁剪区域",
+ "done": "完成",
+ "ratio": "比例",
+ "cropVideo": "裁剪视频",
+ "lockAspectRatio": "锁定宽高比"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "title": "焦点位置",
+ "x": "X (%)"
+ },
+ "deleteZoom": "删除缩放",
+ "focusMode": {
+ "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。",
+ "auto": "自动",
+ "manual": "手动",
+ "autoDescription": "摄像头跟随录制时的光标位置",
+ "title": "对焦模式"
+ },
+ "threeD": {
+ "preset": {
+ "left": "左",
+ "right": "右",
+ "iso": "Iso"
+ },
+ "none": "无",
+ "title": "3D 旋转"
+ },
+ "level": "缩放级别",
+ "previewHold": "按住预览放大效果",
+ "customScale": "自定义缩放",
+ "selectRegion": "选择要调整的缩放区域"
+ },
+ "audio": {
+ "outputGain": "输出电平",
+ "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
+ "reset": "重置音频",
+ "title": "音频"
+ },
+ "language": {
+ "title": "语言"
+ },
+ "project": {
+ "new": "新建项目",
+ "load": "加载项目",
+ "save": "保存项目"
},
"support": {
"starOnGithub": "在 GitHub 上加星",
"saveDiagnostics": "保存诊断信息",
"reportBug": "报告错误"
},
- "gifSettings": {
- "size": "GIF 尺寸",
- "frameRate": "GIF 帧率",
- "loop": "循环 GIF"
+ "cursor": {
+ "smoothing": "平滑",
+ "clickBounce": "点击弹跳",
+ "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。",
+ "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
+ "size": "大小",
+ "title": "光标",
+ "show": "显示光标",
+ "themeDefault": "默认",
+ "clipToBounds": "裁剪到画布",
+ "motionBlur": "运动模糊",
+ "theme": "光标样式"
+ },
+ "export": {
+ "gifButton": "导出 GIF",
+ "chooseSaveLocation": "选择保存位置",
+ "videoButton": "导出视频"
+ },
+ "trim": {
+ "deleteRegion": "删除剪辑区域"
}
}
diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json
index ff397771b..a5d4f897a 100644
--- a/src/i18n/locales/zh-CN/timeline.json
+++ b/src/i18n/locales/zh-CN/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "此录制没有光标移动数据,或现有缩放已覆盖繁忙时刻。",
- "smartCutsNoAudio": "此媒体没有音频",
- "automaticZoomsHint": "基于录制的光标移动",
- "dragToReorderHint": "拖动以重新排序 · 双击以编辑入点/出点",
- "smartCutsNeedsTranscript": "需要转录文本",
- "addedWord": "已添加的词:“{{word}}” — 背后没有声音",
- "smartZoomsAndCuts": "智能剪切",
- "autoZoomFailed": "自动缩放失败",
- "smartCutsWaiting": "正在转录…稍后可用",
- "automaticZooms": "自动缩放",
- "arrangeClipsHint": "拖动下方片段以重新排序,或拖入新片段",
- "comment": "评论",
- "addAudioTooltip": "添加音频",
- "timelineTools": "时间轴工具",
- "deleteClip": "删除片段",
- "arrangeClips": "排列片段",
- "editInOutPoints": "编辑入点/出点",
- "smartZoomsAndCutsHint": "使用 AI",
- "addedAutoZoomPlural": "已添加 {{count}} 个自动缩放",
- "noAutoZoomMoments": "未找到自动缩放时刻",
- "smartCutsFailed": "转录失败 — 请在“媒体”中重试",
- "smartCutsNoSpeech": "未检测到语音",
- "newAnnotation": "标注",
- "importRecordingFirst": "请先导入录制内容",
- "addedAutoZoom": "已添加 {{count}} 个自动缩放",
- "dropToAdd": "拖放以添加到时间轴",
- "aiEnhanceRequested": "已请求 AI 代理剪除空白片段",
- "autoEnhance": "自动增强"
+ "buttons": {
+ "addZoom": "添加缩放 (Z)",
+ "suggestZooms": "根据光标建议缩放",
+ "autoZoomOn": "自动缩放建议已开启 — 点击可移除建议的缩放",
+ "autoZoomOff": "自动缩放建议已关闭 — 点击可根据光标建议缩放",
+ "autoFocusAllOn": "所有缩放的自动对焦已开启 — 点击可将全部切换为手动",
+ "autoFocusAllOff": "为所有缩放开启自动对焦(摄像头跟随光标)",
+ "addTrim": "添加剪辑 (T)",
+ "addAnnotation": "添加标注 (A)",
+ "addSpeed": "添加速度 (S)",
+ "addCameraFullscreen": "添加全屏摄像头 (C)"
+ },
+ "hints": {
+ "pressZoom": "按 Z 添加缩放",
+ "pressTrim": "按 T 添加剪辑",
+ "pressAnnotation": "按 A 添加标注",
+ "pressAudio": "按 M 添加音频,按 V 录制配音",
+ "pressSpeed": "按 S 添加速度",
+ "pressCameraFullscreen": "按 C 添加一个全屏摄像头片段"
},
"labels": {
- "zoom": "缩放",
- "cameraFullscreenItem": "全屏摄像头 {{index}}",
- "imageItem": "图片",
"pan": "平移",
- "zoomItem": "缩放 {{index}}",
- "cameraFullscreen": "全屏摄像头",
+ "zoom": "缩放",
+ "trim": "剪辑",
"speed": "速度",
- "emptyText": "空文本",
+ "zoomItem": "缩放 {{index}}",
"trimItem": "剪辑 {{index}}",
+ "speedItem": "速度 {{index}}",
"annotationItem": "标注",
- "trim": "剪辑",
- "speedItem": "速度 {{index}}"
+ "imageItem": "图片",
+ "emptyText": "空文本",
+ "cameraFullscreen": "全屏摄像头",
+ "cameraFullscreenItem": "全屏摄像头 {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "未加载视频",
+ "dragAndDrop": "拖放视频以开始编辑"
},
"errors": {
- "noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
+ "cannotPlaceZoom": "无法在此处放置缩放",
+ "zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
+ "zoomSuggestionUnavailable": "缩放建议处理器不可用",
+ "noCursorTelemetry": "无可用的光标遥测数据",
"noCursorTelemetryDescription": "请先录制一段屏幕录像以生成基于光标的建议。",
- "cameraFullscreenExistsAtLocation": "此位置已存在全屏摄像头片段或没有足够的空间。",
"noUsableTelemetry": "无可用的光标遥测数据",
"noUsableTelemetryDescription": "录制内容没有包含足够的光标移动数据。",
- "zoomSuggestionUnavailable": "缩放建议处理器不可用",
"noDwellMoments": "未找到明确的光标停留时刻",
- "speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。",
- "noCursorTelemetry": "无可用的光标遥测数据",
+ "noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
"noAutoZoomSlots": "无可用的自动缩放位置",
+ "noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
"cannotPlaceTrim": "无法在此处放置剪辑",
- "cannotPlaceZoom": "无法在此处放置缩放",
- "cannotPlaceSpeed": "无法在此处放置速度",
- "zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
- "noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
"trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
- "cannotPlaceCameraFullscreen": "无法在此处放置全屏摄像头"
+ "cannotPlaceSpeed": "无法在此处放置速度",
+ "speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。",
+ "cannotPlaceCameraFullscreen": "无法在此处放置全屏摄像头",
+ "cameraFullscreenExistsAtLocation": "此位置已存在全屏摄像头片段或没有足够的空间。"
+ },
+ "success": {
+ "addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
+ "addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
+ },
+ "toolbar": {
+ "autoEnhance": "自动增强",
+ "automaticZooms": "自动缩放",
+ "automaticZoomsHint": "基于录制的光标移动",
+ "smartZoomsAndCuts": "智能剪切",
+ "smartZoomsAndCutsHint": "使用 AI",
+ "comment": "评论",
+ "timelineTools": "时间轴工具",
+ "arrangeClips": "排列片段",
+ "arrangeClipsHint": "拖动下方片段以重新排序,或拖入新片段",
+ "newAnnotation": "标注",
+ "dragToReorderHint": "拖动以重新排序 · 双击以编辑入点/出点",
+ "editInOutPoints": "编辑入点/出点",
+ "deleteClip": "删除片段",
+ "dropToAdd": "拖放以添加到时间轴",
+ "importRecordingFirst": "请先导入录制内容",
+ "noAutoZoomMoments": "未找到自动缩放时刻",
+ "noAutoZoomMomentsDescription": "此录制没有光标移动数据,或现有缩放已覆盖繁忙时刻。",
+ "addedAutoZoom": "已添加 {{count}} 个自动缩放",
+ "addedAutoZoomPlural": "已添加 {{count}} 个自动缩放",
+ "autoZoomFailed": "自动缩放失败",
+ "aiEnhanceRequested": "已请求 AI 代理剪除空白片段",
+ "smartCutsWaiting": "正在转录…稍后可用",
+ "smartCutsNeedsTranscript": "需要转录文本",
+ "smartCutsNoAudio": "此媒体没有音频",
+ "smartCutsNoSpeech": "未检测到语音",
+ "smartCutsFailed": "转录失败 — 请在“媒体”中重试",
+ "addAudioTooltip": "添加音频",
+ "addedWord": "已添加的词:“{{word}}” — 背后没有声音"
},
"audio": {
- "micDenied": "麦克风访问被拒绝",
+ "addVoiceover": "添加配音",
+ "addVoiceoverHint": "为视频录制旁白",
"subtitle": "在时间轴上放置配音或背景音乐图层",
- "importFailed": "无法导入音频文件",
+ "record": "录制配音",
+ "importFile": "导入音频文件",
"importFileHint": "导入音乐或音频文件",
- "addVoiceoverHint": "为视频录制旁白",
- "stop": "停止",
"recording": "正在录制",
- "saveFailed": "无法保存录音",
"recordingHint": "跟着视频讲解 — 录制时视频会继续播放",
- "importFile": "导入音频文件",
- "record": "录制配音",
+ "stop": "停止",
+ "micDenied": "麦克风访问被拒绝",
"recordingUnavailable": "此处无法录音",
- "addVoiceover": "添加配音"
- },
- "success": {
- "addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
- "addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
- },
- "hints": {
- "pressAnnotation": "按 A 添加标注",
- "pressSpeed": "按 S 添加速度",
- "pressTrim": "按 T 添加剪辑",
- "pressCameraFullscreen": "按 C 添加一个全屏摄像头片段",
- "pressZoom": "按 Z 添加缩放",
- "pressAudio": "按 M 添加音频,按 V 录制配音"
- },
- "buttons": {
- "autoFocusAllOff": "为所有缩放开启自动对焦(摄像头跟随光标)",
- "autoZoomOff": "自动缩放建议已关闭 — 点击可根据光标建议缩放",
- "addAnnotation": "添加标注 (A)",
- "suggestZooms": "根据光标建议缩放",
- "addSpeed": "添加速度 (S)",
- "addCameraFullscreen": "添加全屏摄像头 (C)",
- "autoFocusAllOn": "所有缩放的自动对焦已开启 — 点击可将全部切换为手动",
- "autoZoomOn": "自动缩放建议已开启 — 点击可移除建议的缩放",
- "addZoom": "添加缩放 (Z)",
- "addTrim": "添加剪辑 (T)"
- },
- "emptyState": {
- "noVideo": "未加载视频",
- "dragAndDrop": "拖放视频以开始编辑"
+ "saveFailed": "无法保存录音",
+ "importFailed": "无法导入音频文件"
}
}
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 5842e9470..cb02cc88f 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -1,354 +1,355 @@
{
- "layout": {
- "webcamBlurIntensity": "模糊強度",
- "bgModes": {
- "transparent": "去背",
- "none": "原畫",
- "blur": "模糊",
- "custom": "自訂"
- },
- "selectPreset": "選擇預設",
- "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
- "reactiveWebcam": "縮放時縮小",
- "shapes": {
- "circle": "圓形",
- "square": "正方形",
- "rectangle": "矩形",
- "rounded": "圓角"
- },
- "webcamBackground": "攝影機背景",
- "verticalStack": "垂直堆疊",
- "pictureInPicture": "子母畫面",
- "webcamShape": "攝影機形狀",
- "webcamCropY": "垂直移動",
- "webcamSize": "攝影機大小",
- "mirrorWebcam": "鏡像攝影機",
- "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
- "webcamFraming": "攝影機構圖",
- "noWebcam": "無網路攝影機",
- "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
- "webcamCropZoom": "裁切縮放",
- "dualFrame": "雙畫框",
- "webcamCropX": "水平移動",
- "title": "攝影機版面",
- "preset": "預設"
- },
- "crop": {
- "done": "完成",
- "ratio": "比例",
- "dragInstruction": "拖動每一側來調整裁剪區域",
- "title": "裁剪",
- "free": "自由",
- "lockAspectRatio": "鎖定長寬比",
- "unlockAspectRatio": "解鎖長寬比",
- "cropVideo": "裁剪影片"
- },
- "zoom": {
- "position": {
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
- "x": "X (%)",
- "y": "Y (%)",
- "title": "焦點位置"
- },
- "threeD": {
- "preset": {
- "right": "右",
- "iso": "Iso",
- "left": "左"
- },
- "none": "無",
- "title": "3D 旋轉"
- },
- "deleteZoom": "刪除縮放",
- "customScale": "自訂縮放",
- "selectRegion": "選擇要調整的縮放區域",
- "focusMode": {
- "manual": "手動",
- "autoDescription": "攝影機跟隨錄製時的游標位置",
- "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
- "title": "對焦模式",
- "auto": "自動"
- },
- "previewHold": "按住預覽放大效果",
- "level": "縮放級別"
- },
"background": {
- "color": "顏色",
- "colorLabel": "顏色 {{color}}",
- "gradient": "漸層",
- "imageReadFailed": "無法讀取該圖片檔案。",
"gradientLabel": "漸層 {{index}}",
+ "uploadCustom": "上傳自訂",
+ "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
+ "title": "背景",
+ "imageLabel": "背景 {{index}}",
"custom": "自訂",
+ "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
+ "gradient": "漸層",
+ "colorLabel": "顏色 {{color}}",
"customWallpaper": "自訂桌布",
- "presets": "預設",
- "image": "圖片",
"colorPalette": "調色盤",
- "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
- "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
- "imageLabel": "背景 {{index}}",
- "title": "背景",
- "uploadCustom": "上傳自訂",
+ "imageReadFailed": "無法讀取該圖片檔案。",
+ "image": "圖片",
+ "presets": "預設",
+ "color": "顏色",
"colorWheel": "色輪"
},
- "cursor": {
- "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
- "clickBounce": "點擊彈跳",
- "clipToBounds": "裁切至畫布",
- "title": "游標",
- "size": "大小",
- "themeDefault": "預設",
- "smoothing": "平滑",
- "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
- "motionBlur": "動態模糊",
- "theme": "游標樣式",
- "show": "顯示游標"
- },
- "captions": {
- "text": "文字",
- "showBackground": "顯示背景",
- "minWords": "每行最少字數",
- "translateFailed": "翻譯失敗。",
- "distanceFromTop": "距上緣",
- "fontSize": "大小",
- "distanceFromRight": "距右緣",
- "bold": "粗體",
- "textColor": "文字顏色",
- "original": "原文(逐字稿)",
- "translating": "翻譯中…",
- "language": "語言",
- "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
- "alignLeft": "靠左",
- "distanceFromBottom": "距下緣",
- "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
- "show": "顯示字幕",
- "background": "背景",
- "backgroundOpacity": "不透明度",
- "removeLegacyAnnotations": "移除舊的字幕註解",
- "anchorTop": "上",
- "translate": "翻譯",
- "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
- "alignCenter": "置中",
- "alignRight": "靠右",
- "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
- "displayLanguage": "顯示",
- "noTranscript": "字幕讀取自媒體轉錄文字。影片轉錄完成後即可啟用。",
- "distanceFromLeft": "距左緣",
- "maxWords": "每行最多字數",
- "anchorBottom": "下",
- "position": "位置",
- "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
- "deleteTranslation": "刪除這個翻譯",
- "backgroundColor": "背景顏色",
- "font": "字型",
- "lineLength": "行長",
- "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
- "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。"
- },
- "exportQuality": {
- "medium": "1080p",
- "low": "720p",
- "high": "Source",
- "title": "匯出解析度"
- },
- "transcript": {
- "transcribing": "轉錄中…",
- "laneFeedsCaptions": "字幕從這條軌道燒錄。",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
- "laneVoiceover": "旁白",
- "blankedWord": "已清空",
- "noTranscript": "尚無逐字稿",
- "noAudio": "此媒體沒有音訊軌道",
- "revertWord": "還原為「{{original}}」",
- "silence": "[靜音 {{duration}} 秒]",
- "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
- "transcribeNow": "立即產生逐字稿",
- "restoreWord": "還原「{{word}}」",
- "clipLabel": "片段 {{index}}",
- "title": "目前的逐字稿",
- "insertAria": "新字詞",
- "laneRecording": "錄影",
- "trimSilence": "修剪靜音({{duration}} 秒)",
- "insertedWord": "你加入的字詞 — 背後沒有聲音",
- "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
- "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
- "removeInserted": "刪除「{{word}}」",
- "editorAria": "{{filename}} 的逐字稿",
- "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
- "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
- "editWord": "編輯「{{word}}」",
- "noClips": "尚無片段",
- "laneLabel": "轉錄文字讀取自",
- "restoreSilence": "還原靜音({{duration}} 秒)"
- },
"customFont": {
+ "namePlaceholder": "我的自訂字體",
+ "failedToAdd": "新增字體失敗",
"addingButton": "新增中...",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
"urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
- "addButton": "新增字體",
- "dialogTitle": "新增 Google 字體",
- "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
- "nameLabel": "顯示名稱",
- "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
"urlLabel": "Google Fonts 匯入 URL",
+ "successMessage": "字體 \"{{fontName}}\" 新增成功",
+ "nameLabel": "顯示名稱",
"errorEmptyName": "請輸入字體名稱",
- "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
- "namePlaceholder": "我的自訂字體",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "failedToAdd": "新增字體失敗",
"nameHelp": "這是字體在字體選擇器中顯示的名稱",
+ "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
"errorExtractFailed": "無法從 URL 中提取字體系列",
- "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
- "successMessage": "字體 \"{{fontName}}\" 新增成功"
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "新增 Google 字體",
+ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
+ "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。",
+ "addButton": "新增字體"
+ },
+ "imageUpload": {
+ "invalidFileType": "無效的檔案類型",
+ "failedToUpload": "上傳圖片失敗",
+ "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。",
+ "uploadSuccess": "自訂圖片上傳成功!",
+ "errorReading": "讀取檔案時出錯。"
},
"annotation": {
- "arrowColor": "箭頭顏色",
- "colorWheel": "色輪",
- "blurType": "模糊類型",
- "active": "啟用",
- "deleteAnnotation": "刪除標註",
- "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
- "strokeWidth": "描邊寬度:{{width}}px",
- "background": "背景",
- "imageUploadSuccess": "圖片上傳成功!",
- "blurColor": "模糊顏色",
- "blurTypeBlur": "高斯",
- "textColor": "文字顏色",
- "blurColorWhite": "白色",
- "title": "標註設定",
- "type": "類型",
- "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
- "typeImage": "圖片",
- "textContent": "文字內容",
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
- "typeText": "文字",
- "blurIntensity": "模糊強度",
- "none": "無",
- "mosaicBlockSize": "馬賽克區塊大小",
- "textPlaceholder": "輸入您的文字...",
- "typeArrow": "箭頭",
- "color": "顏色",
- "blurColorBlack": "黑色",
+ "blurShapeRectangle": "矩形",
"size": "大小",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "clearBackground": "清除背景",
+ "colorPalette": "調色盤",
"invalidImageType": "無效的檔案類型",
+ "background": "背景",
+ "typeText": "文字",
+ "active": "啟用",
+ "color": "顏色",
"blurShapeFreehand": "自由手繪",
- "shortcutsAndTips": "快捷鍵與提示",
- "uploadImage": "上傳圖片",
+ "arrowDirection": "箭頭方向",
"blurTypeMosaic": "馬賽克",
+ "colorWheel": "色輪",
+ "textColor": "文字顏色",
+ "title": "標註設定",
+ "blurType": "模糊類型",
+ "typeBlur": "模糊",
+ "blurIntensity": "模糊強度",
"selectStyle": "選擇樣式",
- "defaultText": "你好",
- "blurShapeRectangle": "矩形",
- "colorPalette": "調色盤",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
- "clearBackground": "清除背景",
+ "textContent": "文字內容",
+ "typeArrow": "箭頭",
+ "none": "無",
+ "blurColor": "模糊顏色",
"customFonts": "自訂字體",
- "typeBlur": "模糊",
+ "imageUploadSuccess": "圖片上傳成功!",
+ "type": "類型",
+ "arrowColor": "箭頭顏色",
+ "textPlaceholder": "輸入您的文字...",
+ "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
+ "blurShape": "模糊形狀",
+ "uploadImage": "上傳圖片",
+ "blurTypeBlur": "高斯",
"tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
+ "shortcutsAndTips": "快捷鍵與提示",
+ "deleteAnnotation": "刪除標註",
"fontStyle": "字體樣式",
- "blurShape": "模糊形狀",
- "arrowDirection": "箭頭方向",
- "blurShapeOval": "橢圓"
- },
- "speed": {
- "customPlaybackSpeed": "自訂播放速度",
- "deleteRegion": "刪除速度區域",
- "selectRegion": "選擇要調整的速度區域",
- "maxSpeedError": "速度不能超過 {{max}}×",
- "playbackSpeed": "播放速度",
- "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。"
- },
- "textAnimation": {
- "selectAnimation": "選擇動畫",
- "pulse": "脈動",
- "rise": "上升",
- "none": "無",
- "slideLeft": "向左滑動",
- "title": "文字動畫",
- "fade": "淡入淡出",
- "pop": "彈出",
- "typewriter": "打字機"
+ "defaultText": "你好",
+ "mosaicBlockSize": "馬賽克區塊大小",
+ "blurColorBlack": "黑色",
+ "strokeWidth": "描邊寬度:{{width}}px",
+ "blurShapeOval": "橢圓",
+ "blurColorWhite": "白色",
+ "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "typeImage": "圖片"
},
"effects": {
- "motion": "動態",
- "title": "畫面合成",
- "format": "格式",
- "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
"fitClipFew": "{{count}} 個片段",
- "motionBlur": "動態模糊",
- "fitClipMany": "{{count}} 個片段",
- "frame": "外框",
- "padding": "內邊距",
- "roundness": "圓角",
- "off": "關",
- "blurBg": "模糊背景",
+ "title": "畫面合成",
"shadow": "陰影",
+ "off": "關",
"on": "開",
+ "blurBg": "模糊背景",
+ "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
"fitClipOne": "{{count}} 個片段",
"formatOriginal": "原始",
- "fitClip": "符合"
+ "fitClipMany": "{{count}} 個片段",
+ "frame": "外框",
+ "motion": "動態",
+ "padding": "內邊距",
+ "format": "格式",
+ "fitClip": "符合",
+ "motionBlur": "動態模糊",
+ "roundness": "圓角"
+ },
+ "transcript": {
+ "laneRecording": "錄影",
+ "noTranscript": "尚無逐字稿",
+ "title": "目前的逐字稿",
+ "restoreWord": "還原「{{word}}」",
+ "revertWord": "還原為「{{original}}」",
+ "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
+ "restoreSilence": "還原靜音({{duration}} 秒)",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
+ "editWord": "編輯「{{word}}」",
+ "laneFeedsCaptions": "字幕從這條軌道燒錄。",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
+ "insertAria": "新字詞",
+ "editorAria": "{{filename}} 的逐字稿",
+ "transcribeNow": "立即產生逐字稿",
+ "transcribing": "轉錄中…",
+ "trimSilence": "修剪靜音({{duration}} 秒)",
+ "removeInserted": "刪除「{{word}}」",
+ "laneLabel": "轉錄文字讀取自",
+ "noClips": "尚無片段",
+ "laneVoiceover": "旁白",
+ "silence": "[靜音 {{duration}} 秒]",
+ "clipLabel": "片段 {{index}}",
+ "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
+ "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "noAudio": "此媒體沒有音訊軌道",
+ "blankedWord": "已清空"
},
"exportFormat": {
- "gifDescription": "可分享的動態圖片",
"mp4": "MP4",
- "mp4Video": "MP4 影片",
- "gif": "GIF",
+ "mp4Description": "高品質影片檔案",
"gifAnimation": "GIF 動畫",
- "mp4Description": "高品質影片檔案"
+ "mp4Video": "MP4 影片",
+ "gifDescription": "可分享的動態圖片",
+ "gif": "GIF"
},
- "imageUpload": {
- "failedToUpload": "上傳圖片失敗",
- "uploadSuccess": "自訂圖片上傳成功!",
- "errorReading": "讀取檔案時出錯。",
- "invalidFileType": "無效的檔案類型",
- "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。"
+ "captions": {
+ "showBackground": "顯示背景",
+ "deleteTranslation": "刪除這個翻譯",
+ "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
+ "backgroundOpacity": "不透明度",
+ "backgroundColor": "背景顏色",
+ "alignCenter": "置中",
+ "translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
+ "distanceFromRight": "距右緣",
+ "language": "語言",
+ "text": "文字",
+ "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
+ "distanceFromTop": "距上緣",
+ "translateFailed": "翻譯失敗。",
+ "alignLeft": "靠左",
+ "distanceFromBottom": "距下緣",
+ "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
+ "translate": "翻譯",
+ "position": "位置",
+ "fontSize": "大小",
+ "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
+ "noTranscript": "字幕取自媒體的逐字稿。請先為這部影片產生逐字稿以啟用字幕。",
+ "distanceFromLeft": "距左緣",
+ "anchorBottom": "下",
+ "transcribe": "為影片產生逐字稿",
+ "bold": "粗體",
+ "alignRight": "靠右",
+ "anchorTop": "上",
+ "minWords": "每行最少字數",
+ "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
+ "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
+ "displayLanguage": "顯示",
+ "removeLegacyAnnotations": "移除舊的字幕註解",
+ "background": "背景",
+ "lineLength": "行長",
+ "original": "原文(逐字稿)",
+ "maxWords": "每行最多字數",
+ "font": "字型",
+ "translating": "翻譯中…",
+ "show": "顯示字幕",
+ "textColor": "文字顏色"
},
- "facets": {
- "captions": "字幕",
- "transcript": "逐字稿"
+ "panes": {
+ "help": "說明"
},
- "audio": {
- "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
- "reset": "重設音訊",
- "title": "音訊",
- "outputGain": "輸出音量"
+ "speed": {
+ "deleteRegion": "刪除速度區域",
+ "maxSpeedError": "速度不能超過 {{max}}×",
+ "selectRegion": "選擇要調整的速度區域",
+ "playbackSpeed": "播放速度",
+ "customPlaybackSpeed": "自訂播放速度",
+ "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。"
},
- "language": {
- "title": "語言"
+ "gifSettings": {
+ "frameRate": "GIF 影格率",
+ "loop": "循環 GIF",
+ "size": "GIF 尺寸"
+ },
+ "exportQuality": {
+ "title": "匯出解析度",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
},
"audioTrack": {
- "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "defaultLabel": "音訊軌道",
"importFailed": "無法新增音訊",
- "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
"fadeOut": "淡出",
- "defaultLabel": "音訊軌道",
- "mute": "靜音",
+ "fadeIn": "淡入",
"remove": "刪除軌道",
- "add": "新增音訊軌道",
"loop": "循環",
- "fadeIn": "淡入"
+ "slipHint": "按住 Alt 拖曳可在其中滑動音訊",
+ "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "add": "新增音訊軌道",
+ "mute": "靜音"
},
- "project": {
- "load": "載入專案",
- "save": "儲存專案",
- "new": "新增專案"
+ "layout": {
+ "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
+ "mirrorWebcam": "鏡像攝影機",
+ "webcamFraming": "攝影機構圖",
+ "shapes": {
+ "rectangle": "矩形",
+ "rounded": "圓角",
+ "circle": "圓形",
+ "square": "正方形"
+ },
+ "selectPreset": "選擇預設",
+ "bgModes": {
+ "custom": "自訂",
+ "none": "原畫",
+ "blur": "模糊",
+ "transparent": "去背"
+ },
+ "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
+ "webcamBlurIntensity": "模糊強度",
+ "preset": "預設",
+ "webcamCropZoom": "裁切縮放",
+ "webcamSize": "攝影機大小",
+ "dualFrame": "雙畫框",
+ "webcamCropY": "垂直移動",
+ "verticalStack": "垂直堆疊",
+ "pictureInPicture": "子母畫面",
+ "webcamShape": "攝影機形狀",
+ "webcamCropX": "水平移動",
+ "reactiveWebcam": "縮放時縮小",
+ "webcamBackground": "攝影機背景",
+ "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
+ "title": "攝影機版面",
+ "noWebcam": "無網路攝影機"
},
- "panes": {
- "help": "說明"
+ "textAnimation": {
+ "slideLeft": "向左滑動",
+ "pulse": "脈動",
+ "typewriter": "打字機",
+ "selectAnimation": "選擇動畫",
+ "fade": "淡入淡出",
+ "title": "文字動畫",
+ "none": "無",
+ "pop": "彈出",
+ "rise": "上升"
},
- "trim": {
- "deleteRegion": "刪除剪輯區域"
+ "facets": {
+ "transcript": "逐字稿",
+ "captions": "字幕"
},
- "export": {
- "videoButton": "匯出影片",
- "gifButton": "匯出 GIF",
- "chooseSaveLocation": "選擇儲存位置"
+ "crop": {
+ "title": "裁剪",
+ "free": "自由",
+ "unlockAspectRatio": "解鎖長寬比",
+ "dragInstruction": "拖動每一側來調整裁剪區域",
+ "done": "完成",
+ "ratio": "比例",
+ "cropVideo": "裁剪影片",
+ "lockAspectRatio": "鎖定長寬比"
+ },
+ "zoom": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = 最左 / 最上,100 = 最右 / 最下",
+ "title": "焦點位置",
+ "x": "X (%)"
+ },
+ "deleteZoom": "刪除縮放",
+ "focusMode": {
+ "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。",
+ "auto": "自動",
+ "manual": "手動",
+ "autoDescription": "攝影機跟隨錄製時的游標位置",
+ "title": "對焦模式"
+ },
+ "threeD": {
+ "preset": {
+ "left": "左",
+ "right": "右",
+ "iso": "Iso"
+ },
+ "none": "無",
+ "title": "3D 旋轉"
+ },
+ "level": "縮放級別",
+ "previewHold": "按住預覽放大效果",
+ "customScale": "自訂縮放",
+ "selectRegion": "選擇要調整的縮放區域"
+ },
+ "audio": {
+ "outputGain": "輸出音量",
+ "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
+ "reset": "重設音訊",
+ "title": "音訊"
+ },
+ "language": {
+ "title": "語言"
+ },
+ "project": {
+ "new": "新增專案",
+ "load": "載入專案",
+ "save": "儲存專案"
},
"support": {
"starOnGithub": "在 GitHub 上加星",
"saveDiagnostics": "儲存診斷資料",
"reportBug": "回報錯誤"
},
- "gifSettings": {
- "size": "GIF 尺寸",
- "frameRate": "GIF 影格率",
- "loop": "循環 GIF"
+ "cursor": {
+ "smoothing": "平滑",
+ "clickBounce": "點擊彈跳",
+ "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。",
+ "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
+ "size": "大小",
+ "title": "游標",
+ "show": "顯示游標",
+ "themeDefault": "預設",
+ "clipToBounds": "裁切至畫布",
+ "motionBlur": "動態模糊",
+ "theme": "游標樣式"
+ },
+ "export": {
+ "gifButton": "匯出 GIF",
+ "chooseSaveLocation": "選擇儲存位置",
+ "videoButton": "匯出影片"
+ },
+ "trim": {
+ "deleteRegion": "刪除剪輯區域"
}
}
diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json
index 01bbc2373..7f5d90beb 100644
--- a/src/i18n/locales/zh-TW/timeline.json
+++ b/src/i18n/locales/zh-TW/timeline.json
@@ -1,108 +1,108 @@
{
- "toolbar": {
- "noAutoZoomMomentsDescription": "此錄製內容沒有游標移動資料,或現有縮放已涵蓋忙碌時刻。",
- "smartCutsNoAudio": "此媒體沒有音訊",
- "automaticZoomsHint": "根據錄製的游標移動",
- "dragToReorderHint": "拖曳以重新排序 · 按兩下以編輯入點/出點",
- "smartCutsNeedsTranscript": "需要轉錄文字",
- "addedWord": "已加入的字詞:「{{word}}」— 背後沒有聲音",
- "smartZoomsAndCuts": "智慧剪輯",
- "autoZoomFailed": "自動縮放失敗",
- "smartCutsWaiting": "正在轉錄…稍後可用",
- "automaticZooms": "自動縮放",
- "arrangeClipsHint": "拖曳下方片段以重新排序,或拖曳新片段至此",
- "comment": "留言",
- "addAudioTooltip": "新增音訊",
- "timelineTools": "時間軸工具",
- "deleteClip": "刪除片段",
- "arrangeClips": "排列片段",
- "editInOutPoints": "編輯入點/出點",
- "smartZoomsAndCutsHint": "使用 AI",
- "addedAutoZoomPlural": "已新增 {{count}} 個自動縮放",
- "noAutoZoomMoments": "找不到自動縮放時刻",
- "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試",
- "smartCutsNoSpeech": "未偵測到語音",
- "newAnnotation": "註解",
- "importRecordingFirst": "請先匯入錄製內容",
- "addedAutoZoom": "已新增 {{count}} 個自動縮放",
- "dropToAdd": "拖放以新增至時間軸",
- "aiEnhanceRequested": "已請求 AI 代理剪除空白片段",
- "autoEnhance": "自動加強"
+ "buttons": {
+ "addZoom": "新增縮放 (Z)",
+ "suggestZooms": "根據游標建議縮放",
+ "autoZoomOn": "自動縮放建議已開啟 — 點擊可移除建議的縮放",
+ "autoZoomOff": "自動縮放建議已關閉 — 點擊可根據游標建議縮放",
+ "autoFocusAllOn": "所有縮放的自動對焦已開啟 — 點擊可將全部切換為手動",
+ "autoFocusAllOff": "為所有縮放開啟自動對焦(攝影機跟隨游標)",
+ "addTrim": "新增剪輯 (T)",
+ "addAnnotation": "新增標註 (A)",
+ "addSpeed": "新增速度 (S)",
+ "addCameraFullscreen": "新增全螢幕攝影機 (C)"
+ },
+ "hints": {
+ "pressZoom": "按 Z 新增縮放",
+ "pressTrim": "按 T 新增剪輯",
+ "pressAnnotation": "按 A 新增標註",
+ "pressAudio": "按 M 新增音訊,按 V 錄製配音",
+ "pressSpeed": "按 S 新增速度",
+ "pressCameraFullscreen": "按 C 新增一個全螢幕攝影機片段"
},
"labels": {
- "zoom": "縮放",
- "cameraFullscreenItem": "全螢幕攝影機 {{index}}",
- "imageItem": "圖片",
"pan": "平移",
- "zoomItem": "縮放 {{index}}",
- "cameraFullscreen": "全螢幕攝影機",
+ "zoom": "縮放",
+ "trim": "剪輯",
"speed": "速度",
- "emptyText": "空文字",
+ "zoomItem": "縮放 {{index}}",
"trimItem": "剪輯 {{index}}",
+ "speedItem": "速度 {{index}}",
"annotationItem": "標註",
- "trim": "剪輯",
- "speedItem": "速度 {{index}}"
+ "imageItem": "圖片",
+ "emptyText": "空文字",
+ "cameraFullscreen": "全螢幕攝影機",
+ "cameraFullscreenItem": "全螢幕攝影機 {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "未載入影片",
+ "dragAndDrop": "拖放影片以開始編輯"
},
"errors": {
- "noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
+ "cannotPlaceZoom": "無法在此處放置縮放",
+ "zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
+ "zoomSuggestionUnavailable": "縮放建議處理器不可用",
+ "noCursorTelemetry": "無可用的游標遙測資料",
"noCursorTelemetryDescription": "請先錄製一段螢幕錄影以產生基於游標的建議。",
- "cameraFullscreenExistsAtLocation": "此位置已存在全螢幕攝影機片段或沒有足夠的空間。",
"noUsableTelemetry": "無可用的游標遙測資料",
"noUsableTelemetryDescription": "錄製內容沒有包含足夠的游標移動資料。",
- "zoomSuggestionUnavailable": "縮放建議處理器不可用",
"noDwellMoments": "未找到明確的游標停留時刻",
- "speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。",
- "noCursorTelemetry": "無可用的游標遙測資料",
+ "noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
"noAutoZoomSlots": "無可用的自動縮放位置",
+ "noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
"cannotPlaceTrim": "無法在此處放置剪輯",
- "cannotPlaceZoom": "無法在此處放置縮放",
- "cannotPlaceSpeed": "無法在此處放置速度",
- "zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
- "noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
"trimExistsAtLocation": "此位置已存在剪輯或沒有足夠的空間。",
- "cannotPlaceCameraFullscreen": "無法在此處放置全螢幕攝影機"
+ "cannotPlaceSpeed": "無法在此處放置速度",
+ "speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。",
+ "cannotPlaceCameraFullscreen": "無法在此處放置全螢幕攝影機",
+ "cameraFullscreenExistsAtLocation": "此位置已存在全螢幕攝影機片段或沒有足夠的空間。"
+ },
+ "success": {
+ "addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
+ "addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
+ },
+ "toolbar": {
+ "autoEnhance": "自動加強",
+ "automaticZooms": "自動縮放",
+ "automaticZoomsHint": "根據錄製的游標移動",
+ "smartZoomsAndCuts": "智慧剪輯",
+ "smartZoomsAndCutsHint": "使用 AI",
+ "comment": "留言",
+ "timelineTools": "時間軸工具",
+ "arrangeClips": "排列片段",
+ "arrangeClipsHint": "拖曳下方片段以重新排序,或拖曳新片段至此",
+ "newAnnotation": "註解",
+ "dragToReorderHint": "拖曳以重新排序 · 按兩下以編輯入點/出點",
+ "editInOutPoints": "編輯入點/出點",
+ "deleteClip": "刪除片段",
+ "dropToAdd": "拖放以新增至時間軸",
+ "importRecordingFirst": "請先匯入錄製內容",
+ "noAutoZoomMoments": "找不到自動縮放時刻",
+ "noAutoZoomMomentsDescription": "此錄製內容沒有游標移動資料,或現有縮放已涵蓋忙碌時刻。",
+ "addedAutoZoom": "已新增 {{count}} 個自動縮放",
+ "addedAutoZoomPlural": "已新增 {{count}} 個自動縮放",
+ "autoZoomFailed": "自動縮放失敗",
+ "aiEnhanceRequested": "已請求 AI 代理剪除空白片段",
+ "smartCutsWaiting": "正在轉錄…稍後可用",
+ "smartCutsNeedsTranscript": "需要轉錄文字",
+ "smartCutsNoAudio": "此媒體沒有音訊",
+ "smartCutsNoSpeech": "未偵測到語音",
+ "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試",
+ "addAudioTooltip": "新增音訊",
+ "addedWord": "已加入的字詞:「{{word}}」— 背後沒有聲音"
},
"audio": {
- "micDenied": "麥克風存取遭拒絕",
+ "addVoiceover": "新增旁白",
+ "addVoiceoverHint": "為影片錄製旁白",
"subtitle": "在時間軸上放置旁白或背景音樂圖層",
- "importFailed": "無法匯入音訊檔案",
+ "record": "錄製旁白",
+ "importFile": "匯入音訊檔案",
"importFileHint": "匯入音樂或音訊檔案",
- "addVoiceoverHint": "為影片錄製旁白",
- "stop": "停止",
"recording": "錄製中",
- "saveFailed": "無法儲存錄音",
"recordingHint": "跟著影片講解 — 錄製時影片會繼續播放",
- "importFile": "匯入音訊檔案",
- "record": "錄製旁白",
+ "stop": "停止",
+ "micDenied": "麥克風存取遭拒絕",
"recordingUnavailable": "此處無法錄音",
- "addVoiceover": "新增旁白"
- },
- "success": {
- "addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
- "addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
- },
- "hints": {
- "pressAnnotation": "按 A 新增標註",
- "pressSpeed": "按 S 新增速度",
- "pressTrim": "按 T 新增剪輯",
- "pressCameraFullscreen": "按 C 新增一個全螢幕攝影機片段",
- "pressZoom": "按 Z 新增縮放",
- "pressAudio": "按 M 新增音訊,按 V 錄製配音"
- },
- "buttons": {
- "autoFocusAllOff": "為所有縮放開啟自動對焦(攝影機跟隨游標)",
- "autoZoomOff": "自動縮放建議已關閉 — 點擊可根據游標建議縮放",
- "addAnnotation": "新增標註 (A)",
- "suggestZooms": "根據游標建議縮放",
- "addSpeed": "新增速度 (S)",
- "addCameraFullscreen": "新增全螢幕攝影機 (C)",
- "autoFocusAllOn": "所有縮放的自動對焦已開啟 — 點擊可將全部切換為手動",
- "autoZoomOn": "自動縮放建議已開啟 — 點擊可移除建議的縮放",
- "addZoom": "新增縮放 (Z)",
- "addTrim": "新增剪輯 (T)"
- },
- "emptyState": {
- "noVideo": "未載入影片",
- "dragAndDrop": "拖放影片以開始編輯"
+ "saveFailed": "無法儲存錄音",
+ "importFailed": "無法匯入音訊檔案"
}
}
diff --git a/src/lib/ai-edition/document/transcribe.ts b/src/lib/ai-edition/document/transcribe.ts
index 77e3de73d..3300b0caf 100644
--- a/src/lib/ai-edition/document/transcribe.ts
+++ b/src/lib/ai-edition/document/transcribe.ts
@@ -79,9 +79,7 @@ export async function transcribeAsset(
// case a user cannot otherwise diagnose.
backend: status.backend,
rtf: status.rtf,
- ...(status.downloadedBytes !== undefined
- ? { downloadedBytes: status.downloadedBytes }
- : {}),
+ ...(status.downloadedBytes !== undefined ? { downloadedBytes: status.downloadedBytes } : {}),
...(status.totalBytes !== undefined ? { totalBytes: status.totalBytes } : {}),
});
diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md
index 3c7cd36f7..ea5b63cf1 100644
--- a/technical-documentation/engineering/rendering-performance.md
+++ b/technical-documentation/engineering/rendering-performance.md
@@ -570,34 +570,6 @@ Unit tests never look at a pixel. The `native*` arms write real files: export th
## Rejected routes
-### Shrinking the macOS `app.asar` to cure the export's cold start
-
-**What it was.** A headless `openscreen export` was measured repeatedly spending 4.2 s between the CLI's `started` event and its first composed frame, then not doing it any more on the same binary. The standing hypothesis was memory pressure on an 8 GiB machine faulting ~1.8 MB of module chunks out of a 274 MB `app.asar`, and the proposed lever was a smaller archive. **What the measurement said.** The cost is real and now reproducible on demand — but the archive is not it, and residency is not the lever. Shipped 1.10.0 bundle, M1 Mac mini, 4 s fixture, conditions interleaved inside one session; the two unpressured blocks closed at 442 ms and 441 ms, so the comparisons sit on a stable floor.
-
-| condition | spawn→`started` | `started`→first frame |
-|---|---:|---:|
-| validated binary, machine free (baseline) | 432 ms | 452 ms |
-| + 1.5 GB pinned and continuously touched | 490 ms | 625 / 555 ms |
-| + 3 GB pinned | 474 ms | 652 / 632 ms |
-| page cache flushed (8 GB read), same binary | 575 ms | 493 ms |
-| **first run of a newly written copy** | **2120 ms** | **780 ms** |
-| same, whole bundle read into cache first | 2130 ms | 771 ms |
-| **newly written copy + 3 GB pinned** | **3988 ms** | **1115 ms** |
-
-**Read the columns, not the total.** The magnitude matches the report — 5103 ms from spawn to the first frame against an 884 ms baseline — but it lands on the other side of `started`: 3988 ms of it before the event, 1115 ms after. The original report put its 4.2 s entirely *after* `started`, with the renderer's `domInteractive` at 3887 ms. Nothing here reproduces that split, which is why [Known gaps](#known-gaps) keeps it open as possibly a second phenomenon.
-
-Five things fall out, each with its own control:
-
-- **Reading every byte of the bundle first changes nothing** — 2130 ms against 2120 ms. That is the ceiling for any lever working through residency, so pre-warming the archive cannot pay. It says nothing about bundle *size*, which is a different variable and untested — see the one-line reason below. A cold read of the entire 261 MB archive costs 110 ms; the machine does 2.4 GB/s and the file is not the problem.
-- **Cold pages are worth ~36 ms** of the `started`→first-frame interval. That is 493 ms against the **paired warm arm of the same experiment** (457 ms), not against the table's baseline row — pairing each flushed run with the unflushed run that followed it is the comparison that holds the machine constant. Against the table row it reads 41 ms; the difference between the two is the noise this pairing exists to remove. The flush is not imaginary: page faults requiring I/O go 656 → 2730, and 12 708 in the most effective trial.
-- **Memory pressure is real, and over the range tested it grows far slower than the pin.** Each figure is the mean of two paired pressure/free blocks: 1.5 GB costs +183 and +114 ms (mean **+148**), 3 GB costs +213 and +195 ms (mean **+204**). Doubling the pin buys 38 % more cost, not 100 % — but 1.5–3 GB is the whole tested range, and nothing here says where it goes above that.
-- **Neither user-space check warms whatever costs the time.** Pre-running `spctl -a -t exec` (372 ms) and `codesign --verify --deep` (209 ms) on a fresh copy leaves the first launch exactly where it was: 2137 ms against 2127 ms without. That is the whole claim: those two tools do not populate the state being paid for. It does not clear Gatekeeper as a mechanism — and it cannot, since every copy measured here was made with `ditto` and carries no quarantine attribute, so the heavier assessment a real download triggers was never exercised.
-- **It is bound to the file's identity.** Rewriting the same bytes to the same path with the same mtime — a new inode and nothing else — brings the whole cost back: 2380 ms against 441 ms. So it is neither a path-keyed nor a `userData`-keyed cache the app could pre-warm; it is charged by the platform against the binary itself — by which layer is exactly what stays open, since ruling out the two user-space checks does not rule out the kernel's own per-page validation, nor a dyld launch closure.
-
-The expensive launch is therefore **the first execution of a newly installed binary**, compounding with memory pressure to the ~4 s that was reported (7578 ms total against 3447 ms). It is paid once per install or update, which is also why it disappeared "on the same binary, hours later" — and why it never shows up in a benchmark, which launches the same binary dozens of times.
-
-**One-line reason not to re-propose:** pre-warming the archive is refuted outright — full residency buys 10 ms out of 2120 — so no lever that works by improving residency can pay. Whether a *smaller* bundle would shorten the identity-bound cost is a different question and an open one: it was not tested here, because removing content invalidates the signature that is part of what is being measured. Re-propose that one only with a size-controlled experiment attached.
-
### Capping the macOS decoder's thread count
**What it was.** After the export moved to the software H.264 decoder it runs with `thread_count = 0`, which in libavcodec means *automatic* — the decoder picks, from the CPU count and its own threading model, and the number it actually chose was never read back here. The export's CPU-seconds went 8.4 → 29.8. Since the walk is bound by the encoder and the decoder has seconds of slack, capping its threads looked like free CPU. **What the measurement said.** It is not free and it does not return CPU. Public bundle, S4, three cycles with a floor inside each, closing drift 0.9979, output identical across variants:
@@ -713,7 +685,7 @@ the bench runs on the reference machine.
## Known gaps
-- **The macOS export's 4 s cold start is priced, but the platform mechanism behind it is unnamed.** The cost reproduces on demand and its levers are settled ([Rejected routes](#shrinking-the-macos-appasar-to-cure-the-exports-cold-start)): it is the first execution of a newly installed binary, amplified by memory pressure. Which per-inode cache that first execution populates — page-granular code-signature validation, a dyld launch closure, or both — was not established, because `DYLD_PRINT_STATISTICS` is stripped from a binary signed with the hardened runtime. Three things stay untested. Whether the cost scales with **bundle size** at all: residency was refuted, size was not, and content cannot be removed without invalidating the signature that is part of what is being measured. Whether a **real download** is worse: a quarantined bundle takes a heavier Gatekeeper path than the `ditto` copies used here, so a user's first launch after downloading may cost more than any number above. And the **original report's split**, which put 4.2 s between `started` and the first frame with the renderer's `domInteractive` at 3887 ms, where this reproduction puts the bulk before `started` — same magnitude, different place, so it may be a second phenomenon wearing the same total.
+- **macOS export startup can cost 4 s, and nobody has reproduced it on demand.** Measured repeatedly at 4208–4502 ms between the CLI's `started` event and the first composed frame — 18 % of a 60 s export, 71 % of a 5 s one — then gone, on the same shipped binary, hours later (481 ms). It is not the compositor (init is 2.4 ms, runtime MSL compilation included), not the `` metadata probes (13 ms and 6 ms), not the CLI prologue (24 ms total), and not the renderer entry point (measured at −0.1 %). It correlates with memory pressure on an 8 GiB machine — `387M unused / 2613M compressor` while it reproduced, `564M unused / 1837M compressor` after — which would fit faulting ~1.8 MB of module chunks out of a 274 MB `app.asar` while the compressor thrashes: seconds of wall clock, no CPU in either process, cost independent of the media. Untested. Recreating the pressure deliberately and watching it return is what would settle it, and then whether asar size is the lever.
- **10-bit and HEVC decode on macOS are unmeasured.** The export's decode predicate is `codec_id == H264 && format == YUV420P`, so both keep VideoToolbox untested. HEVC is the case most likely to invert the result, since its software decoder is materially more expensive. 10-bit needs work beyond the predicate first: `mac_frames::CpuFrames` converts to 8-bit NV12, so routing 10-bit through the software path would silently truncate — the predicate is currently what prevents that.
- **The macOS preview's decode backend has never been measured.** `DecodeIntent` splits preview from export precisely so the preview could keep the old arbitration; the export won on throughput, but the preview scrubs, where seek latency after `avcodec_flush_buffers` may matter more, and it shares the machine with the editor UI. Changing it without measuring it would be the same mistake the export change corrects.
- **The energy cost of software decode on macOS is unmeasured, and the CPU figure is not a proxy for it.** The export burns 3.5× the **CPU-seconds** it used to (8.4 → 29.8 s), and that is the only thing measured. It does not follow that energy moved by the same factor: on an M-series the P and E cores draw very differently, clock is not fixed, and a shorter run at higher occupancy can spend less total energy than a longer one — racing to idle. Nor is the jump waste: VideoToolbox does the same decoding in a fixed-function block that CPU accounting never sees, so the work did not grow, it moved somewhere visible and got 12× faster on the way. Capping the decoder's threads does **not** recover it (see Rejected routes); what it would buy is lower peak core occupancy — how unusable the machine feels during an export — which is a different question and also unmeasured. `powermetrics` would answer the energy half and needs sudo.