diff --git a/.gitignore b/.gitignore
index a1822ee05..24ad425eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,3 +132,4 @@ workbench/fixtures/
/aur_ci
/aur_ci.pub
/aur_known_hosts
+tmp_handoff.md
diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs
index e51f0f8af..ee90c1374 100644
--- a/crates/compositor/src/audio.rs
+++ b/crates/compositor/src/audio.rs
@@ -5,7 +5,7 @@
use crate::ffi::*;
use crate::regions::SpeedSegment;
-use crate::scene::SceneAudio;
+use crate::scene::{SceneAudio, SceneAudioTrack};
use anyhow::{bail, Result};
use std::f32::consts::PI;
use std::ffi::CString;
@@ -1381,6 +1381,153 @@ pub fn assemble_concatenated_pcm(
output
}
+/// Mix imported audio tracks (issue #350) over the assembled programme.
+///
+/// Each track is decoded across its trim window — already resampled to 48 kHz
+/// stereo by `decode_clip_audio`, the same path a clip's own audio takes — scaled
+/// by its per-track gain (the same `10^(dB/20)` law as `finish_audio`), and summed
+/// into the programme at `start_sec`. The programme length is NOT extended: a
+/// track that runs past the video is truncated to it, so the audio and video
+/// streams stay the same length for the muxer.
+///
+/// The decode window is capped up front at the room left in the programme after
+/// `start_sec`, and a track starting at/after the end is skipped without decoding.
+/// `decode_clip_audio` preallocates from the window, so this keeps a long track
+/// pinned near a short programme's end from buffering (and clamping away) hours of
+/// PCM. `trim_end_sec` must therefore be concrete — the renderer sends
+/// `trimEnd ?? durationSec`.
+///
+/// A track whose file has no decodable audio is skipped — the same degradation a
+/// stream-less clip gets.
+pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack]) -> PlanarPcm {
+ let programme_len = programme.first().map(Vec::len).unwrap_or(0);
+ if programme_len == 0 {
+ return programme;
+ }
+ for track in tracks {
+ let offset = (track.start_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize;
+ // A track that starts at or past the programme end contributes nothing —
+ // skip it before decoding anything.
+ if offset >= programme_len {
+ continue;
+ }
+ let trim_start = track.trim_start_sec.max(0.0);
+ let Some(trim_end_full) = track.trim_end_sec else {
+ // Without a concrete end there is no safe window to decode (see the doc
+ // comment); the renderer always resolves one, so this only guards a
+ // hand-written scene.
+ continue;
+ };
+ // Cap the decode window at the room left in the programme. Everything past
+ // `offset` that overflows is discarded by `overlay_track_pcm` anyway, so
+ // decoding it only wastes time and memory — a three-hour track placed at
+ // 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;
+ }
+ let decoded = match decode_clip_audio(&track.path, trim_start, trim_end) {
+ Ok(Some(pcm)) => pcm,
+ _ => continue,
+ };
+ // The app's own range is -60..+12 dB (the inspector slider); clamping at
+ // -12 here floored every quiet bed at a tenth of the attenuation asked for.
+ let gain = 10.0f32.powf(track.gain_db.clamp(-60.0, 12.0) / 20.0);
+ overlay_track_pcm(
+ &mut programme,
+ &decoded,
+ offset,
+ gain,
+ track.fade_in_sec.max(0.0),
+ track.fade_out_sec.max(0.0),
+ full_len,
+ );
+ }
+ programme
+}
+
+/// Sum one decoded track into the programme at `offset` samples, scaled by `gain`,
+/// truncated at the programme's end. Split out of `mix_external_tracks` so the
+/// placement/gain/clamp math is testable without ffmpeg, exactly like
+/// `mix_aligned_tracks` is split from the decode above.
+fn overlay_track_pcm(
+ programme: &mut PlanarPcm,
+ decoded: &PlanarPcm,
+ offset: usize,
+ 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 {
+ return;
+ }
+ let room = programme_len - offset;
+ // The ramps are measured against the DECODED length, not the room left in the
+ // 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 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;
+ };
+ 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, envelope_len, fade_in, fade_out);
+ }
+ }
+}
+
+/// Fade lengths in samples, reduced to fit inside `len`.
+///
+/// Fades that do not fit share the window in proportion rather than being clamped
+/// independently: clamping each to the length first would turn an asymmetric pair
+/// into a symmetric one, losing the shape asked for. Kept identical to the app's
+/// `resolveFadeSecs` so the preview and the render agree.
+fn resolve_fade_samples(len: usize, fade_in_sec: f64, fade_out_sec: f64) -> (usize, usize) {
+ if len == 0 {
+ return (0, 0);
+ }
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let mut fade_in = fade_in_sec.max(0.0) * rate;
+ let mut fade_out = fade_out_sec.max(0.0) * rate;
+ let total = fade_in + fade_out;
+ if total > len as f64 && total > 0.0 {
+ let scale = len as f64 / total;
+ fade_in *= scale;
+ fade_out *= scale;
+ }
+ (fade_in.round() as usize, fade_out.round() as usize)
+}
+
+/// Linear ramp factor at sample `k` of a `len`-sample track.
+fn fade_envelope(k: usize, len: usize, fade_in: usize, fade_out: usize) -> f32 {
+ let mut v = 1.0f32;
+ if fade_in > 0 && k < fade_in {
+ v = v.min(k as f32 / fade_in as f32);
+ }
+ if fade_out > 0 && len > k {
+ let remaining = len - k;
+ if remaining <= fade_out {
+ v = v.min(remaining as f32 / fade_out as f32);
+ }
+ }
+ v
+}
+
/// Encodeur AAC attaché au muxer avant son header. Les paquets utilisent le même interleaver
/// que la vidéo ; les pts restent en unités échantillon jusqu'au rescale vers l'AVStream.
pub(crate) struct AacEncoder {
@@ -1493,6 +1640,40 @@ impl Drop for AacEncoder {
}
}
+#[cfg(test)]
+mod hold_tests {
+ use super::*;
+
+ /// Les images tenues allongent le CRÉNEAU audio du clip sans allonger son PCM, et
+ /// `assemble_concatenated_pcm` laisse des zéros dans ce qui dépasse. Le silence d'une
+ /// pause est donc gratuit : aucun fichier muet à décoder, aucune entrée de mix en plus.
+ #[test]
+ fn a_longer_slot_than_pcm_leaves_silence_at_its_tail() {
+ // 2s de créneau à 1 fps, mais seulement 1s de PCM décodé.
+ let plan = build_audio_concat_plan(&[2], &[true], 1.0);
+ let one_sec = AUDIO_OUTPUT_SAMPLE_RATE as usize;
+ let pcm = vec![Some(vec![vec![0.5f32; one_sec]; AUDIO_OUTPUT_CHANNELS])];
+ let out = assemble_concatenated_pcm(&pcm, &plan);
+ assert_eq!(out[0].len(), 2 * one_sec);
+ assert!((out[0][0] - 0.5).abs() < 1e-6, "le vrai son est bien là");
+ assert_eq!(out[0][2 * one_sec - 1], 0.0, "la queue du créneau est du silence");
+ }
+
+ /// Et le son réel n'est PAS étiré pour remplir le créneau : la voix garde son rythme.
+ #[test]
+ fn the_clips_own_audio_is_not_stretched_to_fill_the_hold() {
+ let plan = build_audio_concat_plan(&[4], &[true], 1.0);
+ let one_sec = AUDIO_OUTPUT_SAMPLE_RATE as usize;
+ let mut source = vec![0.0f32; one_sec];
+ source[0] = 1.0;
+ let pcm = vec![Some(vec![source.clone(), source])];
+ let out = assemble_concatenated_pcm(&pcm, &plan);
+ // L'impulsion reste au premier échantillon, pas répartie sur quatre secondes.
+ assert!((out[0][0] - 1.0).abs() < 1e-6);
+ assert_eq!(out[0][1], 0.0);
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1692,6 +1873,68 @@ mod tests {
assert_eq!(mixed[1], vec![0.25, -0.5, 0.75]);
}
+ // Imported audio track overlay (issue #350).
+ #[test]
+ 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, 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]);
+ }
+
+ #[test]
+ 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, 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, 0);
+ assert_eq!(programme[0], vec![0.3, 0.3]);
+ }
+
+ #[test]
+ fn mix_external_tracks_skips_empty_windows() {
+ let programme = planar(&[0.4, 0.4]);
+ let tracks = vec![SceneAudioTrack {
+ path: "/nope.mp3".into(),
+ start_sec: 0.0,
+ gain_db: 0.0,
+ trim_start_sec: 2.0,
+ trim_end_sec: Some(1.0), // end <= start: empty window, never decoded
+ fade_in_sec: 0.0,
+ fade_out_sec: 0.0,
+ }];
+ // The empty window is skipped before any decode, so the programme is
+ // untouched even though the path does not exist.
+ let out = mix_external_tracks(programme, &tracks);
+ assert_eq!(out[0], vec![0.4, 0.4]);
+ }
+
+ #[test]
+ fn mix_external_tracks_skips_a_track_that_starts_past_the_programme() {
+ // 2 samples = ~0.00004 s of programme at 48 kHz; the track starts at 1 s, so
+ // its offset is past the end. It must be skipped before any decode is
+ // attempted (the path does not exist), never buffering its window.
+ let programme = planar(&[0.4, 0.4]);
+ let tracks = vec![SceneAudioTrack {
+ path: "/nope.mp3".into(),
+ start_sec: 1.0,
+ gain_db: 0.0,
+ trim_start_sec: 0.0,
+ trim_end_sec: Some(3600.0),
+ fade_in_sec: 0.0,
+ fade_out_sec: 0.0,
+ }];
+ let out = mix_external_tracks(programme, &tracks);
+ assert_eq!(out[0], vec![0.4, 0.4]);
+ }
+
#[test]
fn single_track_is_not_clamped() {
// Promesse de non-régression : une source mono-piste ressort telle quelle, y compris
@@ -1784,6 +2027,91 @@ mod tests {
assert!((loud[0][0] - ceiling).abs() < 1e-6);
}
+ #[test]
+ fn fades_that_fit_are_left_alone() {
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let (fin, fout) = resolve_fade_samples(rate as usize, 0.1, 0.2);
+ assert_eq!(fin, (0.1 * rate).round() as usize);
+ assert_eq!(fout, (0.2 * rate).round() as usize);
+ }
+
+ #[test]
+ fn fades_too_long_for_the_track_share_it_in_proportion() {
+ // 6 s + 4 s of fade on a 2 s track → 1.2 s / 0.8 s, not a clamped 1 s / 1 s.
+ // Mirrors `resolveFadeSecs` on the app side; the two must agree or the
+ // preview and the render shape the same track differently.
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let len = (2.0 * rate) as usize;
+ let (fin, fout) = resolve_fade_samples(len, 6.0, 4.0);
+ assert_eq!(fin, (1.2 * rate).round() as usize);
+ assert_eq!(fout, (0.8 * rate).round() as usize);
+ assert!(fin + fout <= len + 1);
+ }
+
+ #[test]
+ fn a_fade_in_longer_than_the_track_still_reaches_full_volume() {
+ // Left unreduced this holds the gain near zero for the whole track — the
+ // layer exports silent.
+ let (fin, fout) = resolve_fade_samples(100, 10.0, 0.0);
+ assert_eq!((fin, fout), (100, 0));
+ assert!((fade_envelope(99, 100, fin, fout) - 0.99).abs() < 1e-3);
+ }
+
+ #[test]
+ fn the_envelope_ramps_at_both_edges_and_holds_between() {
+ assert_eq!(fade_envelope(0, 100, 10, 10), 0.0);
+ assert!((fade_envelope(5, 100, 10, 10) - 0.5).abs() < 1e-6);
+ assert_eq!(fade_envelope(50, 100, 10, 10), 1.0);
+ assert!((fade_envelope(95, 100, 10, 10) - 0.5).abs() < 1e-6);
+ }
+
+ #[test]
+ fn overlay_applies_the_fade_over_the_decoded_length() {
+ // The ramps are measured against the DECODED length, not the room left in
+ // the programme: a fade-out timed to the programme's end would ramp down
+ // over audio the export never reaches.
+ let mut programme = planar(&[0.0, 0.0, 0.0, 0.0]);
+ let decoded = planar(&[1.0, 1.0, 1.0, 1.0]);
+ // 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, 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
+ // output trim's ±12: clamping here at -12 floored every quiet bed at a
+ // tenth of the attenuation asked for.
+ 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, 0);
+ assert!((programme[0][0] - gain).abs() < 1e-9);
+ assert!(programme[0][0] < 10.0f32.powf(-12.0 / 20.0));
+ }
+
#[test]
fn output_is_clipped_to_full_scale_and_keeps_its_length() {
// The trim can push a hot signal past full scale; the timeline must come back the
diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs
index 03a8a5e52..387e29319 100644
--- a/crates/compositor/src/pipeline_linux.rs
+++ b/crates/compositor/src/pipeline_linux.rs
@@ -27,7 +27,7 @@ use std::ffi::CString;
use std::ptr;
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, finish_audio,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
@@ -1002,6 +1002,12 @@ pub fn run_composited_multi(
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
+ // Imported audio tracks (issue #350), cloned out of the borrowed scene so the
+ // mix step below owns them. Empty for a project with no imported audio.
+ let audio_tracks = scene
+ .as_ref()
+ .map(|scene| scene.audio_tracks.clone())
+ .unwrap_or_default();
// Ring de staging a 2 : l'export ne veut que du debit, une frame de latence
// ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour
// la raison pour laquelle la preview, elle, reste a 1.
@@ -1190,7 +1196,10 @@ pub fn run_composited_multi(
let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64);
let octx = mux.octx;
mux.aac.encode(
- &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
+ &finish_audio(
+ mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
+ audio_settings,
+ ),
octx,
)?;
mux.finish()?;
diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs
index ba6034d0f..5721c2011 100644
--- a/crates/compositor/src/pipeline_macos.rs
+++ b/crates/compositor/src/pipeline_macos.rs
@@ -30,7 +30,7 @@
//! décodeurs, symétrique.
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, finish_audio,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
@@ -1172,6 +1172,11 @@ pub fn run_composited_multi(
// raconte avoir déjà coûté une fois.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
+ // Imported audio tracks (issue #350), cloned out of the borrowed scene.
+ let audio_tracks = scene
+ .as_ref()
+ .map(|scene| scene.audio_tracks.clone())
+ .unwrap_or_default();
frames = unsafe {
crate::timeline_walk::walk_composited_timeline(
clips,
@@ -1247,7 +1252,10 @@ pub fn run_composited_multi(
let declared_audio: Vec = clips.iter().map(|clip| clip.has_audio).collect();
let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64);
audio_encoder.encode(
- &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
+ &finish_audio(
+ mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
+ audio_settings,
+ ),
octx,
)?;
diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs
index f51e9e656..8a474f388 100644
--- a/crates/compositor/src/pipeline_windows.rs
+++ b/crates/compositor/src/pipeline_windows.rs
@@ -3,7 +3,7 @@
//! 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, finish_audio,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
@@ -1351,6 +1351,11 @@ unsafe fn run_multi_inner(
// fenêtrage par clip ; `walk_composited_timeline` s'en charge.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
+ // Imported audio tracks (issue #350), cloned out of the borrowed scene.
+ let audio_tracks = scene
+ .as_ref()
+ .map(|scene| scene.audio_tracks.clone())
+ .unwrap_or_default();
// ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ----
// Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur
@@ -1484,7 +1489,7 @@ unsafe fn run_multi_inner(
out_fps as f64,
);
let assembled_audio = finish_audio(
- assemble_concatenated_pcm(&clip_pcm, &audio_plan),
+ mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &audio_plan), &audio_tracks),
audio_settings,
);
audio_encoder.encode(&assembled_audio, octx)?;
diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs
index ff300d475..a42241672 100644
--- a/crates/compositor/src/scene.rs
+++ b/crates/compositor/src/scene.rs
@@ -432,6 +432,39 @@ pub struct SceneAudio {
pub gain_db: f32,
}
+/// One imported audio track (issue #350) mixed over the assembled programme —
+/// voiceover / BGM / SFX. Deliberately a SEPARATE `Scene` field rather than a
+/// member of `SceneAudio`, so `SceneAudio` stays `Copy` and the pipelines keep
+/// copying it out of a borrow unchanged.
+///
+/// `start_sec` is the track's head on the OUTPUT programme; `trim_start_sec` /
+/// `trim_end_sec` window the source file (both source seconds). The renderer
+/// resolves `start_sec` from the track's raw timeline position — equal to it when
+/// the project has no trims/speed, which is the case this first cut mixes exactly.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneAudioTrack {
+ pub path: String,
+ #[serde(default)]
+ pub start_sec: f64,
+ #[serde(default)]
+ pub gain_db: f32,
+ #[serde(default)]
+ pub trim_start_sec: f64,
+ #[serde(default)]
+ pub trim_end_sec: Option,
+ /// Ramp lengths at this entry's own edges, in seconds. The app puts them only
+ /// on the pieces that touch the track's real start and end, so a split or
+ /// looping track fades once instead of at every cut or repeat.
+ ///
+ /// `#[serde(default)]` for the usual reason: a payload from a build that
+ /// predates the field must degrade to "no fade", not fail the whole scene.
+ #[serde(default)]
+ pub fade_in_sec: f64,
+ #[serde(default)]
+ pub fade_out_sec: f64,
+}
+
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneOutput {
@@ -500,6 +533,10 @@ pub struct Scene {
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
pub audio: SceneAudio,
+ /// Imported audio tracks mixed over the programme (issue #350). `#[serde(default)]`:
+ /// absent from every scene written before this, and from a project with none.
+ #[serde(default)]
+ pub audio_tracks: Vec,
/// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS).
#[serde(default)]
pub crop_by_clip: Vec>,
@@ -576,6 +613,9 @@ impl Scene {
mod tests {
use super::*;
+ /// 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 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 e5a2eec68..fe3a2e795 100644
--- a/crates/compositor/src/timeline_walk.rs
+++ b/crates/compositor/src/timeline_walk.rs
@@ -17,6 +17,7 @@ use crate::compositor::Compositor;
use crate::config::Cfg;
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
+use crate::ffi::AVFrame;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::{ClipSource, Decoder};
use crate::regions::{speed_segments_for_window, SpeedSegment};
@@ -336,6 +337,7 @@ pub(crate) unsafe fn walk_composited_timeline(
frames += 1;
}
}
+
on_clip_end(
clip_index,
source_end_sec,
diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs
index aa2de7d60..34460d9aa 100644
--- a/crates/poc-d3d/src/bench.rs
+++ b/crates/poc-d3d/src/bench.rs
@@ -150,6 +150,7 @@ fn run_bench(args: &[String]) -> Result<()> {
source_end_sec: 6.0, // la fixture entière (§ fixture.json : 6 s, 360 frames)
webcam_offset_sec: 0.0,
has_audio: false,
+ hold_sec: 0.0,
};
let path = format!("{out}/{}_{:?}.mp4", cfg.name, backend).to_lowercase();
let s = pipeline::run_composited_multi(
@@ -292,6 +293,7 @@ fn run_gif_bench(
source_end_sec: f64::MAX,
webcam_offset_sec: 0.0,
has_audio: false,
+ hold_sec: 0.0,
}];
for r in 0..repeat {
// Each run writes to the same path — the last frame wins. The
diff --git a/electron/ai-edition/agent-tools.test.ts b/electron/ai-edition/agent-tools.test.ts
index 8f8361e59..20d345298 100644
--- a/electron/ai-edition/agent-tools.test.ts
+++ b/electron/ai-edition/agent-tools.test.ts
@@ -172,6 +172,7 @@ describe("the mutating-tool table", () => {
expect([...MUTATING_TOOL_NAMES].sort()).toEqual(
[
"addAnnotation",
+ "addAudio",
"addCameraFullscreen",
"addSpeed",
"addTrim",
@@ -184,10 +185,12 @@ describe("the mutating-tool table", () => {
"removeTrim",
"replaceTimeline",
"setAnnotation",
+ "setAudio",
"setCameraFullscreen",
"setClipRange",
"setSpeed",
"setTrim",
+ "setWordText",
"setZoom",
].sort(),
);
@@ -2054,3 +2057,324 @@ describe("setZoom answers for the focus it kept", () => {
expect(result.resultJson).not.toContain("cursorAnchor");
});
});
+
+// Issue #350 / #560 — the audio tools. #561 landed the timeline audio without any
+// agent surface, so these cover both that the model can see it and that it cannot
+// invent an asset to place.
+describe("addAudio / setAudio", () => {
+ /** The fixture plus one imported audio asset. */
+ function withAudioAsset(durationSec: number | null = 30): AxcutDocument {
+ const doc = fixtureDocument();
+ return documentSchema.parse({
+ ...doc,
+ assets: [
+ ...doc.assets,
+ {
+ id: "audio_1",
+ kind: "audio",
+ label: "bed.mp3",
+ originalPath: "C:/audio/bed.mp3",
+ ...(durationSec == null ? {} : { durationSec }),
+ },
+ ],
+ });
+ }
+
+ const place = (doc: AxcutDocument, args: Record) =>
+ executeAgentTool(doc, "addAudio", JSON.stringify(args));
+
+ it("reports imported audio in the snapshot, with the asset kind beside it", () => {
+ // Without `kind` the model sees an asset it cannot explain and tries to place it
+ // as footage; without `audioTracks` it cannot see the lanes at all.
+ const placed = place(withAudioAsset(), {
+ assetId: "audio_1",
+ startSec: 2,
+ endSec: 6,
+ kind: "voiceover",
+ });
+ expect(placed.ok).toBe(true);
+ const snapshot = executeAgentTool(placed.document as AxcutDocument, "getCurrentDocument", "");
+ const parsed = JSON.parse(snapshot.resultJson);
+ expect(parsed.assets.find((a: { id: string }) => a.id === "audio_1").kind).toBe("audio");
+ expect(parsed.audioTracks).toHaveLength(1);
+ expect(parsed.audioTracks[0]).toMatchObject({
+ assetId: "audio_1",
+ kind: "voiceover",
+ startSec: 2,
+ endSec: 6,
+ });
+ });
+
+ it("anchors the placed track to the clip under it", () => {
+ const result = place(withAudioAsset(), { assetId: "audio_1", startSec: 2, endSec: 6 });
+ expect(result.ok).toBe(true);
+ const track = (result.document as AxcutDocument).audioTracks[0];
+ // The anchor is what makes it travel with its clip; a bare startMs/endMs would not.
+ expect(track.clipId).toBe("clip_1");
+ expect(track.origin).toBe("agent");
+ });
+
+ it("plays the whole file when endSec is omitted", () => {
+ const result = place(withAudioAsset(20), { assetId: "audio_1", startSec: 0, offsetSec: 5 });
+ expect(result.ok).toBe(true);
+ const track = (result.document as AxcutDocument).audioTracks[0];
+ // 20s file from an in-point of 5s = 15s of span, so the model never computes it.
+ expect(track.endMs - track.startMs).toBe(15_000);
+ });
+
+ it("refuses an offset at or past the end of a known file", () => {
+ // Otherwise the omitted-end fallback mints a 0.1s track that plays silence, and
+ // the model reports it as having placed audio.
+ const result = place(withAudioAsset(20), { assetId: "audio_1", startSec: 0, offsetSec: 20 });
+ expect(result.ok).toBe(false);
+ expect(result.resultJson).toContain("offsetSec");
+ });
+
+ it("allows any offset while the duration is unknown", () => {
+ // A failed probe leaves no duration; refusing on that would block a legitimate call.
+ expect(place(withAudioAsset(null), { assetId: "audio_1", startSec: 0, offsetSec: 99 }).ok).toBe(
+ true,
+ );
+ });
+
+ it("refuses an unknown asset and names the audio the project actually has", () => {
+ const result = place(withAudioAsset(), { assetId: "nope", startSec: 0, endSec: 4 });
+ expect(result.ok).toBe(false);
+ expect(result.resultJson).toContain("audio_1");
+ });
+
+ it("refuses a video asset, pointing at the tool that does place footage", () => {
+ const result = place(withAudioAsset(), { assetId: "asset_1", startSec: 0, endSec: 4 });
+ expect(result.ok).toBe(false);
+ expect(result.resultJson).toContain("replaceTimeline");
+ });
+
+ it("setAudio re-levels and re-lanes the track it names", () => {
+ const placed = place(withAudioAsset(), { assetId: "audio_1", startSec: 2, endSec: 6 });
+ const id = JSON.parse(placed.resultJson).audioId;
+ const result = executeAgentTool(
+ placed.document as AxcutDocument,
+ "setAudio",
+ JSON.stringify({ audioId: id, gainDb: -6, kind: "voiceover" }),
+ );
+ expect(result.ok).toBe(true);
+ expect((result.document as AxcutDocument).audioTracks[0]).toMatchObject({
+ gainDb: -6,
+ kind: "voiceover",
+ });
+ });
+
+ it("setAudio applies the same offset guard as addAudio", () => {
+ const placed = place(withAudioAsset(20), { assetId: "audio_1", startSec: 2, endSec: 6 });
+ const id = JSON.parse(placed.resultJson).audioId;
+ const result = executeAgentTool(
+ placed.document as AxcutDocument,
+ "setAudio",
+ JSON.stringify({ audioId: id, offsetSec: 25 }),
+ );
+ expect(result.ok).toBe(false);
+ });
+
+ it("removeModifier deletes an audio track by id, like every other kind", () => {
+ const placed = place(withAudioAsset(), { assetId: "audio_1", startSec: 2, endSec: 6 });
+ const id = JSON.parse(placed.resultJson).audioId;
+ const result = executeAgentTool(
+ placed.document as AxcutDocument,
+ "removeModifier",
+ JSON.stringify({ id }),
+ );
+ expect(result.ok).toBe(true);
+ 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 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", () => {
+ 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 5a0966398..cfccf9c06 100644
--- a/electron/ai-edition/agent-tools.ts
+++ b/electron/ai-edition/agent-tools.ts
@@ -16,6 +16,12 @@
// described are gone (see `MUTATING_TOOL_NAMES`).
import { z } from "zod";
+import {
+ collapseTracksToPills,
+ patchAudioTrack,
+ placeAudioTrackInDocument,
+ trackGroupId,
+} from "../../src/lib/ai-edition/document/audioTracks";
import { createId } from "../../src/lib/ai-edition/document/ids";
import {
moveClip,
@@ -26,8 +32,10 @@ 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 { isGeneratedAssetId } from "../../src/lib/ai-edition/timeline/clip-parts";
import {
buildCursorTrack,
type CursorTrackSample,
@@ -337,6 +345,11 @@ function droppedByEdit(before: AxcutDocument, after: AxcutDocument) {
// private — callers only ever need the composed `*Args`.)
const secondsSchema = z.number().finite().nonnegative();
+/** Span given to an agent-placed audio track when the asset has no probed duration
+ * yet. Short on purpose: a wrong guess the user has to lengthen beats one that
+ * silently covers the whole programme. */
+const DEFAULT_AGENT_AUDIO_SEC = 10;
+
export const addTrimArgs = z.object({
startSec: secondsSchema,
endSec: secondsSchema,
@@ -479,6 +492,26 @@ export const setAnnotationArgs = z.object({
text: z.string().optional(),
});
+export const addAudioArgs = z.object({
+ assetId: z.string().min(1),
+ startSec: secondsSchema,
+ endSec: secondsSchema.optional(),
+ kind: z.enum(["voiceover", "music"]).default("music"),
+ offsetSec: secondsSchema.default(0),
+ gainDb: z.number().min(-60).max(12).default(0),
+});
+
+export const setAudioArgs = z.object({
+ audioId: z.string().min(1),
+ startSec: secondsSchema.optional(),
+ endSec: secondsSchema.optional(),
+ kind: z.enum(["voiceover", "music"]).optional(),
+ offsetSec: secondsSchema.optional(),
+ gainDb: z.number().min(-60).max(12).optional(),
+ muted: z.boolean().optional(),
+ loop: z.boolean().optional(),
+});
+
export const addCameraFullscreenArgs = z.object({
startSec: secondsSchema,
endSec: secondsSchema,
@@ -490,6 +523,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),
});
@@ -525,7 +570,9 @@ export const removeClipArgs = z.object({
export const OPENSCREEN_TOOL_NAMES = [
"getCurrentDocument",
"getTranscript",
+ "getTranscriptWords",
"getCursorTrack",
+ "setWordText",
"addTrim",
"addTrims",
"setTrim",
@@ -541,6 +588,8 @@ export const OPENSCREEN_TOOL_NAMES = [
"setAnnotation",
"addCameraFullscreen",
"setCameraFullscreen",
+ "addAudio",
+ "setAudio",
"removeTrim",
"removeModifier",
"removeClip",
@@ -592,6 +641,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",
@@ -607,6 +659,8 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([
"setAnnotation",
"addCameraFullscreen",
"setCameraFullscreen",
+ "addAudio",
+ "setAudio",
"removeTrim",
"removeModifier",
"removeClip",
@@ -665,7 +719,9 @@ export function documentSnapshotForModel(
const autoFocusAll = legacy?.autoFocusAll === true;
return {
timeBaseNote:
- "clips and trims are in source-time seconds; zooms, speedRegions, annotations and cameraFullscreenRegions are in virtual (edited-timeline) seconds.",
+ "clips and trims are in source-time seconds; zooms, speedRegions, annotations, cameraFullscreenRegions and audioTracks are in virtual (edited-timeline) seconds.",
+ audioNote:
+ "audioTracks are imported voiceover / music files laid over the recording. They are clip-anchored like every other region, so they travel with their clip through reorder and trim, and they play at 1x whatever a speed region does to the picture under them. addAudio places an EXISTING asset of kind 'audio'; nothing here can import a file from disk or record one, so if the project has no audio asset, say so rather than inventing an id.",
zoomNote:
`renderedScale is what the viewer sees (depth is an ordinal, not a factor: ${ZOOM_DEPTH_LEGEND}). ` +
"When a zoom carries customScale it wins over depth and depthIsOverridden is true — " +
@@ -683,6 +739,10 @@ export function documentSnapshotForModel(
assets: document.assets.map((a) => ({
id: a.id,
label: a.label,
+ // "audio" is an imported voiceover / music file: it is never a clip, it is
+ // played by an audio track. Without this the model sees an asset it cannot
+ // explain and tries to place it on the timeline as footage.
+ kind: a.kind,
durationSec: a.durationSec ?? null,
hasCameraTrack: a.cameraTrack != null,
cameraVisible: a.cameraTrack?.visible ?? false,
@@ -755,6 +815,22 @@ export function documentSnapshotForModel(
startSec: roundSec(c.startMs),
endSec: roundSec(c.endMs),
})),
+ // Imported audio, collapsed to the pills the ruler draws — a track ventilated
+ // across a clip boundary is several fragments the user sees as one thing, and
+ // the model has to name what the user sees.
+ audioTracks: collapseTracksToPills(document.audioTracks).map((t) => ({
+ id: trackGroupId(t),
+ startSec: roundSec(t.startMs),
+ endSec: roundSec(t.endMs),
+ assetId: t.assetId,
+ // Which lane it sits on. Also decides whether it is transcribed at all.
+ kind: t.kind,
+ // Where in the FILE the track starts playing, in that file's own seconds.
+ offsetSec: roundSec(t.offsetMs),
+ gainDb: t.gainDb,
+ muted: t.muted,
+ loop: t.loop,
+ })),
hasTranscript: document.transcripts.length > 0 || document.transcript !== null,
};
}
@@ -1203,6 +1279,107 @@ 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.`);
+ }
+ // 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);
+ } 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);
@@ -1843,6 +2020,165 @@ export function executeAgentTool(
};
}
+ case "addAudio": {
+ const parsed = addAudioArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const { assetId, kind, offsetSec, gainDb } = parsed.data;
+ const asset = document.assets.find((a) => a.id === assetId);
+ // Two distinct refusals, because they need two different corrections: an
+ // unknown id is a hallucinated asset, a video id is the model reaching for
+ // footage. Naming the audio the project HAS is what stops the retry loop.
+ if (!asset) {
+ const available = document.assets.filter((a) => a.kind === "audio");
+ return failure(
+ `Unknown asset: ${assetId}.` +
+ (available.length
+ ? ` Imported audio in this project: ${available.map((a) => `${a.id} (${a.label})`).join(", ")}.`
+ : " This project has no imported audio; a file can only be imported or recorded from the editor, not from here."),
+ );
+ }
+ if (asset.kind !== "audio") {
+ return failure(
+ `Asset ${assetId} is video, not audio. addAudio plays an imported audio file over the recording; to place footage use replaceTimeline.`,
+ );
+ }
+ const durationSec = asset.durationSec ?? 0;
+ // "Start the file at offsetSec" is only answerable when there is file left
+ // there. Past the end it yields a track that plays silence, which the model
+ // then reports as having placed audio. Unknown duration is not a refusal: an
+ // import whose probe failed carries 0 until the renderer re-probes it.
+ if (durationSec > 0 && offsetSec >= durationSec) {
+ return failure(
+ `offsetSec ${offsetSec}s is at or past the end of ${assetId} (${durationSec}s), so the track would play nothing. Pick an offset inside the file.`,
+ );
+ }
+ // No endSec means "as long as the file is" — the natural span, and the one
+ // the editor's own add uses, so the model never has to compute it.
+ const startSec = parsed.data.startSec;
+ const endSec =
+ parsed.data.endSec ??
+ startSec + Math.max(0.1, (durationSec || DEFAULT_AGENT_AUDIO_SEC) - offsetSec);
+ const startMs = toMs(Math.min(startSec, endSec));
+ const endMs = toMs(Math.max(startSec, endSec));
+ const trackId = createId("audio");
+ const withTrack = placeAudioTrackInDocument(
+ document,
+ {
+ id: trackId,
+ trackId,
+ startMs,
+ endMs,
+ assetId,
+ kind,
+ durationSec,
+ offsetMs: toMs(offsetSec),
+ gainDb,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: asset.label,
+ origin: "agent",
+ } as AxcutDocument["audioTracks"][number],
+ () => createId("audio"),
+ "create",
+ );
+ if (withTrack === document) {
+ return coversNoClip("audio", startMs / 1000, endMs / 1000, document);
+ }
+ const placed = withTrack.audioTracks.filter((t) => trackGroupId(t) === trackId);
+ const next: AxcutDocument = withTrack;
+ const landing = landingOf(placed, document);
+ return {
+ ok: true,
+ document: next,
+ resultJson: JSON.stringify({
+ audioId: trackId,
+ ...landingReport(landing, startMs / 1000, endMs / 1000),
+ }),
+ summary:
+ `added ${kind} "${asset.label}" ${formatSec(landing.startSec)} – ${formatSec(landing.endSec)}` +
+ landingSuffix(landing, startMs / 1000, endMs / 1000),
+ };
+ }
+
+ case "setAudio": {
+ const parsed = setAudioArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const { audioId } = parsed.data;
+ const pill = collapseTracksToPills(document.audioTracks).find(
+ (t) => trackGroupId(t) === audioId,
+ );
+ if (!pill) return failure(`Unknown audio track: ${audioId}`);
+
+ if (parsed.data.offsetSec !== undefined) {
+ const asset = document.assets.find((a) => a.id === pill.assetId);
+ const durationSec = asset?.durationSec ?? 0;
+ if (durationSec > 0 && parsed.data.offsetSec >= durationSec) {
+ return failure(
+ `offsetSec ${parsed.data.offsetSec}s is at or past the end of ${pill.assetId} (${durationSec}s), so the track would play nothing.`,
+ );
+ }
+ }
+
+ // Payload first, through the helper that keeps every fragment of the group in
+ // agreement — gain, mute, loop and the offset are all track-wide, and a patch
+ // that reached only one fragment would split the pill in two.
+ let next = patchAudioTrack(document, audioId, {
+ ...(parsed.data.gainDb !== undefined ? { gainDb: parsed.data.gainDb } : {}),
+ ...(parsed.data.muted !== undefined ? { muted: parsed.data.muted } : {}),
+ ...(parsed.data.loop !== undefined ? { loop: parsed.data.loop } : {}),
+ ...(parsed.data.offsetSec !== undefined ? { offsetMs: toMs(parsed.data.offsetSec) } : {}),
+ });
+
+ // A span or lane change re-anchors: drop the group and lay it down again, so
+ // the fragments are re-cut against the clips the new span covers rather than
+ // patched in place against the old ones.
+ const wantsRespan =
+ parsed.data.startSec !== undefined ||
+ parsed.data.endSec !== undefined ||
+ parsed.data.kind !== undefined;
+ if (wantsRespan) {
+ const current =
+ collapseTracksToPills(next.audioTracks).find((t) => trackGroupId(t) === audioId) ?? pill;
+ const { startMs, endMs } = resolveSpanMs(current, parsed.data.startSec, parsed.data.endSec);
+ // A `kind` flip re-clamps against the DESTINATION lane's neighbours, not the
+ // one it is leaving — moving a take onto the music row must respect what is
+ // already on the music row (issue #560).
+ const moved = placeAudioTrackInDocument(
+ next,
+ {
+ ...current,
+ id: audioId,
+ trackId: audioId,
+ startMs,
+ endMs,
+ ...(parsed.data.kind !== undefined ? { kind: parsed.data.kind } : {}),
+ },
+ () => createId("audio"),
+ "move",
+ );
+ if (moved === next) {
+ return coversNoClip("audio", startMs / 1000, endMs / 1000, document);
+ }
+ next = moved;
+ }
+
+ const after = collapseTracksToPills(next.audioTracks).find(
+ (t) => trackGroupId(t) === audioId,
+ );
+ return {
+ ok: true,
+ document: next,
+ resultJson: JSON.stringify({
+ audioId,
+ startSec: roundSec(after?.startMs ?? pill.startMs),
+ endSec: roundSec(after?.endMs ?? pill.endMs),
+ }),
+ summary: `updated audio ${audioId} ${formatSec(roundSec(after?.startMs ?? pill.startMs))} – ${formatSec(roundSec(after?.endMs ?? pill.endMs))}`,
+ };
+ }
+
case "removeTrim": {
const parsed = removeTrimArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
@@ -1872,9 +2208,10 @@ export function executeAgentTool(
else if (document.annotations.some((a) => a.id === id)) kind = "annotation";
else if (speedRegions.some((s) => s.id === id)) kind = "speed";
else if (cameraFullscreenRegions.some((c) => c.id === id)) kind = "cameraFullscreen";
+ 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/electron/ai-edition/deep-agent/service.test.ts b/electron/ai-edition/deep-agent/service.test.ts
index 7729bd624..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 },
@@ -73,6 +75,10 @@ const ARGS: Record = {
setAnnotation: { annotationId: "ann_nope" },
addCameraFullscreen: { startSec: 1, endSec: 2 },
setCameraFullscreen: { cameraFullscreenId: "cam_nope" },
+ // The fixture has no `kind: "audio"` asset, so these exercise the refusal branch —
+ // the honest one to pin: the agent can place imported audio, never import it.
+ addAudio: { assetId: "audio_nope", startSec: 1, endSec: 2 },
+ setAudio: { audioId: "audio_nope" },
removeTrim: { trimRangeId: "trim_1" },
removeModifier: { id: "nope" },
removeClip: { clipId: "clip_1" },
@@ -109,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: {
@@ -351,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 d804a3b1a..7ddc55bfc 100644
--- a/electron/ai-edition/deep-agent/service.ts
+++ b/electron/ai-edition/deep-agent/service.ts
@@ -27,6 +27,7 @@ import type { AxcutDocument } from "../../../src/lib/ai-edition/schema";
import { ZOOM_DEPTH_LEGEND } from "../../../src/lib/ai-edition/timeline/zoom-scale";
import {
addAnnotationArgs,
+ addAudioArgs,
addCameraFullscreenArgs,
addSpeedArgs,
addTrimArgs,
@@ -37,6 +38,7 @@ import {
executeAgentTool,
getCursorTrackArgs,
getTranscriptArgs,
+ getTranscriptWordsArgs,
isMutatingTool,
moveClipArgs,
removeClipArgs,
@@ -45,10 +47,12 @@ import {
replaceTimelineArgs,
resolveCursorAssetId,
setAnnotationArgs,
+ setAudioArgs,
setCameraFullscreenArgs,
setClipRangeArgs,
setSpeedArgs,
setTrimArgs,
+ setWordTextArgs,
setZoomArgs,
} from "../agent-tools";
import {
@@ -115,6 +119,7 @@ const BASE_SYSTEM_PROMPT = [
"- Silences, pauses and dead stretches are removed as trims INSIDE the placed clip. Send them together with addTrims once you know the ranges; addTrim is for a single cut or a correction. The placed clip stays the canonical cut; it is not rebuilt to drop them.",
"- Changing where a clip starts or ends within its source is setClipRange — the clip's in/out, distinct from a trim.",
`- addZoom takes a virtual-timeline span (depth is an ordinal 1–6 selecting from a fixed table — ${ZOOM_DEPTH_LEGEND} — never a multiplier; focus in 0–1 frame fractions). addSpeed changes pacing over a span. addAnnotation puts text on screen. addCameraFullscreen enlarges the webcam, and only does something where assets[].hasCameraTrack is true.`,
+ "- addAudio lays an imported voiceover or music file over a span. It plays an asset the project already has (kind 'audio'); importing or recording one is the editor's job, not a tool you have — so when the project has none, say so rather than naming an id that does not exist.",
"- moveClip changes the order of placed clips, one call per clip that moves, preserving ids, source ranges, trims and anchored effects. replaceTimeline rebuilds the timeline from kept intervals and sorts them, so it cannot reorder anything.",
"- Deleting is a first-class action, not a workaround: removeTrim, removeModifier, removeClip. Never fake a deletion by re-adding an element or zeroing it out (span 0, speed 1×) — that leaves it in the document and misreports what you did.",
"If nothing in the list does what was asked, say so; do not approximate it with a bigger tool.",
@@ -143,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:
@@ -170,10 +179,14 @@ export const TOOL_DESCRIPTIONS: Record = {
"Add a camera-fullscreen region over a span of the edited timeline (virtual seconds): the webcam fills the frame for that span. This only does something when the footage under that span comes from an asset with a linked webcam — check assets[].hasCameraTrack (or hasAnyCamera) in getCurrentDocument first. On footage with no camera the call is refused rather than storing a region that would render nothing; say so instead of retrying.",
setCameraFullscreen:
"Move or resize an existing camera-fullscreen region by id (virtual-timeline seconds). Only the fields you pass are changed. Refused if the new span lands on footage with no linked webcam.",
+ addAudio:
+ "Lay an ALREADY-IMPORTED audio file over the recording across a span of the edited timeline (virtual seconds): a voiceover, or a music bed. assetId must name an asset whose kind is 'audio' — getCurrentDocument lists them; nothing here can import a file from disk or record one, so if there is none, say so instead of guessing an id. Omit endSec to play the whole file from offsetSec. kind picks the lane ('voiceover' or 'music'). offsetSec is where in the FILE playback starts, gainDb its level (0 unchanged, negative ducks it). A voiceover-lane track is also what gets transcribed, so the lane is not only cosmetic.",
+ setAudio:
+ "Move, resize, re-level, re-lane, mute, loop or re-point an existing audio track by id (virtual-timeline seconds). Only the fields you pass are changed. Use it to duck a bed under narration (gainDb), to shift what part of the file plays (offsetSec), or to move it between the voiceover and music lanes (kind). The whole track is edited, not one fragment of it, so a track split across a cut stays one thing.",
removeTrim:
"Delete a trim range by id — the cut is undone and that span plays/exports again. This is how you 'remove a trim'; never re-add a trim to undo one.",
removeModifier:
- "Delete a modifier (zoom / speed / annotation / camera-fullscreen) by id; the kind is resolved from the id. This is how you 'remove'/'delete' one — never neutralise it (span 0, speed 1×), which leaves it in the document. For a trim use removeTrim; for a clip use removeClip.",
+ "Delete a modifier (zoom / speed / annotation / camera-fullscreen / audio) by id; the kind is resolved from the id. This is how you 'remove'/'delete' one — never neutralise it (span 0, speed 1×), which leaves it in the document. For a trim use removeTrim; for a clip use removeClip.",
removeClip:
"Delete a placed clip by id; remaining clips close the gap and effects anchored to it are dropped. Use only when the user asks to remove a clip — to shorten one, use setClipRange.",
};
@@ -323,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),
@@ -339,6 +354,8 @@ export function buildTools(
build("setAnnotation", setAnnotationArgs),
build("addCameraFullscreen", addCameraFullscreenArgs),
build("setCameraFullscreen", setCameraFullscreenArgs),
+ build("addAudio", addAudioArgs),
+ build("setAudio", setAudioArgs),
build("removeTrim", removeTrimArgs),
build("removeModifier", removeModifierArgs),
build("removeClip", removeClipArgs),
diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts
index 6cbdb97c9..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");
@@ -280,6 +308,63 @@ describe("DocumentService", () => {
expect(after.project.primaryAssetId).toBe(first.project.primaryAssetId);
expect(after.assets).toHaveLength(2);
});
+
+ // Issue #350 — external audio import (voiceover / BGM / SFX).
+ it("appends an audio asset without claiming the primary slot", async () => {
+ const doc = await service.createProject("P");
+ const updated = await service.addAsset(doc.project.id, {
+ path: "/tmp/voiceover.mp3",
+ kind: "audio",
+ });
+ expect(updated.assets).toHaveLength(1);
+ expect(updated.assets[0]?.kind).toBe("audio");
+ // An audio-only file must never become the project's primary asset, even
+ // when it is the first file added to an otherwise-empty project.
+ expect(updated.project.primaryAssetId).toBeUndefined();
+ });
+
+ it("keeps the existing video primary when an audio track is added", async () => {
+ const doc = await service.createProject("P");
+ const withVideo = await service.addAsset(doc.project.id, { path: "/tmp/screen.mp4" });
+ const primary = withVideo.project.primaryAssetId;
+ const withAudio = await service.addAsset(doc.project.id, {
+ path: "/tmp/bgm.wav",
+ kind: "audio",
+ });
+ expect(withAudio.project.primaryAssetId).toBe(primary);
+ expect(withAudio.assets).toHaveLength(2);
+ });
+
+ it("rejects unsupported audio extensions", async () => {
+ const doc = await service.createProject("P");
+ await expect(
+ service.addAsset(doc.project.id, { path: "/tmp/clip.mp4", kind: "audio" }),
+ ).rejects.toBeInstanceOf(ProjectFileError);
+ });
+
+ it("accepts a recorded .webm take as audio", async () => {
+ // MediaRecorder writes a voiceover as webm/opus — the same extension a
+ // screen recording uses. The caller has already declared the kind here,
+ // so this gate must take it; only the import PICKER, which has nothing
+ // but the extension to go on, still refuses .webm as audio.
+ const doc = await service.createProject("P");
+ const next = await service.addAsset(doc.project.id, {
+ path: "/tmp/voiceover-2026.webm",
+ kind: "audio",
+ });
+ expect(next.assets.at(-1)).toMatchObject({ kind: "audio" });
+ // ...and it must not have claimed the primary (video-only) slot.
+ expect(next.project.primaryAssetId).toBeUndefined();
+ });
+
+ it("accepts a video extension under the default kind but not as audio", async () => {
+ const doc = await service.createProject("P");
+ // The same extension routing works in reverse: an .mp3 is fine as audio
+ // but rejected as video (covered above), and an .mp4 is the opposite.
+ await expect(
+ service.addAsset(doc.project.id, { path: "/tmp/a.mp3", kind: "audio" }),
+ ).resolves.toBeDefined();
+ });
});
describe("removeAsset", () => {
@@ -363,6 +448,57 @@ describe("DocumentService", () => {
expect(after.project.primaryAssetId).toBe(b.assets[1]?.id);
});
+ // Issue #350 — an audio overlay can never be primary.
+ it("passes primary to the next VIDEO asset, never to an audio asset", async () => {
+ const doc = await service.createProject("P");
+ const video = await service.addAsset(doc.project.id, { path: "/tmp/screen.mp4" });
+ await service.addAsset(doc.project.id, { path: "/tmp/music.mp3", kind: "audio" });
+ const primaryId = video.project.primaryAssetId;
+ expect(primaryId).toBeTruthy();
+ // Removing the only video leaves just the audio asset; primary must clear,
+ // not fall to the audio one.
+ const after = await service.removeAsset(doc.project.id, primaryId ?? "");
+ expect(after.project.primaryAssetId).toBeUndefined();
+ expect(after.assets).toHaveLength(1);
+ expect(after.assets[0]?.kind).toBe("audio");
+ });
+
+ it("drops audioTracks that referenced a removed audio asset", async () => {
+ const doc = await service.createProject("P");
+ await service.addAsset(doc.project.id, { path: "/tmp/screen.mp4" });
+ const withAudio = await service.addAsset(doc.project.id, {
+ path: "/tmp/music.mp3",
+ kind: "audio",
+ });
+ const audioId = withAudio.assets.find((a) => a.kind === "audio")?.id ?? "";
+ expect(audioId).toBeTruthy();
+ const withTrack = await service.saveProject({
+ ...withAudio,
+ audioTracks: [
+ {
+ id: "trk_1",
+ assetId: audioId,
+ kind: "music",
+ startMs: 0,
+ endMs: 10_000,
+ durationSec: 10,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "music",
+ origin: "user",
+ },
+ ],
+ });
+ expect(withTrack.audioTracks).toHaveLength(1);
+ const after = await service.removeAsset(doc.project.id, audioId);
+ expect(after.audioTracks).toEqual([]);
+ expect(after.assets.some((a) => a.id === audioId)).toBe(false);
+ });
+
it("resequences other assets and rederives their anchored regions", async () => {
const created = await service.createProject("P");
const withA = await service.addAsset(created.project.id, { path: "/tmp/a.mp4" });
diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts
index 3c93e3bc0..e78a9c48e 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";
@@ -38,6 +39,9 @@ export interface ProjectSummary {
export interface AddAssetInput {
path: string;
label?: string;
+ // "audio" imports an external voiceover / BGM / SFX file (issue #350).
+ // Defaults to "video" when omitted, so existing callers are unaffected.
+ kind?: "video" | "audio";
}
export class DocumentNotFoundError extends Error {
@@ -72,6 +76,35 @@ function isSupportedVideoPath(filePath: string): boolean {
return SUPPORTED_VIDEO_EXTENSIONS.has(ext);
}
+// Imported audio (issue #350). Decoding is handled downstream by the same
+// WebCodecs / ffmpeg paths that read a video's audio track, so this list is the
+// container formats decodeAudioData and the compositor can open.
+// What may be filed as an AUDIO asset. Deliberately WIDER than the import
+// picker's list in `electron/ipc/handlers.ts`: this gate runs when the caller
+// has already declared `kind: "audio"`, so it only has to reject files that
+// could not carry audio at all, whereas the picker has to guess from the
+// extension alone and must not offer a video as audio.
+//
+// `.webm` is exactly that difference. An in-editor voiceover take is written by
+// MediaRecorder as webm/opus — the same extension a screen recording uses — so
+// the picker rightly refuses it while this gate must accept it.
+const SUPPORTED_AUDIO_EXTENSIONS = new Set([
+ ".mp3",
+ ".wav",
+ ".m4a",
+ ".aac",
+ ".flac",
+ ".ogg",
+ ".oga",
+ ".opus",
+ ".webm",
+]);
+
+function isSupportedAudioPath(filePath: string): boolean {
+ const ext = path.extname(filePath).toLowerCase();
+ return SUPPORTED_AUDIO_EXTENSIONS.has(ext);
+}
+
function safeProjectId(raw: string): string {
// ponytail: project ids are uuid-prefixed strings (e.g. "proj_"). Reject
// anything that smells like path traversal before we ever touch the disk.
@@ -124,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 {
@@ -241,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 {
@@ -262,6 +314,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;
}
@@ -283,7 +338,15 @@ export class DocumentService {
if (!input.path) {
throw new ProjectFileError("Asset path is required.", projectId);
}
- if (!isSupportedVideoPath(input.path)) {
+ const kind = input.kind ?? "video";
+ if (kind === "audio") {
+ if (!isSupportedAudioPath(input.path)) {
+ throw new ProjectFileError(
+ `Unsupported audio extension: ${path.extname(input.path)} (supported: ${[...SUPPORTED_AUDIO_EXTENSIONS].join(", ")})`,
+ projectId,
+ );
+ }
+ } else if (!isSupportedVideoPath(input.path)) {
throw new ProjectFileError(
`Unsupported video extension: ${path.extname(input.path)} (supported: ${[...SUPPORTED_VIDEO_EXTENSIONS].join(", ")})`,
projectId,
@@ -300,18 +363,24 @@ export class DocumentService {
}
const asset: AxcutAsset = {
id: createId("asset"),
- kind: "video",
+ kind,
label: input.label?.trim() || path.basename(absolutePath),
originalPath: absolutePath,
sizeBytes,
cameraTrack: null,
};
+ // An audio import is an overlay, never the thing the timeline is built
+ // around, so it must not claim the empty primaryAssetId slot — otherwise the
+ // first file dropped into a fresh project (a BGM track) would become its
+ // primary asset and the editor would try to lay out clips from a file with
+ // no video.
+ const claimsPrimary = kind !== "audio" && !doc.project.primaryAssetId;
const next: AxcutDocument = {
...doc,
assets: [...doc.assets, asset],
project: {
...doc.project,
- ...(doc.project.primaryAssetId ? {} : { primaryAssetId: asset.id }),
+ ...(claimsPrimary ? { primaryAssetId: asset.id } : {}),
updatedAt: new Date().toISOString(),
},
};
@@ -324,9 +393,13 @@ export class DocumentService {
throw new ProjectFileError(`Asset ${assetId} not found in project ${projectId}.`, projectId);
}
const assets = doc.assets.filter((a) => a.id !== assetId);
+ // Primary is the thing the timeline is built around, so it must fall to the
+ // next VIDEO asset — never an audio overlay (issue #350), which can't be
+ // primary (see addAsset). Falling back to `assets[0]` would hand primary to
+ // an audio asset when the removed one was the last video.
const primaryAssetId =
doc.project.primaryAssetId === assetId
- ? (assets[0]?.id ?? undefined)
+ ? (assets.find((a) => a.kind !== "audio")?.id ?? undefined)
: doc.project.primaryAssetId;
const withoutAssetClips = doc.timeline.clips
.filter((clip) => clip.assetId === assetId)
@@ -334,6 +407,9 @@ export class DocumentService {
const next: AxcutDocument = {
...withoutAssetClips,
assets,
+ // Drop imported audio tracks that referenced the removed asset — they
+ // would otherwise dangle, pointing at an asset the document no longer has.
+ audioTracks: withoutAssetClips.audioTracks.filter((t) => t.assetId !== assetId),
timeline: {
...withoutAssetClips.timeline,
trimRanges: withoutAssetClips.timeline.trimRanges.filter((r) => r.assetId !== assetId),
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index e140a4e37..6de7a3cc4 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -289,6 +289,22 @@ interface Window {
name?: string;
canceled?: boolean;
}>;
+ // Import an external audio file from the timeline toolbar (issue #350).
+ openAudioFilePicker: () => Promise<{
+ success: boolean;
+ path?: string;
+ name?: string;
+ canceled?: boolean;
+ message?: string;
+ }>;
+ // Persist an in-editor voiceover take (raw MediaRecorder bytes) under the
+ // recordings dir, so it outlives the session like every other asset.
+ saveRecordedVoiceover: (data: ArrayBuffer) => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
session: import("../src/lib/recordingSession").RecordingSession | null,
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index aa2014670..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,
@@ -107,6 +108,9 @@ const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([
".ts",
]);
const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio");
+// See the save-recorded-voiceover handler: an upper bound on renderer-supplied
+// bytes written to disk, well past any plausible take.
+const MAX_RECORDED_VOICEOVER_BYTES = 512 * 1024 * 1024;
const nativeMacCaptureEvents = new EventEmitter();
// Enumeration walks every display and window and grabs a thumbnail of each, so it
@@ -186,6 +190,34 @@ function hasAllowedImportVideoExtension(filePath: string): boolean {
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
+// Imported audio (issue #350). Kept separate from the video set so the two
+// pickers stay honest — an audio picker must not approve a video path and vice
+// versa. A SUBSET of SUPPORTED_AUDIO_EXTENSIONS in the document service, which
+// also accepts `.webm`: that gate is told the kind by its caller, while this one
+// only has the extension to go on and `.webm` is far more often a video.
+const ALLOWED_IMPORT_AUDIO_EXTENSIONS = new Set([
+ ".mp3",
+ ".wav",
+ ".m4a",
+ ".aac",
+ ".flac",
+ ".ogg",
+ ".opus",
+]);
+
+function hasAllowedImportAudioExtension(filePath: string): boolean {
+ return ALLOWED_IMPORT_AUDIO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
+}
+
+// Video OR audio. The type-specific pickers stay honest (see the audio set's
+// comment), but the generic media READS — peaks, binary, file-info, chunk — serve
+// whichever kind the document points at, so they must accept both. Gating them on
+// video alone dropped every imported audio path once `approvedPaths` was empty
+// (a project reopen), and the waveform was lost for good (issue #350).
+function hasAllowedImportMediaExtension(filePath: string): boolean {
+ return hasAllowedImportVideoExtension(filePath) || hasAllowedImportAudioExtension(filePath);
+}
+
function runProcess(
command: string,
args: string[],
@@ -282,8 +314,13 @@ async function prepareSupplementalPreviewAudioTrack(videoPath: string) {
return { success: true, path: pathToFileURL(outputPath).toString() };
}
-async function approveReadableVideoPath(
- filePath?: string | null,
+// Shared core behind the media path approvers. `hasAllowedExtension` is the ONLY
+// thing that differs between video and audio imports, so it is the single knob:
+// an already-approved path passes regardless, otherwise the extension gate,
+// optional trusted-dir confinement, and a stat check decide whether to approve.
+async function approveReadableMediaPath(
+ filePath: string | null | undefined,
+ hasAllowedExtension: (p: string) => boolean,
trustedDirs?: string[],
): Promise {
const normalizedPath = normalizeVideoSourcePath(filePath);
@@ -295,7 +332,7 @@ async function approveReadableVideoPath(
return normalizedPath;
}
- if (!hasAllowedImportVideoExtension(normalizedPath)) {
+ if (!hasAllowedExtension(normalizedPath)) {
return null;
}
@@ -322,6 +359,53 @@ async function approveReadableVideoPath(
return normalizedPath;
}
+function approveReadableVideoPath(
+ filePath?: string | null,
+ trustedDirs?: string[],
+): Promise {
+ return approveReadableMediaPath(filePath, hasAllowedImportVideoExtension, trustedDirs);
+}
+
+function approveReadableAudioPath(
+ filePath?: string | null,
+ trustedDirs?: string[],
+): Promise {
+ return approveReadableMediaPath(filePath, hasAllowedImportAudioExtension, 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 {
const trimmed = fileName.trim();
if (!trimmed) {
@@ -3590,6 +3674,8 @@ export function registerIpcHandlers(
}
});
+ // The media tab imports VIDEO (it arranges clips). Audio is imported from the
+ // timeline toolbar instead (issue #350) — see `open-audio-file-picker` below.
ipcMain.handle("open-video-file-picker", async () => {
try {
const dialogOptions = buildDialogOptions(
@@ -3636,6 +3722,84 @@ export function registerIpcHandlers(
}
});
+ // Import an external audio file (voiceover / BGM / SFX) — issue #350. Driven by
+ // the timeline's "Add audio" tool: audio is a timeline overlay (like an
+ // annotation), not a media-tab clip, so it has its own audio-only picker and the
+ // renderer adds it as a kind:"audio" asset + track at the playhead.
+ ipcMain.handle("open-audio-file-picker", async () => {
+ try {
+ const dialogOptions = buildDialogOptions(
+ {
+ title: mainT("dialogs", "fileDialogs.selectAudio"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.audioFiles"),
+ extensions: ["mp3", "wav", "m4a", "aac", "flac", "ogg", "opus"],
+ },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ },
+ getMainWindow(),
+ );
+ const result = await dialog.showOpenDialog(dialogOptions);
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true };
+ }
+
+ const normalizedPath = await approveReadableAudioPath(result.filePaths[0]);
+ if (!normalizedPath) {
+ return {
+ success: false,
+ message: "Selected file is not a supported readable audio file",
+ };
+ }
+
+ return {
+ success: true,
+ path: normalizedPath,
+ };
+ } catch (error) {
+ console.error("Failed to open audio file picker:", error);
+ return {
+ success: false,
+ message: "Failed to open audio file picker",
+ error: String(error),
+ };
+ }
+ });
+
+ // In-editor voiceover recording: the renderer hands over the raw MediaRecorder
+ // blob (webm/opus) and gets back the path it landed at, under the recordings
+ // dir so it lives with the project's other media and survives relaunches.
+ ipcMain.handle("save-recorded-voiceover", async (_event, data: ArrayBuffer) => {
+ try {
+ if (!(data instanceof ArrayBuffer) || data.byteLength === 0) {
+ return { success: false, message: "Empty recording" };
+ }
+ // A cap, because this writes renderer-supplied bytes straight to disk. An
+ // hour of Opus is a few tens of MB, so 512 MB is far past any real take
+ // and still refuses a runaway or malformed payload before it is buffered.
+ if (data.byteLength > MAX_RECORDED_VOICEOVER_BYTES) {
+ return { success: false, message: "Recording too large" };
+ }
+ await fs.mkdir(RECORDINGS_DIR, { recursive: true });
+ const fileName = `voiceover-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`;
+ const target = path.join(RECORDINGS_DIR, fileName);
+ await fs.writeFile(target, Buffer.from(data));
+ return { success: true, path: target };
+ } catch (error) {
+ console.error("Failed to save recorded voiceover:", error);
+ return {
+ success: false,
+ message: "Failed to save recorded voiceover",
+ error: String(error),
+ };
+ }
+ });
+
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
try {
// showItemInFolder returns nothing, it throws on error
@@ -3661,7 +3825,7 @@ export function registerIpcHandlers(
ipcMain.handle("read-binary-file", async (_, filePath: string) => {
try {
- const normalizedPath = await approveReadableVideoPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -3691,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 approveReadableVideoPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -3727,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 approveReadableVideoPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return { success: false, message: "File path is not approved" };
}
@@ -3751,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 approveReadableVideoPath(filePath);
+ const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -4180,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/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts
index 5d4992c10..7b01e0b2f 100644
--- a/electron/ipc/nativeBridge.ts
+++ b/electron/ipc/nativeBridge.ts
@@ -489,6 +489,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
request.payload.projectId,
request.payload.path,
request.payload.label,
+ request.payload.kind,
),
);
case "document.removeAsset":
diff --git a/electron/media/extensionClip.test.ts b/electron/media/extensionClip.test.ts
new file mode 100644
index 000000000..d2f7df681
--- /dev/null
+++ b/electron/media/extensionClip.test.ts
@@ -0,0 +1,74 @@
+// 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 { extensionClipPath } from "../../src/lib/ai-edition/timeline/clip-parts";
+import { extensionClipArgs } from "./extensionClip";
+
+const SPEC = { durationSec: 3.6, fps: 30, width: 1920, height: 1080 };
+
+describe("extensionClipArgs", () => {
+ const args = extensionClipArgs(SPEC, "C:/out/w1_3600.mp4");
+ const filter = (prefix: string) => args.find((a) => a.startsWith(prefix)) ?? "";
+
+ 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("carries an audible noise track rather than silence", () => {
+ expect(filter("anoisesrc=")).toContain("a=0.2");
+ expect(args).toContain("1:a");
+ });
+
+ 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("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("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");
+ });
+});
+
+/** 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(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
new file mode 100644
index 000000000..d7641c25f
--- /dev/null
+++ b/electron/media/extensionClip.ts
@@ -0,0 +1,167 @@
+/**
+ * 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 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
+ * 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 { isGeneratedAssetId } from "../../src/lib/ai-edition/document/insertion";
+import { resolveFfmpeg } from "./audioPeaks";
+
+export interface ExtensionClipSpec {
+ 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 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. 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. */
+const VIDEO_ENCODER = "libopenh264";
+
+/** 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.
+ *
+ * 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",
+ "-f",
+ "lavfi",
+ "-i",
+ `testsrc2=size=${width}x${height}:rate=${fps}:duration=${dur}`,
+ "-f",
+ "lavfi",
+ "-i",
+ `anoisesrc=c=pink:a=${NOISE_AMPLITUDE}:r=${SAMPLE_RATE}:d=${dur}`,
+ "-map",
+ "0:v",
+ "-map",
+ "1:a",
+ "-c:v",
+ VIDEO_ENCODER,
+ "-pix_fmt",
+ "yuv420p",
+ "-c:a",
+ "aac",
+ "-t",
+ dur,
+ outPath,
+ ];
+}
+
+/**
+ * Every insertion's media, generated if it is not already there.
+ *
+ * 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 };
+ }>;
+}): Promise {
+ 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}`);
+ }
+ }
+}
+
+/**
+ * Generate the file if it is not already there, and return its path.
+ *
+ * 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,
+ outPath: string,
+): Promise {
+ 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(path.dirname(outPath), { 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()}` : ""}`)),
+ );
+ });
+}
diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts
index 0fbbccc9c..90088781a 100644
--- a/electron/native-bridge/services/aiEditionService.ts
+++ b/electron/native-bridge/services/aiEditionService.ts
@@ -151,9 +151,17 @@ export class AiEditionService {
}
}
- async addAsset(projectId: string, path: string, label?: string): Promise {
- const document = await this.options.documents.addAsset(projectId, { path, label });
- const assetId = document.project.primaryAssetId ?? document.assets.at(-1)?.id ?? "";
+ async addAsset(
+ projectId: string,
+ path: string,
+ label?: string,
+ kind?: "video" | "audio",
+ ): Promise {
+ const document = await this.options.documents.addAsset(projectId, { path, label, kind });
+ // The just-added asset is always the last one; primaryAssetId is only a
+ // fallback for the video case and would point at the wrong asset for an
+ // audio import (which never claims primary), so prefer the tail.
+ const assetId = document.assets.at(-1)?.id ?? document.project.primaryAssetId ?? "";
return { assetId, document };
}
diff --git a/electron/preload.ts b/electron/preload.ts
index 6aff16407..7873bef90 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -276,6 +276,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
},
+ openAudioFilePicker: () => {
+ return ipcRenderer.invoke("open-audio-file-picker");
+ },
+ saveRecordedVoiceover: (data: ArrayBuffer) => {
+ return ipcRenderer.invoke("save-recorded-voiceover", data);
+ },
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke("set-current-video-path", path);
},
diff --git a/electron/stt/extractAudio.test.ts b/electron/stt/extractAudio.test.ts
new file mode 100644
index 000000000..24e9694c8
--- /dev/null
+++ b/electron/stt/extractAudio.test.ts
@@ -0,0 +1,141 @@
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const spawnMock = vi.fn();
+const resolveFfmpegMock = vi.fn<() => string | null>();
+
+vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawnMock(...args) }));
+vi.mock("../media/audioPeaks", () => ({ resolveFfmpeg: () => resolveFfmpegMock() }));
+
+const { extractMono16kPcm, FfmpegUnavailableError, NoAudioTrackError } = await import(
+ "./extractAudio"
+);
+const { STT_NATIVE_EXTRACTION_UNAVAILABLE } = await import("./transcriptionContract");
+
+/** A stand-in for the ffmpeg child: two pipes and a close event, nothing more. */
+function fakeChild() {
+ const child = new EventEmitter() as EventEmitter & {
+ stdout: PassThrough;
+ stderr: PassThrough;
+ kill: ReturnType;
+ };
+ child.stdout = new PassThrough();
+ child.stderr = new PassThrough();
+ child.kill = vi.fn();
+ return child;
+}
+
+/** The little-endian float32 bytes ffmpeg would emit for `values`. */
+function f32le(values: number[]): Buffer {
+ const buf = Buffer.alloc(values.length * 4);
+ values.forEach((v, i) => buf.writeFloatLE(v, i * 4));
+ return buf;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ resolveFfmpegMock.mockReturnValue("/usr/bin/ffmpeg");
+});
+
+describe("extractMono16kPcm", () => {
+ it("asks ffmpeg for exactly what whisper wants", async () => {
+ const child = fakeChild();
+ spawnMock.mockReturnValue(child);
+ const promise = extractMono16kPcm("/tmp/a.mp3");
+ child.stdout.end(f32le([0.5]));
+ child.emit("close", 0);
+ await promise;
+
+ const args = spawnMock.mock.calls[0][1] as string[];
+ // Mono, 16 kHz, float32 little-endian, no video. Anything else and whisper is
+ // reading the samples wrong rather than failing loudly.
+ expect(args).toContain("-vn");
+ expect(args.join(" ")).toContain("-ac 1");
+ expect(args.join(" ")).toContain("-ar 16000");
+ expect(args.join(" ")).toContain("-f f32le");
+ });
+
+ it("decodes the samples ffmpeg writes", async () => {
+ const child = fakeChild();
+ spawnMock.mockReturnValue(child);
+ const promise = extractMono16kPcm("/tmp/a.mp3");
+ child.stdout.end(f32le([0, 0.5, -0.25]));
+ child.emit("close", 0);
+
+ const out = await promise;
+ expect(Array.from(out)).toEqual([0, 0.5, -0.25]);
+ });
+
+ it("carries a float split across two chunks instead of dropping it", async () => {
+ // THE defect worth a test here: stdout chunk boundaries do not respect sample
+ // boundaries. Dropping the partial tail would shift every following sample and
+ // detune the whole track — audible, and invisible in a length check.
+ const child = fakeChild();
+ spawnMock.mockReturnValue(child);
+ const promise = extractMono16kPcm("/tmp/a.mp3");
+ const bytes = f32le([0.25, -0.75, 1]);
+ child.stdout.write(bytes.subarray(0, 6)); // one whole float + half of the next
+ child.stdout.write(bytes.subarray(6));
+ child.stdout.end();
+ child.emit("close", 0);
+
+ const out = await promise;
+ expect(Array.from(out)).toEqual([0.25, -0.75, 1]);
+ });
+
+ it("reports a file with no audio track", async () => {
+ const child = fakeChild();
+ spawnMock.mockReturnValue(child);
+ const promise = extractMono16kPcm("/tmp/silent.mp4");
+ child.stderr.end("Stream map '0:a' matches no streams");
+ child.stdout.end();
+ child.emit("close", 1);
+
+ await expect(promise).rejects.toBeInstanceOf(NoAudioTrackError);
+ });
+
+ it("keeps the samples when ffmpeg exits non-zero AFTER writing audio", async () => {
+ // A truncated file still yields usable audio; throwing it away would lose a
+ // transcript over a trailing byte.
+ const child = fakeChild();
+ spawnMock.mockReturnValue(child);
+ const promise = extractMono16kPcm("/tmp/truncated.mp3");
+ child.stdout.end(f32le([0.1, 0.2]));
+ child.emit("close", 1);
+
+ expect(Array.from(await promise)).toEqual([expect.closeTo(0.1, 6), expect.closeTo(0.2, 6)]);
+ });
+
+ it("refuses with the marker the renderer falls back on when ffmpeg is missing", async () => {
+ // The string is the contract across the IPC boundary, which drops the class.
+ resolveFfmpegMock.mockReturnValue(null);
+ await expect(extractMono16kPcm("/tmp/a.mp3")).rejects.toBeInstanceOf(FfmpegUnavailableError);
+ await expect(extractMono16kPcm("/tmp/a.mp3")).rejects.toThrow(
+ STT_NATIVE_EXTRACTION_UNAVAILABLE,
+ );
+ expect(spawnMock).not.toHaveBeenCalled();
+ });
+
+ it("kills the child when the caller aborts", async () => {
+ const child = fakeChild();
+ spawnMock.mockReturnValue(child);
+ const controller = new AbortController();
+ const promise = extractMono16kPcm("/tmp/a.mp3", { signal: controller.signal });
+ controller.abort();
+
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
+ // Not merely stopping to await: ffmpeg would keep decoding a long file for
+ // minutes, which is the same leak the STT cancel path exists to prevent.
+ expect(child.kill).toHaveBeenCalled();
+ });
+
+ it("does not spawn at all when the signal is already aborted", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ await expect(
+ extractMono16kPcm("/tmp/a.mp3", { signal: controller.signal }),
+ ).rejects.toMatchObject({ name: "AbortError" });
+ expect(spawnMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/electron/stt/extractAudio.ts b/electron/stt/extractAudio.ts
new file mode 100644
index 000000000..08168d21e
--- /dev/null
+++ b/electron/stt/extractAudio.ts
@@ -0,0 +1,152 @@
+// Native mono-16k extraction for transcription, in the main process.
+//
+// The renderer used to do this: `extractMono16kFromVideoUrl` read the whole media
+// into a `File`, took an `arrayBuffer()`, handed a `slice(0)` copy to
+// `decodeAudioData`, and resampled the result to mono 16k — all of it on the UI
+// thread, all of it before whisper ever saw a sample. On a four-minute bed that is
+// ~86 MB of decoded float32 plus two copies of the encoded bytes, and it froze the
+// editor at open. The inference itself was never the problem: it runs in
+// `whisper-stt-server`, in its own process, on the GPU.
+//
+// So this is the same remedy `useAudioPeaks` already got (see
+// `electron/media/audioPeaks.ts`, and the note there about WHICH ffmpeg is packaged
+// on Windows): let ffmpeg do it, in the main process, streaming. It costs
+// `durationSec * 16000 * 4` bytes — 15.7 MB for that same four-minute bed — and the
+// renderer never allocates any of it.
+//
+// It is deliberately NOT cached on disk, unlike peaks. Peaks are re-read on every
+// project open; extraction feeds one transcription, whose RESULT is what gets
+// persisted (`document.transcripts[]`). Caching the PCM would trade disk for work
+// that is already never repeated.
+
+import { spawn } from "node:child_process";
+import { resolveFfmpeg } from "../media/audioPeaks";
+import { STT_NATIVE_EXTRACTION_UNAVAILABLE } from "./transcriptionContract";
+
+/** What whisper.cpp wants, and what `decodePeaks` already asks ffmpeg for. */
+const SAMPLE_RATE = 16_000;
+
+/** Past this, it is not a recording — it is a wedged ffmpeg. Matches the peaks path. */
+const EXTRACT_TIMEOUT_MS = 60_000;
+
+/**
+ * Thrown when no ffmpeg can be resolved, so the caller can fall back to the
+ * renderer pipeline rather than failing the transcription outright. A distinct type
+ * because "there is no ffmpeg here" and "this file has no audio" want opposite
+ * responses: fall back, versus report a permanent failure for this asset.
+ */
+export class FfmpegUnavailableError extends Error {
+ constructor() {
+ super(`${STT_NATIVE_EXTRACTION_UNAVAILABLE}: no ffmpeg binary for native audio extraction`);
+ this.name = "FfmpegUnavailableError";
+ }
+}
+
+/** A media with no decodable audio track. Permanent for that file. */
+export class NoAudioTrackError extends Error {
+ constructor(filePath: string, detail: string) {
+ super(`No decodable audio in ${filePath}${detail ? `: ${detail}` : ""}`);
+ this.name = "NoAudioTrackError";
+ }
+}
+
+/**
+ * Decode `filePath` to mono 16 kHz float samples.
+ *
+ * Streams `f32le` straight off ffmpeg's stdout, so the only full-size allocation is
+ * the result itself. Chunk boundaries do not respect sample boundaries — a 4-byte
+ * float can straddle two `data` events — so a partial tail is carried into the next
+ * chunk rather than dropped, which would shift every following sample and detune the
+ * whole track.
+ */
+export async function extractMono16kPcm(
+ filePath: string,
+ options: { signal?: AbortSignal } = {},
+): Promise {
+ const ffmpeg = resolveFfmpeg();
+ if (!ffmpeg) throw new FfmpegUnavailableError();
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
+
+ const child = spawn(
+ ffmpeg,
+ [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ filePath,
+ "-vn",
+ "-ac",
+ "1",
+ "-ar",
+ String(SAMPLE_RATE),
+ "-f",
+ "f32le",
+ "-",
+ ],
+ { stdio: ["ignore", "pipe", "pipe"] },
+ );
+
+ return new Promise((resolve, reject) => {
+ const chunks: Float32Array[] = [];
+ let total = 0;
+ /** Bytes of a float that arrived split across two chunks. */
+ let carry: Buffer | null = null;
+ let stderr = "";
+ let settled = false;
+
+ const finish = (fn: () => void) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ options.signal?.removeEventListener("abort", onAbort);
+ fn();
+ };
+
+ const timer = setTimeout(() => {
+ child.kill("SIGKILL");
+ finish(() =>
+ reject(new Error(`ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms on ${filePath}`)),
+ );
+ }, EXTRACT_TIMEOUT_MS);
+
+ const onAbort = () => {
+ child.kill("SIGKILL");
+ finish(() => reject(new DOMException("Aborted", "AbortError")));
+ };
+ options.signal?.addEventListener("abort", onAbort, { once: true });
+
+ child.stdout.on("data", (c: Buffer) => {
+ const buf = carry ? Buffer.concat([carry, c]) : c;
+ const usable = buf.length - (buf.length % 4);
+ if (usable > 0) {
+ // Copy rather than view: a Buffer's memory is rarely 4-byte aligned, and
+ // `byteOffset` is almost never 0.
+ const view = new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + usable));
+ chunks.push(view);
+ total += view.length;
+ }
+ carry = usable < buf.length ? Buffer.from(buf.subarray(usable)) : null;
+ });
+ child.stderr.on("data", (c: Buffer) => {
+ stderr = (stderr + c.toString()).slice(-2048);
+ });
+ child.once("error", (err) => finish(() => reject(err)));
+ child.once("close", (code) => {
+ // A file with no audio track exits non-zero, and so does a corrupt one. The
+ // caller treats both the same way — this asset will not transcribe — so they
+ // share an error type; `stderr` carries which it was.
+ if (code !== 0 && total === 0) {
+ finish(() => reject(new NoAudioTrackError(filePath, stderr.trim())));
+ return;
+ }
+ const out = new Float32Array(total);
+ let at = 0;
+ for (const part of chunks) {
+ out.set(part, at);
+ at += part.length;
+ }
+ finish(() => resolve(out));
+ });
+ });
+}
diff --git a/electron/stt/index.ts b/electron/stt/index.ts
index c250ea4ac..a20f4e577 100644
--- a/electron/stt/index.ts
+++ b/electron/stt/index.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import { app, type IpcMain } from "electron";
import { planChunks } from "./chunking";
+import { extractMono16kPcm } from "./extractAudio";
import { ensureModels, modelPaths } from "./modelManager";
import type {
SttPhraseSegment,
@@ -103,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.
*
@@ -134,6 +142,7 @@ export class SttManager {
*/
cancel(): void {
this.cancelEpoch++;
+ this.extraction?.abort();
}
/**
@@ -237,12 +246,34 @@ export class SttManager {
}
/** Transcribe a whole recording, chunk by chunk, reporting progress as it goes. */
+ /** Decode `sourcePath` into the samples `transcribe` needs. */
+ private async extract(req: SttTranscribeRequest): Promise {
+ if (!req.sourcePath) {
+ throw new Error("stt:transcribe needs either `samples` or `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 {
await this.init();
const epoch = this.cancelEpoch;
- const totalSec = req.samples.length / SAMPLE_RATE;
- const chunks = planChunks(req.samples, SAMPLE_RATE);
+ // Extraction is part of the run, and on a long file it is the part the user used
+ // to watch the editor freeze through. Doing it here means the renderer hands over
+ // a path and gets segments back, holding none of the audio. No new status phase:
+ // the caller already reports "extracting-audio" around this call, and the work
+ // simply moved to the other side of the IPC.
+ const samples = req.samples ?? (await this.extract(req));
+ const totalSec = samples.length / SAMPLE_RATE;
+ const chunks = planChunks(samples, SAMPLE_RATE);
this.emit({ phase: "transcribe", completedSec: 0, totalSec });
const segments: SttPhraseSegment[] = [];
@@ -285,7 +316,7 @@ export class SttManager {
if (this.cancelEpoch !== epoch) throw cancelledError();
const offsetSec = chunk.startSample / SAMPLE_RATE;
const result = await this.transcribeChunk(
- req.samples.subarray(chunk.startSample, chunk.endSample),
+ samples.subarray(chunk.startSample, chunk.endSample),
language,
).catch((error) => {
if (error instanceof Error && error.name === "AbortError") throw error;
diff --git a/electron/stt/transcriptionContract.ts b/electron/stt/transcriptionContract.ts
index cbce5a141..d2e67ae11 100644
--- a/electron/stt/transcriptionContract.ts
+++ b/electron/stt/transcriptionContract.ts
@@ -106,7 +106,24 @@ export interface SttStatusEvent {
/** IPC request: renderer → main. */
export interface SttTranscribeRequest {
- samples: Float32Array;
+ /**
+ * Mono-16k samples the CALLER decoded. Optional since native extraction landed:
+ * pass `sourcePath` instead and the main process decodes with ffmpeg, off the UI
+ * thread and without the renderer ever holding the audio. Kept for the caption
+ * path, which already has samples in hand and has no file to point at.
+ *
+ * Exactly one of `samples` / `sourcePath` is required.
+ */
+ samples?: Float32Array;
+ /**
+ * A media file for the main process to decode itself (ffmpeg -> mono 16k f32).
+ * Preferred: the renderer's own pipeline read the whole file, copied it twice and
+ * resampled it on the UI thread, which is what froze the editor at open.
+ *
+ * The caller falls back to its own decode when this cannot be honoured — see
+ * `FfmpegUnavailableError`.
+ */
+ sourcePath?: string;
/**
* ISO 639-1 language code (e.g. "en", "fr"). Omit / `"auto"` to let Whisper detect.
* The spec locks language detection on by default; we only honour an explicit value.
@@ -114,6 +131,17 @@ export interface SttTranscribeRequest {
language?: string;
}
+/**
+ * Marker carried in the error message when the main process cannot decode a
+ * `sourcePath` because no ffmpeg is resolvable on this install.
+ *
+ * A string rather than an error class because this crosses `ipcRenderer.invoke`,
+ * which reconstructs a plain `Error` from the message and drops the prototype and
+ * the `name`. Exported so neither side spells it out by hand — a fallback keyed on
+ * a literal typed twice is a fallback that silently stops working.
+ */
+export const STT_NATIVE_EXTRACTION_UNAVAILABLE = "stt:native-extraction-unavailable";
+
/** IPC response: main → renderer. */
export interface SttTranscribeResponse {
segments: SttPhraseSegment[];
diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx
index 91ef8dbd4..5eee1b595 100644
--- a/src/components/ai-edition/CaptionsPane.gating.test.tsx
+++ b/src/components/ai-edition/CaptionsPane.gating.test.tsx
@@ -1,9 +1,12 @@
// @vitest-environment jsdom
-// Captions are a view of the transcript, so the pane's "Transcribe video"
-// button is a retry, not a first step — the background pass has already tried.
-// On a media with no audio track that retry can only fail again, so the button
-// has to be dead and the pane has to say what is wrong instead of inviting a
-// pointless click.
+// Captions are a view of the transcript, and since issue #560 they are reached from
+// the transcript tab rather than owning one. So this pane no longer STARTS a
+// transcription — the transcript tab's empty state carries the single gate. Two
+// buttons for one background pass is what made people believe captions were
+// transcribed separately.
+//
+// What the pane still owes the reader is a status: whether a pass is already
+// running, and why there will never be one on a media with no audio track.
import "@testing-library/jest-dom";
import { cleanup, render, screen } from "@testing-library/react";
@@ -76,6 +79,14 @@ function load(document: AxcutDocument) {
});
}
+function mount() {
+ render(
+
+
+ ,
+ );
+}
+
beforeEach(() => {
useTranscriptionStore.getState().reset();
useProjectStore.getState().clear();
@@ -86,36 +97,31 @@ afterEach(() => {
});
describe("captions pane gating", () => {
- it("offers the retry while the media might still yield a transcript", () => {
+ it("does not offer a second way to start a transcription", () => {
load(documentWith(ASSET));
- render(
-
-
- ,
- );
- expect(screen.getByRole("button", { name: "Transcribe video" })).toBeEnabled();
+ mount();
+ expect(screen.queryByRole("button", { name: "Transcribe video" })).toBeNull();
+ expect(
+ screen.getByText("Captions are read from the media transcript.", { exact: false }),
+ ).toBeInTheDocument();
});
- it("shows the queued background run instead of an idle button", () => {
+ it("reports a background run that is already going", () => {
load(documentWith(ASSET));
useTranscriptionStore.setState({
projectId: "proj_1",
jobs: { asset_1: { status: "running", language: "auto", manual: false } },
});
- render(
-
-
- ,
- );
- // A phase-less running job renders the shared busy label ("Transcribing",
- // mediaStage.transcribing) rather than the pane's old private copy.
- expect(screen.getByRole("button", { name: "Transcribing" })).toBeDisabled();
+ mount();
+ expect(screen.getByText("Transcribing")).toBeInTheDocument();
+ // Still not a control: a running pass is news, not something to press.
+ expect(screen.queryByRole("button", { name: "Transcribing" })).toBeNull();
});
- it("keeps the idle button when only an off-timeline asset is busy", () => {
- // The gate answers for the timeline's assets; the label must not answer
- // for the whole bin. A bin asset mid-transcription used to relabel the
- // still-enabled button with its busy copy.
+ it("stays quiet when only an off-timeline asset is busy", () => {
+ // The gate answers for the timeline's assets; the label must not answer for the whole
+ // bin. A bin asset mid-transcription used to relabel the pane as if the film were
+ // being transcribed.
const offTimeline: AxcutAsset = {
id: "asset_2",
kind: "video",
@@ -131,27 +137,18 @@ describe("captions pane gating", () => {
projectId: "proj_1",
jobs: { asset_2: { status: "running", language: "auto", manual: false } },
});
- render(
-
-
- ,
- );
- expect(screen.getByRole("button", { name: "Transcribe video" })).toBeEnabled();
+ mount();
+ expect(screen.queryByText("Transcribing")).toBeNull();
});
- it("kills the retry on a media with no audio track and explains it", () => {
+ it("explains a media with no audio track, where no pass will ever help", () => {
load(
documentWith({
...ASSET,
transcriptionFailure: { kind: "no-audio", message: "No audio track found in this video." },
}),
);
- render(
-
-
- ,
- );
- expect(screen.getByRole("button", { name: "Transcribe video" })).toBeDisabled();
+ mount();
expect(
screen.getByText("This media has no audio track — there is nothing to transcribe."),
).toBeInTheDocument();
diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx
index b425c2ff3..f109e4c6b 100644
--- a/src/components/ai-edition/CaptionsPane.tsx
+++ b/src/components/ai-edition/CaptionsPane.tsx
@@ -22,7 +22,6 @@ import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
useTimelineTranscriptGate,
- useTranscriptionStore,
} from "@/lib/ai-edition/store/transcriptionStore";
import { useCaptions } from "@/lib/ai-edition/store/useCaptions";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
@@ -94,14 +93,14 @@ export function CaptionsPane() {
// Captions are a view of the transcript, and the transcript arrives on its
// own (transcriptionStore's background pass). The pane reads that state
// straight from the store rather than being handed a busy flag: it is the
- // same answer everywhere, and "Transcribe" here is only ever a retry.
+ // same answer everywhere, and this pane only ever reports on the pass —
+ // starting one is the transcript tab's job.
//
// Resolved over the timeline's assets, not the primary one: `hasTranscript`
// below is already timeline-scoped (useCaptions), and mixing the two scopes
// is what let a silent primary asset dead-end this button for a project whose
// actual footage had speech.
const gate = useTimelineTranscriptGate();
- const requestTimelineTranscripts = useTranscriptionStore((s) => s.requestTimelineTranscripts);
const transcriptions = useAssetTranscriptions();
const transcriptionLabel = useTranscriptionLabel();
const isTranscribing = gate.state === "pending";
@@ -237,17 +236,26 @@ export function CaptionsPane() {
{engineError}
) : null}
- void requestTimelineTranscripts()}
- >
- {isTranscribing ? : null}
- {busyLabel ?? t("captions.transcribe")}
-
+ {/* 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}
) : (
{
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,
@@ -434,7 +459,16 @@ export function NewEditorShell() {
const handleDropAsset = useCallback(
(assetId: string) =>
enqueueTimelineWrite(() => {
- const at = useProjectStore.getState().document?.timeline.clips.length ?? 0;
+ const doc = useProjectStore.getState().document;
+ // An audio asset has no video, so it must never become a clip (issue
+ // #350) — it goes on the audio lane as a track. Adding it "to the
+ // timeline" reuses its existing track if it already has one (importing
+ // already placed one) so the same file can't stack up duplicate lanes.
+ if (doc?.assets.find((a) => a.id === assetId)?.kind === "audio") {
+ if (doc.audioTracks.some((t) => t.assetId === assetId)) return Promise.resolve();
+ return tl.addAudioTrack(assetId).then(() => undefined);
+ }
+ const at = doc?.timeline.clips.length ?? 0;
return tl.insertClipAt(assetId, at);
}).catch((error) => {
toast.error(te("mediaStage.couldNotAddAsset"), {
@@ -578,37 +612,142 @@ export function NewEditorShell() {
// axcut's `queueAddTrimRange` / `queueRemoveTrimRange` callbacks in
// apps/web/src/App.tsx. The serialised save + inside-the-chain doc
// read is owned by `useSequentialTimelineOps` above.
- const handleAddTrimRange = useCallback(
- (target: TrimTarget, startSec: number, endSec: number, reason: string) => {
- // `clipId` is what keeps the cut on the block the user typed in: with two clips
- // over the same media, an asset-only trim showed up on both (see `trimAppliesToClip`).
- void applyTimelineOp(
- {
- type: "add_trim_range",
- assetId: target.assetId,
- clipId: target.clipId,
- startSec,
- endSec,
+ // transcript-pane → a cut, authored as a stretch of the RAW ruler (issue #560).
+ //
+ // The pane used to hand over the asset and clip the words belonged TO, which is how a
+ // cut made on the voiceover lane came to be anchored on an audio fragment: it removed
+ // nothing from playback or the export while the word turned red. A cut is a moment of
+ // the programme, so the clips that carry it are resolved HERE, from the clips actually
+ // under the span — `ventilateTimelineSpanToTrims`, the same primitive a zoom straddling
+ // a boundary uses, so one gesture can become several rows and stay one pill.
+ //
+ // On `enqueueTimelineWrite`, not `applyTimelineOp`'s convenience or `tl.setTrimEntries`:
+ // the latter reads `useProjectStore.getState().document` unqueued, so correcting a word
+ // and immediately cutting the next one would let the word edit overwrite the cut. That
+ // is exactly the failure this chain exists to prevent.
+ const handleTrimTimelineSpan = useCallback(
+ (startSec: number, endSec: number, reason: string) => {
+ void enqueueTimelineWrite(async () => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ const ranges = ventilateTimelineSpanToTrims(startSec, endSec, doc.timeline.clips);
+ if (ranges.length === 0) {
+ // No nearest-clip fallback. A span over a gap, or past the last clip, names
+ // no film — cutting the closest thing instead would remove something the
+ // user never pointed at.
+ toast.error(te("errors.trimNoFilm"));
+ return;
+ }
+ const rows = ranges.map((range) => ({
+ id: createId("trim"),
+ assetId: range.assetId,
+ clipId: range.clipId,
+ startSec: range.sourceStartSec,
+ endSec: range.sourceEndSec,
reason,
- },
- { history: true },
- );
+ origin: "user" as const,
+ }));
+ await saveDocument(
+ {
+ ...doc,
+ timeline: { ...doc.timeline, trimRanges: [...doc.timeline.trimRanges, ...rows] },
+ },
+ { history: true },
+ );
+ });
},
- [applyTimelineOp],
+ [enqueueTimelineWrite, saveDocument, te],
);
- const handleRemoveTrimRange = useCallback(
- (trimId: string) => {
- void applyTimelineOp(
- {
- type: "remove_trim_range",
- trimId,
- reason: "Restored from transcript pane.",
- },
- { history: true },
- );
+ // Every row of the pill at once: a cut ventilated across a clip boundary is several
+ // rows and one pill, and `dropTrimPillsByIds` resolves the rest of the group from any
+ // member. Dropping half would leave the word still cut with nothing on screen to say so.
+ const handleRemoveTrimRanges = useCallback(
+ (trimIds: string[]) => {
+ if (trimIds.length === 0) return;
+ void enqueueTimelineWrite(async () => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ 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 } },
+ { history: true },
+ );
+ });
},
- [applyTimelineOp],
+ [enqueueTimelineWrite, saveDocument],
+ );
+
+ // transcript-pane → the word's own text. Unlike Backspace (which writes a trimRange and
+ // cuts the media), this writes only `transcript.words[].text`: the captions follow, the
+ // film is untouched. Queued on the SAME chain as the trims so correcting a word and
+ // cutting the next one cannot overwrite each other's save.
+ const handleSetWordText = useCallback(
+ (assetId: string, wordId: string, text: string) => {
+ void enqueueTimelineWrite(async () => {
+ // Read inside the chain: the previous save has resolved by now, so the store
+ // 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 (!insertionsEnabled() && isGeneratedAssetId(assetId)) return;
+ try {
+ await saveDocument(setDocumentWordText(doc, assetId, wordId, text), { history: true });
+ } catch (err) {
+ // The word or its transcript vanished under the edit (a regeneration landed
+ // mid-typing). Nothing to retry — say so rather than dropping it silently.
+ toast.error(te("errors.wordEditFailed"), {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ }
+ });
+ },
+ [enqueueTimelineWrite, saveDocument, te],
+ );
+
+ // transcript-pane → a word nobody said. It takes the silence it is dropped into and no
+ // audio at all, so unlike a cut it changes nothing about the film; today it reaches the
+ // captions and stops there.
+ const handleInsertWord = useCallback(
+ (assetId: string, anchorWordId: string, side: InsertSide, text: string) => {
+ if (!insertionsEnabled()) return;
+ void enqueueTimelineWrite(async () => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ try {
+ await saveDocument(insertDocumentWord(doc, assetId, anchorWordId, side, text), {
+ history: true,
+ });
+ } catch (err) {
+ toast.error(te("errors.wordInsertFailed"), {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ }
+ });
+ },
+ [enqueueTimelineWrite, saveDocument, te],
+ );
+
+ // Deleting inserted words. One save for the whole set, so a Backspace over several of
+ // them is one Ctrl+Z, and the document layer refuses anything that was actually spoken.
+ const handleRemoveWords = useCallback(
+ (assetId: string, wordIds: string[]) => {
+ void enqueueTimelineWrite(async () => {
+ const doc = useProjectStore.getState().document;
+ if (!doc || wordIds.length === 0) return;
+ try {
+ await saveDocument(removeDocumentWords(doc, assetId, wordIds), { history: true });
+ } catch (err) {
+ toast.error(te("errors.wordRemoveFailed"), {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ }
+ });
+ },
+ [enqueueTimelineWrite, saveDocument, te],
);
const handleSelectProject = useCallback(
@@ -777,6 +916,56 @@ export function NewEditorShell() {
});
}, []);
+ // Voiceover recording (the one audio gesture that is not a file import). The
+ // dialog owns the mic; the shell owns the transport and the placement.
+ const [voiceoverFlow, setVoiceoverFlow] = useState<{ maxDurationSec: number } | null>(null);
+ // The playhead as it was when RECORDING STARTED. Recording plays the video so
+ // the user can narrate what they see, which means the live playhead has moved
+ // on by the take's own length by the time the take ends — reading it then
+ // placed every voiceover one full take-length to the right of where it was
+ // spoken. Captured on the way in, used on the way out.
+ const voiceoverStartSecRef = useRef(0);
+
+ const openVoiceoverFlow = useCallback(() => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ const total = doc.timeline.clips.reduce((max, c) => Math.max(max, c.timelineEndSec), 0);
+ const playhead = useProjectStore.getState().currentTimeSec;
+ voiceoverStartSecRef.current = playhead;
+ // Recording stops itself at the end of the timeline: a take can never
+ // outlive the video it was recorded over.
+ setVoiceoverFlow({ maxDurationSec: Math.max(0.5, total - playhead) });
+ }, []);
+
+ // Silences the timeline's own audio tracks for the duration of a take — see
+ // where it is passed to the preview.
+ const [voiceoverRecording, setVoiceoverRecording] = useState(false);
+
+ const handleVoiceoverRecordingStart = useCallback(() => {
+ // Re-capture: the user may have scrubbed between opening the dialog and
+ // hitting Record, and playback starts from wherever the playhead is now.
+ voiceoverStartSecRef.current = useProjectStore.getState().currentTimeSec;
+ setVoiceoverRecording(true);
+ if (videoElement?.paused) void videoElement.play().catch(() => undefined);
+ }, [videoElement]);
+
+ const handleVoiceoverRecordingStop = useCallback(() => {
+ setVoiceoverRecording(false);
+ videoElement?.pause();
+ }, [videoElement]);
+
+ const handleVoiceoverReady = useCallback(
+ async (assetId: string, durationSec: number) => {
+ setVoiceoverFlow(null);
+ await tl.addAudioTrack(assetId, voiceoverStartSecRef.current, {
+ kind: "voiceover",
+ durationSec,
+ spanSec: durationSec,
+ });
+ },
+ [tl],
+ );
+
const pasteRegion = useCallback(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
@@ -793,6 +982,21 @@ export function NewEditorShell() {
return;
}
+ // Validate before building anything. The clipboard outlives the project, so
+ // a track copied in one project and pasted in another would reference an
+ // asset that only exists back where it came from — a pill that plays
+ // nothing and exports nothing. Audio is the only kind carrying a reference
+ // out of the document today; the next one belongs here too, rather than in
+ // its own branch below.
+ const referencedAssetId = (snapshot.region as { assetId?: unknown }).assetId;
+ if (
+ typeof referencedAssetId === "string" &&
+ !doc.assets.some((a) => a.id === referencedAssetId)
+ ) {
+ toast.error(te("regionClipboard.pasteAssetMissing"));
+ return;
+ }
+
const { anchorRegionsWithDerivedMs } = await import("@/lib/ai-edition/timeline/timelineMap");
const { createId } = await import("@/lib/ai-edition/document/ids");
@@ -800,6 +1004,27 @@ export function NewEditorShell() {
const timeMs = Math.round(useProjectStore.getState().currentTimeSec * 1000);
const src = snapshot.region as { startMs: number; endMs: number };
const prefix = snapshot.kind === "annotation" ? "ann" : snapshot.kind;
+
+ // Audio re-ventilates through its own anchorer, which advances each
+ // fragment's source offset — the generic one would copy the offset into
+ // every fragment and restart the file at each cut.
+ if (snapshot.kind === "audio") {
+ const { placeAudioTrackInDocument } = await import("@/lib/ai-edition/document/audioTracks");
+ const track = {
+ ...(snapshot.region as unknown as AxcutAudioTrack),
+ id: createId("audio"),
+ trackId: undefined,
+ startMs: timeMs,
+ endMs: timeMs + (Number(src.endMs) - Number(src.startMs)),
+ };
+ // Pasting onto an occupied lane queues behind what is there rather than doubling
+ // the row — the same rule every other placement obeys (issue #560).
+ const next = placeAudioTrackInDocument(doc, track, () => createId("audio"), "create");
+ if (next === doc) return;
+ await saveDocument(next, { history: true });
+ toast.success("Region pasted");
+ return;
+ }
const pasted = {
...snapshot.region,
id: createId(prefix),
@@ -849,7 +1074,7 @@ export function NewEditorShell() {
// `tl` belongs here now that the trim branch calls tl.addTrim: useTimeline
// returns a fresh object each render, so memoizing on saveDocument alone
// would paste through a callback holding a stale document.
- }, [saveDocument, tl]);
+ }, [saveDocument, tl, te]);
// Copy the SELECTED pill. Reads the same arrays the lanes render, so what gets
// copied is what the user is looking at — the old version dug into the raw
@@ -877,6 +1102,22 @@ export function NewEditorShell() {
return;
}
+ // An audio track is stored as one fragment per clip it covers; the user
+ // copied the PILL, so collapse it back before it goes on the clipboard.
+ if (sel.kind === "audio") {
+ const { collapseTracksToPills, trackGroupId } = await import(
+ "@/lib/ai-edition/document/audioTracks"
+ );
+ const [pill] = collapseTracksToPills(
+ tl.audioTracks.filter((t) => trackGroupId(t) === sel.id),
+ );
+ if (!pill) return;
+ copyRegion({ kind: "audio", region: pill as unknown as Record });
+ setCopiedClipId(null);
+ toast.success("Region copied");
+ return;
+ }
+
const source =
sel.kind === "zoom"
? tl.zoomRegions
@@ -955,6 +1196,14 @@ export function NewEditorShell() {
}
if (tl.selection) {
void tl.removeRegion(tl.selection.kind, tl.selection.id);
+ return;
+ }
+ // An audio track is selected through its OWN channel, not `selection`
+ // (the two are mutually exclusive — see addAudioTrack), so it needs its
+ // own branch here or Delete does nothing on the one lane that looks
+ // exactly like every other.
+ if (tl.selectedAudioTrackId) {
+ void tl.removeAudioTrack(tl.selectedAudioTrackId);
}
};
@@ -1039,6 +1288,18 @@ export function NewEditorShell() {
void tl.addAnnotation(newRegionDurationSec());
return;
}
+ // Unlike its neighbours this opens a file picker rather than dropping a region at
+ // the playhead — there is nothing to size, so it takes no duration (issue #350).
+ if (matchesShortcut(e, shortcuts.addAudio, isMac)) {
+ e.preventDefault();
+ void tl.addAudio();
+ return;
+ }
+ if (matchesShortcut(e, shortcuts.addVoiceover, isMac)) {
+ e.preventDefault();
+ openVoiceoverFlow();
+ return;
+ }
if (matchesShortcut(e, shortcuts.addSpeed, isMac)) {
e.preventDefault();
void tl.addSpeed(newRegionDurationSec());
@@ -1151,6 +1412,7 @@ export function NewEditorShell() {
const transcriptProps = {
clips,
+ audioTracks: document?.audioTracks ?? [],
transcripts: document?.transcripts ?? [],
assets: document?.assets ?? [],
trimRanges: document?.timeline?.trimRanges ?? [],
@@ -1158,8 +1420,11 @@ export function NewEditorShell() {
transcriptions,
busyView: timelineBusyView,
onSeek: handleSeek,
- onAddTrimRange: handleAddTrimRange,
- onRemoveTrimRange: handleRemoveTrimRange,
+ onTrimTimelineSpan: handleTrimTimelineSpan,
+ onRemoveTrimRanges: handleRemoveTrimRanges,
+ onSetWordText: handleSetWordText,
+ onInsertWord: handleInsertWord,
+ onRemoveWords: handleRemoveWords,
onTranscribe: handleTranscribe,
canTranscribe: hasAsset,
isTranscribing: transcriptGate.state === "pending",
@@ -1244,6 +1509,16 @@ export function NewEditorShell() {
hasProject={hasProject}
hasAsset={hasAsset}
videoSources={videoSources}
+ // Imported audio tracks (issue #350). `videoSources` already
+ // resolves a URL for every asset (audio included), so it doubles as
+ // the audio source list; VirtualPreview looks each track up by assetId.
+ // Nothing already on the timeline plays while a take is being
+ // recorded. On speakers it bleeds straight into the microphone
+ // and lands in the new take; even on headphones, narrating over
+ // an earlier voiceover is not what the button offers. The video
+ // itself keeps playing — that is what the user is narrating to.
+ audioTracks={voiceoverRecording ? NO_AUDIO_TRACKS : tl.audioTracks}
+ audioSources={videoSources}
clips={clips}
zoomRegions={tl.zoomRegions}
speedRegions={tl.speedRegions}
@@ -1333,6 +1608,7 @@ export function NewEditorShell() {
onTogglePlay={togglePlay}
onPrevClip={handlePrevClip}
onNextClip={handleNextClip}
+ onAddVoiceover={openVoiceoverFlow}
onEditClip={setEditClipTarget}
/>
@@ -1392,6 +1668,21 @@ export function NewEditorShell() {
onChoose={handleConfirmUnsaved}
/>
setExportOpen(false)} document={document} />
+ {
+ // Belt and braces: the recorder's own stop handler clears this, but a
+ // flow that ends any other way must not leave the timeline muted.
+ setVoiceoverRecording(false);
+ setVoiceoverFlow(null);
+ }}
+ onComplete={(assetId, durationSec) => {
+ void handleVoiceoverReady(assetId, durationSec);
+ }}
+ onRecordingStart={handleVoiceoverRecordingStart}
+ onRecordingStop={handleVoiceoverRecordingStop}
+ />
);
}
diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx
index aaf04f2db..3a1db9756 100644
--- a/src/components/ai-edition/Preview.tsx
+++ b/src/components/ai-edition/Preview.tsx
@@ -3,7 +3,9 @@ import type { CameraFullscreenRegion, ZoomFocus } from "@/components/video-edito
import { useScopedT } from "@/contexts/I18nContext";
import type {
AxcutAnnotationRegion,
+ AxcutAudioTrack,
AxcutClip,
+ AxcutTranscript,
AxcutTrimRange,
AxcutZoomRegion,
} from "@/lib/ai-edition/schema";
@@ -21,11 +23,18 @@ interface PreviewProps {
hasProject: boolean;
hasAsset: boolean;
videoSources: VideoSource[];
+ /** Imported audio tracks and the (unfiltered) asset URLs they resolve to
+ * (issue #350). Passed straight through to VirtualPreview — unlike the video
+ * `previewSources` below, these are NOT narrowed to clip-referenced assets,
+ * since an audio track has no clip. */
+ audioTracks?: AxcutAudioTrack[];
+ audioSources?: VideoSource[];
clips: AxcutClip[];
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
cameraFullscreenRegions?: CameraFullscreenRegion[];
trimRanges?: AxcutTrimRange[];
+ transcripts?: AxcutTranscript[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -52,11 +61,14 @@ export function Preview({
hasProject,
hasAsset,
videoSources,
+ audioTracks = [],
+ audioSources = [],
clips,
zoomRegions,
speedRegions,
cameraFullscreenRegions,
trimRanges,
+ transcripts,
selectedZoomRegionId,
onZoomFocusChange,
onZoomFocusCommit,
@@ -178,11 +190,14 @@ export function Preview({
<>
;
interface PreviewCanvasProps {
videoSources: VideoSource[];
+ /** Imported audio tracks + their asset URLs (issue #350), forwarded to
+ * VirtualPreview. */
+ audioTracks?: AxcutAudioTrack[];
+ audioSources?: VideoSource[];
clips: AxcutClip[];
zoomRegions?: AxcutZoomRegion[];
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/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 5b7792afe..8d89fa510 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -7,20 +7,25 @@
import {
AudioLines,
+ Captions as CaptionsIcon,
ChevronDown,
FileText,
HelpCircle,
Layout as LayoutIcon,
Loader2,
+ Mic,
MousePointerClick,
+ Music,
Sliders,
Trash2,
+ Undo2,
+ Video,
} from "lucide-react";
import {
type ChangeEvent,
type CSSProperties,
- type FormEvent,
+ Fragment,
memo,
type ClipboardEvent as ReactClipboardEvent,
type KeyboardEvent as ReactKeyboardEvent,
@@ -38,9 +43,13 @@ import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg";
import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
+import { resolveCaptionLane } from "@/lib/ai-edition/captions/settings";
+import { collapseTracksToPills, trackGroupId } from "@/lib/ai-edition/document/audioTracks";
import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat";
+import type { InsertSide } from "@/lib/ai-edition/document/transcript";
import type {
AxcutAsset,
+ AxcutAudioTrack,
AxcutClip,
AxcutTranscript,
AxcutTrimRange,
@@ -51,21 +60,28 @@ import {
type EditorSettingsPatch,
} from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
+import { useCaptions } from "@/lib/ai-edition/store/useCaptions";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
+import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import {
buildAggregatedSections,
type ClipSection,
type ClipWord,
findCueWordId,
+ isInsertedWord,
isSilenceWord,
+ placementRawExtent,
+ placementRawSec,
+ type TranscriptLane,
type TrimRun,
+ voiceoverPlacements,
} 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";
@@ -89,6 +105,8 @@ import {
getAspectRatioLabel,
} 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";
@@ -98,10 +116,13 @@ interface PaneProps {
icon: ReactNode;
// P3.3 — contextual help shown in a popover when the ? button is clicked.
helpText: string;
+ // A control that belongs to the pane as a whole rather than to any one of its
+ // rows, sitting left of the Help button.
+ actions?: ReactNode;
children: ReactNode;
}
-function Pane({ title, icon, helpText, children }: PaneProps) {
+function Pane({ title, icon, helpText, actions, children }: PaneProps) {
const ts = useScopedT("settings");
const helpLabel = ts("panes.help");
const [helpOpen, setHelpOpen] = useState(false);
@@ -109,7 +130,8 @@ function Pane({ title, icon, helpText, children }: PaneProps) {
{title}
-
+
+ {actions}
void;
+}) {
+ const ts = useScopedT("settings");
+ return (
+ <>
+
+ onChange("recording")}
+ >
+
+ {ts("transcript.laneRecording")}
+
+ onChange("voiceover")}
+ >
+
+ {ts("transcript.laneVoiceover")}
+
+
+ {/* Said out loud, because the choice reaches further than this tab: it decides the
+ text burnt into the exported file. A user must never be surprised by which
+ lane their captions came from. */}
+ {ts("transcript.laneFeedsCaptions")}
+ >
+ );
+}
+
+/**
+ * Caption settings, reached from the transcript tab (issue #560).
+ *
+ * The pane is reused VERBATIM rather than rebuilt into a popover body: it is ~600
+ * lines of settings that already work, and "make it a popover" is a question about
+ * where it is mounted, not about what it contains. Rebuilding it would have been the
+ * one reliable way to arrive at a popover that is not at parity with the tab it
+ * replaces.
+ *
+ * Safe inside a Popover specifically because nothing in it takes focus away — no file
+ * input, no OS dialog. That is the trap `useWallpaperFileInput` documents above, and
+ * it is worth re-checking if a picker is ever added to captions.
+ */
+function CaptionSettingsButton() {
+ const ts = useScopedT("settings");
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+
+ {ts("facets.captions")}
+
+
+
+
+
+
+ );
+}
+
export function TranscriptPane({
clips,
+ audioTracks,
transcripts,
assets,
trimRanges,
@@ -713,14 +813,20 @@ export function TranscriptPane({
transcriptions,
busyView,
onSeek,
- onAddTrimRange,
- onRemoveTrimRange,
+ onTrimTimelineSpan,
+ onRemoveTrimRanges,
+ onSetWordText,
+ onInsertWord,
+ onRemoveWords,
onTranscribe,
canTranscribe,
isTranscribing,
blocked,
}: {
clips: AxcutClip[];
+ /** Every audio track on the timeline. Only the voiceover ones can be read from;
+ * music is not transcribed at all, so it never becomes a lane to choose. */
+ audioTracks: AxcutAudioTrack[];
transcripts: AxcutTranscript[];
assets: AxcutAsset[];
trimRanges: AxcutTrimRange[];
@@ -737,8 +843,17 @@ export function TranscriptPane({
* the gate keeps enabled. */
busyView?: AssetTranscriptionView;
onSeek: (sec: number) => void;
- onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
- onRemoveTrimRange: (trimId: string) => void;
+ onTrimTimelineSpan: (startSec: number, endSec: number, reason: string) => void;
+ onRemoveTrimRanges: (trimIds: string[]) => void;
+ /** Rewrite ONE word's text. Takes the bare `AxcutWord.id`, never the clip-scoped
+ * `ClipWord.id`: the transcript belongs to the asset, so a correction lands on the
+ * media and shows on every clip that plays it — which is the point. */
+ onSetWordText: (assetId: string, wordId: string, text: string) => void;
+ /** Add a word nobody said, beside the word the caret was resting on. Bare id, as above. */
+ onInsertWord: (assetId: string, anchorWordId: string, side: InsertSide, text: string) => void;
+ /** Delete inserted words. Only ever called with `source: "synth"` ids — a transcribed
+ * word is cut with a trim, never deleted. */
+ onRemoveWords: (assetId: string, wordIds: string[]) => void;
onTranscribe: () => void;
canTranscribe: boolean;
isTranscribing: boolean;
@@ -755,31 +870,56 @@ export function TranscriptPane({
// on `cueWordId`, so a frame that doesn't cross a word boundary re-renders nothing
// but this component's own (cheap) lookup.
const currentTimeSec = useProjectStore((s) => s.currentTimeSec);
+
+ // Stored in the document, through the caption settings (issue #560). It was local
+ // state until the captions had to follow it — and the captions are burnt into the
+ // exported file by a path that never runs React, so a lane living here would caption
+ // the preview from one lane and the export from the other.
+ //
+ // `resolveCaptionLane` carries the fallback, in the pure layer for the same reason:
+ // deleting the last voiceover pill while reading it must not leave the pane, the
+ // preview and the exporter disagreeing about which lane that project has.
+ const { settings: captionSettings, set: setCaptionSettings } = useCaptions();
+ const document = useProjectStore((s) => s.document);
+ // 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]);
+ // The take's placements are fed the cuts AND its own insertions, so a word after a pause
+ const voiceover = useMemo(
+ () => voiceoverPlacements(audioTracks, removed),
+ [audioTracks, removed],
+ );
+ const activeLane = resolveCaptionLane(document, captionSettings);
+ const setLane = useCallback(
+ (captionLane: TranscriptLane) => {
+ void setCaptionSettings({ captionLane });
+ },
+ [setCaptionSettings],
+ );
+ const placements = activeLane === "voiceover" ? voiceover : clips;
+
const sections = useMemo(
- () => buildAggregatedSections(clips, transcripts, assets, trimRanges),
- [clips, 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 hasAnyTranscript = transcripts.length > 0;
+ const laneSwitch =
+ voiceover.length > 0 ? : null;
+ // Asked of the LANE, not the document: a project with a recording transcript and a
+ // freshly imported voiceover has transcripts, and the voiceover lane still has
+ // nothing to show — the empty state is what says so.
+ const hasAnyTranscript = sections.some((section) => section.transcript !== null);
// Only silence is a dead end: every other reason (a retryable failure, no
// engine, nothing attempted) leaves the button worth pressing.
const silentMedia = blocked?.reason === "no-audio";
@@ -790,13 +930,22 @@ export function TranscriptPane({
transcriptionLabel,
);
- if (clips.length === 0 || !hasAnyTranscript) {
+ // 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");
+ const editingHint = ts(
+ insertionsEnabled() ? "transcript.editingHintDev" : "transcript.editingHint",
+ );
+
+ if (placements.length === 0 || !hasAnyTranscript) {
return (
}
- helpText={ts("transcript.help")}
+ helpText={helpText}
+ actions={ }
>
+ {laneSwitch}
- {clips.length === 0
+ {placements.length === 0
? ts("transcript.noClips")
: isTranscribing
? (paneBusyLabel ?? ts("transcript.transcribing"))
@@ -840,29 +989,46 @@ export function TranscriptPane({
}
return (
-
-
- {ts("transcript.title")}
-
-
- {sections.map((section, idx) => (
-
- ))}
-
-
+
}
+ 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) => (
+
+ ))}
+
);
}
@@ -870,7 +1036,7 @@ export function TranscriptPane({
// range) and a flowing word stream. The stream contains every transcript
// word inside the clip's source range, color-coded by whether the word
// is inside any trimRange. Backspace/Delete adds a new trimRange via
-// onAddTrimRange; hover-bin on a skip run removes it via onRemoveTrimRange.
+// onTrimTimelineSpan; hover-bin on a skip run removes it via onRemoveTrimRanges.
//
// `memo` matters here: this renders one DOM node per transcript word, and its
// parent now re-renders on every playhead tick (~60×/s during playback). The only
@@ -885,8 +1051,11 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
busyLabel,
cueWordId,
onSeek,
- onAddTrimRange,
- onRemoveTrimRange,
+ onTrimTimelineSpan,
+ onRemoveTrimRanges,
+ onSetWordText,
+ onInsertWord,
+ onRemoveWords,
}: {
index: number;
section: ClipSection;
@@ -894,16 +1063,31 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
busyLabel?: string;
cueWordId: string | null;
onSeek: (sec: number) => void;
- onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
- onRemoveTrimRange: (trimId: string) => void;
+ onTrimTimelineSpan: (startSec: number, endSec: number, reason: string) => void;
+ onRemoveTrimRanges: (trimIds: string[]) => void;
+ onSetWordText: (assetId: string, wordId: string, text: string) => void;
+ onInsertWord: (assetId: string, anchorWordId: string, side: InsertSide, text: string) => void;
+ onRemoveWords: (assetId: string, wordIds: string[]) => void;
}) {
const ts = useScopedT("settings");
const { clip, asset, words } = section;
// Memoised: `TranscriptWord` renders once per word, so a fresh object literal here
// would break referential equality for the whole stream on every parent render.
- const trimTarget = useMemo
(
- () => ({ assetId: clip.assetId, clipId: clip.id }),
- [clip.assetId, clip.id],
+ // A cut is authored in RAW seconds, CLAMPED to this placement's own extent.
+ // `wordsInRange` admits a word by OVERLAP and consecutive fragments have touching
+ // source windows, so a word straddling an edge would otherwise produce a span reaching
+ // past this placement — and `ventilateTimelineSpanToTrims` walks every clip a span
+ // touches, so the overspill would cut the head of a neighbouring clip that has nothing
+ // to do with the word the user deleted.
+ const toRawSpan = useCallback(
+ (startSec: number, endSec: number): [number, number] => {
+ 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), lo), hi);
+ return [clamp(startSec), clamp(endSec)];
+ },
+ [clip],
);
const filename = asset?.label ?? clip.assetId;
const sourceRangeLabel =
@@ -968,25 +1152,38 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
// Only skip words that are currently kept (don't double-skip).
const keptRange = rangeWords.filter((w) => w.kept);
if (keptRange.length === 0) return;
+ // An inserted word has no audio to cut, so Backspace deletes it outright. Only a
+ // range made entirely of inserts takes this path: mixed with spoken words the trim
+ // covers them anyway — they sit inside its span and read as cut, which is what the
+ // keystroke asked for.
+ if (keptRange.every((w) => isInsertedWord(w.word))) {
+ onRemoveWords(
+ clip.assetId,
+ keptRange.map((w) => w.word.id),
+ );
+ return;
+ }
pendingCaretWordIdRef.current = keptRange[0].id;
const startSec = Math.min(...keptRange.map((w) => w.word.startSec));
const endSec = Math.max(...keptRange.map((w) => w.word.endSec));
- onAddTrimRange(
- trimTarget,
- startSec,
- endSec,
+ onTrimTimelineSpan(
+ ...toRawSpan(startSec, endSec),
`Skip ${formatMs(startSec * 1000)}-${formatMs(endSec * 1000)} from ${clip.assetId}.`,
);
},
- [busy, clip.assetId, trimTarget, onAddTrimRange],
+ [busy, clip.assetId, toRawSpan, onTrimTimelineSpan, onRemoveWords],
);
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. Otherwise every row goes at once — a cut ventilated across
+ // a clip boundary is several rows and ONE pill, and dropping half of it would
+ // leave the word still cut with nothing left on screen to say so.
+ if (busy || run.trimIds.length === 0) return;
+ onRemoveTrimRanges(run.trimIds);
},
- [busy, onRemoveTrimRange],
+ [busy, onRemoveTrimRanges],
);
const cutNativeSelection = useCallback(
@@ -1041,38 +1238,95 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
[cutNativeSelection],
);
- const handleBeforeInput = useCallback(
- (event: FormEvent) => {
- const inputEvent = event.nativeEvent as InputEvent;
- if (inputEvent.inputType.startsWith("delete")) {
+ // The word an insert will sit beside, and what has been typed into it so far. Held on
+ // the block rather than the word, because the field belongs BETWEEN two words: the id is
+ // only how it finds its place in the stream.
+ const [insertion, setInsertion] = useState<{
+ clipWordId: string;
+ side: InsertSide;
+ draft: string;
+ } | null>(null);
+ const insertionAbandonedRef = useRef(false);
+
+ const openInsertion = useCallback(
+ (seed: string) => {
+ // 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();
+ if (!editor || !selection) return;
+ if (!editor.contains(selection.anchorNode)) return;
+ const caret = findInsertionAnchor(editor, selection.anchorNode, selection.anchorOffset);
+ if (!caret) return;
+ const anchor = resolveInsertionAnchor(words, caret.clipWordId, caret.side);
+ if (!anchor) return;
+ setInsertion({ ...anchor, draft: seed });
+ },
+ [busy, words],
+ );
+
+ const commitInsertion = useCallback(() => {
+ const pending = insertion;
+ setInsertion(null);
+ if (!pending) return;
+ const text = pending.draft.trim();
+ if (!text) return;
+ const anchor = words.find((w) => w.id === pending.clipWordId);
+ if (!anchor) return;
+ onInsertWord(clip.assetId, anchor.word.id, pending.side, text);
+ }, [insertion, words, onInsertWord, clip.assetId]);
+
+ // Attached to the DOM, not through React's `onBeforeInput`.
+ //
+ // React 18 does not build that synthetic event from the native `beforeinput`: it
+ // derives it from the legacy `textInput`, whose event object is a `TextEvent` and
+ // carries no `inputType` at all. So the guard that was supposed to keep typed text out
+ // of the projection threw `Cannot read properties of undefined (reading 'startsWith')`
+ // on every character, never reached its own `preventDefault`, and let the character
+ // land in the contentEditable — the exact desynchronisation between the DOM and `words`
+ // it was written to prevent. Verified in the browser before this was moved.
+ //
+ // The native event is a real `InputEvent`, its `inputType` is the thing both branches
+ // switch on, and preventing it actually stops the browser.
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const onBeforeInput = (event: InputEvent) => {
+ // The word editor and the insertion field are ` `s INSIDE this element, so
+ // their own typing bubbles here natively — React's `stopPropagation` only ever
+ // stopped the synthetic tree. Their text is theirs.
+ if (event.target instanceof HTMLInputElement) return;
+ if (event.inputType.startsWith("delete")) {
event.preventDefault();
- cutNativeSelection(
- inputEvent.inputType === "deleteContentForward" ? "forward" : "backward",
- );
+ cutNativeSelection(event.inputType === "deleteContentForward" ? "forward" : "backward");
return;
}
- // Inserts are blocked to keep the projection stable: every run of text
- // here maps back to a `transcript.words` entry by id, and free text has
- // no id to land on. Deletion is fine because it goes through
- // `cutNativeSelection`, which resolves the selection to word ids first.
- //
- // This used to defer to `SourceTranscriptModal`, deleted with the v3
- // media pane — it never got past read-only, so it was never the answer
- // it was cited as. Editing a word's TEXT therefore has no in-app path
- // today. Adding one means a word-level mutation alongside
- // `skipWordRange`, reached from here; lifting this guard on its own
- // would only desynchronise the DOM from `words`.
- if (inputEvent.inputType === "insertText" || inputEvent.inputType === "insertFromPaste") {
+ // Free text never lands in the block itself: every run of text here maps back to a
+ // `transcript.words` entry by id, and typed characters have no id. What they open
+ // instead is a field beside the word the caret was on, whose commit creates a real
+ // word to hold them. So the gesture is the document one — put the caret somewhere
+ // and type — without the DOM ever getting ahead of `words`.
+ if (event.inputType.startsWith("insert")) {
event.preventDefault();
+ openInsertion(event.data ?? "");
}
+ };
+ editor.addEventListener("beforeinput", onBeforeInput);
+ return () => editor.removeEventListener("beforeinput", onBeforeInput);
+ }, [cutNativeSelection, openInsertion]);
+
+ const handlePaste = useCallback(
+ (event: ReactClipboardEvent) => {
+ // Handled here rather than through `insertFromPaste`: preventing the paste stops
+ // that beforeinput from ever firing, and this is the only place the clipboard text
+ // is still readable.
+ event.preventDefault();
+ openInsertion(event.clipboardData.getData("text/plain"));
},
- [cutNativeSelection],
+ [openInsertion],
);
- const handlePaste = useCallback((event: ReactClipboardEvent) => {
- event.preventDefault();
- }, []);
-
const handlePointerUp = useCallback(
(event: ReactPointerEvent) => {
if (event.button !== 0) return;
@@ -1212,11 +1466,13 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
spellCheck={false}
aria-label={ts("transcript.editorAria", { filename })}
aria-multiline="true"
- onBeforeInput={handleBeforeInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onPointerUp={handlePointerUp}
style={{
+ // Inline so a split clip reads as one sentence rather than one line per
+ // piece. The block that fronts a run still owns the header above it.
+ display: "inline",
padding: "4px 4px",
font: "400 13px/1.65 var(--font-body)",
color: "var(--fg)",
@@ -1234,16 +1490,39 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
// scrollbar that breaks the cue auto-scroll UX.
}}
>
- {words.map((cw) => (
-
- ))}
+ {words.map((cw) => {
+ const field =
+ insertion?.clipWordId === cw.id ? (
+ setInsertion({ ...insertion, draft })}
+ onCommit={commitInsertion}
+ onCancel={() => {
+ insertionAbandonedRef.current = true;
+ setInsertion(null);
+ }}
+ abandonedRef={insertionAbandonedRef}
+ />
+ ) : null;
+ return (
+
+ {insertion?.side === "before" ? field : null}
+
+ {insertion?.side === "after" ? field : null}
+
+ );
+ })}
)}
@@ -1267,24 +1546,74 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
// the two words whose `isCue` actually flipped re-render.
//
// This holds because every other prop is referentially stable across a
-// playhead tick: `cw` comes from the memoised `sections`, `target` from a
+// playhead tick: `cw` comes from the memoised `sections`, `assetId` from a
// `useMemo`, and both callbacks from `useCallback`s that do not depend on time.
const TranscriptWord = memo(function TranscriptWord({
cw,
isCue,
- target,
+ editable,
+ assetId,
+ toRawSpan,
onRestore,
- onAddTrimRange,
+ onTrimTimelineSpan,
+ onSetWordText,
+ onRemoveWords,
}: {
cw: ClipWord;
isCue: boolean;
- target: TrimTarget;
+ /** False while this clip's transcript is being regenerated — the words on screen are
+ * about to be replaced, so an edit typed into them would be thrown away. */
+ editable: boolean;
+ assetId: string;
+ /** Clamped source→raw for this word's placement — see `toRawSpan` above. */
+ toRawSpan: (startSec: number, endSec: number) => [number, number];
onRestore: (run: TrimRun) => void;
- onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
+ onTrimTimelineSpan: (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);
+ // The text being typed, or null when the word is not under edit.
+ const [draft, setDraft] = useState(null);
+ // Escape unmounts the field, and an abandoned field's blur must not commit what the
+ // user just walked away from.
+ const abandonedRef = useRef(false);
const removed = !cw.kept;
+ // `originalText` is only ever written by a user edit (see `document/transcript.ts`), so
+ // it is what tells a corrected word from a transcribed one.
+ const original = cw.word.originalText;
+ const corrected = original !== undefined;
+ const blanked = corrected && cw.word.text.trim().length === 0;
+
+ 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]);
+
+ const commitDraft = useCallback(() => {
+ const next = (draft ?? "").trim();
+ setDraft(null);
+ if (next === cw.word.text) return;
+ onSetWordText(assetId, cw.word.id, next);
+ }, [draft, cw.word.text, cw.word.id, onSetWordText, assetId]);
+
+ const inserted = isInsertedWord(cw.word);
+
+ const removeInserted = useCallback(() => {
+ onRemoveWords(assetId, [cw.word.id]);
+ }, [onRemoveWords, assetId, cw.word.id]);
+
+ const revert = useCallback(() => {
+ if (original === undefined) return;
+ // Writing the original back through the same path is what clears the provenance
+ // pair — there is no separate "unedit" operation that could fall out of step.
+ onSetWordText(assetId, cw.word.id, original);
+ }, [original, cw.word.id, onSetWordText, assetId]);
if (isSilenceWord(cw.word)) {
const durationSec = cw.word.endSec - cw.word.startSec;
@@ -1302,7 +1631,7 @@ const TranscriptWord = memo(function TranscriptWord({
onClick={(e) => {
e.stopPropagation();
onRestore({
- trimId: cw.trimId ?? "",
+ trimIds: cw.trimIds,
assetId: "",
startWordIndex: 0,
endWordIndex: 0,
@@ -1337,10 +1666,8 @@ const TranscriptWord = memo(function TranscriptWord({
aria-label={ts("transcript.trimSilence", { duration })}
onClick={(e) => {
e.stopPropagation();
- onAddTrimRange(
- target,
- cw.word.startSec,
- cw.word.endSec,
+ onTrimTimelineSpan(
+ ...toRawSpan(cw.word.startSec, cw.word.endSec),
`Skip silence ${formatMs(cw.word.startSec * 1000)}-${formatMs(cw.word.endSec * 1000)}.`,
);
}}
@@ -1362,31 +1689,197 @@ const TranscriptWord = memo(function TranscriptWord({
);
}
+ // The inline editor. `contentEditable={false}` keeps the browser from treating it as
+ // part of the enclosing editable block, and every event it raises is stopped here rather
+ // than in the block handlers: Backspace inside the field has to type, not cut, and a
+ // click in it must not seek.
+ if (draft !== null) {
+ return (
+ setDraft(event.target.value)}
+ onFocus={(event) => event.currentTarget.select()}
+ onBlur={() => {
+ if (abandonedRef.current) {
+ abandonedRef.current = false;
+ return;
+ }
+ commitDraft();
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ commitDraft();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ abandonedRef.current = true;
+ setDraft(null);
+ }
+ }}
+ onPaste={(event) => event.stopPropagation()}
+ onPointerUp={(event) => event.stopPropagation()}
+ style={{
+ display: "inline",
+ // `ch` is the digit width, not the real glyph width, so this only
+ // approximates the word it replaces — the slack keeps it from clipping.
+ width: `${Math.max(draft.length, 3) + 2}ch`,
+ margin: 0,
+ padding: "0 2px",
+ border: 0,
+ borderBottom: "2px solid var(--accent)",
+ borderRadius: 0,
+ background: "var(--accent-soft)",
+ color: "var(--fg)",
+ font: "inherit",
+ outline: "none",
+ }}
+ />
+ );
+ }
+
+ // 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.
+ if (blanked) {
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ onDoubleClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ startEditing();
+ }}
+ >
+
+ {ts("transcript.blankedWord")}
+
+ {hover ? (
+
+ ) : null}{" "}
+
+ );
+ }
+
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
+ onDoubleClick={(e) => {
+ // Without this the browser selects the word inside the enclosing
+ // contentEditable; the field about to replace it does its own selecting.
+ e.preventDefault();
+ e.stopPropagation();
+ startEditing();
+ }}
>
{/* no filler chip. axcut renders every word the same way;
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,
@@ -1423,10 +1916,138 @@ const TranscriptWord = memo(function TranscriptWord({
) : null}
+ {/* A cut word's bin already restores it — showing the revert beside it would put
+ two undos for two different things one pixel apart. */}
+ {!removed && corrected && hover ? (
+
+ ) : null}
);
});
+/** Hover affordance on a corrected word: put the transcriber's own text back. Mirrors the
+ * 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 (
+ {
+ e.stopPropagation();
+ onPress();
+ }}
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 18,
+ height: 18,
+ marginLeft: 4,
+ padding: 0,
+ border: 0,
+ borderRadius: 4,
+ 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
@@ -1451,7 +2072,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));
@@ -1521,6 +2142,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;
@@ -2332,6 +3026,166 @@ export function AudioPane() {
);
}
+type TimelineApi = ReturnType;
+
+// Per-track controls for the selected imported audio track (issue #350). Shown by
+// the inspector in place of the facet when an audio track is selected (see
+// FloatingInspector). The header is the generic "Audio track"; the body leads
+// with the file name, then the volume (a local live value during the drag,
+// committed as one undo step on release), then a delete button styled like the
+// region panes' (position and mute are edited on the lane itself).
+// Longest fade the inspector offers. Past a few seconds a fade stops reading as
+// a fade and starts reading as a level change, and the track's own span caps it
+// anyway (`resolveFadeSecs` reduces one that does not fit).
+const FADE_MAX_MS = 5000;
+
+export function AudioTrackPane({ tl }: { tl: TimelineApi }) {
+ const ts = useScopedT("settings");
+ const trackId = tl.selectedAudioTrackId;
+ // The document stores one clip-anchored fragment per clip the track covers;
+ // the inspector edits the user-visible TRACK, so collapse first. Editing a
+ // single fragment would let the halves of a split take disagree.
+ const track = trackId
+ ? collapseTracksToPills(tl.audioTracks.filter((t) => trackGroupId(t) === trackId))[0]
+ : undefined;
+ const asset = track ? tl.assets.find((a) => a.id === track.assetId) : undefined;
+ // Live-drag values; null means "show the committed value".
+ const [liveGain, setLiveGain] = useState(null);
+ const [liveFadeIn, setLiveFadeIn] = useState(null);
+ const [liveFadeOut, setLiveFadeOut] = useState(null);
+ // Drop the live value when the selected track changes: a drag released outside
+ // the input never fires onCommit, so without this an uncommitted -10 dB from
+ // track A would show as track B's gain the moment B is selected.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: trackId is the trigger, not a read — the body only resets the live value.
+ useEffect(() => {
+ setLiveGain(null);
+ setLiveFadeIn(null);
+ setLiveFadeOut(null);
+ }, [trackId]);
+ if (!track) return null;
+ const fileName = track.label || asset?.label || asset?.originalPath?.split(/[\\/]/).pop() || "";
+
+ // Match the region panes' danger-outlined delete button (see SelectionPane).
+ const deleteBtnStyle: CSSProperties = {
+ display: "flex",
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 7,
+ padding: "9px 14px",
+ borderRadius: 10,
+ border: "1px solid var(--danger)",
+ background: "var(--danger-soft)",
+ color: "var(--danger)",
+ font: "600 13px var(--font-display)",
+ cursor: "pointer",
+ };
+
+ return (
+ }
+ helpText={ts("audioTrack.help")}
+ >
+
+ {fileName}
+
+
+ setLiveGain(value)}
+ onCommit={() => {
+ if (liveGain !== null) void tl.setAudioTrackGain(track.id, liveGain);
+ setLiveGain(null);
+ }}
+ />
+ {
+ if (liveFadeIn !== null) void tl.updateAudioTrack(track.id, { fadeInMs: liveFadeIn });
+ setLiveFadeIn(null);
+ }}
+ />
+ {
+ if (liveFadeOut !== null)
+ void tl.updateAudioTrack(track.id, { fadeOutMs: liveFadeOut });
+ setLiveFadeOut(null);
+ }}
+ />
+
+
+ {ts("audioTrack.mute")}
+ void tl.updateAudioTrack(track.id, { muted: v })}
+ />
+
+
+ {ts("audioTrack.loop")}
+ void tl.setAudioTrackLoop(track.id, v)}
+ />
+
+ {
+ setLiveGain(null);
+ void tl.setAudioTrackGain(track.id, 0);
+ }}
+ >
+ {ts("audio.reset")}
+
+ void tl.removeAudioTrack(track.id)}
+ style={deleteBtnStyle}
+ >
+
+ {ts("audioTrack.remove")}
+
+
+ );
+}
+
// ─── Cursor ───────────────────────────────────────────────────────
function safeAssetUrl(relativePath: string): string {
diff --git a/src/components/ai-edition/TranscriptPane.captions.test.tsx b/src/components/ai-edition/TranscriptPane.captions.test.tsx
new file mode 100644
index 000000000..96aae2c17
--- /dev/null
+++ b/src/components/ai-edition/TranscriptPane.captions.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+// Issue #560: captions used to be their own inspector tab, next to the transcript
+// they are a view OF. Two tabs meant two entry points to the same background pass,
+// and the caption one was the only one many people ever found.
+//
+// So the tab is gone and its pane hangs off the transcript tab instead. What that
+// costs is reachability, and that is exactly what these assertions pin: the control
+// is present in BOTH of the transcript pane's states — including the empty one,
+// where a user with no transcript would otherwise have no way back to caption
+// settings at all — and it really does open the pane, not a rebuilt stub of it.
+
+import "@testing-library/jest-dom";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import type { AxcutAsset, AxcutClip, AxcutTranscript } from "@/lib/ai-edition/schema";
+import { TranscriptPane } from "./RightPanes";
+
+vi.mock("@/native", () => ({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+const ASSET: AxcutAsset = {
+ id: "asset_1",
+ kind: "video",
+ label: "recording.mp4",
+ originalPath: "/rec.mp4",
+ durationSec: 12,
+ cameraTrack: null,
+};
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 12,
+ timelineStartSec: 0,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+const TRANSCRIPT: AxcutTranscript = {
+ assetId: "asset_1",
+ language: "en",
+ words: [
+ { id: "w_1", text: "hello", startSec: 0.2, endSec: 0.6 },
+ { id: "w_2", text: "there", startSec: 0.6, endSec: 1.1 },
+ ],
+ segments: [],
+} as unknown as AxcutTranscript;
+
+function mount(transcripts: AxcutTranscript[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("caption settings on the transcript tab", () => {
+ it("is reachable before any transcript exists", () => {
+ mount([]);
+ expect(screen.getByRole("button", { name: "Captions" })).toBeInTheDocument();
+ // The single transcription gate stays where it was: on this pane, not
+ // hidden one popover deep.
+ expect(screen.getByRole("button", { name: "Transcribe now" })).toBeInTheDocument();
+ });
+
+ it("is reachable once there is a transcript to caption", () => {
+ mount([TRANSCRIPT]);
+ expect(screen.getByRole("button", { name: "Captions" })).toBeInTheDocument();
+ });
+
+ it("opens the real caption settings rather than a stub", async () => {
+ const user = userEvent.setup();
+ mount([TRANSCRIPT]);
+ await user.click(screen.getByRole("button", { name: "Captions" }));
+ // A control that only the actual CaptionsPane renders — proof the pane was
+ // mounted whole rather than reimplemented into the popover.
+ expect(await screen.findByText("Show captions")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ai-edition/TranscriptPane.gating.test.tsx b/src/components/ai-edition/TranscriptPane.gating.test.tsx
index e0a424eba..787549f2e 100644
--- a/src/components/ai-edition/TranscriptPane.gating.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.gating.test.tsx
@@ -49,13 +49,17 @@ function renderPane(
('[role="textbox"]');
if (!editor) throw new Error("transcript editor not rendered");
- return { ...view, editor, onAddTrimRange };
+ return { ...view, editor, onTrimTimelineSpan };
}
/**
@@ -109,10 +113,11 @@ function caretBeforeWordAt(editor: HTMLElement, index: number) {
selection?.addRange(range);
}
-/** The words the pane cut, as `[startSec, endSec]` — what `onAddTrimRange` was asked for. */
-function cutRange(onAddTrimRange: ReturnType): [number, number] | null {
- const call = onAddTrimRange.mock.calls.at(-1);
- return call ? [call[1] as number, call[2] as number] : null;
+/** The RAW span the pane cut — what `onTrimTimelineSpan` was asked for. There is no
+ * leading target any more: a cut names a moment of the programme, not an owner. */
+function cutRange(onTrimTimelineSpan: ReturnType): [number, number] | null {
+ const call = onTrimTimelineSpan.mock.calls.at(-1);
+ return call ? [call[0] as number, call[1] as number] : null;
}
beforeEach(() => {
@@ -127,29 +132,29 @@ afterEach(() => {
describe("keyboard cut with the caret between words", () => {
it("Backspace cuts the word before the caret", () => {
// The ordinary case, and the one that already worked: nothing trimmed yet.
- const { editor, onAddTrimRange } = renderPane([]);
+ const { editor, onTrimTimelineSpan } = renderPane([]);
caretBeforeWordAt(editor, 3); // before "quatre"
fireEvent.keyDown(editor, { key: "Backspace" });
- expect(cutRange(onAddTrimRange)).toEqual([2, 3]); // "trois"
+ expect(cutRange(onTrimTimelineSpan)).toEqual([2, 3]); // "trois"
});
it("keeps cutting while ANOTHER asset is being transcribed", () => {
// The background pass runs on its own now, so a run on some other media must
// not quietly turn this block into an editor that ignores Backspace — the
// read-only state is scoped to the asset whose transcript is being rewritten.
- const { editor, onAddTrimRange } = renderPane([], vi.fn(), ["asset_other"]);
+ const { editor, onTrimTimelineSpan } = renderPane([], vi.fn(), ["asset_other"]);
caretBeforeWordAt(editor, 3);
fireEvent.keyDown(editor, { key: "Backspace" });
- expect(cutRange(onAddTrimRange)).toEqual([2, 3]);
+ expect(cutRange(onTrimTimelineSpan)).toEqual([2, 3]);
});
it("stops cutting, visibly, while THIS asset is being transcribed", () => {
// Its transcript is about to be replaced, so the block is read-only — and it
// says so, instead of swallowing the keystroke in silence.
- const { editor, onAddTrimRange, getByText } = renderPane([], vi.fn(), ["asset_1"]);
+ const { editor, onTrimTimelineSpan, getByText } = renderPane([], vi.fn(), ["asset_1"]);
caretBeforeWordAt(editor, 3);
fireEvent.keyDown(editor, { key: "Backspace" });
- expect(cutRange(onAddTrimRange)).toBeNull();
+ expect(cutRange(onTrimTimelineSpan)).toBeNull();
expect(editor).toHaveAttribute("aria-busy", "true");
expect(getByText("Transcribing…")).toBeInTheDocument();
});
@@ -159,36 +164,36 @@ describe("keyboard cut with the caret between words", () => {
// immediately before the caret has nothing left to cut. The keystroke used to
// resolve to it anyway, `skipWordRange` dropped it as not-kept, and the user got
// silence — they had to click elsewhere to carry on.
- const { editor, onAddTrimRange } = renderPane([W2_TRIMMED]);
+ const { editor, onTrimTimelineSpan } = renderPane([W2_TRIMMED]);
caretBeforeWordAt(editor, 2); // before "trois", i.e. right after the trimmed "deux"
fireEvent.keyDown(editor, { key: "Backspace" });
- expect(cutRange(onAddTrimRange)).toEqual([0, 1]); // "un" — the nearest word still there
+ expect(cutRange(onTrimTimelineSpan)).toEqual([0, 1]); // "un" — the nearest word still there
});
it("Delete skips over an already-trimmed word instead of doing nothing", () => {
// The mirror case going forward. It had no guard at all: the candidate walk simply
// returned the first word it met, trimmed or not.
- const { editor, onAddTrimRange } = renderPane([W2_TRIMMED]);
+ const { editor, onTrimTimelineSpan } = renderPane([W2_TRIMMED]);
caretBeforeWordAt(editor, 1); // before the trimmed "deux"
fireEvent.keyDown(editor, { key: "Delete" });
- expect(cutRange(onAddTrimRange)).toEqual([2, 3]); // "trois"
+ expect(cutRange(onTrimTimelineSpan)).toEqual([2, 3]); // "trois"
});
it("does nothing when every word in that direction is already trimmed", () => {
// Not a regression — there is genuinely nothing left to cut, so no document write.
- const { editor, onAddTrimRange } = renderPane([
+ const { editor, onTrimTimelineSpan } = renderPane([
{ ...W2_TRIMMED, id: "t_head", startSec: 0, endSec: 2 },
]);
caretBeforeWordAt(editor, 2); // before "trois"; "un" and "deux" are both gone
fireEvent.keyDown(editor, { key: "Backspace" });
- expect(onAddTrimRange).not.toHaveBeenCalled();
+ expect(onTrimTimelineSpan).not.toHaveBeenCalled();
});
it("cuts nothing when the caret is at the very start and Backspace is pressed", () => {
- const { editor, onAddTrimRange } = renderPane([]);
+ const { editor, onTrimTimelineSpan } = renderPane([]);
caretBeforeWordAt(editor, 0);
fireEvent.keyDown(editor, { key: "Backspace" });
- expect(onAddTrimRange).not.toHaveBeenCalled();
+ expect(onTrimTimelineSpan).not.toHaveBeenCalled();
});
// The story the whole thing exists for, in the shape the user meets it: the tests above
@@ -207,12 +212,13 @@ describe("keyboard cut with the caret between words", () => {
+ onTrimTimelineSpan={(startSec: number, endSec: number) =>
setTrims((prev) => [
...prev,
{
@@ -226,7 +232,10 @@ describe("keyboard cut with the caret between words", () => {
},
])
}
- onRemoveTrimRange={vi.fn()}
+ onRemoveTrimRanges={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.lanes.test.tsx b/src/components/ai-edition/TranscriptPane.lanes.test.tsx
new file mode 100644
index 000000000..e84868f3e
--- /dev/null
+++ b/src/components/ai-edition/TranscriptPane.lanes.test.tsx
@@ -0,0 +1,187 @@
+// @vitest-environment jsdom
+// Issue #560: the transcript tab reads ONE lane, and which one is the user's
+// choice. These pin the two halves of that: the switch only exists when there is
+// somewhere to switch to, and choosing actually changes what the tab is reading —
+// not what it is showing of the same thing.
+
+import "@testing-library/jest-dom";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import type {
+ AxcutAsset,
+ AxcutAudioTrack,
+ AxcutClip,
+ AxcutTranscript,
+} from "@/lib/ai-edition/schema";
+import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
+import { TranscriptPane } from "./RightPanes";
+
+vi.mock("@/native", () => ({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+const ASSETS: AxcutAsset[] = [
+ {
+ id: "asset_rec",
+ kind: "video",
+ label: "recording.mp4",
+ originalPath: "/rec.mp4",
+ durationSec: 12,
+ cameraTrack: null,
+ },
+ {
+ id: "asset_vo",
+ kind: "audio",
+ label: "voiceover.mp3",
+ originalPath: "/vo.mp3",
+ durationSec: 30,
+ cameraTrack: null,
+ },
+];
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "clip_1",
+ assetId: "asset_rec",
+ sourceStartSec: 0,
+ sourceEndSec: 12,
+ timelineStartSec: 0,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+function words(...texts: string[]) {
+ return texts.map((text, i) => ({
+ id: `w${i}`,
+ segmentId: "s",
+ text,
+ startSec: i * 0.5,
+ endSec: i * 0.5 + 0.4,
+ }));
+}
+
+const TRANSCRIPTS = [
+ { assetId: "asset_rec", language: "en", words: words("filmed", "words"), segments: [] },
+ { assetId: "asset_vo", language: "en", words: words("narrated", "words"), segments: [] },
+] as unknown as AxcutTranscript[];
+
+const VOICEOVER: AxcutAudioTrack = {
+ id: "track_1",
+ startMs: 0,
+ endMs: 4000,
+ clipId: "clip_1",
+ sourceStartSec: 0,
+ sourceEndSec: 4,
+ assetId: "asset_vo",
+ kind: "voiceover",
+ durationSec: 30,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+} as unknown as AxcutAudioTrack;
+
+function mount(audioTracks: AxcutAudioTrack[]) {
+ // The lane lives in the DOCUMENT now (issue #560), so the switch needs one to write
+ // to — it is no longer a piece of component state that answers on its own.
+ useProjectStore.setState({
+ projectId: "proj_1",
+ document: {
+ schemaVersion: 7,
+ project: {
+ id: "proj_1",
+ title: "T",
+ createdAt: "2026-06-25T10:00:00.000Z",
+ updatedAt: "2026-06-25T10:00:00.000Z",
+ primaryAssetId: "asset_rec",
+ },
+ assets: ASSETS,
+ transcript: null,
+ transcripts: TRANSCRIPTS,
+ timeline: {
+ clips: CLIPS,
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks,
+ legacyEditor: null,
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ } as any,
+ status: "ready",
+ error: null,
+ dirty: false,
+ });
+ render(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ useProjectStore.getState().clear();
+});
+
+describe("transcript lane switch", () => {
+ it("stays out of the way when there is no voiceover to switch to", () => {
+ mount([]);
+ expect(screen.queryByRole("group", { name: "Read the transcript from" })).toBeNull();
+ expect(screen.getByText("filmed", { exact: false })).toBeInTheDocument();
+ });
+
+ it("appears once a voiceover is on the timeline, reading the recording first", () => {
+ mount([VOICEOVER]);
+ expect(screen.getByRole("group", { name: "Read the transcript from" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Recording" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ expect(screen.getByText("filmed", { exact: false })).toBeInTheDocument();
+ });
+
+ it("reads the voiceover's own words once chosen", async () => {
+ const user = userEvent.setup();
+ mount([VOICEOVER]);
+ await user.click(screen.getByRole("button", { name: "Voice-over" }));
+ expect(await screen.findByText("narrated", { exact: false })).toBeInTheDocument();
+ // The recording is not filtered out of a shared view — it is not what the tab
+ // is reading any more.
+ expect(screen.queryByText("filmed", { exact: false })).toBeNull();
+ });
+
+ it("ignores music, which is never transcribed", () => {
+ mount([{ ...VOICEOVER, kind: "music" } as AxcutAudioTrack]);
+ expect(screen.queryByRole("group", { name: "Read the transcript from" })).toBeNull();
+ });
+});
diff --git a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
index f11151122..4f1aa8c79 100644
--- a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
@@ -71,13 +71,17 @@ function renderPane(onSeek: (sec: number) => void = vi.fn()) {
({ 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: a gap would insert a `[silence]` pill between the words and move the
+// indices these tests address words by.
+const WORDS: AxcutWord[] = [
+ { id: "w1", segmentId: "s", startSec: 0, endSec: 1, text: "Bonjour" },
+ { id: "w2", segmentId: "s", startSec: 1, endSec: 2, text: "Kubernetes" },
+ { id: "w3", segmentId: "s", startSec: 2, endSec: 3, text: "tout" },
+];
+
+function transcript(words: AxcutWord[] = WORDS): AxcutTranscript {
+ return { assetId: "asset_1", language: "fr", segments: [], words };
+}
+
+function renderPane(words?: AxcutWord[], busyAssetIds: string[] = []) {
+ const onSetWordText = vi.fn();
+ const onAddTrimRange = vi.fn();
+ const view = render(
+
+
+ ,
+ );
+ 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;
+ };
+ const field = () => view.container.querySelector("input[data-word-editor]");
+ return { ...view, wordEl, field, onSetWordText, onAddTrimRange };
+}
+
+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();
+ expect(view.field()).toBeNull();
+ fireEvent.doubleClick(view.wordEl("w2"));
+ expect(view.field()).toHaveValue("Kubernetes");
+ });
+
+ it("commits on Enter, addressing the word by its BARE id and the clip's asset", () => {
+ const view = renderPane();
+ fireEvent.doubleClick(view.wordEl("w2"));
+ const field = view.field();
+ if (!field) throw new Error("no editing field");
+ fireEvent.change(field, { target: { value: "Kubernetes 1.31" } });
+ fireEvent.keyDown(field, { key: "Enter" });
+ // `clip_1:w2` is what the DOM node carries; the transcript knows only `w2`.
+ expect(view.onSetWordText).toHaveBeenCalledWith("asset_1", "w2", "Kubernetes 1.31");
+ });
+
+ it("commits on blur, so clicking away does not throw the correction out", () => {
+ const view = renderPane();
+ fireEvent.doubleClick(view.wordEl("w1"));
+ const field = view.field();
+ if (!field) throw new Error("no editing field");
+ fireEvent.change(field, { target: { value: "Bonsoir" } });
+ fireEvent.blur(field);
+ expect(view.onSetWordText).toHaveBeenCalledWith("asset_1", "w1", "Bonsoir");
+ });
+
+ it("abandons on Escape, and a blur afterwards does not resurrect the draft", () => {
+ const view = renderPane();
+ fireEvent.doubleClick(view.wordEl("w1"));
+ const field = view.field();
+ if (!field) throw new Error("no editing field");
+ fireEvent.change(field, { target: { value: "Bonsoir" } });
+ fireEvent.keyDown(field, { key: "Escape" });
+ fireEvent.blur(field);
+ expect(view.onSetWordText).not.toHaveBeenCalled();
+ expect(view.field()).toBeNull();
+ });
+
+ it("writes nothing when the text comes back unchanged", () => {
+ const view = renderPane();
+ fireEvent.doubleClick(view.wordEl("w2"));
+ const field = view.field();
+ if (!field) throw new Error("no editing field");
+ fireEvent.keyDown(field, { key: "Enter" });
+ expect(view.onSetWordText).not.toHaveBeenCalled();
+ });
+
+ // The field lives inside the block's contentEditable, whose Backspace handler cuts the
+ // media. Without the stopPropagation on the field, deleting a letter would trim the clip.
+ it("does not cut the media when Backspace is pressed inside the field", () => {
+ const view = renderPane();
+ fireEvent.doubleClick(view.wordEl("w2"));
+ const field = view.field();
+ if (!field) throw new Error("no editing field");
+ fireEvent.keyDown(field, { key: "Backspace" });
+ expect(view.onAddTrimRange).not.toHaveBeenCalled();
+ });
+
+ it("stays read-only while the transcript is being regenerated", () => {
+ const view = renderPane(undefined, ["asset_1"]);
+ fireEvent.doubleClick(view.wordEl("w2"));
+ expect(view.field()).toBeNull();
+ });
+});
+
+describe("a word already corrected", () => {
+ const CORRECTED: AxcutWord[] = [
+ WORDS[0],
+ { ...WORDS[1], text: "Kubernetes", originalText: "Cuber Nettes", source: "user" },
+ WORDS[2],
+ ];
+
+ it("is marked as corrected and names what the transcriber heard", () => {
+ const view = renderPane(CORRECTED);
+ const el = view.wordEl("w2");
+ expect(el).toHaveAttribute("data-corrected", "true");
+ expect(el.title).toContain("Cuber Nettes");
+ });
+
+ it("offers a revert that writes the transcriber's own text back", () => {
+ const view = renderPane(CORRECTED);
+ fireEvent.mouseEnter(view.wordEl("w2"));
+ const revert = view.wordEl("w2").querySelector("button");
+ if (!revert) throw new Error("no revert control");
+ fireEvent.click(revert);
+ expect(view.onSetWordText).toHaveBeenCalledWith("asset_1", "w2", "Cuber Nettes");
+ });
+
+ it("leaves an untouched word unmarked and without a revert", () => {
+ const view = renderPane(CORRECTED);
+ fireEvent.mouseEnter(view.wordEl("w1"));
+ expect(view.wordEl("w1")).not.toHaveAttribute("data-corrected");
+ expect(view.wordEl("w1").querySelector("button")).toBeNull();
+ });
+});
+
+// Emptying a word is how a junk token gets out of the captions without cutting the audio.
+// Rendered as its own (empty) text it would be a bare space: invisible, un-clickable, and
+// therefore impossible to undo.
+describe("a word the user emptied", () => {
+ const BLANKED: AxcutWord[] = [
+ WORDS[0],
+ { ...WORDS[1], text: "", originalText: "Kubernetes", source: "user" },
+ WORDS[2],
+ ];
+
+ it("keeps a visible, clickable place in the stream", () => {
+ const view = renderPane(BLANKED);
+ const el = view.wordEl("w2");
+ expect(el).toHaveAttribute("data-blanked", "true");
+ expect(el.textContent?.trim()).not.toBe("");
+ });
+
+ it("can be reopened for editing and reverted", () => {
+ const view = renderPane(BLANKED);
+ fireEvent.doubleClick(view.wordEl("w2"));
+ expect(view.field()).toHaveValue("");
+
+ fireEvent.keyDown(view.field() as HTMLInputElement, { key: "Escape" });
+ fireEvent.mouseEnter(view.wordEl("w2"));
+ const revert = view.wordEl("w2").querySelector("button");
+ if (!revert) throw new Error("no revert control");
+ fireEvent.click(revert);
+ expect(view.onSetWordText).toHaveBeenCalledWith("asset_1", "w2", "Kubernetes");
+ });
+});
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..e51a81113
--- /dev/null
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -0,0 +1,293 @@
+// @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("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("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`.
+ 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/components/ai-edition/VirtualPreview.audio.test.ts b/src/components/ai-edition/VirtualPreview.audio.test.ts
index 208bf05a2..624272848 100644
--- a/src/components/ai-edition/VirtualPreview.audio.test.ts
+++ b/src/components/ai-edition/VirtualPreview.audio.test.ts
@@ -1,8 +1,12 @@
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 {
applyPreviewAudioSettings,
type PreviewAudioGraph,
resolveAudioTrackPlayback,
+ resolveTimelineAudioPlayback,
+ timelineAudioFadeAt,
} from "./VirtualPreview";
/** Minimal stand-in: the function only ever touches `gain.gain.value`. */
@@ -80,3 +84,227 @@ describe("applyPreviewAudioSettings", () => {
expect(graph.gain.gain.value).toBeCloseTo(0.5, 4);
});
});
+
+describe("resolveTimelineAudioPlayback", () => {
+ // A 6s track placed 10s into the RAW timeline, playing the source from 2s in.
+ const track: AxcutAudioTrack = {
+ id: "t1",
+ assetId: "a1",
+ kind: "music",
+ startMs: 10_000,
+ endMs: 16_000,
+ durationSec: 20,
+ offsetMs: 2000,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+ };
+ const spanSec = (t: AxcutAudioTrack) => (t.endMs - t.startMs) / 1000;
+
+ // Second arg is the track head projected to output seconds; with no trims it is
+ // just the raw head (10), so these read the same as before the output-space
+ // change — the projection is exercised separately below.
+ it("maps the playhead to a source position offset by the track offset", () => {
+ // 3s into the track's span → 2 (offset) + 3 = 5s of source.
+ expect(resolveTimelineAudioPlayback(13, 10, track, spanSec(track))).toEqual({
+ targetTimeSec: 5,
+ shouldPlay: true,
+ });
+ });
+
+ it("does not play before the track starts, parked at the in-point", () => {
+ expect(resolveTimelineAudioPlayback(9, 10, track, spanSec(track))).toEqual({
+ targetTimeSec: 2,
+ shouldPlay: false,
+ });
+ });
+
+ it("does not play past the end of its span, parked at the out-point", () => {
+ expect(resolveTimelineAudioPlayback(16, 10, track, spanSec(track))).toEqual({
+ targetTimeSec: 8,
+ shouldPlay: false,
+ });
+ });
+
+ it("goes silent when the file runs out before the span does", () => {
+ // A 5s file under a 10s span: at 6s in there is no source left, and the
+ // element holds at the end rather than restarting.
+ const short = { ...track, endMs: 20_000, offsetMs: 0, durationSec: 5 };
+ expect(resolveTimelineAudioPlayback(14, 10, short, spanSec(short))).toEqual({
+ targetTimeSec: 4,
+ shouldPlay: true,
+ });
+ expect(resolveTimelineAudioPlayback(16, 10, short, spanSec(short)).shouldPlay).toBe(false);
+ });
+
+ it("folds a looping track back into its window, in phase with the export", () => {
+ // 4s of source (offset 0, 4s file) under a 10s span.
+ const looped = { ...track, endMs: 20_000, offsetMs: 0, durationSec: 4, loop: true };
+ const span = spanSec(looped);
+ expect(resolveTimelineAudioPlayback(13, 10, looped, span).targetTimeSec).toBeCloseTo(3, 6);
+ // 5s in is 1s into the second repeat — the export's second mix entry agrees.
+ expect(resolveTimelineAudioPlayback(15, 10, looped, span).targetTimeSec).toBeCloseTo(1, 6);
+ expect(resolveTimelineAudioPlayback(15, 10, looped, span).shouldPlay).toBe(true);
+ // Past the span it stops, however much file is left.
+ expect(resolveTimelineAudioPlayback(21, 10, looped, span).shouldPlay).toBe(false);
+ });
+
+ // The regression: an interior trim must NOT skip the track's own content, so the
+ // preview stays byte-for-byte with `audio::mix_external_tracks`, which overlays the
+ // decoded window contiguously. Same scenario as Etienne's review and the projection's
+ // own test: a 10s clip with raw 2..4 cut, background track at raw head 0 spanning 0..10.
+ it("plays contiguously across an interior trim, matching the export", () => {
+ const clip: AxcutClip = {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ };
+ const trim: AxcutTrimRange = {
+ id: "trim_1",
+ assetId: "asset_1",
+ startSec: 2,
+ endSec: 4,
+ origin: "user",
+ reason: "",
+ };
+ const bgm: AxcutAudioTrack = {
+ ...track,
+ id: "bgm",
+ assetId: "a2",
+ startMs: 0,
+ endMs: 10_000,
+ durationSec: 10,
+ offsetMs: 0,
+ };
+ const project = (rawSec: number) => projectRawTimelineSecToPlayback([clip], [trim], rawSec, []);
+ const outputStart = project(bgm.startMs / 1000); // 0
+
+ // Raw playhead 5 sits 1s past the 2s cut → output 3. The track is a contiguous
+ // block, so it must be at source 3 — NOT source 5, which the old raw-space
+ // `local` produced (the 2s desync).
+ expect(resolveTimelineAudioPlayback(project(5), outputStart, bgm, spanSec(bgm))).toEqual({
+ targetTimeSec: 3,
+ shouldPlay: true,
+ });
+ // Just before the cut is unaffected: raw 1 → output 1 → source 1.
+ expect(
+ resolveTimelineAudioPlayback(project(1), outputStart, bgm, spanSec(bgm)).targetTimeSec,
+ ).toBeCloseTo(1, 6);
+ });
+});
+
+describe("resolveTimelineAudioPlayback under a trim", () => {
+ const clip: AxcutClip = {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ };
+ const trim: AxcutTrimRange = {
+ id: "trim_1",
+ assetId: "asset_1",
+ startSec: 4,
+ endSec: 8,
+ origin: "user",
+ reason: "",
+ };
+ const project = (rawSec: number) => projectRawTimelineSecToPlayback([clip], [trim], rawSec, []);
+
+ const buried: AxcutAudioTrack = {
+ id: "buried",
+ assetId: "a1",
+ kind: "music",
+ startMs: 5000,
+ endMs: 7000,
+ durationSec: 30,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+ };
+
+ it("never plays a track buried inside the trim", () => {
+ // Both ends project onto the cut, so the track's OUTPUT span is zero. Read
+ // off the raw span instead it stayed 2s long and played at the boundary,
+ // with nothing on screen to explain the sound.
+ const outStart = project(buried.startMs / 1000);
+ const outSpan = project(buried.endMs / 1000) - outStart;
+ expect(outSpan).toBeCloseTo(0, 6);
+ for (const raw of [3, 5, 6, 9, 12]) {
+ expect(resolveTimelineAudioPlayback(project(raw), outStart, buried, outSpan).shouldPlay).toBe(
+ false,
+ );
+ }
+ });
+
+ it("plays a track that merely crosses the trim, for the length that survives", () => {
+ const crossing = { ...buried, id: "crossing", startMs: 2000, endMs: 12_000 };
+ const outStart = project(crossing.startMs / 1000);
+ const outSpan = project(crossing.endMs / 1000) - outStart;
+ // Raw 2..12 with raw 4..8 cut leaves 6s of programme.
+ expect(outSpan).toBeCloseTo(6, 6);
+ expect(resolveTimelineAudioPlayback(project(3), outStart, crossing, outSpan).shouldPlay).toBe(
+ true,
+ );
+ // Just past the end of what survives.
+ expect(
+ resolveTimelineAudioPlayback(outStart + 6.1, outStart, crossing, outSpan).shouldPlay,
+ ).toBe(false);
+ });
+});
+
+describe("timelineAudioFadeAt", () => {
+ const track: AxcutAudioTrack = {
+ id: "t1",
+ assetId: "a1",
+ kind: "music",
+ startMs: 0,
+ endMs: 10_000,
+ durationSec: 20,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 1000,
+ fadeOutMs: 2000,
+ muted: false,
+ label: "",
+ origin: "user",
+ };
+
+ it("ramps in and out over the track's own edges", () => {
+ expect(timelineAudioFadeAt(track, 0, 10)).toBe(0);
+ expect(timelineAudioFadeAt(track, 0.5, 10)).toBeCloseTo(0.5, 6);
+ expect(timelineAudioFadeAt(track, 5, 10)).toBe(1);
+ expect(timelineAudioFadeAt(track, 9, 10)).toBeCloseTo(0.5, 6);
+ expect(timelineAudioFadeAt(track, 10, 10)).toBe(0);
+ });
+
+ it("is silent when muted", () => {
+ expect(timelineAudioFadeAt({ ...track, muted: true }, 5, 10)).toBe(0);
+ });
+
+ it("still reaches full volume when a fade is longer than the span", () => {
+ // Unreduced, the ramp never completes and the track plays near-silent.
+ const long = { ...track, fadeInMs: 20_000, fadeOutMs: 0 };
+ expect(timelineAudioFadeAt(long, 2, 2)).toBe(1);
+ });
+});
diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx
index f0389fe2a..f4ffe88a0 100644
--- a/src/components/ai-edition/VirtualPreview.playback.test.tsx
+++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx
@@ -207,3 +207,176 @@ describe("VirtualPreview playback across a clip boundary", () => {
expect(video.pauseCalls).toHaveLength(0);
});
});
+
+// Issue #350 — imported audio tracks follow the RAW virtual playhead. The
+// decision math is unit-tested in VirtualPreview.audio.test.ts; here we prove the
+// rAF loop applies it to the mounted element (seek + play/pause).
+function driveAudioEl(el: HTMLAudioElement) {
+ let currentTime = 0;
+ let paused = true;
+ Object.defineProperty(el, "currentTime", {
+ configurable: true,
+ get: () => currentTime,
+ set: (next: number) => {
+ currentTime = next;
+ },
+ });
+ Object.defineProperty(el, "paused", { configurable: true, get: () => paused });
+ Object.defineProperty(el, "duration", { configurable: true, get: () => 10 });
+ el.play = vi.fn(() => {
+ paused = false;
+ return Promise.resolve();
+ });
+ el.pause = vi.fn(() => {
+ paused = true;
+ });
+ return {
+ get currentTime() {
+ return currentTime;
+ },
+ };
+}
+
+describe("VirtualPreview imported audio tracks", () => {
+ // A 2s span at raw 2..4, playing the source from 1s in → source 1..3.
+ const track = {
+ id: "trk",
+ assetId: "aud",
+ kind: "music" as const,
+ startMs: 2000,
+ endMs: 4000,
+ durationSec: 10,
+ offsetMs: 1000,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user" as const,
+ };
+
+ function mountWithAudio() {
+ const sources: VideoSource[] = [{ id: "a1", src: "file:///tmp/a1.mp4", label: "a1" }];
+ const audioSources: VideoSource[] = [{ id: "aud", src: "file:///tmp/vo.mp3", label: "vo" }];
+ const { container } = render(
+ ,
+ );
+ const videoEl = container.querySelector("video");
+ if (!videoEl) throw new Error("no ");
+ const video = driveVideo(videoEl as HTMLVideoElement);
+ act(() => fireEvent.loadedMetadata(videoEl));
+ const audioEl = container.querySelector(
+ '[data-testid="preview-audio-track-trk"]',
+ );
+ if (!audioEl) throw new Error("no track ");
+ return { video, audioEl, audio: driveAudioEl(audioEl) };
+ }
+
+ it("mounts one per track with the asset's URL", () => {
+ const { audioEl } = mountWithAudio();
+ expect(audioEl.getAttribute("src")).toBe("file:///tmp/vo.mp3");
+ });
+
+ it("plays inside the window at the trim-offset source time, pauses outside", () => {
+ const { video, audioEl, audio } = mountWithAudio();
+ video.play();
+ // virtualTime lands one tick after the video seek, and the audio loop reads
+ // last frame's virtualTime, so two ticks settle the decision.
+ video.seekTo(3); // virtual 3 → 1s into the 2..4 span
+ tick();
+ tick();
+ expect(audioEl.play).toHaveBeenCalled();
+ expect(audio.currentTime).toBeCloseTo(2, 1); // trimStart 1 + 1s in
+
+ video.seekTo(5); // virtual 5 → past the window end (4)
+ tick();
+ tick();
+ expect(audioEl.pause).toHaveBeenCalled();
+ });
+});
+
+// Issue #350 — a track boosted past 0 dB must sound boosted in the preview too, not just
+// in the export. `element.volume` caps at 1, so the boost has to ride a WebAudio gain node.
+// jsdom has no WebAudio, so install a minimal fake context and watch the nodes it mints.
+class FakeAudioNode {
+ connect = vi.fn();
+ disconnect = vi.fn();
+}
+class FakeGainNode extends FakeAudioNode {
+ gain = { value: 1 };
+}
+let createdGains: FakeGainNode[] = [];
+class FakeAudioContext {
+ state = "running";
+ destination = new FakeAudioNode();
+ resume = vi.fn(() => Promise.resolve());
+ close = vi.fn(() => Promise.resolve());
+ createMediaElementSource = vi.fn(() => new FakeAudioNode());
+ createGain = vi.fn(() => {
+ const node = new FakeGainNode();
+ createdGains.push(node);
+ return node;
+ });
+}
+
+describe("VirtualPreview imported audio track boost", () => {
+ beforeEach(() => {
+ createdGains = [];
+ vi.stubGlobal("AudioContext", FakeAudioContext);
+ });
+
+ // +6.0206 dB is exactly ×2 in linear gain — a boost `element.volume` (max 1) could never
+ // reach. The graph mints the output gain first, then one gain per track, so the track's
+ // node is the last one created.
+ const boosted = {
+ id: "trk",
+ assetId: "aud",
+ kind: "music" as const,
+ startMs: 2000,
+ endMs: 4000,
+ durationSec: 10,
+ offsetMs: 1000,
+ gainDb: 6.0206,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user" as const,
+ };
+
+ it("drives a per-track gain node past unity instead of capping element.volume", () => {
+ const sources: VideoSource[] = [{ id: "a1", src: "file:///tmp/a1.mp4", label: "a1" }];
+ const audioSources: VideoSource[] = [{ id: "aud", src: "file:///tmp/vo.mp3", label: "vo" }];
+ const { container } = render(
+ ,
+ );
+ const videoEl = container.querySelector("video");
+ if (!videoEl) throw new Error("no ");
+ driveVideo(videoEl as HTMLVideoElement);
+ act(() => fireEvent.loadedMetadata(videoEl));
+ const audioEl = container.querySelector(
+ '[data-testid="preview-audio-track-trk"]',
+ );
+ if (!audioEl) throw new Error("no track ");
+ driveAudioEl(audioEl);
+
+ tick(); // let the rAF stamp the live gain onto the node
+ const trackGain = createdGains.at(-1);
+ expect(trackGain?.gain.value).toBeCloseTo(2, 3); // boosted, NOT clamped to 1
+ expect(audioEl.volume).toBe(1); // volume left at unity so it doesn't double-attenuate
+ });
+});
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 804cfa5a5..feb174ff8 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -4,12 +4,32 @@ import {
DEFAULT_CROP_REGION,
MAX_NATIVE_PLAYBACK_RATE,
} from "@/components/video-editor/types";
-import { resolvePlaybackSegments } from "@/lib/ai-edition/document/timeline";
-import type { AxcutClip, AxcutTrimRange, AxcutZoomRegion } from "@/lib/ai-edition/schema";
+import {
+ collapseTracksToPills,
+ resolveFadeSecs,
+ trackGroupId,
+} from "@/lib/ai-edition/document/audioTracks";
+import {
+ projectRawTimelineSecToPlayback,
+ resolvePlaybackSegments,
+} from "@/lib/ai-edition/document/timeline";
+import type {
+ AxcutAudioTrack,
+ AxcutClip,
+ AxcutTrimRange,
+ AxcutZoomRegion,
+} from "@/lib/ai-edition/schema";
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 { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
+import {
+ consumedSourceSec,
+ type TakePiece,
+ takePlaybackAt,
+ takeProgramme,
+} from "@/lib/ai-edition/timeline/take-programme";
import {
clampVirtualTime,
findNextKeptSegment,
@@ -65,6 +85,77 @@ export function resolveAudioTrackPlayback(
};
}
+/**
+ * Where an imported audio track (issue #350) should sit against the playback
+ * clock, and whether it should be playing there. Both arguments are in
+ * trim-compressed OUTPUT-programme seconds: `outputTimeSec` is the playhead and
+ * `outputStartSec` is the track's head, each already projected from raw through
+ * the trims by `projectRawTimelineSecToPlayback` in the caller.
+ *
+ * The track plays as one CONTIGUOUS block: `[outputStartSec, outputStartSec +
+ * (trimEnd - trimStart)]`, its source position `trimStart` plus how far the
+ * playhead is past the head. Outside that span it parks at the nearer trim edge
+ * and stays paused, the same discipline `resolveAudioTrackPlayback` uses so the
+ * rAF never seeks an element into nothing.
+ *
+ * Working in output space is what keeps the preview identical to the export: the
+ * native `audio::mix_external_tracks` overlays the decoded window
+ * `[trimStart, trimEnd]` contiguously at its projected offset, so an interior
+ * trim shortens the programme UNDER the track without cutting the track's own
+ * content. Deriving `local` from the RAW playhead instead (which jumps across a
+ * cut) made the element skip that much source and end early — the preview/export
+ * desync this fixes.
+ */
+export function resolveTimelineAudioPlayback(
+ outputTimeSec: number,
+ outputStartSec: number,
+ track: AxcutAudioTrack,
+ /** The fragment's own span in seconds — how long it plays on the timeline,
+ * which is independent of how much file is left after the offset. */
+ spanSec: number,
+) {
+ const offset = Math.max(0, track.offsetMs / 1000);
+ const sourceEnd = track.durationSec > 0 ? track.durationSec : offset + spanSec;
+ // The window the file has left after the offset; the fragment stops at
+ // whichever runs out first, its span or the file.
+ const windowLen = Math.max(0, sourceEnd - offset);
+ const local = outputTimeSec - outputStartSec;
+ const active = local >= 0 && local < spanSec;
+ if (track.loop && windowLen > 0) {
+ // Fold into the repeating window, exactly as the export's per-repeat mix
+ // entries do, so preview and render stay in phase.
+ return {
+ targetTimeSec: offset + (local > 0 ? local % windowLen : 0),
+ shouldPlay: active,
+ };
+ }
+ return {
+ targetTimeSec: Math.min(Math.max(offset, offset + local), sourceEnd),
+ // A file shorter than its span goes silent at the end rather than
+ // restarting: seeking a finished element back would stutter it every frame.
+ shouldPlay: active && local < windowLen,
+ };
+}
+
+/** 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. */
+export function timelineAudioFadeAt(
+ track: AxcutAudioTrack,
+ localSec: number,
+ spanSec: number,
+): number {
+ if (track.muted) return 0;
+ const { fadeInSec, fadeOutSec } = resolveFadeSecs(track.fadeInMs, track.fadeOutMs, spanSec);
+ let v = 1;
+ if (fadeInSec > 0 && localSec < fadeInSec) v = Math.min(v, Math.max(0, localSec / fadeInSec));
+ if (fadeOutSec > 0) {
+ const remaining = spanSec - localSec;
+ if (remaining < fadeOutSec) v = Math.min(v, Math.max(0, remaining / fadeOutSec));
+ }
+ return v;
+}
+
export interface PreviewAudioGraph {
context: AudioContext;
gain: GainNode;
@@ -112,6 +203,11 @@ function findNextClipByTimelineOrder(
interface VirtualPreviewProps {
videoSources: VideoSource[];
+ /** Imported audio tracks to mix over the video (issue #350), and the file URLs
+ * their assets resolve to (keyed by assetId in `id`). Both default to empty, so
+ * a project with no imported audio behaves exactly as before. */
+ audioTracks?: AxcutAudioTrack[];
+ audioSources?: VideoSource[];
clips: AxcutClip[];
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
@@ -156,6 +252,8 @@ interface VirtualPreviewProps {
export function VirtualPreview({
videoSources,
+ audioTracks = [],
+ audioSources = [],
clips,
zoomRegions = [],
speedRegions = [],
@@ -206,6 +304,11 @@ export function VirtualPreview({
const audioContextRef = useRef(null);
const audioContextCloseTimerRef = useRef | null>(null);
const audioSourceNodesRef = useRef(new WeakMap());
+ // Per-track gain nodes for imported audio (issue #350). Keyed by track id so the rAF
+ // can set each track's level live (a boost past 0 dB, which `element.volume` can't do —
+ // same reason the primary/supplemental sum through a gain node). The graph effect owns
+ // their lifecycle; the map is cleared and rebuilt whenever the routing is torn down.
+ const audioTrackGainNodesRef = useRef>(new Map());
const audioGraphRef = useRef(null);
const videoFrameRef = useRef(null);
@@ -272,10 +375,21 @@ export function VirtualPreview({
};
}, [activeSource?.filePath]);
+ // Which imported-track elements are actually mounted (a track is rendered only once its
+ // asset URL resolves — see the JSX). Re-routing the graph is keyed on this set, NOT on the
+ // tracks' gains: a level change is applied live on the existing node by the rAF, so it must
+ // not tear the graph down. Joined ids change only on a real mount/unmount.
+ const mountedAudioTrackKey = audioTracks
+ .filter((track) => audioSources.some((source) => source.id === track.assetId))
+ .map((track) => track.id)
+ .join(",");
+
// Sum the audio elements into one gain node so the output trim can boost past 0 dB,
// which `element.volume` cannot do. The primary media element carries track 1; on macOS
// the existing IPC helper extracts track 2 (normally the microphone) so both are audible
- // instead of Chromium silently choosing one.
+ // instead of Chromium silently choosing one. Imported tracks (issue #350) join the same
+ // graph through a per-track gain node so their boost survives the preview too.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: mountedAudioTrackKey is the trigger for re-routing tracks; the elements are read from the ref.
useEffect(() => {
if (!primaryAudioEl || !audioProbeComplete) return;
if (supplementalAudioSrc && !supplementalAudioEl) return;
@@ -322,14 +436,48 @@ export function VirtualPreview({
// preview outright rather than degrade it.
}
}
+ // Imported tracks (issue #350): each mounted element gets source → per-track gain →
+ // the output gain, so the effective level is trackGain × outputGain — the same order
+ // the exporter mixes in (mix_external_tracks applies the track gain, finish_audio the
+ // output gain). The rAF sets each node's value; created here at unity as a safe default.
+ const trackGainNodes: GainNode[] = [];
+ audioTrackGainNodesRef.current = new Map();
+ for (const [trackId, element] of audioTrackElsRef.current) {
+ try {
+ let source = audioSourceNodesRef.current.get(element);
+ if (!source) {
+ source = graph.context.createMediaElementSource(element);
+ audioSourceNodesRef.current.set(element, source);
+ }
+ source.disconnect();
+ const trackGain = graph.context.createGain();
+ source.connect(trackGain);
+ trackGain.connect(graph.gain);
+ audioTrackGainNodesRef.current.set(trackId, trackGain);
+ connectedSources.push(source);
+ trackGainNodes.push(trackGain);
+ } catch {
+ // Same rationale as the primary/supplemental loop: routing THIS track failed, so
+ // leave the rest connected. The rAF falls back to `element.volume` for a track
+ // with no gain node (capped at 0 dB, but audible).
+ }
+ }
audioGraphRef.current = graph;
applyPreviewAudioSettings(graph, elements, audioGainDbRef.current);
return () => {
audioGraphRef.current = null;
for (const source of connectedSources) source.disconnect();
+ for (const trackGain of trackGainNodes) trackGain.disconnect();
+ audioTrackGainNodesRef.current = new Map();
graph.gain.disconnect();
};
- }, [primaryAudioEl, supplementalAudioEl, supplementalAudioSrc, audioProbeComplete]);
+ }, [
+ primaryAudioEl,
+ supplementalAudioEl,
+ supplementalAudioSrc,
+ audioProbeComplete,
+ mountedAudioTrackKey,
+ ]);
// Keep one AudioContext for the component. Closing and recreating it on an effect rerun
// permanently silences an HTMLAudioElement because createMediaElementSource may only be
@@ -352,6 +500,7 @@ export function VirtualPreview({
const context = audioContextRef.current;
audioContextRef.current = null;
audioSourceNodesRef.current = new WeakMap();
+ audioTrackGainNodesRef.current = new Map();
if (context) void context.close();
}, 0);
};
@@ -400,6 +549,35 @@ export function VirtualPreview({
// mutation.
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.
+ 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.
+ // 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.
+ 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 takeWalks = useMemo(() => {
+ const pieces = new Map();
+ const heads = new Map();
+ 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));
+ }
+ return { pieces, heads };
+ }, [audioTracks, clips, trimRanges]);
+ takePiecesRef.current = takeWalks.pieces;
+ takeHeadsRef.current = takeWalks.heads;
// Trim-narrowed (`resolvePlaybackSegments`) — used ONLY to detect "has the 's own
// currentTime drifted into a trim" and where to jump it back out to. Everything ELSE in
// this component (`clips`/`clipsRef` above, virtualTimeSec, zoom/speed region lookups,
@@ -426,6 +604,17 @@ export function VirtualPreview({
virtualDurationSecRef.current = virtualDurationSec;
const speedRegionsRef = useRef(speedRegions);
speedRegionsRef.current = speedRegions;
+ // Imported audio tracks (issue #350): the rAF reads these through refs, same as
+ // everything else it touches, so a track added/edited mid-playback is picked up
+ // without re-creating the loop. `audioTrackElsRef` maps a track id to its mounted
+ // element (registered by the ref callback on render).
+ const audioTracksRef = useRef(audioTracks);
+ audioTracksRef.current = audioTracks;
+ const audioTrackElsRef = useRef>(new Map());
+ const registerAudioTrackEl = useCallback((trackId: string, element: HTMLAudioElement | null) => {
+ if (element) audioTrackElsRef.current.set(trackId, element);
+ else audioTrackElsRef.current.delete(trackId);
+ }, []);
// Same reasoning as `clipsRef` above, for the one thing the rAF calls rather than reads:
// `seekToVirtualTime` is a `useCallback` whose deps include `clips`, so it takes a new
// identity on every clip mutation — a REORDER included. The rAF below is deliberately
@@ -471,6 +660,129 @@ export function VirtualPreview({
audio.pause();
}
}
+ // Imported audio tracks (issue #350): project the playhead raw→output
+ // once, then position each track as a contiguous block against that
+ // output clock (see `resolveTimelineAudioPlayback`) so an interior trim
+ // shortens the programme without cutting the track — identical to the
+ // export's `mix_external_tracks`. Play it only inside its window, and set
+ // its level from the track gain. When the WebAudio graph is up the level
+ // rides a per-track gain node (which CAN boost past 0 dB, and the output node
+ // applies the global gain on top, matching the export); the `.volume` path is
+ // the fallback for when the graph is unavailable — there a boost caps at 0 dB.
+ const globalGain = audioGainScalar(audioGainDbRef.current);
+ // Speed-aware: under a 2x region the raw playhead races, and a projection
+ // blind to speed raced the audio's target position with it — the track
+ // 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,
+ speedRegionsRef.current,
+ );
+ for (const track of audioTracksRef.current) {
+ const el = audioTrackElsRef.current.get(track.id);
+ if (!el) continue;
+ const outputStartSec = projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ track.startMs / 1000,
+ speedRegionsRef.current,
+ );
+ // Length is measured WITHOUT speed, position WITH it. A trim REMOVES
+ // timeline — a track buried in one has zero length and stays silent,
+ // rather than playing its full raw length parked at the cut. A speed
+ // region only COMPRESSES: the track still holds all its audio and
+ // still plays at 1x, so it must not be cut short because the video
+ // under it was sped up.
+ const spanSec = Math.max(
+ 0,
+ projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ track.endMs / 1000,
+ ) -
+ projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ track.startMs / 1000,
+ ),
+ );
+ // A voiceover follows the cuts AND its own insertions, through one walk over
+ // its PILL. Every fragment but the head is silenced: the document keeps one
+ // per clip the take covers, and letting each play its own slice would put the
+ // take on top of itself, exactly as it would in the export.
+ //
+ // A bed plays through a cut and ends early, on purpose. A looping voiceover
+ // keeps the bed's treatment — step 6 of #560 refuses that combination, and
+ // inventing semantics for it would be the worse answer.
+ const takeGroupId = trackGroupId(track);
+ const takePieces =
+ track.kind === "voiceover" && !track.loop
+ ? takePiecesRef.current.get(takeGroupId)
+ : undefined;
+ if (takePieces && takeHeadsRef.current.get(takeGroupId) !== track.id) {
+ if (!el.paused) el.pause();
+ continue;
+ }
+ const trackTarget = takePieces
+ ? (takePlaybackAt(takePieces, virtualTimeSecRef.current) ?? {
+ targetTimeSec: track.offsetMs / 1000,
+ shouldPlay: false,
+ })
+ : resolveTimelineAudioPlayback(outputTimeSec, outputStartSec, track, spanSec);
+ // Fades measure against the SOURCE the walk consumes, never the take's ruler
+ // extent: an insertion grows the extent without adding a second of file, and
+ // a fade-out measured on it would start early here and nowhere else.
+ const fadeSpanSec = takePieces ? consumedSourceSec(takePieces) : spanSec;
+ const fadeLocalSec = takePieces
+ ? Math.max(0, trackTarget.targetTimeSec - track.offsetMs / 1000)
+ : outputTimeSec - outputStartSec;
+ const fade = timelineAudioFadeAt(track, fadeLocalSec, fadeSpanSec);
+ // Imported audio plays at its natural 1× rate, NOT the video's. The export
+ // sums it into the programme at 1× — speed regions stretch clip PCM only,
+ // never the imported track — so following `v.playbackRate` would pitch a
+ // voiceover up under a 2× region and finish it early, diverging from export.
+ if (el.playbackRate !== 1) el.playbackRate = 1;
+ const trackGainNode = audioTrackGainNodesRef.current.get(track.id);
+ if (trackGainNode) {
+ trackGainNode.gain.value = audioGainScalar(track.gainDb) * fade;
+ if (el.volume !== 1) el.volume = 1;
+ } else {
+ el.volume = Math.min(1, audioGainScalar(track.gainDb) * globalGain * fade);
+ }
+ // Only re-seek on a real discontinuity (a scrub, a trim jump, a first
+ // play), NOT on the sub-frame drift of normal playback. The primary audio
+ // can afford a 25 ms leash because it syncs to the 's own
+ // authoritative clock; an imported track syncs to `virtualTimeSec`, which is
+ // DERIVED from that clock each frame and so is slightly noisy — at a 25 ms
+ // leash it re-seeks most frames, and each seek briefly stalls the element:
+ // the jitter. A started element already plays at the right rate from the
+ // right offset, so it free-runs in sync; this wide leash just catches the
+ // jumps. BGM/voiceover tolerates it; frame-tight sync is the video's job.
+ const leashSec = !el.paused && trackTarget.shouldPlay ? 0.3 : 0.025;
+ if (Math.abs(el.currentTime - trackTarget.targetTimeSec) > leashSec) {
+ try {
+ el.currentTime = trackTarget.targetTimeSec;
+ } catch {
+ // media metadata not ready yet
+ }
+ }
+ 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
+ // audio element) routes into a suspended context and plays nothing.
+ if (audioGraphRef.current?.context.state === "suspended") {
+ void audioGraphRef.current.context.resume();
+ }
+ const playback = el.play();
+ if (playback) void playback.catch(() => undefined);
+ } else if ((v.paused || !trackTarget.shouldPlay) && !el.paused) {
+ el.pause();
+ }
+ }
// Publish this frame's live position/rate for other media elements
// (webcam) to read directly — see playback-clock.ts for why this
// bypasses React state entirely.
@@ -1146,6 +1458,23 @@ export function VirtualPreview({
data-testid="preview-audio-supplemental"
/>
) : null}
+ {/* Imported audio tracks (issue #350). One element per track, kept in
+ sync by the rAF loop above via audioTrackElsRef. A track whose asset
+ URL isn't resolved yet is skipped rather than mounted src-less. */}
+ {audioTracks.map((track) => {
+ const src = audioSources.find((s) => s.id === track.assetId)?.src;
+ if (!src) return null;
+ return (
+ registerAudioTrackEl(track.id, element)}
+ src={src}
+ preload="metadata"
+ aria-hidden="true"
+ data-testid={`preview-audio-track-${track.id}`}
+ />
+ );
+ })}
{/* Plus d'overlay ici du tout. « Loading preview… » reflétait l'état du
CACHÉ (source horloge/audio), pas la preview RÉELLE — le canvas
natif, qui montre déjà une image valide pendant que le re-seek.
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/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/components/ai-edition/v4/AddAudioLayerDialog.test.tsx b/src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx
new file mode 100644
index 000000000..696452ed6
--- /dev/null
+++ b/src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx
@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AddAudioLayerDialog } from "./AddAudioLayerDialog";
+
+vi.mock("@/contexts/I18nContext", () => ({
+ useScopedT: () => (key: string) => key,
+ useI18n: () => ({ locale: "en", setLocale: () => undefined }),
+}));
+
+const addAudioAsset = vi.fn();
+vi.mock("@/lib/ai-edition/store/projectStore", () => ({
+ useProjectStore: {
+ getState: () => ({ document: { assets: [] }, addAudioAsset }),
+ },
+}));
+
+vi.mock("@/lib/ai-edition/timeline/duration", () => ({
+ probeAudioDuration: vi.fn(async () => 3),
+}));
+
+/** A MediaRecorder stand-in that records whether it was ever stopped. */
+class FakeRecorder {
+ static instances: FakeRecorder[] = [];
+ state: "inactive" | "recording" = "inactive";
+ stopped = false;
+ ondataavailable: ((e: { data: Blob }) => void) | null = null;
+ onstop: (() => void) | null = null;
+ mimeType = "audio/webm";
+ constructor() {
+ FakeRecorder.instances.push(this);
+ }
+ static isTypeSupported() {
+ return true;
+ }
+ start() {
+ this.state = "recording";
+ }
+ stop() {
+ this.stopped = true;
+ this.state = "inactive";
+ this.onstop?.();
+ }
+}
+
+const stopTrack = vi.fn();
+
+beforeEach(() => {
+ FakeRecorder.instances = [];
+ stopTrack.mockClear();
+ addAudioAsset.mockReset();
+ vi.stubGlobal("MediaRecorder", FakeRecorder);
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }] })) },
+ });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function renderDialog(over: Partial[0]> = {}) {
+ const props = {
+ open: true,
+ maxDurationSec: 60,
+ onClose: vi.fn(),
+ onComplete: vi.fn(),
+ onRecordingStart: vi.fn(),
+ onRecordingStop: vi.fn(),
+ ...over,
+ };
+ const view = render( );
+ return { ...view, props };
+}
+
+describe("AddAudioLayerDialog", () => {
+ it("tells the shell when a take starts, so it can capture the playhead", async () => {
+ // The shell reads the playhead HERE, not when the take ends: recording
+ // plays the video, so by the end the live playhead has advanced by the
+ // take's own length. Every voiceover used to land that far to the right.
+ const { props } = renderDialog();
+ fireEvent.click(screen.getByText("audio.record"));
+ await vi.waitFor(() => expect(props.onRecordingStart).toHaveBeenCalledTimes(1));
+ });
+
+ it("does not cover the video it is recording against", () => {
+ // It used to be a modal over a dimmed backdrop, which hid the one thing a
+ // voiceover needs you to watch. It is a docked bar now: no backdrop, and
+ // nothing claiming to be a modal dialog.
+ const { container } = renderDialog();
+ expect(container.querySelector('[aria-modal="true"]')).toBeNull();
+ expect(container.querySelector('[class*="Backdrop"]')).toBeNull();
+ expect(screen.getByText("audio.record")).toBeTruthy();
+ });
+
+ it("reports the end of a take, which is what un-mutes the timeline", async () => {
+ // The shell silences the existing audio tracks between these two callbacks,
+ // so a stop that never reported would leave the timeline mute for good.
+ const { props } = renderDialog();
+ fireEvent.click(screen.getByText("audio.record"));
+ await vi.waitFor(() => expect(FakeRecorder.instances).toHaveLength(1));
+ fireEvent.click(screen.getByText("audio.stop"));
+ expect(props.onRecordingStop).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops the recorder when the dialog is torn down mid-take", async () => {
+ const { unmount, props } = renderDialog();
+ fireEvent.click(screen.getByText("audio.record"));
+ await vi.waitFor(() => expect(FakeRecorder.instances).toHaveLength(1));
+
+ unmount();
+
+ // Without this the take was never flushed, `onRecordingStop` never fired,
+ // and the video element was left playing after the shell went away.
+ expect(FakeRecorder.instances[0].stopped).toBe(true);
+ expect(props.onRecordingStop).toHaveBeenCalled();
+ // The microphone is released too.
+ expect(stopTrack).toHaveBeenCalled();
+ });
+
+ it("does not import a take that was discarded by the teardown", async () => {
+ const { unmount } = renderDialog();
+ fireEvent.click(screen.getByText("audio.record"));
+ await vi.waitFor(() => expect(FakeRecorder.instances).toHaveLength(1));
+ unmount();
+ expect(addAudioAsset).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/ai-edition/v4/AddAudioLayerDialog.tsx b/src/components/ai-edition/v4/AddAudioLayerDialog.tsx
new file mode 100644
index 000000000..d10edf08d
--- /dev/null
+++ b/src/components/ai-edition/v4/AddAudioLayerDialog.tsx
@@ -0,0 +1,333 @@
+// Voiceover dialog: record a narration take against the timeline, live from the
+// microphone (MediaRecorder → webm/opus, written to the recordings dir by the
+// main process), or import a file if the user already has one.
+//
+// Music and other imports do NOT come through here — they are a plain file
+// import on the timeline toolbar (`tl.addAudio`). Recording is the only audio
+// gesture that needs a dialog, because it has a live state to show.
+//
+// The dialog resolves the AUDIO ASSET and its duration, then reports back via
+// `onComplete` — placing the track on the timeline (span, anchor, inspector
+// selection) is the caller's job, exactly like the other add* flows.
+
+import { Mic, StopCircle, Upload } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { toFileUrl } from "@/components/video-editor/projectPersistence";
+import { useScopedT } from "@/contexts/I18nContext";
+import type { AxcutAsset } from "@/lib/ai-edition/schema";
+import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
+import { probeAudioDuration } from "@/lib/ai-edition/timeline/duration";
+import styles from "./EditorShellV4.module.css";
+
+const RECORDER_MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"];
+
+function pickRecorderMimeType(): string {
+ if (typeof MediaRecorder === "undefined") return "";
+ for (const mime of RECORDER_MIME_PREFERENCES) {
+ if (MediaRecorder.isTypeSupported(mime)) return mime;
+ }
+ return "";
+}
+
+/** Reuse an already-imported asset over importing the same file twice. */
+function findExistingAsset(path: string): AxcutAsset | null {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return null;
+ return (
+ doc.assets.find((a) => a.kind === "audio" && a.originalPath === path) ??
+ doc.assets.find((a) => a.originalPath === path) ??
+ null
+ );
+}
+
+export function AddAudioLayerDialog({
+ open,
+ /** Timeline length in seconds — recording stops by itself when reached. */
+ maxDurationSec,
+ onClose,
+ onComplete,
+ onRecordingStart,
+ onRecordingStop,
+}: {
+ open: boolean;
+ maxDurationSec: number;
+ onClose: () => void;
+ onComplete: (assetId: string, durationSec: number) => void;
+ onRecordingStart: () => void;
+ onRecordingStop: () => void;
+}) {
+ const t = useScopedT("timeline");
+ const tc = useScopedT("common");
+ const [busy, setBusy] = useState(false);
+ const [recording, setRecording] = useState(false);
+ const [elapsedSec, setElapsedSec] = useState(0);
+ const recorderRef = useRef(null);
+ const streamRef = useRef(null);
+ const chunksRef = useRef([]);
+ const startedAtRef = useRef(0);
+ const timerRef = useRef | null>(null);
+ // Set when the user cancels (closes the dialog mid-take) — the stop handler
+ // then discards the blob instead of importing it as a layer.
+ const discardRef = useRef(false);
+ // Read by the Escape handler, which must not re-subscribe every time the
+ // elapsed-time state ticks.
+ const recordingRef = useRef(false);
+
+ // Reset whenever the dialog opens again — a cancelled recording must not
+ // leak its stream or timer into the next session.
+ useEffect(() => {
+ if (open) {
+ setBusy(false);
+ setRecording(false);
+ recordingRef.current = false;
+ setElapsedSec(0);
+ }
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ timerRef.current = null;
+ // Stop the RECORDER, not just the stream. Tearing the dialog down
+ // mid-take (a project close, a shell unmount) used to drop the take on
+ // the floor: `onstop` never fired, so the blob was never flushed and
+ // `onRecordingStop` never ran — leaving the video element playing.
+ // Discard rather than import: nobody is left to place the layer.
+ const recorder = recorderRef.current;
+ if (recorder && recorder.state !== "inactive") {
+ discardRef.current = true;
+ try {
+ recorder.stop();
+ } catch {
+ // already torn down by the browser
+ }
+ }
+ for (const track of streamRef.current?.getTracks() ?? []) track.stop();
+ streamRef.current = null;
+ recorderRef.current = null;
+ };
+ }, [open]);
+
+ const stopRecording = useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ const recorder = recorderRef.current;
+ // `recorder.onstop` (registered at start) owns the save path.
+ if (recorder && recorder.state !== "inactive") {
+ recorder.stop();
+ }
+ for (const track of streamRef.current?.getTracks() ?? []) track.stop();
+ streamRef.current = null;
+ }, []);
+
+ const cancelRecording = useCallback(() => {
+ discardRef.current = true;
+ stopRecording();
+ onRecordingStop();
+ setRecording(false);
+ recordingRef.current = false;
+ setElapsedSec(0);
+ }, [stopRecording, onRecordingStop]);
+
+ const finishWithPath = useCallback(
+ async (path: string, durationSec: number) => {
+ setBusy(true);
+ try {
+ const existing = findExistingAsset(path);
+ // `addAudioAsset` files it as audio explicitly: a recorded voiceover
+ // lands as `.webm`, the same extension as a screen recording, so
+ // extension guessing would import it as a video asset.
+ const asset = existing ?? (await useProjectStore.getState().addAudioAsset(path));
+ if (!asset) {
+ toast.error(t("audio.importFailed"));
+ return;
+ }
+ onComplete(asset.id, durationSec);
+ } catch (err) {
+ toast.error(t("audio.importFailed"), {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ } finally {
+ setBusy(false);
+ }
+ },
+ [onComplete, t],
+ );
+
+ const startRecording = useCallback(async () => {
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
+ toast.error(t("audio.recordingUnavailable"));
+ return;
+ }
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: { echoCancellation: true, noiseSuppression: true },
+ });
+ streamRef.current = stream;
+ const mimeType = pickRecorderMimeType();
+ const recorder = mimeType
+ ? new MediaRecorder(stream, { mimeType })
+ : new MediaRecorder(stream);
+ recorderRef.current = recorder;
+ chunksRef.current = [];
+ recorder.ondataavailable = (event) => {
+ if (event.data.size > 0) chunksRef.current.push(event.data);
+ };
+ recorder.onstop = () => {
+ const blob = new Blob(chunksRef.current, {
+ type: recorder.mimeType || "audio/webm",
+ });
+ const duration = (performance.now() - startedAtRef.current) / 1000;
+ const discarded = discardRef.current;
+ discardRef.current = false;
+ setRecording(false);
+ recordingRef.current = false;
+ setElapsedSec(0);
+ onRecordingStop();
+ if (discarded) return;
+ void (async () => {
+ try {
+ const data = await blob.arrayBuffer();
+ const saved = window.electronAPI?.saveRecordedVoiceover
+ ? await window.electronAPI.saveRecordedVoiceover(data)
+ : { success: false as const };
+ if (saved.success && saved.path) {
+ await finishWithPath(saved.path, duration);
+ } else {
+ // Browser-mode fallback: no main process to persist the
+ // blob, so the layer references the in-memory blob URL —
+ // good for the session, gone on reload.
+ const url = URL.createObjectURL(blob);
+ await finishWithPath(url, duration);
+ }
+ } catch (err) {
+ toast.error(t("audio.saveFailed"), {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ }
+ })();
+ };
+ startedAtRef.current = performance.now();
+ discardRef.current = false;
+ recorder.start(250);
+ setRecording(true);
+ recordingRef.current = true;
+ setElapsedSec(0);
+ onRecordingStart();
+ timerRef.current = setInterval(() => {
+ setElapsedSec((performance.now() - startedAtRef.current) / 1000);
+ }, 200);
+ } catch {
+ toast.error(t("audio.micDenied"));
+ }
+ }, [finishWithPath, onRecordingStart, onRecordingStop, t]);
+
+ // Stop by itself at the end of the timeline so a layer can never outlive
+ // the video it was recorded over.
+ useEffect(() => {
+ if (!recording || !Number.isFinite(maxDurationSec) || maxDurationSec <= 0) return;
+ if (elapsedSec >= maxDurationSec) {
+ stopRecording();
+ // `onRecordingStop` fires from the recorder's stop handler.
+ }
+ }, [recording, elapsedSec, maxDurationSec, stopRecording]);
+
+ // Re-entrancy guard: the shell passes an inline `onComplete` and re-renders on
+ // every playhead tick during playback, so a dialog left open while the video
+ // plays re-creates this callback constantly. Without the guard a second click
+ // (or any caller that fires on re-render) would stack `showOpenDialog` calls.
+ const pickerOpenRef = useRef(false);
+
+ const importFile = useCallback(async () => {
+ if (pickerOpenRef.current) return;
+ pickerOpenRef.current = true;
+ try {
+ const picker = await window.electronAPI?.openAudioFilePicker?.();
+ if (!picker?.success || !picker.path) return;
+ const url = toFileUrl(picker.path);
+ // The probe needs the real duration to size the layer; when it fails the
+ // caller falls back to the default span.
+ const duration = (await probeAudioDuration(url)) ?? 0;
+ await finishWithPath(picker.path, duration);
+ } finally {
+ pickerOpenRef.current = false;
+ }
+ }, [finishWithPath]);
+
+ // Escape closes, the way it did when this was a modal — cancelling a take in
+ // progress rather than saving a half-recorded one.
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ e.preventDefault();
+ if (recordingRef.current) cancelRecording();
+ onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, cancelRecording, onClose]);
+
+ if (!open) return null;
+
+ return (
+ // A toolbar, not a dialog: no backdrop, nothing dimmed, and the preview
+ // keeps playing behind it. `aria-live` so a screen reader hears the take
+ // start and stop without the focus trap a modal would impose.
+
+
+ {t("audio.addVoiceover")}
+ {recording ? t("audio.recordingHint") : t("audio.subtitle")}
+
+ {recording ? (
+ <>
+
+
+ {t("audio.recording")} {elapsedSec.toFixed(1)}s
+
+
+
+ {t("audio.stop")}
+
+ {
+ cancelRecording();
+ onClose();
+ }}
+ className={styles.voiceoverBarBtn}
+ >
+ {tc("actions.cancel")}
+
+ >
+ ) : (
+ <>
+ void startRecording()}
+ className={styles.voiceoverBarBtn}
+ >
+
+ {t("audio.record")}
+
+ void importFile()}
+ disabled={busy}
+ className={styles.voiceoverBarBtn}
+ >
+
+ {t("audio.importFile")}
+
+
+ {tc("actions.close")}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 1e313e34c..c69b4277a 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -966,6 +966,21 @@
cursor: pointer;
text-align: left;
}
+/* The key that does the same thing, parked at the end of a menu row. Teaches
+ the shortcut at the moment the user is reaching for the slow way to do it. */
+.recMenuKey {
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 2px 6px;
+ border-radius: 5px;
+ border: 1px solid var(--border-soft);
+ background: var(--surface-2);
+ color: var(--muted);
+ font-size: 10.5px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
.recMenuRow:hover:not(:disabled) {
background: var(--surface-2);
}
@@ -1496,6 +1511,118 @@
border-color: #a855f7;
background: rgba(168, 85, 247, 0.14);
}
+/* Imported audio tracks (issue #350). A distinct teal so a BGM/voiceover track
+ reads apart from the effect pills, and taller than a lane pill so the waveform
+ inside it is legible. */
+/* The rest of an audio pill's tape (audioGhostExtent): where its file's content still
+ sits around the pill. Dimmed and unclickable, BELOW the pill, so the pill reads as a
+ window onto it and an edge drag shows what is still available on each side.
+
+ Height and row are set inline to match the pill exactly. They have to: a ghost that
+ does not line up with the pill reads as a separate object sitting behind it rather
+ than as the rest of the same strip. */
+.lanePillGhost {
+ position: absolute;
+ min-width: 1px;
+ overflow: hidden;
+ border-radius: 6px;
+ /* Faint enough to read as "not the pill". Louder than this and it draws the eye to
+ the part you are NOT editing — and on a long file it spans the whole ruler. */
+ border: 1px dashed color-mix(in oklch, var(--accent) 18%, transparent);
+ pointer-events: none;
+ z-index: 1;
+}
+.lanePillGhost .tlWave {
+ opacity: 0.18;
+}
+/* Crop readout pinned to the pointer while an audio pill's edge is pulled, or while
+ Alt slips the media under it — in -> out over the file's length. Rendered at the
+ component root (see the JSX note about the canvas transform). */
+.tlDragTip {
+ position: fixed;
+ z-index: 1200;
+ transform: translate(-50%, calc(-100% - 18px));
+ padding: 3px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border-hi);
+ background: var(--bg);
+ color: var(--fg);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ pointer-events: none;
+}
+.laneAudio {
+ border-color: #14b8a6;
+ background: rgba(20, 184, 166, 0.16);
+ height: 30px;
+ top: 1px;
+ cursor: pointer;
+}
+/* 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;
+}
+.laneAudio:active {
+ cursor: pointer;
+}
+/* The label rides above the waveform (which is inset:0 behind it). */
+/* Where a looping track starts its file over. A hairline rather than a full
+ divider: it has to be legible against the waveform without reading as a cut,
+ which is what a solid line on a timeline means everywhere else. */
+.laneLoopMark {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ z-index: 1;
+ pointer-events: none;
+ background: color-mix(in srgb, var(--fg) 45%, transparent);
+}
+
+.laneAudioLabel {
+ position: relative;
+ z-index: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+/* The audio lane is taller than the effect lanes to give the waveform room. */
+/* Height is set inline from the row count — the lane grows only as far as the
+ tracks actually overlap. This is the single-row floor. */
+.tlLaneAudio {
+ height: 32px;
+}
.tlClips {
position: relative;
/* NOT a flex row. Clips are absolutely positioned by percentage of the
@@ -1553,6 +1680,45 @@
.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. */
+/* 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));
+}
/* 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. */
@@ -1643,6 +1809,11 @@
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. */
.tlDropHint {
position: absolute;
inset: 0;
@@ -1756,3 +1927,83 @@
pointer-events: none;
z-index: 14;
}
+
+/* ── Voiceover recorder ──────────────────────────────────────────────────
+ Deliberately NOT a modal. The whole point of a voiceover is narrating to
+ the video that is playing, so a centred dialog over a dimmed backdrop hides
+ the one thing the user needs to watch. This docks at the bottom instead:
+ no backdrop, nothing covered but a strip of the timeline, and the preview
+ stays lit and playing behind it. */
+.voiceoverBar {
+ position: fixed;
+ left: 50%;
+ bottom: 24px;
+ transform: translateX(-50%);
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 16px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: var(--surface-1);
+ box-shadow: 0 18px 40px rgb(0 0 0 / 38%);
+}
+
+.voiceoverBarTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-right: 4px;
+}
+.voiceoverBarTitle strong {
+ font: 600 13px var(--font-display);
+ color: var(--fg);
+}
+.voiceoverBarTitle span {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.voiceoverBarBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ padding: 9px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--bg-2, var(--surface-2));
+ color: var(--fg-1, var(--fg));
+ font: 600 13px var(--font-display);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.voiceoverBarBtn:hover:not(:disabled) {
+ background: var(--surface-3);
+}
+.voiceoverBarBtn:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+.voiceoverBarBtnDanger {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+/* The live take: a pulsing dot and the running length, so the user can see it
+ is actually capturing without looking away from the video. */
+.voiceoverBarLive {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--danger);
+ font: 600 13px var(--font-display);
+ font-variant-numeric: tabular-nums;
+}
+.voiceoverBarDot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: var(--danger);
+}
diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx
index ac162d322..254da35f2 100644
--- a/src/components/ai-edition/v4/FloatingInspector.tsx
+++ b/src/components/ai-edition/v4/FloatingInspector.tsx
@@ -1,6 +1,5 @@
import {
AudioLines,
- Captions as CaptionsIcon,
ChevronRight,
FileText,
Layout as LayoutIcon,
@@ -39,10 +38,10 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { formatSeconds } from "@/lib/ai-edition/timeline/format";
import { coalescedTrimGroups } from "@/lib/ai-edition/timeline/trim-mapping";
-import { CaptionsPane } from "../CaptionsPane";
import { ColorField } from "../ColorField";
import {
AudioPane,
+ AudioTrackPane,
CursorPane,
LayoutPane,
SliderCell,
@@ -54,7 +53,11 @@ import styles from "./EditorShellV4.module.css";
type TimelineApi = ReturnType;
-export type Facet = "effects" | "layout" | "audio" | "cursor" | "captions" | "transcript";
+// No "captions" facet: caption settings are a popover on the transcript tab now.
+// They were never a separate concern from the transcript — they RENDER it — and two
+// tabs meant two entry points to transcription, one of which ("transcribe video",
+// on the caption tab) was the only one many users ever found. See issue #560.
+export type Facet = "effects" | "layout" | "audio" | "cursor" | "transcript";
const FACETS: Array<{ id: Facet; labelKey: string; icon: typeof SlidersHorizontal }> = [
// Background is a SECTION of this facet now, not a facet of its own — see
@@ -63,7 +66,6 @@ const FACETS: Array<{ id: Facet; labelKey: string; icon: typeof SlidersHorizonta
{ id: "layout", labelKey: "layout.title", icon: LayoutIcon },
{ id: "audio", labelKey: "audio.title", icon: AudioLines },
{ id: "cursor", labelKey: "cursor.title", icon: MousePointer2 },
- { id: "captions", labelKey: "facets.captions", icon: CaptionsIcon },
{ id: "transcript", labelKey: "facets.transcript", icon: FileText },
];
@@ -113,13 +115,18 @@ export function FloatingInspector({
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [clipPickerOpen]);
const selection = tl.selection;
- const effectiveOpen = open || selection !== null;
+ // An imported audio track is selected (issue #350) — like a region selection it
+ // takes over the inspector body with its own pane (see AudioTrackPane).
+ const audioTrackSelected = tl.selectedAudioTrackId !== null;
+ const effectiveOpen = open || selection !== null || audioTrackSelected;
return (
{effectiveOpen ? (
{selection ? (
tl.clearSelection()} />
+ ) : audioTrackSelected ? (
+
) : (
)}
@@ -132,11 +139,11 @@ export function FloatingInspector({
type="button"
title={ts(labelKey)}
aria-label={ts(labelKey)}
- aria-pressed={!selection && open && facet === id}
+ aria-pressed={!selection && !audioTrackSelected && open && facet === id}
onClick={() => {
// Switching facets while an element is selected should show
// the facet, not leave the selection pane on top of it.
- if (selection) tl.clearSelection();
+ if (selection || audioTrackSelected) tl.clearSelection();
if (facet === id && open) {
onToggleOpen();
} else {
@@ -1065,12 +1072,14 @@ function FacetBody({
);
- if (facet === "effects") return wrap(collapse, );
if (facet === "layout") return wrap(collapse, );
if (facet === "audio") return wrap(collapse, );
if (facet === "cursor") return wrap(collapse, );
if (facet === "transcript") return wrap(collapse, );
- return wrap(collapse, );
+ // `effects` is the fallthrough rather than a branch of its own: the union has no
+ // tail left now that captions is a popover, and a `never` check here would only
+ // restate what the type already says.
+ return wrap(collapse, );
}
function wrap(collapse: React.ReactNode, body: React.ReactNode) {
diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx
index e867c9c2d..a69c615fe 100644
--- a/src/components/ai-edition/v4/MediaStage.tsx
+++ b/src/components/ai-edition/v4/MediaStage.tsx
@@ -73,7 +73,10 @@ export function MediaStage({
[locale, t],
);
- const assets = document?.assets ?? [];
+ // Video only — this stage arranges clips. Imported audio (issue #350) is a
+ // timeline overlay added from the timeline toolbar, not a clip, so it never
+ // appears in this list.
+ const assets = (document?.assets ?? []).filter((a) => a.kind !== "audio");
const filtered = useMemo(
() =>
assets.filter((a) => {
diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
index 8e515568c..5119eedae 100644
--- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
@@ -13,8 +13,12 @@ vi.mock("@/contexts/I18nContext", () => ({
useScopedT: () => (key: string) => key,
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } }));
+// The audio lane's pill renders a ClipWaveform; no decode in this geometry suite.
+vi.mock("@/hooks/useAudioPeaks", () => ({ useAudioPeaks: () => null }));
+import { ShortcutsProvider } from "@/contexts/ShortcutsContext";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
+import { DEFAULT_SHORTCUTS, formatBinding } from "@/lib/shortcuts";
import { V4Timeline } from "./V4Timeline";
beforeAll(() => {
@@ -75,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: [],
@@ -84,6 +91,9 @@ function renderTimeline(
selection: null,
multiSelection: [],
clipSelection: null,
+ audioTracks: [],
+ selectedAudioTrackId: null,
+ selectAudioTrack: vi.fn(),
clearSelection: vi.fn(),
selectRegion: vi.fn(),
selectClip: vi.fn(),
@@ -95,17 +105,20 @@ function renderTimeline(
}),
};
render(
- }
- setCurrentTime={vi.fn()}
- playing={false}
- onTogglePlay={vi.fn()}
- onPrevClip={vi.fn()}
- onNextClip={vi.fn()}
- onEditClip={vi.fn()}
- />,
+
+ }
+ setCurrentTime={vi.fn()}
+ playing={false}
+ onTogglePlay={vi.fn()}
+ onPrevClip={vi.fn()}
+ onNextClip={vi.fn()}
+ onEditClip={vi.fn()}
+ onAddVoiceover={vi.fn()}
+ />
+ ,
);
return {
pill: screen.getByTitle("toolbar.newAnnotation"),
@@ -194,6 +207,60 @@ describe("V4Timeline lane pills", () => {
});
});
+describe("V4Timeline lane pill keyboard", () => {
+ // A pill carries `role="button"` and `tabIndex={0}`, so it is reachable by Tab and
+ // announced as activatable. Selection was pointer-only, which meant a keyboard user
+ // could focus a region and then reach nothing that acts on a selection — Delete,
+ // copy/paste and the inspector all key off `tl.selection`.
+ it("selects the focused pill on Enter", () => {
+ const { pill, tl } = renderTimeline();
+ fireEvent.keyDown(pill, { key: "Enter" });
+ expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: false });
+ });
+
+ it("selects it on Space too, the other key a button answers to", () => {
+ const { pill, tl } = renderTimeline();
+ fireEvent.keyDown(pill, { key: " " });
+ expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: false });
+ });
+
+ it("adds to the selection when Shift is held, matching shift-click", () => {
+ const { pill, tl } = renderTimeline();
+ fireEvent.keyDown(pill, { key: "Enter", shiftKey: true });
+ expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: true });
+ });
+
+ it("leaves every other key to the shell's shortcut handler", () => {
+ // The editor binds single letters (Z adds a zoom, T a trim, D deletes). Swallowing
+ // them here would silently disable every shortcut while a pill has focus.
+ const { pill, tl } = renderTimeline();
+ for (const key of ["z", "t", "d", "Escape", "ArrowRight"]) {
+ fireEvent.keyDown(pill, { key });
+ }
+ expect(tl.selectRegion).not.toHaveBeenCalled();
+ });
+
+ it("stops Enter and Space reaching the window listener", () => {
+ // Space is bound to play/pause on WINDOW, above React's root container. Without
+ // stopping the NATIVE event the same keystroke would select the pill and toggle
+ // playback; the synthetic `stopPropagation` alone does not reach that far.
+ const onWindowKey = vi.fn();
+ window.addEventListener("keydown", onWindowKey);
+ try {
+ const { pill } = renderTimeline();
+ fireEvent.keyDown(pill, { key: " " });
+ fireEvent.keyDown(pill, { key: "Enter" });
+ expect(onWindowKey).not.toHaveBeenCalled();
+
+ // A key the pill ignores still gets there, or the shortcuts would be dead.
+ fireEvent.keyDown(pill, { key: "z" });
+ expect(onWindowKey).toHaveBeenCalledTimes(1);
+ } finally {
+ window.removeEventListener("keydown", onWindowKey);
+ }
+ });
+});
+
describe("V4Timeline create-from-toolbar", () => {
// The button asks for a DURATION worth a fixed number of pixels at the current
// zoom, so the pill you get is always the same size on screen — which is what
@@ -205,14 +272,14 @@ describe("V4Timeline create-from-toolbar", () => {
it("scales the new region's duration with the zoom", () => {
const { tl } = renderTimeline();
- fireEvent.click(screen.getByTitle("buttons.addZoom"));
+ fireEvent.click(screen.getByLabelText("buttons.addZoom"));
// 900px viewport / 1800 s = 0.5 px per second, so a 96px pill is 192 s.
expect(durationOf(tl)).toBeCloseTo(192, 3);
// Zoomed to the 50x ceiling the same 96px is worth 3.84 s: same pill on
// screen, a region 50x shorter.
zoomIn(40);
- fireEvent.click(screen.getByTitle("buttons.addZoom"));
+ fireEvent.click(screen.getByLabelText("buttons.addZoom"));
expect(durationOf(tl)).toBeCloseTo(3.84, 3);
});
@@ -224,7 +291,7 @@ describe("V4Timeline create-from-toolbar", () => {
const { tl } = renderTimeline();
const ruler = document.querySelector("[class*=tlRulerRow]") as HTMLElement;
wheelZoomOn(ruler, 40);
- fireEvent.click(screen.getByTitle("buttons.addZoom"));
+ fireEvent.click(screen.getByLabelText("buttons.addZoom"));
expect(durationOf(tl)).toBeCloseTo(3.84, 3);
});
@@ -247,7 +314,7 @@ describe("V4Timeline create-from-toolbar", () => {
// second; the region would be born unusable, so the duration floors.
const { tl } = renderTimeline([clip(0, 3)]);
zoomIn(40);
- fireEvent.click(screen.getByTitle("buttons.addZoom"));
+ fireEvent.click(screen.getByLabelText("buttons.addZoom"));
expect(durationOf(tl)).toBeCloseTo(0.25, 3);
});
@@ -257,7 +324,7 @@ describe("V4Timeline create-from-toolbar", () => {
// so before it is clicked instead of looking like it worked.
it("disables Add Full Camera when no clip on the timeline has a camera", () => {
renderTimeline();
- expect(screen.getByTitle("buttons.addCameraFullscreen")).toBeDisabled();
+ expect(screen.getByLabelText("buttons.addCameraFullscreen")).toBeDisabled();
});
it("enables Add Full Camera as soon as a clip's asset carries one", () => {
@@ -267,7 +334,7 @@ describe("V4Timeline create-from-toolbar", () => {
cameraTrack: { sourcePath: "/tmp/cam.webm", startMs: 0, offsetMs: 0, visible: true },
},
]);
- expect(screen.getByTitle("buttons.addCameraFullscreen")).toBeEnabled();
+ expect(screen.getByLabelText("buttons.addCameraFullscreen")).toBeEnabled();
});
// The disabled button is only half the promise: an empty lane advertises the shortcut
@@ -338,3 +405,245 @@ describe("V4Timeline clip row", () => {
}
});
});
+
+// Issue #350 — dragging an imported audio track on its lane. The pixel→second
+// math and the single-write commit are what these pin; the clamp/guard math is
+// covered by document/audioTracks.test.ts.
+describe("V4Timeline audio lane drag", () => {
+ const AUDIO_ASSET = { id: "aud", label: "voiceover", originalPath: "/vo.mp3", durationSec: 60 };
+ // A 60s track whose head sits at raw 100s.
+ const makeTrack = () => ({
+ id: "trk1",
+ assetId: "aud",
+ kind: "music" as const,
+ startMs: 100_000,
+ endMs: 160_000,
+ durationSec: 60,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "vo",
+ origin: "user" as const,
+ });
+
+ function renderAudioTracks(tracks: Array>) {
+ return renderAudio({}, {}, tracks);
+ }
+
+ function renderAudio(
+ trackOverrides: Partial> = {},
+ props: { onAddVoiceover?: () => void } = {},
+ tracks?: Array>,
+ ) {
+ const placeAudioTrack = vi.fn(
+ async (_id: string, _span: { startMs: number; endMs: number; offsetMs?: number }) => {
+ /* the drag only awaits it */
+ },
+ );
+ const selectAudioTrack = vi.fn();
+ const tl = {
+ clips: [clip(0, TOTAL_SEC)],
+ assets: [AUDIO_ASSET],
+ annotationRegions: [],
+ speedRegions: [],
+ cameraFullscreenRegions: [],
+ zoomRegions: [],
+ trimRanges: [],
+ selection: null,
+ 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,
+ clearSelection: vi.fn(),
+ selectRegion: vi.fn(),
+ selectClip: vi.fn(),
+ updateAnnotationSpan: vi.fn(async () => undefined),
+ addZoom: vi.fn(async () => undefined),
+ };
+ const { container } = render(
+
+ }
+ setCurrentTime={vi.fn()}
+ playing={false}
+ onTogglePlay={vi.fn()}
+ onPrevClip={vi.fn()}
+ onNextClip={vi.fn()}
+ onEditClip={vi.fn()}
+ onAddVoiceover={props.onAddVoiceover ?? vi.fn()}
+ />
+ ,
+ );
+ // `pill` is a getter: the multi-track cases render no "vo" pill, and an
+ // eager lookup would throw before their own assertions ran.
+ return {
+ get pill() {
+ return screen.getByTitle((t) => t.startsWith("vo "));
+ },
+ container,
+ placeAudioTrack,
+ selectAudioTrack,
+ };
+ }
+
+ // 900px / 1800s = 0.5 px per second, so +90px is +180s.
+ const secForPx = (px: number) => (px / VIEWPORT_PX) * TOTAL_SEC;
+
+ it("offers both audio paths behind one toolbar button", () => {
+ // A mic and a music note side by side both just said "audio"; one button
+ // with a named menu is what tells a first-time user the two paths apart.
+ const onAddVoiceover = vi.fn();
+ renderAudio({}, { onAddVoiceover });
+ fireEvent.click(screen.getByLabelText("toolbar.addAudioTooltip"));
+ fireEvent.click(screen.getByText("audio.addVoiceover"));
+ expect(onAddVoiceover).toHaveBeenCalledTimes(1);
+ });
+
+ it("teaches the key that does the same thing", () => {
+ // Read off the live bindings rather than hardcoded here, so a rebind in the
+ // shortcuts dialog moves the menu with it instead of teaching a stale key.
+ renderAudio();
+ fireEvent.click(screen.getByLabelText("toolbar.addAudioTooltip"));
+ const keys = Array.from(document.querySelectorAll("kbd"), (k) => k.textContent);
+ expect(keys).toEqual([
+ formatBinding(DEFAULT_SHORTCUTS.addVoiceover, false),
+ formatBinding(DEFAULT_SHORTCUTS.addAudio, false),
+ ]);
+ });
+
+ it("marks where a looping track starts its file over", () => {
+ // A 60s file under a 180s span repeats twice more after the first pass, so
+ // there are two boundaries to show — at a third and two thirds.
+ const { pill } = renderAudio({ loop: true, endMs: 100_000 + 180_000 });
+ expect(pill.querySelectorAll('[data-testid="audio-loop-mark"]')).toHaveLength(2);
+ });
+
+ it("draws no loop marks when the track fits inside its source", () => {
+ const { pill } = renderAudio({ loop: true });
+ expect(pill.querySelectorAll('[data-testid="audio-loop-mark"]')).toHaveLength(0);
+ });
+
+ it("stacks overlapping tracks on separate rows", () => {
+ // Three takes over the same stretch used to draw at the same height, one
+ // hiding the next — you could not tell which pill you were about to drag.
+ const { container } = renderAudioTracks([
+ { ...makeTrack(), id: "a", label: "a", startMs: 0, endMs: 60_000 },
+ { ...makeTrack(), id: "b", label: "b", startMs: 10_000, endMs: 70_000 },
+ { ...makeTrack(), id: "c", label: "c", startMs: 20_000, endMs: 80_000 },
+ ]);
+ const tops = ["a", "b", "c"].map(
+ (l) => (screen.getByTitle((t) => t.startsWith(`${l} `)) as HTMLElement).style.top,
+ );
+ expect(new Set(tops).size).toBe(3);
+ // ...and the lane grew to hold them rather than clipping.
+ const lane = container.querySelector('[class*="tlLaneAudio"]') as HTMLElement;
+ expect(Number.parseInt(lane.style.height, 10)).toBeGreaterThan(60);
+ });
+
+ it("keeps non-overlapping tracks on one row", () => {
+ renderAudioTracks([
+ { ...makeTrack(), id: "a", label: "a", startMs: 0, endMs: 10_000 },
+ { ...makeTrack(), id: "b", label: "b", startMs: 20_000, endMs: 30_000 },
+ ]);
+ const tops = ["a", "b"].map(
+ (l) => (screen.getByTitle((t) => t.startsWith(`${l} `)) as HTMLElement).style.top,
+ );
+ expect(new Set(tops).size).toBe(1);
+ });
+
+ it("selects the track on pointer-down before any movement", () => {
+ const { pill, selectAudioTrack } = renderAudio();
+ fireEvent.pointerDown(pill, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: 0 }));
+ expect(selectAudioTrack).toHaveBeenCalledWith("trk1");
+ });
+
+ it("body drag slides the head and commits once, trims untouched", () => {
+ const { pill, placeAudioTrack } = renderAudio();
+ fireEvent.pointerDown(pill, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 90 }));
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: 90 }));
+ expect(placeAudioTrack).toHaveBeenCalledTimes(1);
+ const [id, placement] = placeAudioTrack.mock.calls[0];
+ expect(id).toBe("trk1");
+ // The span slides whole: head moves, length is unchanged.
+ expect(placement.startMs / 1000).toBeCloseTo(100 + secForPx(90), 3);
+ expect((placement.endMs - placement.startMs) / 1000).toBeCloseTo(60, 3);
+ });
+
+ it("left-handle drag trims into the source instead of sliding the audio", () => {
+ // A left-edge drag is a trim IN: the head moves right by N seconds and the
+ // same N is skipped in the file, so what plays under the pill stays put.
+ // Committing the span alone left `offsetMs` untouched, which just slid the
+ // whole track along — the "my music starts five seconds late" symptom.
+ const { pill, placeAudioTrack } = renderAudio();
+ const handle = pill.firstElementChild as Element;
+ fireEvent.pointerDown(handle, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 15 }));
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: 15 }));
+ expect(placeAudioTrack).toHaveBeenCalledTimes(1);
+ const [, placement] = placeAudioTrack.mock.calls[0];
+ const movedSec = placement.startMs / 1000 - 100;
+ expect(movedSec).toBeGreaterThan(0);
+ // The head moved and the source in-point advanced by the same amount.
+ expect((placement.offsetMs ?? 0) / 1000).toBeCloseTo(movedSec, 3);
+ // The tail is untouched, so the span shortens by exactly what was trimmed.
+ expect((placement.endMs - placement.startMs) / 1000).toBeCloseTo(60 - movedSec, 3);
+ });
+
+ it("a plain move leaves the source in-point alone", () => {
+ const { pill, placeAudioTrack } = renderAudio();
+ fireEvent.pointerDown(pill, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 90 }));
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: 90 }));
+ const [, placement] = placeAudioTrack.mock.calls[0];
+ expect(placement.offsetMs).toBe(0);
+ });
+
+ it("caps the out-point at the source length when the track does not loop", () => {
+ // A non-looping track has nothing to play past the end of its file, so the
+ // right edge stops there however far the pointer goes.
+ const { pill, placeAudioTrack } = renderAudio();
+ const handle = pill.lastElementChild as Element;
+ fireEvent.pointerDown(handle, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 400 }));
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: 400 }));
+ const [, placement] = placeAudioTrack.mock.calls[0];
+ expect((placement.endMs - placement.startMs) / 1000).toBeCloseTo(60, 3);
+ });
+
+ it("lets a looping track be pulled out past the end of its file", () => {
+ // This is what makes the loop toggle mean anything: the span has to be able
+ // to EXCEED the source, or the audio always plays exactly once and turning
+ // loop on does nothing at all.
+ const { pill, placeAudioTrack } = renderAudio({ loop: true });
+ const handle = pill.lastElementChild as Element;
+ fireEvent.pointerDown(handle, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 400 }));
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: 400 }));
+ const [, placement] = placeAudioTrack.mock.calls[0];
+ expect((placement.endMs - placement.startMs) / 1000).toBeGreaterThan(60);
+ });
+
+ it("right-handle drag pulls the out-point in, head fixed", () => {
+ const { pill, placeAudioTrack } = renderAudio();
+ // The right resize handle is the last child of the pill.
+ const handle = pill.lastElementChild as Element;
+ fireEvent.pointerDown(handle, { clientX: 0 });
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: -30 }));
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX: -30 }));
+ expect(placeAudioTrack).toHaveBeenCalledTimes(1);
+ const [, placement] = placeAudioTrack.mock.calls[0];
+ // The head is pinned; only the tail comes in, so the span gets shorter.
+ expect(placement.startMs).toBe(100_000);
+ expect(placement.endMs - placement.startMs).toBeLessThan(60_000);
+ });
+});
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index f6c94e2ca..3fd5a82ce 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -1,9 +1,12 @@
import {
+ AudioLines,
Clock,
Crosshair,
Loader2,
Maximize2,
MessageSquare,
+ Mic,
+ Music,
Pencil,
Scissors,
Sparkles,
@@ -13,6 +16,7 @@ import {
ZoomIn,
} from "lucide-react";
import {
+ Fragment,
memo,
type PointerEvent as ReactPointerEvent,
useCallback,
@@ -23,13 +27,22 @@ import {
} from "react";
import { toast } from "sonner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { fromFileUrl } from "@/components/video-editor/projectPersistence";
+import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
+import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersistence";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import { useScopedT } from "@/contexts/I18nContext";
+import { useShortcuts } from "@/contexts/ShortcutsContext";
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
+import {
+ audioGhostExtent,
+ collapseTracksToPills,
+ packAudioTrackRows,
+ 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 { AxcutClip } 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";
@@ -53,6 +66,7 @@ import {
type AutoZoomSuggestion,
buildAutoZoomSuggestionsForClips,
} from "@/lib/ai-edition/timeline/zoom-suggestions";
+import { formatBinding } from "@/lib/shortcuts";
import { nativeBridgeClient } from "@/native/client";
import { TransportBar } from "../TransportBar";
import type { VideoSource } from "../VirtualPreview";
@@ -127,12 +141,23 @@ const PILL_HANDLE_OUT_PX = PILL_HANDLE_PX + PILL_MOVE_GAP_PX;
const PILL_CONTENT_MIN_PX = 34;
/** Edge-snap radius while dragging a pill, in screen px. */
const PILL_SNAP_PX = 8;
+// One audio pill's height, and the vertical step between stacked rows. The lane
+// grows by a row for each track that overlaps one already placed — see
+// `packAudioTrackRows`.
+const AUDIO_ROW_HEIGHT_PX = 26;
+const AUDIO_ROW_GAP_PX = 3;
+// Breathing room above the first row and below the last, so a pill never sits
+// flush against the lane's rounded edge.
+const AUDIO_LANE_PAD_PX = 3;
// The size a newly created pill aims for (PILL_CREATE_PX) lives in
// timeline/newRegionDuration, because the keyboard shortcuts create regions too
// and they are handled in NewEditorShell, outside this component.
/** 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
@@ -335,6 +360,172 @@ const ClipWaveform = memo(function ClipWaveform({
);
});
+// One imported audio track on its lane (issue #350). Grab the body to move it,
+// the edge handles to trim (left = in-point, which moves the head too; right =
+// out-point). The waveform reuses ClipWaveform (its `.tlWave` is inset:0, so it
+// paints behind the label here just as it does inside a clip), windowed to the
+// track's trim and scaled by the track's own gain. `leftPct`/`widthPct` are
+// precomputed by the parent — during a drag they carry the live preview geometry
+// — so this stays memoisable: a doc edit that doesn't touch this track, and a
+// drag on another one, won't re-render it.
+const AudioLanePill = memo(function AudioLanePill({
+ track,
+ url,
+ assetDurationSec,
+ leftPct,
+ widthPct,
+ sourceStartSec,
+ sourceEndSec,
+ spanSec,
+ loopWindowSec,
+ row,
+ rowHeight,
+ selected,
+ onStartDrag,
+ onSelect,
+ label,
+ slipHint,
+ slipArmed,
+ outputGain,
+ ghost,
+}: {
+ track: AxcutAudioTrack;
+ url: string | undefined;
+ assetDurationSec: number | undefined;
+ leftPct: number;
+ widthPct: number;
+ /** The slice of the source the pill is showing — the track's offset and its
+ * span, or the live window while an edge is being dragged. */
+ sourceStartSec: number;
+ sourceEndSec: number;
+ /** The pill's own length in seconds, and how much source one repeat plays —
+ * together they say where the loop boundaries fall. */
+ spanSec: number;
+ loopWindowSec: number;
+ /** Which row of the audio lane this pill occupies, and how tall a row is —
+ * overlapping tracks are stacked rather than drawn on top of each other. */
+ row: number;
+ rowHeight: number;
+ selected: boolean;
+ onStartDrag: (e: ReactPointerEvent, track: AxcutAudioTrack, mode: "move" | "l" | "r") => void;
+ onSelect: (id: string) => void;
+ label: string;
+ /** Appended to the pill's tooltip. A modifier is never discoverable on its own —
+ * you either read it somewhere or you never find it — and the tooltip is where a
+ * user already looks to ask what a thing does. */
+ slipHint: string;
+ /** True while Alt is held, so the pill can say the next drag will slip rather than
+ * move. Confirms the modifier; the tooltip is what teaches it. */
+ slipArmed: boolean;
+ /** Linear project output gain, applied on top of the track gain — the mixer
+ * applies both, so the bars must too or they under-read the exported level. */
+ outputGain: number;
+ /** Where the rest of the file sits around the pill, as percentages of the canvas
+ * and the source window it covers. Absent when there is nothing to show. */
+ ghost?: {
+ leftPct: number;
+ widthPct: number;
+ sourceStartSec: number;
+ sourceEndSec: number;
+ } | null;
+}) {
+ const duration = assetDurationSec ?? track.durationSec;
+ return (
+ <>
+ {/* The rest of the tape, dimmed and unclickable, behind the pill — so the pill
+ reads as a window onto it and an edge drag shows what is still available on
+ each side before it hits the stop. Same height and row as the pill: a ghost
+ that does not line up reads as a separate object sitting behind it. */}
+ {ghost ? (
+
+
+
+ ) : null}
+ onStartDrag(e, track, "move")}
+ onKeyDown={(e) => {
+ 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}`}
+ >
+ onStartDrag(e, track, "l")}
+ />
+
+ {/* 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. */}
+ {track.loop && loopWindowSec > 0
+ ? Array.from(
+ { length: Math.min(200, Math.ceil(spanSec / loopWindowSec) - 1) },
+ (_, i) => (
+
+ ),
+ )
+ : null}
+
+
+ {label}
+
+ onStartDrag(e, track, "r")}
+ />
+
+ >
+ );
+});
+
interface LanePill {
id: string;
kind: "annotation" | "speed" | "trim" | "zoom" | "cameraFullscreen";
@@ -356,6 +547,7 @@ export function V4Timeline({
onPrevClip,
onNextClip,
onEditClip,
+ onAddVoiceover,
}: {
tl: TimelineApi;
setCurrentTime: (sec: number) => void;
@@ -369,8 +561,14 @@ export function V4Timeline({
/** Opens the (now single, shell-level) EditClipModal for this clip —
* trim in/out and crop both live there per-clip. */
onEditClip: (clip: AxcutClip) => void;
+ /** Opens the voiceover recorder. Shell-level like the clip editor: the
+ * dialog owns the microphone and the shell owns the transport. */
+ onAddVoiceover: () => void;
}) {
const t = useScopedT("timeline");
+ // The live bindings, not the defaults: these keys are remappable, and a menu
+ // that taught the wrong one would be worse than teaching none.
+ const { shortcuts, isMac } = useShortcuts();
// The camera lane borrows the Layout pane's "No Webcam" wording when there is no
// camera to grow, so the two surfaces say the same thing about the same project.
const ts = useScopedT("settings");
@@ -412,6 +610,7 @@ export function V4Timeline({
const { settings, set: setSettings } = useEditorSettings();
const [autoEnhanceOpen, setAutoEnhanceOpen] = useState(false);
+ const [audioMenuOpen, setAudioMenuOpen] = useState(false);
const [autoBusy, setAutoBusy] = useState(false);
// The AI cut pass reads the transcript, and the transcript is produced in the
// background (see transcriptionStore). Until it is there, the entry says why
@@ -439,6 +638,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 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
const total = useMemo(
() =>
Math.max(
@@ -448,6 +650,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
@@ -509,6 +713,7 @@ export function V4Timeline({
label: `${(p.member.customScale ?? ZOOM_DEPTH_SCALES[p.member.depth]).toFixed(2)}×`,
sourceIds: p.ids,
}));
+
// 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
@@ -660,11 +865,18 @@ export function V4Timeline({
// Drag a lane pill to move it (mode "move", keeps duration) or resize one
// edge (mode "l"/"r"). Zoom/speed/annotation are timeline-ms; trims map
// back to source-seconds through their carrying clip.
+ const selectPill = useCallback(
+ (pill: LanePill, additive: boolean) => {
+ tl.selectRegion(pill.kind, pill.id, { additive });
+ },
+ [tl],
+ );
+
const startPillDrag = useCallback(
(e: ReactPointerEvent, pill: LanePill, dragMode: "move" | "l" | "r") => {
e.preventDefault();
e.stopPropagation();
- tl.selectRegion(pill.kind, pill.id, { additive: e.shiftKey });
+ selectPill(pill, e.shiftKey);
// Scale drag deltas against the canvas (full zoomed timeline) width, so a
// drag tracks the cursor exactly regardless of padding, scrollbar or zoom.
const el = canvasRef.current;
@@ -776,7 +988,252 @@ export function V4Timeline({
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
},
- [tl, total, clips, pxPerSec],
+ [tl, selectPill, total, clips, pxPerSec],
+ );
+
+ // Live preview geometry for an audio track being dragged (issue #350), the
+ // audio-lane counterpart of activePillDrag — see startAudioDrag. Times are
+ // output-timeline (start) and source (trimStart/trimEnd) seconds.
+ const [audioDrag, setAudioDrag] = useState<{
+ id: string;
+ start: number;
+ trimStart: number;
+ trimEnd: number;
+ } | null>(null);
+ const audioDragRef = useRef(null);
+ /** `in -> out / length` pinned to the pointer while an audio edge is pulled or the
+ * media is slipped under the pill. Rendered at the component root: the lane sits
+ * inside the zoomed canvas transform, which would scale a chip placed in it. */
+ const [audioDragTip, setAudioDragTip] = useState<{
+ x: number;
+ y: number;
+ inSec: number;
+ outSec: number;
+ durationSec: number;
+ } | null>(null);
+ // The user-visible tracks and their lane rows. Packed from the STORED spans,
+ // not the live drag geometry: a pill that changed rows halfway through a drag
+ // would jump out from under the pointer.
+ const audioPills = useMemo(() => collapseTracksToPills(tl.audioTracks), [tl.audioTracks]);
+ // One row per KIND, and the packer unchanged INSIDE each kind (issue #560). Placement
+ // now clamps same-kind pills apart, so intra-kind packing is the legacy escape hatch —
+ // 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.
+ const audioRows = useMemo(() => {
+ const voice = audioPills.filter((p) => p.kind === "voiceover");
+ const music = audioPills.filter((p) => p.kind !== "voiceover");
+ const voiceRows = packAudioTrackRows(voice);
+ const musicRows = packAudioTrackRows(music);
+ const rowOf = new Map();
+ const base = voice.length > 0 ? voiceRows.rowCount : 0;
+ for (const pill of voice) rowOf.set(pill.id, voiceRows.rowOf.get(pill.id) ?? 0);
+ for (const pill of music) rowOf.set(pill.id, base + (musicRows.rowOf.get(pill.id) ?? 0));
+ return {
+ rowOf,
+ rowCount: Math.max(1, base + (music.length > 0 ? musicRows.rowCount : 0)),
+ };
+ }, [audioPills]);
+
+ // Whether Alt is held, so an audio pill can show that the next drag slips. Window
+ // listeners rather than per-pill handlers: the key is pressed BEFORE the pointer
+ // reaches the pill as often as after it, so a pill-local listener would miss the
+ // case the affordance exists for. `blur` clears it because a modifier held while
+ // the window loses focus never sends its keyup.
+ const [slipArmed, setSlipArmed] = useState(false);
+ useEffect(() => {
+ const sync = (e: KeyboardEvent) => setSlipArmed(e.altKey);
+ const clear = () => setSlipArmed(false);
+ window.addEventListener("keydown", sync);
+ window.addEventListener("keyup", sync);
+ window.addEventListener("blur", clear);
+ return () => {
+ window.removeEventListener("keydown", sync);
+ window.removeEventListener("keyup", sync);
+ window.removeEventListener("blur", clear);
+ };
+ }, []);
+
+ // Drag an audio track: "move" slides the head (both edges together), "l"/"r"
+ // trim the in/out points. The left edge moves the head AND the in-point so the
+ // right edge stays put — hence the single placeAudioTrack commit on release.
+ // Like the region pills, the preview is local state and the document is written
+ // once, on pointerup.
+ const startAudioDrag = useCallback(
+ (e: ReactPointerEvent, track: AxcutAudioTrack, mode: "move" | "l" | "r") => {
+ e.preventDefault();
+ e.stopPropagation();
+ tl.selectAudioTrack(track.id);
+ // Start clean: a previous drag's commit may still be in flight (its ref is
+ // cleared only when `placeAudioTrack` resolves). Without this, a plain
+ // select-click that never moves would let `up` read that stale value and
+ // re-commit the old drag — a redundant write and an extra undo step.
+ audioDragRef.current = null;
+ const el = canvasRef.current;
+ if (!el) return;
+ const r = el.getBoundingClientRect();
+ const startX = e.clientX;
+ const asset = tl.assets.find((a) => a.id === track.assetId);
+ // The source length caps the out-point; fall back to the current window when
+ // the file hasn't been probed (durationSec 0), so a drag can't extend past it.
+ const spanSec = Math.max(0, (track.endMs - track.startMs) / 1000);
+ const sourceLen = asset?.durationSec || track.durationSec || spanSec;
+ const origStart = track.startMs / 1000;
+ const origTrimStart = track.offsetMs / 1000;
+ const origTrimEnd = origTrimStart + spanSec;
+ // Alt inside the pill slips it: the span stays put and the media slides under
+ // it. On the BODY only — the edges keep their crop semantics.
+ //
+ // An edge drag sets the in-point at TIMELINE scale, which is unusable once the
+ // file is much longer than the pill: reaching 3:00 inside a four-minute bed on
+ // a five-second view means dragging three minutes of ruler. So the slip rate is
+ // derived from the FILE — one viewport width traverses all of it — floored at
+ // the timeline's own scale so a slip is never slower than moving the pill,
+ // which would be its own surprise on a file shorter than the view.
+ const slipping = mode === "move" && e.altKey && sourceLen > 0;
+ const slipSecPerPx = Math.max(total / r.width, sourceLen / Math.max(1, r.width * navSpan));
+ // A looping track may be pulled out PAST the end of its file — that is
+ // the whole point of looping, and capping at the source length is what
+ // made the loop toggle do nothing: the span could never exceed the
+ // window loop repeats, so it always played exactly once. Only the
+ // programme end bounds it (applied below).
+ const maxEnd = track.loop
+ ? Number.POSITIVE_INFINITY
+ : sourceLen > 0
+ ? sourceLen
+ : origTrimEnd;
+ // Snap the moving edge to clip boundaries and the timeline ends, same PILL_SNAP_PX
+ // magnet the region pills use.
+ const snapTargets = [
+ 0,
+ total,
+ ...clips.map((c) => c.timelineStartSec),
+ ...clips.map((c) => c.timelineEndSec),
+ ];
+ const snapThresh = pxPerSec > 0 ? PILL_SNAP_PX / pxPerSec : 0;
+ const snap = (v: number): number => {
+ let best = v;
+ let bestD = snapThresh;
+ for (const target of snapTargets) {
+ const d = Math.abs(target - v);
+ if (d < bestD) {
+ bestD = d;
+ best = target;
+ }
+ }
+ setSnapPct(best === v ? null : (best / total) * 100);
+ return best;
+ };
+ const move = (ev: PointerEvent) => {
+ if (slipping) {
+ const nextOffsetMs = slipAudioOffsetMs(
+ track.offsetMs,
+ track.endMs - track.startMs,
+ sourceLen,
+ (ev.clientX - startX) * slipSecPerPx * 1000,
+ );
+ if (nextOffsetMs == null) return;
+ const nextTrimStart = nextOffsetMs / 1000;
+ setAudioDragTip({
+ x: ev.clientX,
+ y: ev.clientY,
+ inSec: nextTrimStart,
+ outSec: nextTrimStart + spanSec,
+ durationSec: sourceLen,
+ });
+ // The span does not move; only the window onto the file does.
+ const slipState = {
+ id: track.id,
+ start: origStart,
+ trimStart: nextTrimStart,
+ trimEnd: nextTrimStart + spanSec,
+ };
+ audioDragRef.current = slipState;
+ setAudioDrag(slipState);
+ return;
+ }
+ const dxSec = ((ev.clientX - startX) / r.width) * total;
+ let ns = origStart;
+ let nts = origTrimStart;
+ let nte = origTrimEnd;
+ if (mode === "move") {
+ // Cap so the whole track lands by `total`: no pill past 100%, and the
+ // export (which truncates at the programme end) matches what's shown.
+ const upper = Math.max(0, total - (origTrimEnd - origTrimStart));
+ ns = Math.min(Math.max(0, snap(origStart + dxSec)), upper);
+ } else if (mode === "l") {
+ // The left edge can't cross the right one, and can't reveal more head
+ // than the source has (trimStart floors at 0 → head floors at
+ // origStart - origTrimStart).
+ const rightEdge = origStart + (origTrimEnd - origTrimStart);
+ const lowerLeft = Math.max(0, origStart - origTrimStart);
+ let newLeft = snap(origStart + dxSec);
+ newLeft = Math.min(Math.max(newLeft, lowerLeft), rightEdge - MIN_REGION_SEC);
+ ns = newLeft;
+ nts = origTrimStart + (newLeft - origStart);
+ nte = origTrimEnd;
+ } else {
+ // Right edge: move the out-point, head fixed. Snap on the timeline
+ // position of the edge, then map back to a source out-point.
+ const snappedRight = snap(origStart + (origTrimEnd - origTrimStart) + dxSec);
+ const newTrimEnd = origTrimStart + (snappedRight - origStart);
+ // Cap the out-point at the source length AND the programme end (`total`).
+ nte = Math.min(
+ Math.max(newTrimEnd, origTrimStart + MIN_REGION_SEC),
+ maxEnd,
+ origTrimStart + Math.max(0, total - origStart),
+ );
+ }
+ // The readout answers "where am I in the file", which is the one thing the
+ // pill cannot show: its edges stop at the content, but nothing said where
+ // that content was.
+ if (mode !== "move") {
+ setAudioDragTip({
+ x: ev.clientX,
+ y: ev.clientY,
+ inSec: nts,
+ outSec: nte,
+ durationSec: sourceLen,
+ });
+ }
+ const next = { id: track.id, start: ns, trimStart: nts, trimEnd: nte };
+ audioDragRef.current = next;
+ setAudioDrag(next);
+ };
+ const up = () => {
+ setSnapPct(null);
+ setAudioDragTip(null);
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ const fin = audioDragRef.current;
+ if (fin) {
+ void tl
+ .placeAudioTrack(fin.id, {
+ startMs: Math.round(fin.start * 1000),
+ endMs: Math.round((fin.start + Math.max(0, fin.trimEnd - fin.trimStart)) * 1000),
+ // Carries the left-edge trim: without it the head moved but the
+ // source kept playing from the same point, so dragging the edge
+ // in just slid the audio along instead of cutting its head off.
+ offsetMs: Math.round(fin.trimStart * 1000),
+ })
+ .finally(() => {
+ if (audioDragRef.current === fin) {
+ audioDragRef.current = null;
+ setAudioDrag(null);
+ }
+ });
+ } else {
+ audioDragRef.current = null;
+ setAudioDrag(null);
+ }
+ };
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ },
+ // navSpan: the slip rate is derived from the VISIBLE width, so a zoom that
+ // leaves `total` alone still changes it. Left out, the rate froze at whatever
+ // the zoom was when the callback was last built.
+ [tl, total, clips, pxPerSec, navSpan],
);
const startNavDrag = useCallback(
@@ -1142,8 +1599,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(seg.segEnd - seg.segStart)}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
? undefined
@@ -1158,6 +1617,24 @@ export function V4Timeline({
: {}),
}}
onPointerDown={seg.interactive ? (e) => startPillDrag(e, p, "move") : undefined}
+ // A pill is focusable and announced as a button, so Enter and Space have to
+ // activate it — without this a keyboard user could tab to a region and then
+ // reach nothing that acts on a selection: Delete, copy/paste, the inspector.
+ //
+ // `nativeEvent.stopPropagation()`, not just the synthetic one: the editor
+ // shell listens on WINDOW, above React's root container, and Space is bound
+ // to play/pause there. Stopping only the synthetic event would select the
+ // pill and toggle playback in the same keystroke.
+ onKeyDown={
+ seg.interactive
+ ? (e) => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ e.nativeEvent.stopPropagation();
+ selectPill(p, e.shiftKey);
+ }
+ : undefined
+ }
title={p.label}
>
{seg.interactive ? (
@@ -1271,118 +1748,211 @@ export function V4Timeline({
{showLanes ? (
-
-
-
+ // Its own provider rather than leaning on the app root's: the toolbar
+ // is the only thing here that needs one, and every test that renders
+ // a timeline (directly or through the shell) would otherwise have to
+ // know to supply it. Nesting under the root provider is harmless.
+
+
+
+
+
+
+ {autoBusy ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ void runAutoZooms()}
+ >
+
+
+ {t("toolbar.automaticZooms")}
+
+ {t("toolbar.automaticZoomsHint")}
+
+
+
+
+ {transcriptGate.state === "pending" ? (
+
+ ) : (
+
+ )}
+
+ {t("toolbar.smartZoomsAndCuts")}
+ {smartCutsHint}
+
+
+
+
+
+
+ {tools.map((tool) => (
+
+
+ {
+ // Read at CLICK time: a render-time value would be one zoom
+ // notch stale when the user zooms and immediately creates.
+ const dur = newRegionDurationSec();
+ if (tool.id === "speed") void tl.addSpeed(dur);
+ if (tool.id === "comment") void tl.addAnnotation(dur);
+ if (tool.id === "cut") void tl.addTrim(dur);
+ }}
+ >
+ {tool.icon}
+
+
+ {/* Add audio sits right after Add annotation (issue #350). */}
+ {/* One audio button, two ways in. A mic and a music note side by
+ side both just said "audio" and left the user to guess which
+ was which; a waveform is neutral between them, and the menu
+ names the two paths outright. Mirrors the auto-enhance
+ button's menu right next to it. */}
+ {tool.id === "comment" ? (
+
+
+
+
+
+
+
+
+
+
+ {
+ setAudioMenuOpen(false);
+ onAddVoiceover();
+ }}
+ >
+
+
+ {t("audio.addVoiceover")}
+
+ {t("audio.addVoiceoverHint")}
+
+
+
+ {formatBinding(shortcuts.addVoiceover, isMac)}
+
+
+ {
+ setAudioMenuOpen(false);
+ void tl.addAudio();
+ }}
+ >
+
+
+ {ts("audioTrack.add")}
+
+ {t("audio.importFileHint")}
+
+
+
+ {formatBinding(shortcuts.addAudio, isMac)}
+
+
+
+
+
+ ) : null}
+
+ ))}
+
void tl.addZoom(newRegionDurationSec())}
>
- {autoBusy ? : }
+
-
-
+
- void setSettings({ autoFocusAll: !settings.autoFocusAll })}
>
- void runAutoZooms()}
- >
-
-
- {t("toolbar.automaticZooms")}
-
- {t("toolbar.automaticZoomsHint")}
-
-
-
-
- {transcriptGate.state === "pending" ? (
-
- ) : (
-
- )}
-
- {t("toolbar.smartZoomsAndCuts")}
- {smartCutsHint}
-
-
-
-
-
-
- {tools.map((tool) => (
- {
- // Read at CLICK time: a render-time value would be one zoom
- // notch stale when the user zooms and immediately creates.
- const dur = newRegionDurationSec();
- if (tool.id === "speed") void tl.addSpeed(dur);
- if (tool.id === "comment") void tl.addAnnotation(dur);
- if (tool.id === "cut") void tl.addTrim(dur);
- }}
- >
- {tool.icon}
-
- ))}
- void tl.addZoom(newRegionDurationSec())}
- >
-
-
- void setSettings({ autoFocusAll: !settings.autoFocusAll })}
- >
-
-
- void tl.addCameraFullscreen(newRegionDurationSec())}
- >
-
-
-
+
+
+
+
+ void tl.addCameraFullscreen(newRegionDurationSec())}
+ >
+
+
+
+
+
) : (
// Media is an ARRANGING surface: add, remove, reorder. Nothing here
// plays or edits, so the transport, the scroll hints, the zoom nav and
@@ -1443,7 +2013,7 @@ export function V4Timeline({
{tick.major ? (
{fmtTick(tick.sec, rulerTicks.step)}
@@ -1482,6 +2052,93 @@ export function V4Timeline({
hasAnyCamera ? t("hints.pressCameraFullscreen") : ts("layout.noWebcam"),
)}
+ {/* Imported audio tracks (issue #350). Always shown, like every other
+ lane — "Add audio" is a toolbar peer of the region tools now (and
+ has a keyboard shortcut), so an empty lane advertises the shortcut
+ that fills it rather than hiding until the first import. */}
+
+ {tl.audioTracks.length === 0 ? (
+
+ {t("hints.pressAudio")}
+
+ ) : (
+ // One pill per user-visible track: the document stores one
+ // clip-anchored fragment per clip the track covers, and the
+ // lane must not show a split take as two pills.
+ audioPills.map((track) => {
+ const asset = tl.assets.find((a) => a.id === track.assetId);
+ const duration = asset?.durationSec ?? track.durationSec;
+ // While this track is being dragged, lay it out from the live
+ // preview geometry instead of the not-yet-written document.
+ const drag = audioDrag?.id === track.id ? audioDrag : null;
+ const start = drag ? drag.start : track.startMs / 1000;
+ // A drag carries its span as the trim window it is dragging
+ // the edges of; the pill's width is that window.
+ const widthSec = drag
+ ? Math.max(0, drag.trimEnd - drag.trimStart)
+ : Math.max(0, (track.endMs - track.startMs) / 1000);
+ const trimStart = drag ? drag.trimStart : track.offsetMs / 1000;
+ const trimEnd = trimStart + widthSec;
+ return (
+
0 ? Math.min(trimEnd, duration) : trimEnd}
+ selected={tl.selectedAudioTrackId === track.id}
+ onStartDrag={startAudioDrag}
+ onSelect={tl.selectAudioTrack}
+ label={track.label || asset?.label || ts("audioTrack.defaultLabel")}
+ slipHint={ts("audioTrack.slipHint")}
+ slipArmed={slipArmed}
+ outputGain={audioGainScalar(settings.audioGainDb)}
+ ghost={((g) =>
+ g
+ ? {
+ leftPct: pctOf(g.startT),
+ widthPct: pctOf(g.endT - g.startT),
+ sourceStartSec: g.sourceStartSec,
+ sourceEndSec: g.sourceEndSec,
+ }
+ : null)(
+ audioGhostExtent(
+ trimStart,
+ widthSec,
+ duration,
+ start,
+ start + widthSec,
+ total,
+ ),
+ )}
+ />
+ );
+ })
+ )}
+
>
) : null}
@@ -1503,6 +2160,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 = 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;
const selected = tl.clipSelection === c.id;
@@ -1522,20 +2185,29 @@ 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 (
startClipDrag(e, c)}
@@ -1583,6 +2255,7 @@ export function V4Timeline({
type="button"
data-no-clip-drag
className={styles.tlClipDelete}
+ data-narrow={narrow ? "true" : undefined}
title={t("toolbar.deleteClip")}
aria-label={t("toolbar.deleteClip")}
onClick={(e) => {
@@ -1650,6 +2323,16 @@ export function V4Timeline({
) : null}
+ {/* The crop readout, at the component ROOT rather than in the lane: the lane
+ sits inside the zoomed canvas transform, which would scale a chip placed
+ there. `in -> out / length` — 0:00.0 and out = length are the boundary
+ states, self-evident without copy, which is why this adds no locale key. */}
+ {audioDragTip ? (
+
+ {formatSec(audioDragTip.inSec)} → {formatSec(audioDragTip.outSec)}
+ {audioDragTip.durationSec > 0 ? ` / ${formatSec(audioDragTip.durationSec)}` : ""}
+
+ ) : null}
);
}
diff --git a/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx b/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx
index b5b2d9949..b62a303e7 100644
--- a/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx
@@ -54,6 +54,7 @@ vi.mock("@/lib/ai-edition/store/useEditorSettings", () => ({
}),
}));
+import { ShortcutsProvider } from "@/contexts/ShortcutsContext";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { V4Timeline } from "./V4Timeline";
@@ -96,6 +97,8 @@ beforeAll(() => {
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",
@@ -115,6 +118,9 @@ function renderBars(atGainDb: number): string[] {
selection: null,
multiSelection: [],
clipSelection: null,
+ audioTracks: [],
+ selectedAudioTrackId: null,
+ selectAudioTrack: vi.fn(),
clearSelection: vi.fn(),
selectRegion: vi.fn(),
selectClip: vi.fn(),
@@ -122,16 +128,19 @@ function renderBars(atGainDb: number): string[] {
addZoom: vi.fn(async () => undefined),
};
const view = render(
- }
- videoSources={[{ id: "a1", src: "file:///tmp/rec.mp4", label: "rec" }]}
- setCurrentTime={vi.fn()}
- playing={false}
- onTogglePlay={vi.fn()}
- onPrevClip={vi.fn()}
- onNextClip={vi.fn()}
- onEditClip={vi.fn()}
- />,
+
+ }
+ videoSources={[{ id: "a1", src: "file:///tmp/rec.mp4", label: "rec" }]}
+ setCurrentTime={vi.fn()}
+ playing={false}
+ onTogglePlay={vi.fn()}
+ onPrevClip={vi.fn()}
+ onNextClip={vi.fn()}
+ onEditClip={vi.fn()}
+ onAddVoiceover={vi.fn()}
+ />
+ ,
);
const bars = Array.from(
document.querySelectorAll('[class*="tlWave"] span'),
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index 9341c8d3e..807481b6d 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -9,9 +9,18 @@ function Popover({ ...props }: React.ComponentProps ;
}
-function PopoverTrigger({ ...props }: React.ComponentProps) {
- return ;
-}
+// forwardRef, like the Dialog parts: on React 18 a plain function component
+// cannot receive a ref, so anything that wraps this trigger with its own
+// `asChild` — a Tooltip around a popover button, say — fails to anchor and
+// warns. The primitive underneath has always forwarded; only this wrapper
+// swallowed it.
+const PopoverTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentProps
+>(({ ...props }, ref) => (
+
+));
+PopoverTrigger.displayName = "PopoverTrigger";
function PopoverContent({
className,
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index c5dfc12b5..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,
@@ -46,25 +53,27 @@ function TooltipContent({
);
}
-function Tooltip({
- children,
- content,
- side,
- className,
-}: {
- children: React.ReactNode;
- content: React.ReactNode;
- side?: "top" | "right" | "bottom" | "left";
- className?: string;
-}) {
- return (
-
- {children}
-
- {content}
-
-
- );
-}
+// forwardRef for the same reason as PopoverTrigger above: this is a convenience
+// wrapper people nest inside other `asChild` triggers, and on React 18 a plain
+// function component silently drops the ref it is handed.
+const Tooltip = React.forwardRef<
+ React.ComponentRef,
+ {
+ children: React.ReactNode;
+ content: React.ReactNode;
+ side?: "top" | "right" | "bottom" | "left";
+ className?: string;
+ }
+>(({ children, content, side, className }, ref) => (
+
+
+ {children}
+
+
+ {content}
+
+
+));
+Tooltip.displayName = "Tooltip";
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx
index 91bd7f8d3..6f5e13731 100644
--- a/src/contexts/ShortcutsContext.tsx
+++ b/src/contexts/ShortcutsContext.tsx
@@ -39,9 +39,14 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
setIsMac(getIsMac());
+ // Guard `electronAPI` itself, not just the method on it — that is what the
+ // note above is after. Without preload (browser mode, and any test that
+ // renders a consumer) the bare property read threw and took the whole
+ // subtree with it, rather than falling back to the defaults already in
+ // state.
window.electronAPI
- .getShortcuts?.()
- .then((saved) => {
+ ?.getShortcuts?.()
+ ?.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial));
}
@@ -54,9 +59,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
const configToSave = config ?? shortcuts;
- await window.electronAPI.saveShortcuts?.(configToSave);
+ await window.electronAPI?.saveShortcuts?.(configToSave);
- const result = await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp);
+ const result = await window.electronAPI?.updateGlobalShortcut?.(configToSave.openApp);
return result ? result.success : true;
},
[shortcuts],
diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json
index 43caf5938..cf7c6f554 100644
--- a/src/i18n/locales/ar/dialogs.json
+++ b/src/i18n/locales/ar/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "حفظ GIF المصدر",
"saveVideo": "حفظ الفيديو المصدر",
"selectVideo": "حدد ملف فيديو",
+ "selectAudio": "اختر ملف صوت",
"saveProject": "حفظ مشروع OpenScreen",
"openProject": "فتح مشروع OpenScreen",
"gifImage": "صورة GIF",
"mp4Video": "فيديو MP4",
"videoFiles": "ملفات فيديو",
+ "audioFiles": "ملفات الصوت",
"openscreenProject": "مشروع OpenScreen",
"allFiles": "جميع الملفات"
}
diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json
index 04bd4eba8..45190b2eb 100644
--- a/src/i18n/locales/ar/editor.json
+++ b/src/i18n/locales/ar/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "جاري تحميل الفيديو...",
"loadingEditor": "جارٍ تحميل المحرر...",
"errors": {
- "noVideoLoaded": "لم يتم تحميل أي فيديو",
- "videoNotReady": "الفيديو غير جاهز",
- "unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
- "failedToSaveGif": "فشل حفظ GIF",
- "gifExportFailed": "فشل تصدير GIF",
- "failedToSaveVideo": "فشل حفظ الفيديو",
+ "exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
"exportFailed": "فشل التصدير",
"exportFailedWithError": "فشل التصدير: {{error}}",
- "exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
+ "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}",
"failedToSaveExport": "فشل حفظ التصدير",
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
- "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}",
- "previewCompositorUnavailable": "المعاينة غير متوفرة على هذا الجهاز"
+ "failedToSaveGif": "فشل حفظ GIF",
+ "failedToSaveVideo": "فشل حفظ الفيديو",
+ "gifExportFailed": "فشل تصدير GIF",
+ "noVideoLoaded": "لم يتم تحميل أي فيديو",
+ "previewCompositorUnavailable": "المعاينة غير متوفرة على هذا الجهاز",
+ "trimNoFilm": "لا شيء لقصّه هناك — لا توجد لقطات أسفل تلك الكلمات.",
+ "unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
+ "videoNotReady": "الفيديو غير جاهز",
+ "wordEditFailed": "تعذّر تغيير هذه الكلمة",
+ "wordInsertFailed": "تعذّرت إضافة هذه الكلمة",
+ "wordRemoveFailed": "تعذّر حذف هذه الكلمة"
},
"export": {
"canceled": "تم إلغاء التصدير",
@@ -71,6 +75,7 @@
"pasted": "تم لصق سمات {{region}}",
"nothingToCopy": "حدد منطقة لنسخ سماتها",
"nothingToPaste": "لم يتم نسخ أي سمات بعد",
+ "pasteAssetMissing": "ملف هذا المسار الصوتي غير موجود في هذا المشروع",
"kinds": {
"zoom": "تكبير",
"speed": "سرعة",
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index f1e65ba1c..3a9e392ca 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
- "level": "مستوى التكبير",
- "selectRegion": "حدد منطقة التكبير للتعديل",
- "deleteZoom": "حذف التكبير",
- "focusMode": {
- "title": "وضع التركيز",
- "manual": "يدوي",
- "auto": "تلقائي",
- "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل",
- "lockedDisclaimer": "يتم التحكم به بواسطة مفتاح التركيز التلقائي العام في الخط الزمني. أوقف تشغيله لضبط وضع التركيز لكل تكبير على حدة."
- },
- "customScale": "تكبير مخصص",
- "position": {
- "title": "موضع التركيز",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل"
- },
- "threeD": {
- "title": "دوران ثلاثي الأبعاد",
- "preset": {
- "iso": "متساوي القياس",
- "left": "يسار",
- "right": "يمين"
- },
- "none": "بلا"
- }
- },
- "speed": {
- "playbackSpeed": "سرعة التشغيل",
- "selectRegion": "حدد منطقة السرعة للتعديل",
- "deleteRegion": "حذف منطقة السرعة",
- "customPlaybackSpeed": "سرعة تشغيل مخصصة",
- "maxSpeedError": "لا يمكن للسرعة أن تتجاوز {{max}}×",
- "previewFrameSteppingHint": "فوق {{native}}×، تعرض المعاينة إطارًا تلو الآخر وبدون صوت. لا يتأثر التصدير."
- },
- "trim": {
- "deleteRegion": "حذف منطقة القص"
- },
- "layout": {
- "title": "تخطيط الكاميرا",
- "preset": "الإعداد المسبق",
- "selectPreset": "حدد إعدادًا مسبقًا",
- "pictureInPicture": "صورة داخل صورة",
- "verticalStack": "تكدس عمودي",
- "dualFrame": "إطار مزدوج",
- "webcamShape": "شكل الكاميرا",
- "webcamSize": "حجم كاميرا الويب",
- "noWebcam": "بدون كاميرا",
- "mirrorWebcam": "عكس كاميرا الويب",
- "reactiveWebcam": "تصغير عند التكبير",
- "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
- "help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
- "helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
- "webcamFraming": "تأطير كاميرا الويب",
- "webcamCropZoom": "تكبير الاقتصاص",
- "webcamCropX": "تحريك أفقي",
- "webcamCropY": "تحريك عمودي",
- "shapes": {
- "rectangle": "مستطيل",
- "circle": "دائرة",
- "square": "مربع",
- "rounded": "زوايا مستديرة"
- },
- "webcamBackground": "خلفية الكاميرا",
- "webcamBlurIntensity": "شدة الضبابية",
- "bgModes": {
- "none": "الأصلي",
- "transparent": "تفريغ",
- "blur": "تمويه",
- "custom": "مخصص"
- }
- },
- "effects": {
- "title": "التركيب",
- "blurBg": "تمويه الخلفية",
- "motionBlur": "ضبابية الحركة",
- "off": "إيقاف",
- "on": "تشغيل",
- "shadow": "ظل",
- "roundness": "الاستدارة",
- "padding": "المسافة البادئة",
- "frame": "الإطار",
- "format": "التنسيق",
- "formatOriginal": "الأصلي",
- "fitClip": "ملاءمة",
- "fitClipOne": "مقطع واحد",
- "fitClipFew": "{{count}} مقاطع",
- "fitClipMany": "{{count}} مقاطع",
- "motion": "الحركة",
- "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو."
- },
"background": {
+ "gradientLabel": "تدرج لوني {{index}}",
+ "uploadCustom": "رفع صورة مخصصة",
+ "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
"title": "الخلفية",
- "image": "صورة",
- "color": "لون",
- "gradient": "تدرج لوني",
+ "imageLabel": "الخلفية {{index}}",
"custom": "مخصص",
- "uploadCustom": "رفع صورة مخصصة",
- "gradientLabel": "تدرج لوني {{index}}",
- "colorWheel": "عجلة الألوان",
- "colorPalette": "لوحة الألوان",
- "presets": "إعدادات مسبقة",
"help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
+ "gradient": "تدرج لوني",
+ "colorLabel": "اللون {{color}}",
"customWallpaper": "خلفية مخصصة",
- "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
+ "colorPalette": "لوحة الألوان",
"imageReadFailed": "تعذّر قراءة ملف الصورة.",
- "imageLabel": "الخلفية {{index}}",
- "colorLabel": "اللون {{color}}"
- },
- "crop": {
- "title": "اقتصاص",
- "cropVideo": "اقتصاص الفيديو",
- "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
- "ratio": "النسبة",
- "free": "حر",
- "done": "تم",
- "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
- "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "فيديو MP4",
- "mp4Description": "ملف فيديو عالي الجودة",
- "gifAnimation": "صورة GIF متحركة",
- "gifDescription": "صورة متحركة للمشاركة"
- },
- "exportQuality": {
- "title": "دقة التصدير",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "معدل إطارات GIF",
- "size": "حجم GIF",
- "loop": "تكرار GIF"
- },
- "project": {
- "save": "حفظ المشروع",
- "load": "تحميل المشروع",
- "new": "مشروع جديد"
- },
- "export": {
- "videoButton": "تصدير الفيديو",
- "gifButton": "تصدير GIF",
- "chooseSaveLocation": "اختيار موقع الحفظ"
+ "image": "صورة",
+ "presets": "إعدادات مسبقة",
+ "color": "لون",
+ "colorWheel": "عجلة الألوان"
},
- "support": {
- "reportBug": "الإبلاغ عن خطأ",
- "saveDiagnostics": "حفظ التشخيصات",
- "starOnGithub": "إعطاء نجمة على GitHub"
+ "customFont": {
+ "namePlaceholder": "خطي المخصص",
+ "failedToAdd": "فشل في إضافة الخط",
+ "addingButton": "جاري الإضافة...",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
+ "urlLabel": "رابط استيراد خطوط Google",
+ "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
+ "nameLabel": "اسم العرض",
+ "errorEmptyName": "يرجى إدخال اسم الخط",
+ "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
+ "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "dialogTitle": "إضافة خط Google",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
+ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google.",
+ "addButton": "إضافة خط"
},
"imageUpload": {
"invalidFileType": "نوع ملف غير صالح",
+ "failedToUpload": "فشل رفع الصورة",
"jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
"uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
- "failedToUpload": "فشل رفع الصورة",
"errorReading": "حدث خطأ أثناء قراءة الملف."
},
"annotation": {
- "title": "إعدادات الشروح",
- "active": "نشط",
- "typeText": "نص",
- "typeImage": "صورة",
- "typeArrow": "سهم",
- "typeBlur": "تمويه",
- "textContent": "محتوى النص",
- "textPlaceholder": "أدخل النص هنا...",
- "defaultText": "مرحبا",
- "fontStyle": "نمط الخط",
- "selectStyle": "حدد النمط",
+ "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "مستطيل",
"size": "الحجم",
- "customFonts": "خطوط مخصصة",
- "textColor": "لون النص",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "clearBackground": "مسح الخلفية",
+ "colorPalette": "لوحة الألوان",
+ "invalidImageType": "نوع ملف غير صالح",
"background": "الخلفية",
- "none": "بدون",
+ "typeText": "نص",
+ "active": "نشط",
"color": "لون",
- "colorWheel": "عجلة الألوان",
- "colorPalette": "لوحة الألوان",
- "clearBackground": "مسح الخلفية",
- "uploadImage": "رفع صورة",
- "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "رسم حر",
"arrowDirection": "اتجاه السهم",
- "strokeWidth": "عرض الخط: {{width}}px",
- "arrowColor": "لون السهم",
- "blurType": "نوع التمويه",
- "blurTypeBlur": "غاوسي",
"blurTypeMosaic": "فسيفساء",
+ "colorWheel": "عجلة الألوان",
+ "textColor": "لون النص",
+ "title": "إعدادات الشروح",
+ "blurType": "نوع التمويه",
+ "typeBlur": "تمويه",
+ "blurIntensity": "كثافة التمويه",
+ "selectStyle": "حدد النمط",
+ "textContent": "محتوى النص",
+ "typeArrow": "سهم",
+ "none": "بدون",
"blurColor": "لون التمويه",
- "blurColorWhite": "أبيض",
- "blurColorBlack": "أسود",
+ "customFonts": "خطوط مخصصة",
+ "imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "type": "النوع",
+ "arrowColor": "لون السهم",
+ "textPlaceholder": "أدخل النص هنا...",
+ "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
"blurShape": "شكل التمويه",
- "blurIntensity": "كثافة التمويه",
+ "uploadImage": "رفع صورة",
+ "blurTypeBlur": "غاوسي",
+ "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
+ "shortcutsAndTips": "اختصارات ونصائح",
+ "deleteAnnotation": "حذف الشرح",
+ "fontStyle": "نمط الخط",
+ "defaultText": "مرحبا",
"mosaicBlockSize": "حجم كتلة الفسيفساء",
- "blurShapeRectangle": "مستطيل",
+ "blurColorBlack": "أسود",
+ "strokeWidth": "عرض الخط: {{width}}px",
"blurShapeOval": "بيضاوي",
- "blurShapeFreehand": "رسم حر",
- "deleteAnnotation": "حذف الشرح",
- "shortcutsAndTips": "اختصارات ونصائح",
+ "blurColorWhite": "أبيض",
"tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
- "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
- "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
- "invalidImageType": "نوع ملف غير صالح",
- "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
- "imageUploadSuccess": "تم رفع الصورة بنجاح!",
- "type": "النوع"
- },
- "textAnimation": {
- "title": "تحريك النص",
- "selectAnimation": "حدد الحركة",
- "none": "بدون",
- "fade": "تلاشي",
- "rise": "ارتفاع",
- "pop": "ظهور",
- "slideLeft": "انزلاق لليسار",
- "typewriter": "آلة كاتبة",
- "pulse": "نبض"
+ "typeImage": "صورة"
},
- "customFont": {
- "dialogTitle": "إضافة خط Google",
- "urlLabel": "رابط استيراد خطوط Google",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
- "nameLabel": "اسم العرض",
- "namePlaceholder": "خطي المخصص",
- "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
- "addButton": "إضافة خط",
- "addingButton": "جاري الإضافة...",
- "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
- "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
- "errorEmptyName": "يرجى إدخال اسم الخط",
- "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
- "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
- "failedToAdd": "فشل في إضافة الخط",
- "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
- "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
- },
- "cursor": {
- "title": "المؤشر",
- "theme": "نمط المؤشر",
- "themeDefault": "افتراضي",
- "show": "إظهار المؤشر",
- "size": "الحجم",
- "smoothing": "التنعيم",
+ "effects": {
+ "fitClipFew": "{{count}} مقاطع",
+ "title": "التركيب",
+ "shadow": "ظل",
+ "off": "إيقاف",
+ "on": "تشغيل",
+ "blurBg": "تمويه الخلفية",
+ "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو.",
+ "fitClipOne": "مقطع واحد",
+ "formatOriginal": "الأصلي",
+ "fitClipMany": "{{count}} مقاطع",
+ "frame": "الإطار",
+ "motion": "الحركة",
+ "padding": "المسافة البادئة",
+ "format": "التنسيق",
+ "fitClip": "ملاءمة",
"motionBlur": "ضبابية الحركة",
- "clickBounce": "ارتداد النقر",
- "clipToBounds": "القص ضمن اللوحة",
- "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.",
- "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر."
- },
- "language": {
- "title": "اللغة"
- },
- "facets": {
- "captions": "الترجمة",
- "transcript": "النص"
- },
- "panes": {
- "help": "مساعدة"
+ "roundness": "الاستدارة"
},
"transcript": {
- "title": "النص الحالي",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لتمييزه كمتخطّى (بالأحمر). مرّر المؤشر فوق المقطع الأحمر لاستعادته.",
- "noClips": "لا توجد مقاطع بعد",
+ "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": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
- "editorAria": "نص {{filename}}",
- "silence": "[صمت {{duration}} ث]",
- "restoreSilence": "استعادة الصمت ({{duration}} ث)",
- "trimSilence": "قص الصمت ({{duration}} ث)",
- "restoreWord": "استعادة \"{{word}}\"",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي"
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "blankedWord": "مُفرَّغة"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "ملف فيديو عالي الجودة",
+ "gifAnimation": "صورة GIF متحركة",
+ "mp4Video": "فيديو MP4",
+ "gifDescription": "صورة متحركة للمشاركة",
+ "gif": "GIF"
},
"captions": {
- "show": "إظهار الترجمة",
- "noTranscript": "تُقرأ الترجمة من نص الوسائط. فرّغ نص هذا الفيديو لتفعيلها.",
- "transcribe": "تفريغ نص الفيديو",
- "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
- "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
- "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
- "removeLegacyAnnotations": "إزالة تعليقات الترجمة القديمة",
- "language": "اللغة",
- "displayLanguage": "العرض",
- "original": "الأصل (النص المفرّغ)",
- "translate": "ترجمة",
- "translating": "جارٍ الترجمة…",
- "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
- "translateFailed": "فشلت الترجمة.",
+ "showBackground": "إظهار الخلفية",
"deleteTranslation": "حذف هذه الترجمة",
+ "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
+ "backgroundOpacity": "العتامة",
+ "backgroundColor": "لون الخلفية",
+ "alignCenter": "توسيط",
"translationIsNonDestructive": "تُحفظ الترجمات بجانب النص المفرّغ لا داخله — يبقى النص الأصلي وتوقيتاته دون تغيير.",
+ "distanceFromRight": "المسافة من اليمين",
+ "language": "اللغة",
"text": "النص",
- "font": "الخط",
- "fontSize": "الحجم",
- "bold": "عريض",
- "textColor": "لون النص",
- "background": "الخلفية",
- "showBackground": "إظهار الخلفية",
- "backgroundColor": "لون الخلفية",
- "backgroundOpacity": "العتامة",
- "position": "الموضع",
- "anchorBottom": "أسفل",
- "anchorTop": "أعلى",
"anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.",
- "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.",
- "distanceFromBottom": "المسافة من الأسفل",
"distanceFromTop": "المسافة من الأعلى",
- "distanceFromLeft": "المسافة من اليسار",
- "distanceFromRight": "المسافة من اليمين",
+ "translateFailed": "فشلت الترجمة.",
"alignLeft": "يسار",
- "alignCenter": "توسيط",
+ "distanceFromBottom": "المسافة من الأسفل",
+ "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
+ "translate": "ترجمة",
+ "position": "الموضع",
+ "fontSize": "الحجم",
+ "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
+ "noTranscript": "تُقرأ الترجمة من نص الوسائط. فرّغ نص هذا الفيديو لتفعيلها.",
+ "distanceFromLeft": "المسافة من اليسار",
+ "anchorBottom": "أسفل",
+ "transcribe": "تفريغ نص الفيديو",
+ "bold": "عريض",
"alignRight": "يمين",
- "lineLength": "طول السطر",
+ "anchorTop": "أعلى",
"minWords": "أقل عدد كلمات في السطر",
- "maxWords": "أكثر عدد كلمات في السطر"
+ "translateHint": "ترجمة النص المفرّغ باستخدام مزوّد الذكاء الاصطناعي المُعدّ",
+ "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": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
+ "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": {
+ "slideLeft": "انزلاق لليسار",
+ "pulse": "نبض",
+ "typewriter": "آلة كاتبة",
+ "selectAnimation": "حدد الحركة",
+ "fade": "تلاشي",
+ "title": "تحريك النص",
+ "none": "بدون",
+ "pop": "ظهور",
+ "rise": "ارتفاع"
+ },
+ "facets": {
+ "transcript": "النص",
+ "captions": "الترجمة"
+ },
+ "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": {
- "title": "الصوت",
"outputGain": "ضبط مستوى الإخراج",
+ "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير.",
"reset": "إعادة ضبط الصوت",
- "help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير."
+ "title": "الصوت"
+ },
+ "language": {
+ "title": "اللغة"
+ },
+ "project": {
+ "new": "مشروع جديد",
+ "load": "تحميل المشروع",
+ "save": "حفظ المشروع"
+ },
+ "support": {
+ "starOnGithub": "إعطاء نجمة على GitHub",
+ "saveDiagnostics": "حفظ التشخيصات",
+ "reportBug": "الإبلاغ عن خطأ"
+ },
+ "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/shortcuts.json b/src/i18n/locales/ar/shortcuts.json
index d8c063784..137a0186e 100644
--- a/src/i18n/locales/ar/shortcuts.json
+++ b/src/i18n/locales/ar/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "إضافة قص",
"addSpeed": "إضافة سرعة",
"addAnnotation": "إضافة شرح",
+ "addAudio": "إضافة صوت",
+ "addVoiceover": "تسجيل تعليق صوتي",
"addKeyframe": "إضافة إطار رئيسي",
"addCameraFullscreen": "إضافة كاميرا كاملة الشاشة",
"deleteSelected": "حذف المحدد",
diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json
index 4412a0a0b..d4d77c4af 100644
--- a/src/i18n/locales/ar/timeline.json
+++ b/src/i18n/locales/ar/timeline.json
@@ -15,6 +15,7 @@
"pressZoom": "اضغط Z لإضافة تكبير",
"pressTrim": "اضغط T لإضافة قص",
"pressAnnotation": "اضغط A لإضافة شرح",
+ "pressAudio": "اضغط M لإضافة صوت، وV لتسجيل تعليق صوتي",
"pressSpeed": "اضغط S لإضافة سرعة",
"pressCameraFullscreen": "اضغط C لإضافة مقطع كاميرا كاملة الشاشة"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "يتطلب نصًا منسوخًا",
"smartCutsNoAudio": "لا يحتوي هذا الملف على صوت",
"smartCutsNoSpeech": "لم يتم اكتشاف كلام",
- "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط"
+ "smartCutsFailed": "فشل النسخ — أعد المحاولة من قسم الوسائط",
+ "addAudioTooltip": "إضافة صوت",
+ "addedWord": "كلمة مضافة: \"{{word}}\" — لا صوت خلفها"
+ },
+ "audio": {
+ "addVoiceover": "إضافة تعليق صوتي",
+ "addVoiceoverHint": "سجّل تعليقًا صوتيًا فوق الفيديو",
+ "subtitle": "ضع تعليقًا صوتيًا أو موسيقى خلفية على المخطط الزمني",
+ "record": "تسجيل تعليق صوتي",
+ "importFile": "استيراد ملف صوتي",
+ "importFileHint": "أدرج موسيقى أو ملفًا صوتيًا",
+ "recording": "جارٍ التسجيل",
+ "recordingHint": "علّق صوتيًا مع الفيديو — يعمل أثناء التسجيل",
+ "stop": "إيقاف",
+ "micDenied": "تم رفض الوصول إلى الميكروفون",
+ "recordingUnavailable": "التسجيل غير متاح هنا",
+ "saveFailed": "تعذر حفظ التسجيل",
+ "importFailed": "تعذر استيراد الملف الصوتي"
}
}
diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json
index 90599af16..9ce8e3ded 100644
--- a/src/i18n/locales/en/dialogs.json
+++ b/src/i18n/locales/en/dialogs.json
@@ -42,7 +42,6 @@
"step1Title": "1. Add Trim",
"step1DescriptionBefore": "Press ",
"step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
-
"step2Title": "2. Adjust",
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
},
@@ -80,11 +79,13 @@
"saveGif": "Save Exported GIF",
"saveVideo": "Save Exported Video",
"selectVideo": "Select Video File",
+ "selectAudio": "Select Audio File",
"saveProject": "Save OpenScreen Project",
"openProject": "Open OpenScreen Project",
"gifImage": "GIF Image",
"mp4Video": "MP4 Video",
"videoFiles": "Video Files",
+ "audioFiles": "Audio Files",
"openscreenProject": "OpenScreen Project",
"allFiles": "All Files"
}
diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json
index e1692652d..6b738c399 100644
--- a/src/i18n/locales/en/editor.json
+++ b/src/i18n/locales/en/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "Loading video...",
"loadingEditor": "Loading editor...",
"errors": {
- "noVideoLoaded": "No video loaded",
- "videoNotReady": "Video not ready",
- "unableToDetermineSourcePath": "Unable to determine source video path",
- "failedToSaveGif": "Failed to save GIF",
- "gifExportFailed": "GIF export failed",
- "failedToSaveVideo": "Failed to save video",
+ "exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {{error}}",
- "exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})",
+ "failedToRevealInFolder": "Error revealing in folder: {{error}}",
"failedToSaveExport": "Failed to save export",
"failedToSaveExportedVideo": "Failed to save exported video",
- "failedToRevealInFolder": "Error revealing in folder: {{error}}",
- "previewCompositorUnavailable": "Preview unavailable on this machine"
+ "failedToSaveGif": "Failed to save GIF",
+ "failedToSaveVideo": "Failed to save video",
+ "gifExportFailed": "GIF export failed",
+ "noVideoLoaded": "No video loaded",
+ "previewCompositorUnavailable": "Preview unavailable on this machine",
+ "trimNoFilm": "Nothing to cut there — no film sits under those words.",
+ "unableToDetermineSourcePath": "Unable to determine source video path",
+ "videoNotReady": "Video not ready",
+ "wordEditFailed": "Could not change that word",
+ "wordInsertFailed": "Could not add that word",
+ "wordRemoveFailed": "Could not delete that word"
},
"export": {
"canceled": "Export canceled",
@@ -71,6 +75,7 @@
"pasted": "{{region}} attributes pasted",
"nothingToCopy": "Select a region to copy its attributes",
"nothingToPaste": "No attributes copied yet",
+ "pasteAssetMissing": "That audio track's file isn't in this project",
"kinds": {
"zoom": "Zoom",
"speed": "Speed",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 845f7e7fe..7053126b8 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Hold to preview zoom effect",
- "level": "Zoom Level",
- "customScale": "Custom Zoom",
- "selectRegion": "Select a zoom region to adjust",
- "deleteZoom": "Delete Zoom",
- "focusMode": {
- "title": "Focus Mode",
- "manual": "Manual",
- "auto": "Auto",
- "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."
- },
- "threeD": {
- "title": "3D Rotation",
- "preset": {
- "iso": "Iso",
- "left": "Left",
- "right": "Right"
- },
- "none": "None"
- },
- "position": {
- "title": "Focus Position",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost"
- }
- },
- "speed": {
- "playbackSpeed": "Playback Speed",
- "selectRegion": "Select a speed region to adjust",
- "deleteRegion": "Delete Speed Region",
- "customPlaybackSpeed": "Custom Playback Speed",
- "maxSpeedError": "Speed can't go higher than {{max}}×",
- "previewFrameSteppingHint": "Above {{native}}×, preview is frame-stepped and muted. Export is unaffected."
- },
- "trim": {
- "deleteRegion": "Delete Trim Region"
- },
- "layout": {
- "title": "Camera layout",
- "preset": "Preset",
- "selectPreset": "Select preset",
- "pictureInPicture": "Picture in Picture",
- "verticalStack": "Vertical Stack",
- "dualFrame": "Dual Frame",
- "noWebcam": "No Webcam",
- "webcamShape": "Camera Shape",
- "webcamSize": "Webcam Size",
- "webcamFraming": "Webcam crop",
- "webcamCropZoom": "Zoom",
- "webcamCropX": "Pan horizontally",
- "webcamCropY": "Pan vertically",
- "mirrorWebcam": "Mirror Webcam",
- "reactiveWebcam": "Shrink on Zoom",
- "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
- "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
- "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.",
- "shapes": {
- "rectangle": "Rect",
- "circle": "Circle",
- "square": "Square",
- "rounded": "Rounded"
- },
- "webcamBackground": "Camera Background",
- "webcamBlurIntensity": "Blur Intensity",
- "bgModes": {
- "none": "Original",
- "transparent": "Cutout",
- "blur": "Blur",
- "custom": "Custom"
- }
- },
- "audio": {
- "title": "Audio",
- "help": "Adjust the audio output level. It applies identically in the preview and the export.",
- "outputGain": "Output level",
- "reset": "Reset audio"
- },
- "effects": {
- "title": "Composition",
- "blurBg": "Blur BG",
- "motionBlur": "Motion Blur",
- "off": "off",
- "on": "on",
- "shadow": "Shadow",
- "roundness": "Roundness",
- "padding": "Padding",
- "frame": "Frame",
- "format": "Format",
- "formatOriginal": "Original",
- "fitClip": "Fit",
- "fitClipOne": "{{count}} clip",
- "fitClipFew": "{{count}} clips",
- "fitClipMany": "{{count}} clips",
- "motion": "Motion",
- "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video."
- },
"background": {
+ "gradientLabel": "Gradient {{index}}",
+ "uploadCustom": "Upload Custom",
+ "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
"title": "Background",
- "image": "Image",
- "color": "Color",
- "gradient": "Gradient",
+ "imageLabel": "Background {{index}}",
"custom": "Custom",
- "uploadCustom": "Upload Custom",
- "gradientLabel": "Gradient {{index}}",
- "colorWheel": "Color Wheel",
- "colorPalette": "Color Palette",
- "presets": "Presets",
"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",
- "unsupportedImage": "Unsupported image. Use a JPG or PNG file.",
+ "colorPalette": "Color Palette",
"imageReadFailed": "Could not read that image file.",
- "imageLabel": "Background {{index}}",
- "colorLabel": "Color {{color}}"
- },
- "crop": {
- "title": "Crop",
- "cropVideo": "Crop Video",
- "dragInstruction": "Drag on each side to adjust the crop area",
- "ratio": "Ratio",
- "free": "Free",
- "done": "Done",
- "lockAspectRatio": "Lock aspect ratio",
- "unlockAspectRatio": "Unlock aspect ratio"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 Video",
- "mp4Description": "High quality video file",
- "gifAnimation": "GIF Animation",
- "gifDescription": "Animated image for sharing"
- },
- "exportQuality": {
- "title": "Export resolution",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "GIF Frame Rate",
- "size": "GIF Size",
- "loop": "Loop GIF"
- },
- "project": {
- "save": "Save Project",
- "load": "Load Project",
- "new": "New Project"
- },
- "export": {
- "videoButton": "Export Video",
- "gifButton": "Export GIF",
- "chooseSaveLocation": "Choose Save Location"
+ "image": "Image",
+ "presets": "Presets",
+ "color": "Color",
+ "colorWheel": "Color Wheel"
},
- "support": {
- "reportBug": "Report Bug",
- "saveDiagnostics": "Save Diagnostics",
- "starOnGithub": "Star on GitHub"
+ "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",
+ "urlLabel": "Google Fonts Import URL",
+ "successMessage": "Font \"{{fontName}}\" added successfully",
+ "nameLabel": "Display Name",
+ "errorEmptyName": "Please enter a font name",
+ "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",
+ "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!",
- "failedToUpload": "Failed to upload image",
"errorReading": "There was an error reading the file."
},
"annotation": {
- "title": "Annotation Settings",
- "active": "Active",
- "typeText": "Text",
- "typeImage": "Image",
- "typeArrow": "Arrow",
- "typeBlur": "Blur",
- "textContent": "Text Content",
- "textPlaceholder": "Enter your text...",
- "defaultText": "Hello",
- "fontStyle": "Font Style",
- "selectStyle": "Select style",
+ "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Rectangle",
"size": "Size",
- "customFonts": "Custom Fonts",
- "textColor": "Text Color",
+ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
+ "clearBackground": "Clear Background",
+ "colorPalette": "Color Palette",
+ "invalidImageType": "Invalid file type",
"background": "Background",
- "none": "None",
+ "typeText": "Text",
+ "active": "Active",
"color": "Color",
- "colorWheel": "Color Wheel",
- "colorPalette": "Color Palette",
- "clearBackground": "Clear Background",
- "uploadImage": "Upload Image",
- "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "Freehand",
"arrowDirection": "Arrow Direction",
- "strokeWidth": "Stroke Width: {{width}}px",
- "arrowColor": "Arrow Color",
- "blurType": "Blur Type",
- "blurTypeBlur": "Gaussian",
"blurTypeMosaic": "Mosaic",
+ "colorWheel": "Color Wheel",
+ "textColor": "Text Color",
+ "title": "Annotation Settings",
+ "blurType": "Blur Type",
+ "typeBlur": "Blur",
+ "blurIntensity": "Blur Intensity",
+ "selectStyle": "Select style",
+ "textContent": "Text Content",
+ "typeArrow": "Arrow",
+ "none": "None",
"blurColor": "Blur Color",
- "blurColorWhite": "White",
- "blurColorBlack": "Black",
+ "customFonts": "Custom Fonts",
+ "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",
- "blurIntensity": "Blur Intensity",
+ "uploadImage": "Upload Image",
+ "blurTypeBlur": "Gaussian",
+ "tipTabCycle": "Use Tab to cycle through overlapping items.",
+ "shortcutsAndTips": "Shortcuts & Tips",
+ "deleteAnnotation": "Delete Annotation",
+ "fontStyle": "Font Style",
+ "defaultText": "Hello",
"mosaicBlockSize": "Mosaic Block Size",
- "blurShapeRectangle": "Rectangle",
+ "blurColorBlack": "Black",
+ "strokeWidth": "Stroke Width: {{width}}px",
"blurShapeOval": "Oval",
- "blurShapeFreehand": "Freehand",
- "deleteAnnotation": "Delete Annotation",
- "shortcutsAndTips": "Shortcuts & Tips",
+ "blurColorWhite": "White",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
- "tipTabCycle": "Use Tab to cycle through overlapping items.",
- "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
- "invalidImageType": "Invalid file type",
- "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
- "imageUploadSuccess": "Image uploaded successfully!",
- "type": "Type"
- },
- "textAnimation": {
- "title": "Text Animation",
- "selectAnimation": "Select animation",
- "none": "None",
- "fade": "Fade",
- "rise": "Rise",
- "pop": "Pop",
- "slideLeft": "Slide Left",
- "typewriter": "Typewriter",
- "pulse": "Pulse"
- },
- "customFont": {
- "dialogTitle": "Add Google Font",
- "urlLabel": "Google Fonts Import URL",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
- "nameLabel": "Display Name",
- "namePlaceholder": "My Custom Font",
- "nameHelp": "This is how the font will appear in the font selector",
- "addButton": "Add Font",
- "addingButton": "Adding...",
- "errorEmptyUrl": "Please enter a Google Fonts import URL",
- "errorInvalidUrl": "Please enter a valid Google Fonts URL",
- "errorEmptyName": "Please enter a font name",
- "errorExtractFailed": "Could not extract font family from URL",
- "successMessage": "Font \"{{fontName}}\" added successfully",
- "failedToAdd": "Failed to add 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."
+ "typeImage": "Image"
},
- "cursor": {
- "title": "Cursor",
- "theme": "Cursor Style",
- "themeDefault": "Default",
- "show": "Show Cursor",
- "size": "Size",
- "smoothing": "Smoothing",
+ "effects": {
+ "fitClipFew": "{{count}} clips",
+ "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",
+ "fitClipMany": "{{count}} clips",
+ "frame": "Frame",
+ "motion": "Motion",
+ "padding": "Padding",
+ "format": "Format",
+ "fitClip": "Fit",
"motionBlur": "Motion Blur",
- "clickBounce": "Click Bounce",
- "clipToBounds": "Clip to Canvas",
- "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.",
- "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis."
- },
- "language": {
- "title": "Language"
- },
- "facets": {
- "captions": "Captions",
- "transcript": "Transcript"
- },
- "panes": {
- "help": "Help"
+ "roundness": "Roundness"
},
"transcript": {
- "title": "Current transcription",
- "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to mark it as skipped (red). Hover a red span to restore it.",
- "noClips": "No clips yet",
+ "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.",
- "editorAria": "Transcript for {{filename}}",
- "silence": "[silence {{duration}}s]",
- "restoreSilence": "Restore silence ({{duration}}s)",
- "trimSilence": "Trim silence ({{duration}}s)",
- "restoreWord": "Restore \"{{word}}\"",
- "noAudio": "This media has no audio track"
+ "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": {
+ "mp4": "MP4",
+ "mp4Description": "High quality video file",
+ "gifAnimation": "GIF Animation",
+ "mp4Video": "MP4 Video",
+ "gifDescription": "Animated image for sharing",
+ "gif": "GIF"
},
"captions": {
- "show": "Show captions",
- "noTranscript": "Captions are read from the media transcript. Transcribe this video to turn them on.",
- "transcribe": "Transcribe video",
- "derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
- "hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
- "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",
- "language": "Language",
- "displayLanguage": "Display",
- "original": "Original (transcript)",
- "translate": "Translate",
- "translating": "Translating…",
- "translateHint": "Translate the transcript with the configured AI provider",
- "translateFailed": "Translation failed.",
+ "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",
- "font": "Font",
- "fontSize": "Size",
- "bold": "Bold",
- "textColor": "Text color",
- "background": "Background",
- "showBackground": "Show background",
- "backgroundColor": "Background color",
- "backgroundOpacity": "Opacity",
- "position": "Position",
- "anchorBottom": "Bottom",
- "anchorTop": "Top",
"anchorHintBottom": "Long captions grow upward — the bottom edge stays put.",
- "anchorHintTop": "Long captions grow downward — the top edge stays put.",
- "distanceFromBottom": "Distance from bottom",
"distanceFromTop": "Distance from top",
- "distanceFromLeft": "Distance from left",
- "distanceFromRight": "Distance from right",
+ "translateFailed": "Translation failed.",
"alignLeft": "Left",
- "alignCenter": "Center",
+ "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",
- "lineLength": "Line length",
+ "anchorTop": "Top",
"minWords": "Min words per line",
- "maxWords": "Max 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"
+ },
+ "panes": {
+ "help": "Help"
+ },
+ "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."
+ },
+ "gifSettings": {
+ "frameRate": "GIF Frame Rate",
+ "loop": "Loop GIF",
+ "size": "GIF Size"
+ },
+ "exportQuality": {
+ "title": "Export resolution",
+ "low": "720p",
+ "high": "Source",
+ "medium": "1080p"
+ },
+ "audioTrack": {
+ "defaultLabel": "Audio track",
+ "importFailed": "Could not add audio",
+ "fadeOut": "Fade out",
+ "fadeIn": "Fade in",
+ "remove": "Delete track",
+ "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.",
+ "add": "Add audio track",
+ "mute": "Mute"
+ },
+ "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"
+ },
+ "textAnimation": {
+ "slideLeft": "Slide Left",
+ "pulse": "Pulse",
+ "typewriter": "Typewriter",
+ "selectAnimation": "Select animation",
+ "fade": "Fade",
+ "title": "Text Animation",
+ "none": "None",
+ "pop": "Pop",
+ "rise": "Rise"
+ },
+ "facets": {
+ "transcript": "Transcript",
+ "captions": "Captions"
+ },
+ "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"
+ },
+ "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/shortcuts.json b/src/i18n/locales/en/shortcuts.json
index 081aceada..99f3ce90a 100644
--- a/src/i18n/locales/en/shortcuts.json
+++ b/src/i18n/locales/en/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
+ "addAudio": "Add Audio",
+ "addVoiceover": "Record Voiceover",
"addKeyframe": "Add Keyframe",
"addCameraFullscreen": "Add Full Camera",
"deleteSelected": "Delete Selected",
diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json
index c41966115..fa8a50b7e 100644
--- a/src/i18n/locales/en/timeline.json
+++ b/src/i18n/locales/en/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "Needs a transcript",
"smartCutsNoAudio": "This media has no audio",
"smartCutsNoSpeech": "No speech detected",
- "smartCutsFailed": "Transcription failed — retry it from Media"
+ "smartCutsFailed": "Transcription failed — retry it from Media",
+ "addAudioTooltip": "Add audio",
+ "addedWord": "Added word: \"{{word}}\" — no audio behind it"
+ },
+ "audio": {
+ "addVoiceover": "Add Voiceover",
+ "addVoiceoverHint": "Record narration over your video",
+ "subtitle": "Place a voiceover or background music layer on the timeline",
+ "record": "Record voiceover",
+ "importFile": "Import audio file",
+ "importFileHint": "Bring in music or an audio file",
+ "recording": "Recording",
+ "recordingHint": "Narrate along with the video — it plays while you record",
+ "stop": "Stop",
+ "micDenied": "Microphone access was denied",
+ "recordingUnavailable": "Recording is not available here",
+ "saveFailed": "Could not save the recording",
+ "importFailed": "Could not import the audio file"
}
}
diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json
index 954121938..8f26a3aff 100644
--- a/src/i18n/locales/es/dialogs.json
+++ b/src/i18n/locales/es/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Guardar GIF exportado",
"saveVideo": "Guardar video exportado",
"selectVideo": "Seleccionar archivo de video",
+ "selectAudio": "Seleccionar archivo de audio",
"saveProject": "Guardar proyecto OpenScreen",
"openProject": "Abrir proyecto OpenScreen",
"gifImage": "Imagen GIF",
"mp4Video": "Video MP4",
"videoFiles": "Archivos de video",
+ "audioFiles": "Archivos de audio",
"openscreenProject": "Proyecto OpenScreen",
"allFiles": "Todos los archivos"
}
diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json
index 46f0d3eaf..397a86298 100644
--- a/src/i18n/locales/es/editor.json
+++ b/src/i18n/locales/es/editor.json
@@ -1,18 +1,22 @@
{
"errors": {
- "noVideoLoaded": "No hay video cargado",
- "videoNotReady": "El video no está listo",
- "unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
- "failedToSaveGif": "Error al guardar el GIF",
- "gifExportFailed": "La exportación de GIF falló",
- "failedToSaveVideo": "Error al guardar el video",
+ "exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})",
"exportFailed": "La exportación falló",
"exportFailedWithError": "La exportación falló: {{error}}",
- "exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})",
+ "failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}",
"failedToSaveExport": "Error al guardar la exportación",
"failedToSaveExportedVideo": "Error al guardar el video exportado",
- "failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}",
- "previewCompositorUnavailable": "Vista previa no disponible en este equipo"
+ "failedToSaveGif": "Error al guardar el GIF",
+ "failedToSaveVideo": "Error al guardar el video",
+ "gifExportFailed": "La exportación de GIF falló",
+ "noVideoLoaded": "No hay video cargado",
+ "previewCompositorUnavailable": "Vista previa no disponible en este equipo",
+ "trimNoFilm": "No hay nada que cortar ahí: no hay metraje bajo esas palabras.",
+ "unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
+ "videoNotReady": "El video no está listo",
+ "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",
@@ -71,6 +75,7 @@
"pasted": "Atributos de {{region}} pegados",
"nothingToCopy": "Selecciona una región para copiar sus atributos",
"nothingToPaste": "Aún no se han copiado atributos",
+ "pasteAssetMissing": "El archivo de esa pista de audio no está en este proyecto",
"kinds": {
"zoom": "Zoom",
"speed": "Velocidad",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 56976bcfc..84419849c 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Mantener para previsualizar el efecto de zoom",
- "level": "Nivel de zoom",
- "selectRegion": "Selecciona una región de zoom para ajustar",
- "deleteZoom": "Eliminar zoom",
- "focusMode": {
- "title": "Modo de enfoque",
- "manual": "Manual",
- "auto": "Auto",
- "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."
- },
- "threeD": {
- "title": "Rotación 3D",
- "preset": {
- "iso": "Iso",
- "left": "Izquierda",
- "right": "Derecha"
- },
- "none": "Ninguna"
- },
- "customScale": "Zoom personalizado",
- "position": {
- "title": "Posición de enfoque",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior"
- }
- },
- "speed": {
- "playbackSpeed": "Velocidad de reproducción",
- "selectRegion": "Selecciona una región de velocidad para ajustar",
- "deleteRegion": "Eliminar región de velocidad",
- "customPlaybackSpeed": "Velocidad personalizada",
- "maxSpeedError": "La velocidad no puede superar {{max}}×",
- "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"
- },
- "layout": {
- "title": "Disposición de cámara",
- "preset": "Predefinido",
- "selectPreset": "Seleccionar predefinido",
- "pictureInPicture": "Imagen en imagen",
- "verticalStack": "Apilado vertical",
- "dualFrame": "Marco dual",
- "webcamShape": "Forma de cámara",
- "webcamSize": "Tamaño de cámara",
- "noWebcam": "Sin cámara",
- "mirrorWebcam": "Reflejar cámara",
- "reactiveWebcam": "Reducir al ampliar",
- "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
- "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.",
- "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.",
- "webcamFraming": "Encuadre de cámara",
- "webcamCropZoom": "Zoom de recorte",
- "webcamCropX": "Desplazamiento horizontal",
- "webcamCropY": "Desplazamiento vertical",
- "shapes": {
- "rectangle": "Rect.",
- "circle": "Círculo",
- "square": "Cuadrado",
- "rounded": "Redondeado"
- },
- "webcamBackground": "Fondo de la cámara",
- "webcamBlurIntensity": "Intensidad del desenfoque",
- "bgModes": {
- "none": "Original",
- "transparent": "Recortado",
- "blur": "Desenfocado",
- "custom": "Personalizado"
- }
- },
- "effects": {
- "title": "Composición",
- "blurBg": "Desenfocar fondo",
- "motionBlur": "Desenfoque de movimiento",
- "off": "desactivado",
- "shadow": "Sombra",
- "roundness": "Redondez",
- "padding": "Relleno",
- "frame": "Marco",
- "format": "Formato",
- "formatOriginal": "Original",
- "fitClip": "Ajustar",
- "fitClipOne": "{{count}} clip",
- "fitClipFew": "{{count}} clips",
- "fitClipMany": "{{count}} clips",
- "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."
- },
"background": {
+ "gradientLabel": "Degradado {{index}}",
+ "uploadCustom": "Subir personalizado",
+ "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
"title": "Fondo",
- "image": "Imagen",
- "color": "Color",
- "gradient": "Degradado",
+ "imageLabel": "Fondo {{index}}",
"custom": "Personalizado",
- "uploadCustom": "Subir personalizado",
- "gradientLabel": "Degradado {{index}}",
- "colorWheel": "Rueda de colores",
- "colorPalette": "Paleta de colores",
- "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.",
+ "gradient": "Degradado",
+ "colorLabel": "Color {{color}}",
"customWallpaper": "Fondo personalizado",
- "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.",
+ "colorPalette": "Paleta de colores",
"imageReadFailed": "No se pudo leer ese archivo de imagen.",
- "imageLabel": "Fondo {{index}}",
- "colorLabel": "Color {{color}}"
- },
- "crop": {
- "title": "Recortar",
- "cropVideo": "Recortar video",
- "dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
- "ratio": "Proporción",
- "free": "Libre",
- "done": "Listo",
- "lockAspectRatio": "Bloquear relación de aspecto",
- "unlockAspectRatio": "Desbloquear relación de aspecto"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "Video MP4",
- "mp4Description": "Archivo de video de alta calidad",
- "gifAnimation": "Animación GIF",
- "gifDescription": "Imagen animada para compartir"
- },
- "exportQuality": {
- "title": "Resolución de exportación",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "Velocidad de cuadros del GIF",
- "size": "Tamaño del GIF",
- "loop": "Repetir GIF"
- },
- "project": {
- "save": "Guardar proyecto",
- "load": "Cargar proyecto",
- "new": "Nuevo proyecto"
- },
- "export": {
- "videoButton": "Exportar video",
- "gifButton": "Exportar GIF",
- "chooseSaveLocation": "Elegir ubicación de guardado"
+ "image": "Imagen",
+ "presets": "Ajustes preestablecidos",
+ "color": "Color",
+ "colorWheel": "Rueda de colores"
},
- "support": {
- "reportBug": "Reportar error",
- "saveDiagnostics": "Guardar diagnósticos",
- "starOnGithub": "Dar estrella en GitHub"
+ "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",
+ "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",
+ "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",
+ "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!",
- "failedToUpload": "Error al subir la imagen",
"errorReading": "Hubo un error al leer el archivo."
},
"annotation": {
- "title": "Configuración de anotaciones",
- "active": "Activo",
- "typeText": "Texto",
- "typeImage": "Imagen",
- "typeArrow": "Flecha",
- "typeBlur": "Desenfoque",
- "textContent": "Contenido de texto",
- "textPlaceholder": "Escribe tu texto...",
- "defaultText": "Hola",
- "fontStyle": "Estilo de fuente",
- "selectStyle": "Seleccionar estilo",
+ "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Rectángulo",
"size": "Tamaño",
- "customFonts": "Fuentes personalizadas",
- "textColor": "Color de texto",
+ "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",
- "none": "Ninguno",
+ "typeText": "Texto",
+ "active": "Activo",
"color": "Color",
- "colorWheel": "Rueda de colores",
- "colorPalette": "Paleta de colores",
- "clearBackground": "Quitar fondo",
- "uploadImage": "Subir imagen",
- "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "Mano alzada",
"arrowDirection": "Dirección de la flecha",
- "strokeWidth": "Grosor del trazo: {{width}}px",
- "arrowColor": "Color de la flecha",
- "blurType": "Tipo de desenfoque",
- "blurTypeBlur": "Gaussiano",
"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",
+ "textContent": "Contenido de texto",
+ "typeArrow": "Flecha",
+ "none": "Ninguno",
"blurColor": "Color del desenfoque",
- "blurColorWhite": "Blanco",
- "blurColorBlack": "Negro",
+ "customFonts": "Fuentes personalizadas",
+ "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",
- "blurIntensity": "Intensidad 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",
+ "defaultText": "Hola",
"mosaicBlockSize": "Tamano del bloque mosaico",
- "blurShapeRectangle": "Rectángulo",
+ "blurColorBlack": "Negro",
+ "strokeWidth": "Grosor del trazo: {{width}}px",
"blurShapeOval": "Óvalo",
- "blurShapeFreehand": "Mano alzada",
- "deleteAnnotation": "Eliminar anotación",
- "shortcutsAndTips": "Atajos y consejos",
+ "blurColorWhite": "Blanco",
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
- "tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
- "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
- "invalidImageType": "Tipo de archivo no válido",
- "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
- "imageUploadSuccess": "¡Imagen subida exitosamente!",
- "type": "Tipo"
- },
- "textAnimation": {
- "title": "Animación de texto",
- "selectAnimation": "Seleccionar animación",
- "none": "Ninguna",
- "fade": "Desvanecimiento",
- "rise": "Ascender",
- "pop": "Aparecer",
- "slideLeft": "Deslizar izquierda",
- "typewriter": "Máquina de escribir",
- "pulse": "Pulso"
- },
- "customFont": {
- "dialogTitle": "Agregar fuente de Google",
- "urlLabel": "URL de importación de Google Fonts",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
- "nameLabel": "Nombre para mostrar",
- "namePlaceholder": "Mi fuente personalizada",
- "nameHelp": "Así aparecerá la fuente en el selector de fuentes",
- "addButton": "Agregar fuente",
- "addingButton": "Agregando...",
- "errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
- "errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
- "errorEmptyName": "Por favor ingresa un nombre de fuente",
- "errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
- "successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
- "failedToAdd": "Error al agregar la fuente",
- "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."
+ "typeImage": "Imagen"
},
- "cursor": {
- "title": "Cursor",
- "theme": "Estilo del cursor",
- "themeDefault": "Predeterminado",
- "show": "Mostrar cursor",
- "size": "Tamaño",
- "smoothing": "Suavizado",
+ "effects": {
+ "fitClipFew": "{{count}} clips",
+ "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",
+ "fitClipMany": "{{count}} clips",
+ "frame": "Marco",
+ "motion": "Movimiento",
+ "padding": "Relleno",
+ "format": "Formato",
+ "fitClip": "Ajustar",
"motionBlur": "Desenfoque de movimiento",
- "clickBounce": "Rebote al clic",
- "clipToBounds": "Recortar al lienzo",
- "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.",
- "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic."
- },
- "language": {
- "title": "Idioma"
- },
- "facets": {
- "captions": "Subtítulos",
- "transcript": "Transcripción"
- },
- "panes": {
- "help": "Ayuda"
+ "roundness": "Redondez"
},
"transcript": {
- "title": "Transcripción actual",
- "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la marca como omitida (en rojo). Pasa el cursor sobre un fragmento rojo para restaurarlo.",
- "noClips": "Aún no hay clips",
+ "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.",
- "editorAria": "Transcripción de {{filename}}",
- "silence": "[silencio {{duration}} s]",
- "restoreSilence": "Restaurar silencio ({{duration}} s)",
- "trimSilence": "Recortar silencio ({{duration}} s)",
- "restoreWord": "Restaurar «{{word}}»",
- "noAudio": "Este medio no tiene pista de audio"
+ "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": {
+ "mp4": "MP4",
+ "mp4Description": "Archivo de video de alta calidad",
+ "gifAnimation": "Animación GIF",
+ "mp4Video": "Video MP4",
+ "gifDescription": "Imagen animada para compartir",
+ "gif": "GIF"
},
"captions": {
- "show": "Mostrar subtítulos",
- "noTranscript": "Los subtítulos se leen de la transcripción del recurso. Transcribe este vídeo para activarlos.",
- "transcribe": "Transcribir vídeo",
- "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.",
- "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.",
- "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",
- "language": "Idioma",
- "displayLanguage": "Visualización",
- "original": "Original (transcripción)",
- "translate": "Traducir",
- "translating": "Traduciendo…",
- "translateHint": "Traducir la transcripción con el proveedor de IA configurado",
- "translateFailed": "La traducción ha fallado.",
+ "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",
- "font": "Fuente",
- "fontSize": "Tamaño",
- "bold": "Negrita",
- "textColor": "Color del texto",
- "background": "Fondo",
- "showBackground": "Mostrar fondo",
- "backgroundColor": "Color del fondo",
- "backgroundOpacity": "Opacidad",
- "position": "Posición",
- "anchorBottom": "Abajo",
- "anchorTop": "Arriba",
"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.",
- "distanceFromBottom": "Distancia desde abajo",
"distanceFromTop": "Distancia desde arriba",
- "distanceFromLeft": "Distancia desde la izquierda",
- "distanceFromRight": "Distancia desde la derecha",
+ "translateFailed": "La traducción ha fallado.",
"alignLeft": "Izquierda",
- "alignCenter": "Centro",
+ "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",
- "lineLength": "Longitud de línea",
+ "anchorTop": "Arriba",
"minWords": "Mín. palabras por línea",
- "maxWords": "Máx. 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"
+ },
+ "panes": {
+ "help": "Ayuda"
+ },
+ "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."
+ },
+ "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": {
+ "defaultLabel": "Pista de audio",
+ "importFailed": "No se pudo añadir el audio",
+ "fadeOut": "Desvanecido",
+ "fadeIn": "Aparición",
+ "remove": "Eliminar pista",
+ "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.",
+ "add": "Añadir pista de audio",
+ "mute": "Silenciar"
+ },
+ "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"
+ },
+ "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"
+ },
+ "facets": {
+ "transcript": "Transcripción",
+ "captions": "Subtítulos"
+ },
+ "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": {
- "title": "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",
- "help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación."
+ "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"
+ },
+ "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/shortcuts.json b/src/i18n/locales/es/shortcuts.json
index 970c52306..688f218f0 100644
--- a/src/i18n/locales/es/shortcuts.json
+++ b/src/i18n/locales/es/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
+ "addAudio": "Añadir audio",
+ "addVoiceover": "Grabar voz en off",
"addKeyframe": "Agregar fotograma clave",
"addCameraFullscreen": "Agregar cámara a pantalla completa",
"deleteSelected": "Eliminar seleccionado",
diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json
index 989289e00..045f66809 100644
--- a/src/i18n/locales/es/timeline.json
+++ b/src/i18n/locales/es/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"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"
+ "smartCutsFailed": "La transcripción falló: reinténtala desde Medios",
+ "addAudioTooltip": "Añadir audio",
+ "addedWord": "Palabra añadida: «{{word}}» — sin audio detrás"
+ },
+ "audio": {
+ "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",
+ "record": "Grabar voz en off",
+ "importFile": "Importar archivo de audio",
+ "importFileHint": "Importa música o un archivo de audio",
+ "recording": "Grabando",
+ "recordingHint": "Narra junto al vídeo: se reproduce mientras grabas",
+ "stop": "Detener",
+ "micDenied": "Se denegó el acceso al micrófono",
+ "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"
}
}
diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json
index 82fc95dcb..ad5b4edf6 100644
--- a/src/i18n/locales/fr/dialogs.json
+++ b/src/i18n/locales/fr/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Enregistrer le GIF exporté",
"saveVideo": "Enregistrer la vidéo exportée",
"selectVideo": "Sélectionner un fichier vidéo",
+ "selectAudio": "Sélectionner un fichier audio",
"saveProject": "Enregistrer le projet OpenScreen",
"openProject": "Ouvrir un projet OpenScreen",
"gifImage": "Image GIF",
"mp4Video": "Vidéo MP4",
"videoFiles": "Fichiers vidéo",
+ "audioFiles": "Fichiers audio",
"openscreenProject": "Projet OpenScreen",
"allFiles": "Tous les fichiers"
}
diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json
index 3eb0dfa4e..dcc83218f 100644
--- a/src/i18n/locales/fr/editor.json
+++ b/src/i18n/locales/fr/editor.json
@@ -6,19 +6,23 @@
"confirm": "Confirmer"
},
"errors": {
- "noVideoLoaded": "Aucune vidéo chargée",
- "videoNotReady": "Vidéo non prête",
- "unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
- "failedToSaveGif": "Échec de l'enregistrement du GIF",
- "gifExportFailed": "L'export du GIF a échoué",
- "failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
+ "exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})",
"exportFailed": "L'export a échoué",
"exportFailedWithError": "L'export a échoué : {{error}}",
- "exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})",
+ "failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"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"
+ "failedToSaveGif": "Échec de l'enregistrement du GIF",
+ "failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
+ "gifExportFailed": "L'export du GIF a échoué",
+ "noVideoLoaded": "Aucune vidéo chargée",
+ "previewCompositorUnavailable": "Aperçu indisponible sur cette machine",
+ "trimNoFilm": "Rien à couper ici : aucun film ne se trouve sous ces mots.",
+ "unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
+ "videoNotReady": "Vidéo non prête",
+ "wordEditFailed": "Impossible de modifier ce mot",
+ "wordInsertFailed": "Impossible d'ajouter ce mot",
+ "wordRemoveFailed": "Impossible de supprimer ce mot"
},
"export": {
"canceled": "Export annulé",
@@ -71,6 +75,7 @@
"pasted": "Attributs de {{region}} collés",
"nothingToCopy": "Sélectionnez une région pour copier ses attributs",
"nothingToPaste": "Aucun attribut copié pour l'instant",
+ "pasteAssetMissing": "Le fichier de cette piste audio n'est pas dans ce projet",
"kinds": {
"zoom": "Zoom",
"speed": "Vitesse",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 70c7f4b35..1c8bf2764 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Maintenir pour prévisualiser l'effet de zoom",
- "level": "Niveau de zoom",
- "selectRegion": "Sélectionnez une région de zoom à ajuster",
- "deleteZoom": "Supprimer le zoom",
- "focusMode": {
- "title": "Mode focus",
- "manual": "Manuel",
- "auto": "Auto",
- "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."
- },
- "threeD": {
- "title": "Rotation 3D",
- "preset": {
- "iso": "Iso",
- "left": "Gauche",
- "right": "Droite"
- },
- "none": "Aucune"
- },
- "customScale": "Zoom personnalisé",
- "position": {
- "title": "Position du focus",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas"
- }
- },
- "speed": {
- "playbackSpeed": "Vitesse de lecture",
- "selectRegion": "Sélectionnez une région de vitesse à ajuster",
- "deleteRegion": "Supprimer la région de vitesse",
- "customPlaybackSpeed": "Vitesse de lecture personnalisée",
- "maxSpeedError": "La vitesse ne peut pas dépasser {{max}}×",
- "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"
- },
- "layout": {
- "title": "Disposition caméra",
- "preset": "Préréglage",
- "selectPreset": "Choisir un préréglage",
- "pictureInPicture": "Incrustation d'image",
- "verticalStack": "Empilement vertical",
- "dualFrame": "Double cadre",
- "webcamShape": "Forme de la caméra",
- "webcamSize": "Taille de la caméra",
- "noWebcam": "Sans webcam",
- "mirrorWebcam": "Inverser la webcam",
- "reactiveWebcam": "Réduire au zoom",
- "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
- "help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
- "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.",
- "webcamFraming": "Cadrage de la webcam",
- "webcamCropZoom": "Zoom du recadrage",
- "webcamCropX": "Déplacement horizontal",
- "webcamCropY": "Déplacement vertical",
- "shapes": {
- "rectangle": "Rect.",
- "circle": "Cercle",
- "square": "Carré",
- "rounded": "Arrondi"
- },
- "webcamBackground": "Arrière-plan de la caméra",
- "webcamBlurIntensity": "Intensité du flou",
- "bgModes": {
- "none": "Original",
- "transparent": "Détouré",
- "blur": "Flouté",
- "custom": "Personnalisé"
- }
- },
- "effects": {
- "title": "Composition",
- "blurBg": "Flou arrière-plan",
- "motionBlur": "Flou de mouvement",
- "off": "désactivé",
- "shadow": "Ombre",
- "roundness": "Arrondi",
- "padding": "Marge",
- "frame": "Cadre",
- "format": "Format",
- "formatOriginal": "Original",
- "fitClip": "Ajuster",
- "fitClipOne": "{{count}} clip",
- "fitClipFew": "{{count}} clips",
- "fitClipMany": "{{count}} clips",
- "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."
- },
"background": {
+ "gradientLabel": "Dégradé {{index}}",
+ "uploadCustom": "Téléverser une image",
+ "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
"title": "Arrière-plan",
- "image": "Image",
- "color": "Couleur",
- "gradient": "Dégradé",
+ "imageLabel": "Fond {{index}}",
"custom": "Personnalisé",
- "uploadCustom": "Téléverser une image",
- "gradientLabel": "Dégradé {{index}}",
- "colorWheel": "Roue chromatique",
- "colorPalette": "Palette de couleurs",
- "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.",
+ "gradient": "Dégradé",
+ "colorLabel": "Couleur {{color}}",
"customWallpaper": "Fond personnalisé",
- "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.",
+ "colorPalette": "Palette de couleurs",
"imageReadFailed": "Impossible de lire ce fichier image.",
- "imageLabel": "Fond {{index}}",
- "colorLabel": "Couleur {{color}}"
- },
- "crop": {
- "title": "Recadrage",
- "cropVideo": "Recadrer la vidéo",
- "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
- "ratio": "Ratio",
- "free": "Libre",
- "done": "Terminer",
- "lockAspectRatio": "Verrouiller le ratio",
- "unlockAspectRatio": "Déverrouiller le ratio"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "Vidéo MP4",
- "mp4Description": "Fichier vidéo haute qualité",
- "gifAnimation": "Animation GIF",
- "gifDescription": "Image animée pour le partage"
- },
- "exportQuality": {
- "title": "Résolution d'export",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "Fréquence d'images GIF",
- "size": "Taille du GIF",
- "loop": "GIF en boucle"
- },
- "project": {
- "save": "Enregistrer le projet",
- "load": "Charger un projet",
- "new": "Nouveau projet"
- },
- "export": {
- "videoButton": "Exporter la vidéo",
- "gifButton": "Exporter le GIF",
- "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
+ "image": "Image",
+ "presets": "Préréglages",
+ "color": "Couleur",
+ "colorWheel": "Roue chromatique"
},
- "support": {
- "reportBug": "Signaler un bug",
- "saveDiagnostics": "Enregistrer les diagnostics",
- "starOnGithub": "Étoile sur GitHub"
+ "customFont": {
+ "namePlaceholder": "Ma police personnalisée",
+ "failedToAdd": "Échec de l'ajout de la police",
+ "addingButton": "Ajout en cours...",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
+ "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",
+ "errorEmptyName": "Veuillez saisir un nom de police",
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
+ "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 !",
- "failedToUpload": "Échec du téléversement de l'image",
"errorReading": "Une erreur s'est produite lors de la lecture du fichier."
},
"annotation": {
- "title": "Paramètres d'annotation",
- "active": "Actif",
- "typeText": "Texte",
- "typeImage": "Image",
- "typeArrow": "Flèche",
- "typeBlur": "Flou",
- "textContent": "Contenu du texte",
- "textPlaceholder": "Saisissez votre texte...",
- "defaultText": "Bonjour",
- "fontStyle": "Style de police",
- "selectStyle": "Choisir un style",
+ "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Rectangle",
"size": "Taille",
- "customFonts": "Polices personnalisées",
- "textColor": "Couleur du texte",
+ "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",
- "none": "Aucun",
+ "typeText": "Texte",
+ "active": "Actif",
"color": "Couleur",
- "colorWheel": "Roue chromatique",
- "colorPalette": "Palette de couleurs",
- "clearBackground": "Supprimer l'arrière-plan",
- "uploadImage": "Téléverser une image",
- "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "Main levée",
"arrowDirection": "Direction de la flèche",
- "strokeWidth": "Épaisseur du trait : {{width}}px",
- "arrowColor": "Couleur de la flèche",
- "blurType": "Type de flou",
- "blurTypeBlur": "Gaussien",
"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",
- "blurColorWhite": "Blanc",
- "blurColorBlack": "Noir",
+ "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",
- "blurIntensity": "Intensité 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",
- "blurShapeRectangle": "Rectangle",
+ "blurColorBlack": "Noir",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
"blurShapeOval": "Ovale",
- "blurShapeFreehand": "Main levée",
- "deleteAnnotation": "Supprimer l'annotation",
- "shortcutsAndTips": "Raccourcis & Astuces",
+ "blurColorWhite": "Blanc",
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
- "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
- "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
- "invalidImageType": "Type de fichier invalide",
- "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
- "imageUploadSuccess": "Image téléversée avec succès !",
- "type": "Type"
- },
- "textAnimation": {
- "title": "Animation de texte",
- "selectAnimation": "Sélectionner une animation",
- "none": "Aucune",
- "fade": "Fondu",
- "rise": "Monter",
- "pop": "Apparition",
- "slideLeft": "Glisser à gauche",
- "typewriter": "Machine à écrire",
- "pulse": "Pulsation"
+ "typeImage": "Image"
},
- "customFont": {
- "dialogTitle": "Ajouter une police Google",
- "urlLabel": "URL d'import Google Fonts",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
- "nameLabel": "Nom d'affichage",
- "namePlaceholder": "Ma police personnalisée",
- "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
- "addButton": "Ajouter la police",
- "addingButton": "Ajout en cours...",
- "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
- "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
- "errorEmptyName": "Veuillez saisir un nom de police",
- "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
- "successMessage": "Police « {{fontName}} » ajoutée avec succès",
- "failedToAdd": "Échec de l'ajout de la police",
- "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."
- },
- "cursor": {
- "title": "Curseur",
- "theme": "Style du curseur",
- "themeDefault": "Par défaut",
- "show": "Afficher le curseur",
- "size": "Taille",
- "smoothing": "Lissage",
+ "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",
- "clickBounce": "Rebond au clic",
- "clipToBounds": "Rogner au canevas",
- "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.",
- "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic."
- },
- "language": {
- "title": "Langue"
- },
- "facets": {
- "captions": "Sous-titres",
- "transcript": "Transcription"
- },
- "panes": {
- "help": "Aide"
+ "roundness": "Arrondi"
},
"transcript": {
- "title": "Transcription actuelle",
- "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le marque comme ignoré (en rouge). Survolez un passage rouge pour le restaurer.",
- "noClips": "Aucun clip pour l'instant",
+ "laneRecording": "Enregistrement",
"noTranscript": "Aucune transcription pour l'instant",
+ "title": "Transcription actuelle",
+ "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.",
+ "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",
+ "noClips": "Aucun clip pour l'instant",
+ "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.",
- "editorAria": "Transcription de {{filename}}",
- "silence": "[silence {{duration}} s]",
- "restoreSilence": "Restaurer le silence ({{duration}} s)",
- "trimSilence": "Couper le silence ({{duration}} s)",
- "restoreWord": "Restaurer « {{word}} »",
- "noAudio": "Ce média n'a pas de piste audio"
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "noAudio": "Ce média n'a pas de piste audio",
+ "blankedWord": "vidé"
+ },
+ "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": {
- "show": "Afficher les sous-titres",
- "noTranscript": "Les sous-titres sont issus de la transcription du média. Transcrivez cette vidéo pour les activer.",
- "transcribe": "Transcrire la vidéo",
- "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.",
- "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.",
- "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",
- "language": "Langue",
- "displayLanguage": "Affichage",
- "original": "Original (transcription)",
- "translate": "Traduire",
- "translating": "Traduction…",
- "translateHint": "Traduire la transcription avec le fournisseur IA configuré",
- "translateFailed": "La traduction a échoué.",
+ "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",
- "font": "Police",
- "fontSize": "Taille",
- "bold": "Gras",
- "textColor": "Couleur du texte",
- "background": "Fond",
- "showBackground": "Afficher le fond",
- "backgroundColor": "Couleur du fond",
- "backgroundOpacity": "Opacité",
- "position": "Position",
- "anchorBottom": "Bas",
- "anchorTop": "Haut",
"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.",
- "distanceFromBottom": "Distance depuis le bas",
"distanceFromTop": "Distance depuis le haut",
- "distanceFromLeft": "Distance depuis la gauche",
- "distanceFromRight": "Distance depuis la droite",
+ "translateFailed": "La traduction a échoué.",
"alignLeft": "Gauche",
- "alignCenter": "Centre",
+ "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",
- "lineLength": "Longueur des lignes",
+ "anchorTop": "Haut",
"minWords": "Mots min. par ligne",
- "maxWords": "Mots max. 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": {
+ "slideLeft": "Glisser à gauche",
+ "pulse": "Pulsation",
+ "typewriter": "Machine à écrire",
+ "selectAnimation": "Sélectionner une animation",
+ "fade": "Fondu",
+ "title": "Animation de texte",
+ "none": "Aucune",
+ "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": {
+ "position": {
+ "y": "Y (%)",
+ "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas",
+ "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"
+ },
+ "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",
+ "customScale": "Zoom personnalisé",
+ "selectRegion": "Sélectionnez une région de zoom à ajuster"
},
"audio": {
- "title": "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",
- "help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export."
+ "title": "Audio"
+ },
+ "language": {
+ "title": "Langue"
+ },
+ "project": {
+ "new": "Nouveau projet",
+ "load": "Charger un projet",
+ "save": "Enregistrer le projet"
+ },
+ "support": {
+ "starOnGithub": "Étoile sur GitHub",
+ "saveDiagnostics": "Enregistrer les diagnostics",
+ "reportBug": "Signaler un bug"
+ },
+ "cursor": {
+ "smoothing": "Lissage",
+ "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.",
+ "size": "Taille",
+ "title": "Curseur",
+ "show": "Afficher le curseur",
+ "themeDefault": "Par défaut",
+ "clipToBounds": "Rogner au canevas",
+ "motionBlur": "Flou de mouvement",
+ "theme": "Style du curseur"
+ },
+ "export": {
+ "gifButton": "Exporter le GIF",
+ "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/shortcuts.json b/src/i18n/locales/fr/shortcuts.json
index 659ef13b8..3b28fca17 100644
--- a/src/i18n/locales/fr/shortcuts.json
+++ b/src/i18n/locales/fr/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Ajouter une coupe",
"addSpeed": "Ajouter une vitesse",
"addAnnotation": "Ajouter une annotation",
+ "addAudio": "Ajouter un audio",
+ "addVoiceover": "Enregistrer une voix off",
"addKeyframe": "Ajouter une image-clé",
"addCameraFullscreen": "Ajouter une caméra en plein écran",
"deleteSelected": "Supprimer la sélection",
diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json
index a35b8858a..cfaf9662a 100644
--- a/src/i18n/locales/fr/timeline.json
+++ b/src/i18n/locales/fr/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"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"
+ "smartCutsFailed": "Échec de la transcription — relancez-la depuis Médias",
+ "addAudioTooltip": "Ajouter un audio",
+ "addedWord": "Mot ajouté : « {{word}} » — aucun son derrière"
+ },
+ "audio": {
+ "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",
+ "record": "Enregistrer une voix off",
+ "importFile": "Importer un fichier audio",
+ "importFileHint": "Importez une musique ou un fichier audio",
+ "recording": "Enregistrement",
+ "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é",
+ "recordingUnavailable": "L'enregistrement n'est pas disponible ici",
+ "saveFailed": "Impossible d'enregistrer la capture",
+ "importFailed": "Impossible d'importer le fichier audio"
}
}
diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json
index 0fad7d36e..e8e326c91 100644
--- a/src/i18n/locales/it/dialogs.json
+++ b/src/i18n/locales/it/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Salva GIF esportata",
"saveVideo": "Salva video esportato",
"selectVideo": "Seleziona file video",
+ "selectAudio": "Seleziona file audio",
"saveProject": "Salva progetto OpenScreen",
"openProject": "Apri progetto OpenScreen",
"gifImage": "Immagine GIF",
"mp4Video": "Video MP4",
"videoFiles": "File video",
+ "audioFiles": "File audio",
"openscreenProject": "Progetto OpenScreen",
"allFiles": "Tutti i file"
}
diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json
index 214b0d10a..e343d482e 100644
--- a/src/i18n/locales/it/editor.json
+++ b/src/i18n/locales/it/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "Caricamento video...",
"loadingEditor": "Caricamento editor...",
"errors": {
- "noVideoLoaded": "Nessun video caricato",
- "videoNotReady": "Video non pronto",
- "unableToDetermineSourcePath": "Impossibile determinare il percorso del video sorgente",
- "failedToSaveGif": "Impossibile salvare la GIF",
- "gifExportFailed": "Esportazione GIF fallita",
- "failedToSaveVideo": "Impossibile salvare il video",
+ "exportBackgroundLoadFailed": "Esportazione fallita: impossibile caricare l'immagine di sfondo ({{url}})",
"exportFailed": "Esportazione fallita",
"exportFailedWithError": "Esportazione fallita: {{error}}",
- "exportBackgroundLoadFailed": "Esportazione fallita: impossibile caricare l'immagine di sfondo ({{url}})",
+ "failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}",
"failedToSaveExport": "Impossibile salvare l'esportazione",
"failedToSaveExportedVideo": "Impossibile salvare il video esportato",
- "failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}",
- "previewCompositorUnavailable": "Anteprima non disponibile su questo computer"
+ "failedToSaveGif": "Impossibile salvare la GIF",
+ "failedToSaveVideo": "Impossibile salvare il video",
+ "gifExportFailed": "Esportazione GIF fallita",
+ "noVideoLoaded": "Nessun video caricato",
+ "previewCompositorUnavailable": "Anteprima non disponibile su questo computer",
+ "trimNoFilm": "Non c'è niente da tagliare lì: sotto quelle parole non c'è filmato.",
+ "unableToDetermineSourcePath": "Impossibile determinare il percorso del video sorgente",
+ "videoNotReady": "Video non pronto",
+ "wordEditFailed": "Impossibile modificare questa parola",
+ "wordInsertFailed": "Impossibile aggiungere questa parola",
+ "wordRemoveFailed": "Impossibile eliminare questa parola"
},
"export": {
"canceled": "Esportazione annullata",
@@ -71,6 +75,7 @@
"pasted": "Attributi di {{region}} incollati",
"nothingToCopy": "Seleziona una regione per copiarne gli attributi",
"nothingToPaste": "Nessun attributo copiato",
+ "pasteAssetMissing": "Il file di questa traccia audio non è in questo progetto",
"kinds": {
"zoom": "Zoom",
"speed": "Velocità",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 41b398039..2e5ac7381 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
- "level": "Livello zoom",
- "customScale": "Zoom personalizzato",
- "selectRegion": "Seleziona una regione zoom da regolare",
- "deleteZoom": "Elimina zoom",
- "focusMode": {
- "title": "Modalità messa a fuoco",
- "manual": "Manuale",
- "auto": "Automatico",
- "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."
- },
- "threeD": {
- "title": "Rotazione 3D",
- "preset": {
- "iso": "Iso",
- "left": "Sinistra",
- "right": "Destra"
- },
- "none": "Nessuna"
- },
- "position": {
- "title": "Posizione messa a fuoco",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso"
- }
- },
- "speed": {
- "playbackSpeed": "Velocità di riproduzione",
- "selectRegion": "Seleziona una regione velocità da regolare",
- "deleteRegion": "Elimina regione velocità",
- "customPlaybackSpeed": "Velocità di riproduzione personalizzata",
- "maxSpeedError": "La velocità non può superare {{max}}×",
- "previewFrameSteppingHint": "Oltre {{native}}×, l'anteprima procede fotogramma per fotogramma e senza audio. L'esportazione non è influenzata."
- },
- "trim": {
- "deleteRegion": "Elimina regione taglio"
- },
- "layout": {
- "title": "Disposizione camera",
- "preset": "Predefinito",
- "selectPreset": "Seleziona predefinito",
- "pictureInPicture": "Immagine nell'immagine",
- "verticalStack": "Pila verticale",
- "dualFrame": "Doppio frame",
- "noWebcam": "Nessuna webcam",
- "webcamShape": "Forma fotocamera",
- "webcamSize": "Dimensione webcam",
- "mirrorWebcam": "Specchia webcam",
- "reactiveWebcam": "Riduci con lo zoom",
- "reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
- "help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
- "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.",
- "webcamFraming": "Inquadratura webcam",
- "webcamCropZoom": "Zoom ritaglio",
- "webcamCropX": "Spostamento orizzontale",
- "webcamCropY": "Spostamento verticale",
- "shapes": {
- "rectangle": "Rett.",
- "circle": "Cerchio",
- "square": "Quadrato",
- "rounded": "Arrotondato"
- },
- "webcamBackground": "Sfondo della fotocamera",
- "webcamBlurIntensity": "Intensità sfocatura",
- "bgModes": {
- "none": "Originale",
- "transparent": "Scontornato",
- "blur": "Sfocato",
- "custom": "Personalizzato"
- }
- },
- "effects": {
- "title": "Composizione",
- "blurBg": "Sfuma sfondo",
- "motionBlur": "Sfocatura movimento",
- "off": "spento",
- "on": "acceso",
- "shadow": "Ombra",
- "roundness": "Arrotondamento",
- "padding": "Spaziatura",
- "frame": "Cornice",
- "format": "Formato",
- "formatOriginal": "Originale",
- "fitClip": "Adatta",
- "fitClipOne": "{{count}} clip",
- "fitClipFew": "{{count}} clip",
- "fitClipMany": "{{count}} clip",
- "motion": "Movimento",
- "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video."
- },
"background": {
+ "gradientLabel": "Sfumatura {{index}}",
+ "uploadCustom": "Carica personalizzato",
+ "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
"title": "Sfondo",
- "image": "Immagine",
- "color": "Colore",
- "gradient": "Sfumatura",
+ "imageLabel": "Sfondo {{index}}",
"custom": "Personalizzato",
- "uploadCustom": "Carica personalizzato",
- "gradientLabel": "Sfumatura {{index}}",
- "colorWheel": "Ruota dei colori",
- "colorPalette": "Tavolozza dei colori",
- "presets": "Predefiniti",
"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",
- "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.",
+ "colorPalette": "Tavolozza dei colori",
"imageReadFailed": "Impossibile leggere quel file immagine.",
- "imageLabel": "Sfondo {{index}}",
- "colorLabel": "Colore {{color}}"
- },
- "crop": {
- "title": "Ritaglia",
- "cropVideo": "Ritaglia video",
- "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
- "ratio": "Proporzioni",
- "free": "Libero",
- "done": "Fatto",
- "lockAspectRatio": "Blocca proporzioni",
- "unlockAspectRatio": "Sblocca proporzioni"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "Video MP4",
- "mp4Description": "File video di alta qualità",
- "gifAnimation": "Animazione GIF",
- "gifDescription": "Immagine animata per la condivisione"
- },
- "exportQuality": {
- "title": "Risoluzione esportazione",
- "low": "720p",
- "medium": "1080p",
- "high": "Originale"
- },
- "gifSettings": {
- "frameRate": "Frequenza fotogrammi GIF",
- "size": "Dimensione GIF",
- "loop": "GIF in loop"
- },
- "project": {
- "save": "Salva progetto",
- "load": "Carica progetto",
- "new": "Nuovo progetto"
- },
- "export": {
- "videoButton": "Esporta video",
- "gifButton": "Esporta GIF",
- "chooseSaveLocation": "Scegli posizione di salvataggio"
+ "image": "Immagine",
+ "presets": "Predefiniti",
+ "color": "Colore",
+ "colorWheel": "Ruota dei colori"
},
- "support": {
- "reportBug": "Segnala bug",
- "saveDiagnostics": "Salva dati diagnostici",
- "starOnGithub": "Metti stella su GitHub"
+ "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",
+ "urlLabel": "URL importazione Google Fonts",
+ "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
+ "nameLabel": "Nome visualizzato",
+ "errorEmptyName": "Inserisci un nome per 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",
+ "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!",
- "failedToUpload": "Impossibile caricare l'immagine",
"errorReading": "Si è verificato un errore durante la lettura del file."
},
"annotation": {
- "title": "Impostazioni annotazione",
- "active": "Attivo",
- "typeText": "Testo",
- "typeImage": "Immagine",
- "typeArrow": "Freccia",
- "typeBlur": "Sfocatura",
- "textContent": "Contenuto testo",
- "textPlaceholder": "Inserisci il tuo testo...",
- "defaultText": "Ciao",
- "fontStyle": "Stile carattere",
- "selectStyle": "Seleziona stile",
+ "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Rettangolo",
"size": "Dimensione",
- "customFonts": "Caratteri personalizzati",
- "textColor": "Colore testo",
+ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
+ "clearBackground": "Rimuovi sfondo",
+ "colorPalette": "Tavolozza dei colori",
+ "invalidImageType": "Tipo di file non valido",
"background": "Sfondo",
- "none": "Nessuno",
+ "typeText": "Testo",
+ "active": "Attivo",
"color": "Colore",
- "colorWheel": "Ruota dei colori",
- "colorPalette": "Tavolozza dei colori",
- "clearBackground": "Rimuovi sfondo",
- "uploadImage": "Carica immagine",
- "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "A mano libera",
"arrowDirection": "Direzione freccia",
- "strokeWidth": "Larghezza tratto: {{width}}px",
- "arrowColor": "Colore freccia",
- "blurType": "Tipo sfocatura",
- "blurTypeBlur": "Gaussiano",
"blurTypeMosaic": "Mosaico",
+ "colorWheel": "Ruota dei colori",
+ "textColor": "Colore testo",
+ "title": "Impostazioni annotazione",
+ "blurType": "Tipo sfocatura",
+ "typeBlur": "Sfocatura",
+ "blurIntensity": "Intensità sfocatura",
+ "selectStyle": "Seleziona stile",
+ "textContent": "Contenuto testo",
+ "typeArrow": "Freccia",
+ "none": "Nessuno",
"blurColor": "Colore sfocatura",
- "blurColorWhite": "Bianco",
- "blurColorBlack": "Nero",
+ "customFonts": "Caratteri personalizzati",
+ "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",
- "blurIntensity": "Intensità sfocatura",
+ "uploadImage": "Carica immagine",
+ "blurTypeBlur": "Gaussiano",
+ "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
+ "shortcutsAndTips": "Scorciatoie e suggerimenti",
+ "deleteAnnotation": "Elimina annotazione",
+ "fontStyle": "Stile carattere",
+ "defaultText": "Ciao",
"mosaicBlockSize": "Dimensione blocco mosaico",
- "blurShapeRectangle": "Rettangolo",
+ "blurColorBlack": "Nero",
+ "strokeWidth": "Larghezza tratto: {{width}}px",
"blurShapeOval": "Ovale",
- "blurShapeFreehand": "A mano libera",
- "deleteAnnotation": "Elimina annotazione",
- "shortcutsAndTips": "Scorciatoie e suggerimenti",
+ "blurColorWhite": "Bianco",
"tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
- "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
- "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
- "invalidImageType": "Tipo di file non valido",
- "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
- "imageUploadSuccess": "Immagine caricata con successo!",
- "type": "Tipo"
- },
- "textAnimation": {
- "title": "Animazione testo",
- "selectAnimation": "Seleziona animazione",
- "none": "Nessuna",
- "fade": "Dissolvenza",
- "rise": "Ascesa",
- "pop": "Apparizione",
- "slideLeft": "Scivola a sinistra",
- "typewriter": "Macchina da scrivere",
- "pulse": "Pulsazione"
- },
- "customFont": {
- "dialogTitle": "Aggiungi font Google",
- "urlLabel": "URL importazione Google Fonts",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
- "nameLabel": "Nome visualizzato",
- "namePlaceholder": "Il mio font personalizzato",
- "nameHelp": "Così apparirà il font nel selettore",
- "addButton": "Aggiungi font",
- "addingButton": "Aggiunta in corso...",
- "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
- "errorInvalidUrl": "Inserisci un URL Google Fonts valido",
- "errorEmptyName": "Inserisci un nome per il font",
- "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
- "successMessage": "Font \"{{fontName}}\" aggiunto con successo",
- "failedToAdd": "Impossibile aggiungere il font",
- "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."
+ "typeImage": "Immagine"
},
- "cursor": {
- "title": "Cursore",
- "theme": "Stile del cursore",
- "themeDefault": "Predefinito",
- "show": "Mostra cursore",
- "size": "Dimensione",
- "smoothing": "Smussatura",
+ "effects": {
+ "fitClipFew": "{{count}} clip",
+ "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",
+ "fitClipMany": "{{count}} clip",
+ "frame": "Cornice",
+ "motion": "Movimento",
+ "padding": "Spaziatura",
+ "format": "Formato",
+ "fitClip": "Adatta",
"motionBlur": "Sfocatura movimento",
- "clickBounce": "Rimbalzo clic",
- "clipToBounds": "Ritaglia al canvas",
- "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.",
- "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic."
- },
- "language": {
- "title": "Lingua"
- },
- "facets": {
- "captions": "Sottotitoli",
- "transcript": "Trascrizione"
- },
- "panes": {
- "help": "Aiuto"
+ "roundness": "Arrotondamento"
},
"transcript": {
- "title": "Trascrizione corrente",
- "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la segna come saltata (in rosso). Passa sopra un tratto rosso per ripristinarlo.",
- "noClips": "Ancora nessun clip",
+ "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.",
- "editorAria": "Trascrizione di {{filename}}",
- "silence": "[silenzio {{duration}} s]",
- "restoreSilence": "Ripristina silenzio ({{duration}} s)",
- "trimSilence": "Taglia silenzio ({{duration}} s)",
- "restoreWord": "Ripristina «{{word}}»",
- "noAudio": "Questo contenuto non ha una traccia audio"
+ "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": {
+ "mp4": "MP4",
+ "mp4Description": "File video di alta qualità",
+ "gifAnimation": "Animazione GIF",
+ "mp4Video": "Video MP4",
+ "gifDescription": "Immagine animata per la condivisione",
+ "gif": "GIF"
},
"captions": {
- "show": "Mostra sottotitoli",
- "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Trascrivi questo video per attivarli.",
- "transcribe": "Trascrivi video",
- "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.",
- "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.",
- "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",
- "language": "Lingua",
- "displayLanguage": "Visualizzazione",
- "original": "Originale (trascrizione)",
- "translate": "Traduci",
- "translating": "Traduzione…",
- "translateHint": "Traduci la trascrizione con il provider IA configurato",
- "translateFailed": "Traduzione non riuscita.",
+ "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",
- "font": "Carattere",
- "fontSize": "Dimensione",
- "bold": "Grassetto",
- "textColor": "Colore del testo",
- "background": "Sfondo",
- "showBackground": "Mostra sfondo",
- "backgroundColor": "Colore dello sfondo",
- "backgroundOpacity": "Opacità",
- "position": "Posizione",
- "anchorBottom": "Basso",
- "anchorTop": "Alto",
"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.",
- "distanceFromBottom": "Distanza dal basso",
"distanceFromTop": "Distanza dall'alto",
- "distanceFromLeft": "Distanza da sinistra",
- "distanceFromRight": "Distanza da destra",
+ "translateFailed": "Traduzione non riuscita.",
"alignLeft": "Sinistra",
- "alignCenter": "Centro",
+ "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",
- "lineLength": "Lunghezza riga",
+ "anchorTop": "Alto",
"minWords": "Parole min. per riga",
- "maxWords": "Parole max. 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"
+ },
+ "panes": {
+ "help": "Aiuto"
+ },
+ "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."
+ },
+ "gifSettings": {
+ "frameRate": "Frequenza fotogrammi GIF",
+ "loop": "GIF in loop",
+ "size": "Dimensione GIF"
+ },
+ "exportQuality": {
+ "title": "Risoluzione esportazione",
+ "low": "720p",
+ "high": "Originale",
+ "medium": "1080p"
+ },
+ "audioTrack": {
+ "defaultLabel": "Traccia audio",
+ "importFailed": "Impossibile aggiungere l’audio",
+ "fadeOut": "Dissolvenza in uscita",
+ "fadeIn": "Dissolvenza in entrata",
+ "remove": "Elimina traccia",
+ "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.",
+ "add": "Aggiungi traccia audio",
+ "mute": "Muto"
+ },
+ "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"
+ },
+ "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"
+ },
+ "facets": {
+ "transcript": "Trascrizione",
+ "captions": "Sottotitoli"
+ },
+ "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": {
- "title": "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",
- "help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione."
+ "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"
+ },
+ "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/shortcuts.json b/src/i18n/locales/it/shortcuts.json
index 6a2da2208..0940258b2 100644
--- a/src/i18n/locales/it/shortcuts.json
+++ b/src/i18n/locales/it/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Aggiungi taglio",
"addSpeed": "Aggiungi velocità",
"addAnnotation": "Aggiungi annotazione",
+ "addAudio": "Aggiungi audio",
+ "addVoiceover": "Registra voce fuori campo",
"addKeyframe": "Aggiungi fotogramma chiave",
"addCameraFullscreen": "Aggiungi Camera a schermo intero",
"deleteSelected": "Elimina selezionato",
diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json
index 09bb116ee..f0431fceb 100644
--- a/src/i18n/locales/it/timeline.json
+++ b/src/i18n/locales/it/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "Richiede una trascrizione",
"smartCutsNoAudio": "Questo contenuto non ha audio",
"smartCutsNoSpeech": "Nessun parlato rilevato",
- "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali"
+ "smartCutsFailed": "Trascrizione non riuscita: riprova da Contenuti multimediali",
+ "addAudioTooltip": "Aggiungi audio",
+ "addedWord": "Parola aggiunta: «{{word}}» — nessun audio dietro"
+ },
+ "audio": {
+ "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",
+ "record": "Registra voce fuori campo",
+ "importFile": "Importa file audio",
+ "importFileHint": "Importa musica o un file audio",
+ "recording": "Registrazione",
+ "recordingHint": "Racconta insieme al video: continua a riprodursi mentre registri",
+ "stop": "Ferma",
+ "micDenied": "Accesso al microfono negato",
+ "recordingUnavailable": "La registrazione non è disponibile qui",
+ "saveFailed": "Impossibile salvare la registrazione",
+ "importFailed": "Impossibile importare il file audio"
}
}
diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json
index 7ee976a77..c0c00ad80 100644
--- a/src/i18n/locales/ja-JP/dialogs.json
+++ b/src/i18n/locales/ja-JP/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "エクスポートしたGIFを保存",
"saveVideo": "エクスポートした動画を保存",
"selectVideo": "動画ファイルを選択",
+ "selectAudio": "オーディオファイルを選択",
"saveProject": "OpenScreen プロジェクトを保存",
"openProject": "OpenScreen プロジェクトを開く",
"gifImage": "GIF 画像",
"mp4Video": "MP4 動画",
"videoFiles": "動画ファイル",
+ "audioFiles": "オーディオファイル",
"openscreenProject": "OpenScreen プロジェクト",
"allFiles": "すべてのファイル"
}
diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json
index e12096ed1..239bc1287 100644
--- a/src/i18n/locales/ja-JP/editor.json
+++ b/src/i18n/locales/ja-JP/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "動画を読み込み中...",
"loadingEditor": "エディターを読み込み中...",
"errors": {
- "noVideoLoaded": "動画が読み込まれていません",
- "videoNotReady": "動画の準備ができていません",
- "unableToDetermineSourcePath": "元動画のパスを特定できません",
- "failedToSaveGif": "GIFの保存に失敗しました",
- "gifExportFailed": "GIFのエクスポートに失敗しました",
- "failedToSaveVideo": "動画の保存に失敗しました",
+ "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})",
"exportFailed": "エクスポートに失敗しました",
"exportFailedWithError": "エクスポートに失敗しました: {{error}}",
+ "failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
"failedToSaveExport": "エクスポートの保存に失敗しました",
"failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました",
- "failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
- "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})",
- "previewCompositorUnavailable": "このマシンではプレビューを表示できません"
+ "failedToSaveGif": "GIFの保存に失敗しました",
+ "failedToSaveVideo": "動画の保存に失敗しました",
+ "gifExportFailed": "GIFのエクスポートに失敗しました",
+ "noVideoLoaded": "動画が読み込まれていません",
+ "previewCompositorUnavailable": "このマシンではプレビューを表示できません",
+ "trimNoFilm": "そこには切るものがありません。その言葉の下に映像がありません。",
+ "unableToDetermineSourcePath": "元動画のパスを特定できません",
+ "videoNotReady": "動画の準備ができていません",
+ "wordEditFailed": "この単語を変更できませんでした",
+ "wordInsertFailed": "この単語を追加できませんでした",
+ "wordRemoveFailed": "この単語を削除できませんでした"
},
"export": {
"canceled": "エクスポートがキャンセルされました",
@@ -71,6 +75,7 @@
"pasted": "{{region}}の属性を貼り付けました",
"nothingToCopy": "属性をコピーする領域を選択してください",
"nothingToPaste": "コピーされた属性がありません",
+ "pasteAssetMissing": "このオーディオトラックのファイルはこのプロジェクトにありません",
"kinds": {
"zoom": "ズーム",
"speed": "速度",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index a908e0550..6c746cdfa 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "押している間ズーム効果をプレビュー",
- "level": "ズーム倍率",
- "selectRegion": "ズーム範囲を選択して調整",
- "deleteZoom": "ズームを削除",
- "focusMode": {
- "title": "フォーカスモード",
- "manual": "手動",
- "auto": "自動",
- "autoDescription": "表示範囲が録画中のカーソル位置に追従します",
- "lockedDisclaimer": "タイムラインの全体オートフォーカス切り替えによって制御されます。オフにすると、ズームごとにフォーカスモードを設定できます。"
- },
- "threeD": {
- "title": "3D回転",
- "preset": {
- "iso": "Iso",
- "left": "左",
- "right": "右"
- },
- "none": "なし"
- },
- "customScale": "カスタムズーム",
- "position": {
- "title": "フォーカス位置",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = 左端 / 上端、100 = 右端 / 下端"
- }
- },
- "speed": {
- "playbackSpeed": "再生速度",
- "selectRegion": "再生速度の範囲を選択して調整",
- "deleteRegion": "再生速度の範囲を削除",
- "customPlaybackSpeed": "カスタム再生速度",
- "maxSpeedError": "速度は{{max}}×を超えることはできません",
- "previewFrameSteppingHint": "{{native}}×を超えるとプレビューはフレーム送りかつ無音になります。書き出しには影響しません。"
- },
- "trim": {
- "deleteRegion": "トリム範囲を削除"
- },
- "layout": {
- "title": "カメラレイアウト",
- "preset": "プリセット",
- "selectPreset": "プリセットを選択",
- "pictureInPicture": "ピクチャーインピクチャ",
- "verticalStack": "縦並び",
- "dualFrame": "デュアルフレーム",
- "webcamShape": "カメラの形状",
- "webcamSize": "カメラのサイズ",
- "noWebcam": "Webカメラなし",
- "mirrorWebcam": "Webカメラを反転",
- "reactiveWebcam": "ズーム時に縮小",
- "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
- "help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
- "helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
- "webcamFraming": "ウェブカメラの構図",
- "webcamCropZoom": "クロップのズーム",
- "webcamCropX": "水平方向に移動",
- "webcamCropY": "垂直方向に移動",
- "shapes": {
- "rectangle": "長方形",
- "circle": "円",
- "square": "正方形",
- "rounded": "角丸"
- },
- "webcamBackground": "カメラ背景",
- "webcamBlurIntensity": "ぼかしの強さ",
- "bgModes": {
- "none": "オリジナル",
- "transparent": "切り抜き",
- "blur": "ぼかし",
- "custom": "カスタム"
- }
- },
- "effects": {
- "title": "コンポジション",
- "blurBg": "背景をぼかす",
- "motionBlur": "モーションブラー",
- "off": "オフ",
- "shadow": "影",
- "roundness": "丸み",
- "padding": "余白",
- "frame": "フレーム",
- "format": "フォーマット",
- "formatOriginal": "元のサイズ",
- "fitClip": "合わせる",
- "fitClipOne": "{{count}} クリップ",
- "fitClipFew": "{{count}} クリップ",
- "fitClipMany": "{{count}} クリップ",
- "motion": "モーション",
- "on": "オン",
- "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。"
- },
"background": {
+ "gradientLabel": "グラデーション {{index}}",
+ "uploadCustom": "カスタム画像を読み込む",
+ "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
"title": "背景",
- "image": "画像",
- "color": "色",
- "gradient": "グラデーション",
+ "imageLabel": "背景 {{index}}",
"custom": "カスタム",
- "uploadCustom": "カスタム画像を読み込む",
- "gradientLabel": "グラデーション {{index}}",
- "colorWheel": "カラーホイール",
- "colorPalette": "カラーパレット",
- "presets": "プリセット",
"help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。",
+ "gradient": "グラデーション",
+ "colorLabel": "色 {{color}}",
"customWallpaper": "カスタム壁紙",
- "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。",
+ "colorPalette": "カラーパレット",
"imageReadFailed": "この画像ファイルを読み込めませんでした。",
- "imageLabel": "背景 {{index}}",
- "colorLabel": "色 {{color}}"
- },
- "crop": {
- "title": "クロップ",
- "cropVideo": "動画をクロップ",
- "dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
- "ratio": "比率",
- "free": "自由",
- "done": "完了",
- "lockAspectRatio": "アスペクト比を固定",
- "unlockAspectRatio": "アスペクト比の固定を解除"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 動画",
- "mp4Description": "高品質の動画ファイル",
- "gifAnimation": "GIF アニメーション",
- "gifDescription": "共有用のアニメーション画像"
- },
- "exportQuality": {
- "title": "書き出し解像度",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "GIF フレームレート",
- "size": "GIF サイズ",
- "loop": "GIF をループする"
- },
- "project": {
- "save": "プロジェクトを保存",
- "load": "プロジェクトを読み込む",
- "new": "新規プロジェクト"
- },
- "export": {
- "videoButton": "動画をエクスポート",
- "gifButton": "GIF をエクスポート",
- "chooseSaveLocation": "保存場所を選択"
+ "image": "画像",
+ "presets": "プリセット",
+ "color": "色",
+ "colorWheel": "カラーホイール"
},
- "support": {
- "reportBug": "バグを報告",
- "saveDiagnostics": "診断情報を保存",
- "starOnGithub": "GitHub でスターを付ける"
+ "customFont": {
+ "namePlaceholder": "マイカスタムフォント",
+ "failedToAdd": "フォントの追加に失敗しました",
+ "addingButton": "追加中...",
+ "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
+ "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
+ "urlLabel": "GoogleフォントのインポートURL",
+ "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
+ "nameLabel": "表示名",
+ "errorEmptyName": "フォント名を入力してください",
+ "nameHelp": "フォントセレクターに表示される名前です",
+ "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
+ "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
+ "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": "カスタム画像を読み込みました。",
- "failedToUpload": "画像の読み込みに失敗しました",
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
},
"annotation": {
- "title": "注釈設定",
- "active": "アクティブ",
- "typeText": "テキスト",
- "typeImage": "画像",
- "typeArrow": "矢印",
- "typeBlur": "ぼかし",
- "textContent": "テキスト内容",
- "textPlaceholder": "テキストを入力してください...",
- "defaultText": "こんにちは",
- "fontStyle": "フォントスタイル",
- "selectStyle": "スタイルを選択",
+ "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "長方形",
"size": "サイズ",
- "customFonts": "カスタムフォント",
- "textColor": "文字色",
+ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
+ "clearBackground": "背景をクリア",
+ "colorPalette": "カラーパレット",
+ "invalidImageType": "無効なファイル形式",
"background": "背景",
- "none": "なし",
+ "typeText": "テキスト",
+ "active": "アクティブ",
"color": "色",
- "colorWheel": "カラーホイール",
- "colorPalette": "カラーパレット",
- "clearBackground": "背景をクリア",
- "uploadImage": "画像を読み込む",
- "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "自由形状",
"arrowDirection": "矢印の方向",
- "strokeWidth": "線の太さ: {{width}}px",
- "arrowColor": "矢印の色",
- "blurType": "ぼかしの種類",
- "blurTypeBlur": "ガウス",
"blurTypeMosaic": "モザイク",
+ "colorWheel": "カラーホイール",
+ "textColor": "文字色",
+ "title": "注釈設定",
+ "blurType": "ぼかしの種類",
+ "typeBlur": "ぼかし",
+ "blurIntensity": "ぼかしの強さ",
+ "selectStyle": "スタイルを選択",
+ "textContent": "テキスト内容",
+ "typeArrow": "矢印",
+ "none": "なし",
"blurColor": "ぼかしの色",
- "blurColorWhite": "白",
- "blurColorBlack": "黒",
+ "customFonts": "カスタムフォント",
+ "imageUploadSuccess": "画像を読み込みました。",
+ "type": "種類",
+ "arrowColor": "矢印の色",
+ "textPlaceholder": "テキストを入力してください...",
+ "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
"blurShape": "ぼかしの形状",
- "blurIntensity": "ぼかしの強さ",
+ "uploadImage": "画像を読み込む",
+ "blurTypeBlur": "ガウス",
+ "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
+ "shortcutsAndTips": "ショートカットとヒント",
+ "deleteAnnotation": "注釈を削除",
+ "fontStyle": "フォントスタイル",
+ "defaultText": "こんにちは",
"mosaicBlockSize": "モザイクブロックのサイズ",
- "blurShapeRectangle": "長方形",
+ "blurColorBlack": "黒",
+ "strokeWidth": "線の太さ: {{width}}px",
"blurShapeOval": "楕円",
- "blurShapeFreehand": "自由形状",
- "deleteAnnotation": "注釈を削除",
- "shortcutsAndTips": "ショートカットとヒント",
+ "blurColorWhite": "白",
"tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
- "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
- "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
- "invalidImageType": "無効なファイル形式",
- "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
- "imageUploadSuccess": "画像を読み込みました。",
- "type": "種類"
- },
- "textAnimation": {
- "title": "テキストアニメーション",
- "selectAnimation": "アニメーションを選択",
- "none": "なし",
- "fade": "フェード",
- "rise": "上昇",
- "pop": "ポップ",
- "slideLeft": "左へスライド",
- "typewriter": "タイプライター",
- "pulse": "パルス"
+ "typeImage": "画像"
},
- "customFont": {
- "dialogTitle": "Googleフォントを追加",
- "urlLabel": "GoogleフォントのインポートURL",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
- "nameLabel": "表示名",
- "namePlaceholder": "マイカスタムフォント",
- "nameHelp": "フォントセレクターに表示される名前です",
- "addButton": "フォントを追加",
- "addingButton": "追加中...",
- "errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
- "errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
- "errorEmptyName": "フォント名を入力してください",
- "errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
- "successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
- "failedToAdd": "フォントの追加に失敗しました",
- "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
- "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。"
- },
- "cursor": {
- "title": "カーソル",
- "theme": "カーソルのスタイル",
- "themeDefault": "デフォルト",
- "show": "カーソルを表示",
- "size": "サイズ",
- "smoothing": "スムージング",
+ "effects": {
+ "fitClipFew": "{{count}} クリップ",
+ "title": "コンポジション",
+ "shadow": "影",
+ "off": "オフ",
+ "on": "オン",
+ "blurBg": "背景をぼかす",
+ "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。",
+ "fitClipOne": "{{count}} クリップ",
+ "formatOriginal": "元のサイズ",
+ "fitClipMany": "{{count}} クリップ",
+ "frame": "フレーム",
+ "motion": "モーション",
+ "padding": "余白",
+ "format": "フォーマット",
+ "fitClip": "合わせる",
"motionBlur": "モーションブラー",
- "clickBounce": "クリックバウンス",
- "clipToBounds": "キャンバスにクリップ",
- "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。",
- "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。"
- },
- "language": {
- "title": "言語"
- },
- "facets": {
- "captions": "字幕",
- "transcript": "文字起こし"
- },
- "panes": {
- "help": "ヘルプ"
+ "roundness": "丸み"
},
"transcript": {
- "title": "現在の文字起こし",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete でスキップ(赤色)にできます。赤い部分にカーソルを合わせると元に戻せます。",
- "noClips": "クリップがまだありません",
+ "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": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
- "editorAria": "{{filename}} の文字起こし",
- "silence": "[無音 {{duration}} 秒]",
- "restoreSilence": "無音を元に戻す({{duration}} 秒)",
- "trimSilence": "無音をトリム({{duration}} 秒)",
- "restoreWord": "「{{word}}」を元に戻す",
- "noAudio": "このメディアには音声トラックがありません"
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "noAudio": "このメディアには音声トラックがありません",
+ "blankedWord": "空欄"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "高品質の動画ファイル",
+ "gifAnimation": "GIF アニメーション",
+ "mp4Video": "MP4 動画",
+ "gifDescription": "共有用のアニメーション画像",
+ "gif": "GIF"
},
"captions": {
- "show": "字幕を表示",
- "noTranscript": "字幕はメディアの文字起こしから読み込まれます。有効にするにはこの動画を文字起こししてください。",
- "transcribe": "動画を文字起こし",
- "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
- "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
- "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
- "removeLegacyAnnotations": "古い字幕の注釈を削除",
- "language": "言語",
- "displayLanguage": "表示",
- "original": "オリジナル(文字起こし)",
- "translate": "翻訳",
- "translating": "翻訳中…",
- "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
- "translateFailed": "翻訳に失敗しました。",
+ "showBackground": "背景を表示",
"deleteTranslation": "この翻訳を削除",
+ "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。",
+ "backgroundOpacity": "不透明度",
+ "backgroundColor": "背景色",
+ "alignCenter": "中央",
"translationIsNonDestructive": "翻訳は文字起こしの中ではなく横に保存されます。元のテキストとタイミングはそのまま残ります。",
+ "distanceFromRight": "右端からの距離",
+ "language": "言語",
"text": "テキスト",
- "font": "フォント",
- "fontSize": "サイズ",
- "bold": "太字",
- "textColor": "文字色",
- "background": "背景",
- "showBackground": "背景を表示",
- "backgroundColor": "背景色",
- "backgroundOpacity": "不透明度",
- "position": "位置",
- "anchorBottom": "下",
- "anchorTop": "上",
"anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。",
- "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
- "distanceFromBottom": "下端からの距離",
"distanceFromTop": "上端からの距離",
- "distanceFromLeft": "左端からの距離",
- "distanceFromRight": "右端からの距離",
+ "translateFailed": "翻訳に失敗しました。",
"alignLeft": "左",
- "alignCenter": "中央",
+ "distanceFromBottom": "下端からの距離",
+ "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
+ "translate": "翻訳",
+ "position": "位置",
+ "fontSize": "サイズ",
+ "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
+ "noTranscript": "字幕はメディアの文字起こしから読み込まれます。有効にするにはこの動画を文字起こししてください。",
+ "distanceFromLeft": "左端からの距離",
+ "anchorBottom": "下",
+ "transcribe": "動画を文字起こし",
+ "bold": "太字",
"alignRight": "右",
- "lineLength": "行の長さ",
+ "anchorTop": "上",
"minWords": "1 行の最小単語数",
- "maxWords": "1 行の最大単語数"
+ "translateHint": "設定した AI プロバイダーで文字起こしを翻訳します",
+ "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。",
+ "displayLanguage": "表示",
+ "removeLegacyAnnotations": "古い字幕の注釈を削除",
+ "background": "背景",
+ "lineLength": "行の長さ",
+ "original": "オリジナル(文字起こし)",
+ "maxWords": "1 行の最大単語数",
+ "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": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
+ "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カメラなし"
+ },
+ "textAnimation": {
+ "slideLeft": "左へスライド",
+ "pulse": "パルス",
+ "typewriter": "タイプライター",
+ "selectAnimation": "アニメーションを選択",
+ "fade": "フェード",
+ "title": "テキストアニメーション",
+ "none": "なし",
+ "pop": "ポップ",
+ "rise": "上昇"
+ },
+ "facets": {
+ "transcript": "文字起こし",
+ "captions": "字幕"
+ },
+ "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": {
- "title": "オーディオ",
"outputGain": "出力レベル",
+ "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。",
"reset": "オーディオをリセット",
- "help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。"
+ "title": "オーディオ"
+ },
+ "language": {
+ "title": "言語"
+ },
+ "project": {
+ "new": "新規プロジェクト",
+ "load": "プロジェクトを読み込む",
+ "save": "プロジェクトを保存"
+ },
+ "support": {
+ "starOnGithub": "GitHub でスターを付ける",
+ "saveDiagnostics": "診断情報を保存",
+ "reportBug": "バグを報告"
+ },
+ "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/shortcuts.json b/src/i18n/locales/ja-JP/shortcuts.json
index 173355c66..82f1c2291 100644
--- a/src/i18n/locales/ja-JP/shortcuts.json
+++ b/src/i18n/locales/ja-JP/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "トリムを追加",
"addSpeed": "速度を追加",
"addAnnotation": "注釈を追加",
+ "addAudio": "音声を追加",
+ "addVoiceover": "ナレーションを録音",
"addKeyframe": "キーフレームを追加",
"addCameraFullscreen": "フルスクリーンカメラを追加",
"deleteSelected": "選択を削除",
diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json
index 68911ba91..8c8b0c76d 100644
--- a/src/i18n/locales/ja-JP/timeline.json
+++ b/src/i18n/locales/ja-JP/timeline.json
@@ -15,6 +15,7 @@
"pressZoom": "Zキーを押してズームを追加",
"pressTrim": "Tキーを押してトリムを追加",
"pressAnnotation": "Aキーを押して注釈を追加",
+ "pressAudio": "M キーで音声を追加、V キーでナレーションを録音",
"pressSpeed": "Sキーを押して再生速度を追加",
"pressCameraFullscreen": "Cキーを押してフルスクリーンカメラのセグメントを追加"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "文字起こしが必要です",
"smartCutsNoAudio": "このメディアには音声がありません",
"smartCutsNoSpeech": "音声が検出されませんでした",
- "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください"
+ "smartCutsFailed": "文字起こしに失敗しました — メディアから再試行してください",
+ "addAudioTooltip": "音声を追加",
+ "addedWord": "追加した単語:「{{word}}」— 音声はありません"
+ },
+ "audio": {
+ "addVoiceover": "ナレーションを追加",
+ "addVoiceoverHint": "動画にナレーションを録音",
+ "subtitle": "タイムラインにナレーションまたは BGM のレイヤーを配置します",
+ "record": "ナレーションを録音",
+ "importFile": "音声ファイルを読み込む",
+ "importFileHint": "音楽やオーディオファイルを読み込む",
+ "recording": "録音中",
+ "recordingHint": "動画に合わせて話してください — 録音中も再生されます",
+ "stop": "停止",
+ "micDenied": "マイクへのアクセスが拒否されました",
+ "recordingUnavailable": "ここでは録音できません",
+ "saveFailed": "録音を保存できませんでした",
+ "importFailed": "音声ファイルを読み込めませんでした"
}
}
diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json
index 5891f44c1..2b64240ae 100644
--- a/src/i18n/locales/ko-KR/dialogs.json
+++ b/src/i18n/locales/ko-KR/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "내보낸 GIF 저장",
"saveVideo": "내보낸 비디오 저장",
"selectVideo": "비디오 파일 선택",
+ "selectAudio": "오디오 파일 선택",
"saveProject": "OpenScreen 프로젝트 저장",
"openProject": "OpenScreen 프로젝트 열기",
"gifImage": "GIF 이미지",
"mp4Video": "MP4 비디오",
"videoFiles": "비디오 파일",
+ "audioFiles": "오디오 파일",
"openscreenProject": "OpenScreen 프로젝트",
"allFiles": "모든 파일"
}
diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json
index a1fc34a62..8dd4be036 100644
--- a/src/i18n/locales/ko-KR/editor.json
+++ b/src/i18n/locales/ko-KR/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "비디오 로드 중...",
"loadingEditor": "편집기 로드 중...",
"errors": {
- "noVideoLoaded": "불러온 비디오가 없습니다",
- "videoNotReady": "비디오가 준비되지 않았습니다",
- "unableToDetermineSourcePath": "소스 비디오 경로를 확인할 수 없습니다",
- "failedToSaveGif": "GIF 저장에 실패했습니다",
- "gifExportFailed": "GIF 내보내기에 실패했습니다",
- "failedToSaveVideo": "비디오 저장에 실패했습니다",
+ "exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})",
"exportFailed": "내보내기에 실패했습니다",
"exportFailedWithError": "내보내기 실패: {{error}}",
- "exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})",
+ "failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}",
"failedToSaveExport": "내보낸 파일 저장에 실패했습니다",
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
- "failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}",
- "previewCompositorUnavailable": "이 컴퓨터에서는 미리보기를 사용할 수 없습니다"
+ "failedToSaveGif": "GIF 저장에 실패했습니다",
+ "failedToSaveVideo": "비디오 저장에 실패했습니다",
+ "gifExportFailed": "GIF 내보내기에 실패했습니다",
+ "noVideoLoaded": "불러온 비디오가 없습니다",
+ "previewCompositorUnavailable": "이 컴퓨터에서는 미리보기를 사용할 수 없습니다",
+ "trimNoFilm": "여기서는 자를 것이 없습니다. 그 단어들 아래에 영상이 없습니다.",
+ "unableToDetermineSourcePath": "소스 비디오 경로를 확인할 수 없습니다",
+ "videoNotReady": "비디오가 준비되지 않았습니다",
+ "wordEditFailed": "이 단어를 변경할 수 없습니다",
+ "wordInsertFailed": "이 단어를 추가할 수 없습니다",
+ "wordRemoveFailed": "이 단어를 삭제할 수 없습니다"
},
"export": {
"canceled": "내보내기가 취소되었습니다",
@@ -71,6 +75,7 @@
"pasted": "{{region}} 속성을 붙여넣었습니다",
"nothingToCopy": "속성을 복사할 영역을 선택하세요",
"nothingToPaste": "복사된 속성이 없습니다",
+ "pasteAssetMissing": "이 오디오 트랙의 파일이 이 프로젝트에 없습니다",
"kinds": {
"zoom": "줌",
"speed": "속도",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 3bf3e59a7..66cbbd245 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "누르고 있으면 줌 효과 미리보기",
- "level": "줌 레벨",
- "customScale": "커스텀 줌",
- "selectRegion": "조정할 줌 구간을 선택하세요",
- "deleteZoom": "줌 삭제",
- "focusMode": {
- "title": "포커스 모드",
- "manual": "수동",
- "auto": "자동",
- "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다",
- "lockedDisclaimer": "타임라인의 전역 자동 포커스 스위치로 제어됩니다. 줌마다 포커스 모드를 설정하려면 이 옵션을 꺼주세요."
- },
- "threeD": {
- "title": "3D 회전",
- "preset": {
- "iso": "Iso",
- "left": "왼쪽",
- "right": "오른쪽"
- },
- "none": "없음"
- },
- "position": {
- "title": "포커스 위치",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽"
- }
- },
- "speed": {
- "playbackSpeed": "재생 속도",
- "selectRegion": "조정할 속도 구간을 선택하세요",
- "deleteRegion": "속도 구간 삭제",
- "customPlaybackSpeed": "재생 속도 직접 입력",
- "maxSpeedError": "속도는 {{max}}×를 초과할 수 없습니다",
- "previewFrameSteppingHint": "{{native}}×를 초과하면 미리보기가 프레임 단위로 재생되고 음소거됩니다. 내보내기에는 영향이 없습니다."
- },
- "trim": {
- "deleteRegion": "트림 구간 삭제"
- },
- "layout": {
- "title": "카메라 레이아웃",
- "preset": "프리셋",
- "selectPreset": "프리셋 선택",
- "pictureInPicture": "화면 속 화면",
- "verticalStack": "세로 배치",
- "webcamShape": "카메라 모양",
- "webcamSize": "웹캠 크기",
- "dualFrame": "듀얼 프레임",
- "noWebcam": "웹캠 없음",
- "mirrorWebcam": "웹캠 미러링",
- "reactiveWebcam": "확대 시 축소",
- "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
- "help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
- "helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
- "webcamFraming": "웹캠 구도",
- "webcamCropZoom": "자르기 확대",
- "webcamCropX": "가로 이동",
- "webcamCropY": "세로 이동",
- "shapes": {
- "rectangle": "직사각형",
- "circle": "원형",
- "square": "정사각형",
- "rounded": "둥근 모서리"
- },
- "webcamBackground": "카메라 배경",
- "webcamBlurIntensity": "블러 강도",
- "bgModes": {
- "none": "원본",
- "transparent": "누끼",
- "blur": "블러",
- "custom": "사용자 지정"
- }
- },
- "effects": {
- "title": "컴포지션",
- "blurBg": "배경 흐림",
- "motionBlur": "모션 블러",
- "off": "끄기",
- "on": "켜기",
- "shadow": "그림자",
- "roundness": "모서리 둥글기",
- "padding": "여백",
- "frame": "프레임",
- "format": "형식",
- "formatOriginal": "원본",
- "fitClip": "맞추기",
- "fitClipOne": "{{count}}개 클립",
- "fitClipFew": "{{count}}개 클립",
- "fitClipMany": "{{count}}개 클립",
- "motion": "모션",
- "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백."
- },
"background": {
+ "gradientLabel": "그라디언트 {{index}}",
+ "uploadCustom": "직접 업로드",
+ "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
"title": "배경",
- "image": "이미지",
- "color": "색상",
- "gradient": "그라디언트",
+ "imageLabel": "배경 {{index}}",
"custom": "사용자 지정",
- "uploadCustom": "직접 업로드",
- "gradientLabel": "그라디언트 {{index}}",
- "colorWheel": "색상 휠",
- "colorPalette": "색상 팔레트",
- "presets": "프리셋",
"help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.",
+ "gradient": "그라디언트",
+ "colorLabel": "색상 {{color}}",
"customWallpaper": "사용자 배경",
- "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.",
+ "colorPalette": "색상 팔레트",
"imageReadFailed": "이미지 파일을 읽을 수 없습니다.",
- "imageLabel": "배경 {{index}}",
- "colorLabel": "색상 {{color}}"
- },
- "crop": {
- "title": "자르기",
- "cropVideo": "비디오 자르기",
- "dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
- "ratio": "비율",
- "free": "자유",
- "done": "완료",
- "lockAspectRatio": "화면 비율 고정",
- "unlockAspectRatio": "화면 비율 해제"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 비디오",
- "mp4Description": "고화질 비디오 파일",
- "gifAnimation": "GIF 애니메이션",
- "gifDescription": "공유용 애니메이션 이미지"
- },
- "exportQuality": {
- "title": "내보내기 해상도",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "GIF 프레임 속도",
- "size": "GIF 크기",
- "loop": "GIF 반복"
- },
- "project": {
- "save": "프로젝트 저장",
- "load": "프로젝트 불러오기",
- "new": "새 프로젝트"
- },
- "export": {
- "videoButton": "비디오 내보내기",
- "gifButton": "GIF 내보내기",
- "chooseSaveLocation": "저장 위치 선택"
+ "image": "이미지",
+ "presets": "프리셋",
+ "color": "색상",
+ "colorWheel": "색상 휠"
},
- "support": {
- "reportBug": "버그 신고",
- "saveDiagnostics": "Save Diagnostics",
- "starOnGithub": "GitHub에 Star 남기기"
+ "customFont": {
+ "namePlaceholder": "내 커스텀 폰트",
+ "failedToAdd": "폰트 추가에 실패했습니다",
+ "addingButton": "추가 중...",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
+ "nameLabel": "표시 이름",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
+ "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": "커스텀 이미지가 성공적으로 업로드되었습니다!",
- "failedToUpload": "이미지 업로드에 실패했습니다",
"errorReading": "파일을 읽는 중 오류가 발생했습니다."
},
"annotation": {
- "title": "주석 설정",
- "active": "활성",
- "typeText": "텍스트",
- "typeImage": "이미지",
- "typeArrow": "화살표",
- "typeBlur": "블러",
- "textContent": "텍스트 내용",
- "textPlaceholder": "텍스트를 입력하세요...",
- "defaultText": "안녕하세요",
- "fontStyle": "폰트 스타일",
- "selectStyle": "스타일 선택",
+ "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "사각형",
"size": "크기",
- "customFonts": "커스텀 폰트",
- "textColor": "텍스트 색상",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "clearBackground": "배경 지우기",
+ "colorPalette": "색상 팔레트",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
"background": "배경",
- "none": "없음",
+ "typeText": "텍스트",
+ "active": "활성",
"color": "색상",
- "colorWheel": "색상 휠",
- "colorPalette": "색상 팔레트",
- "clearBackground": "배경 지우기",
- "uploadImage": "이미지 업로드",
- "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "자유 곡선",
"arrowDirection": "화살표 방향",
- "strokeWidth": "선 두께: {{width}}px",
- "arrowColor": "화살표 색상",
- "blurType": "블러 종류",
- "blurTypeBlur": "가우시안",
"blurTypeMosaic": "모자이크",
+ "colorWheel": "색상 휠",
+ "textColor": "텍스트 색상",
+ "title": "주석 설정",
+ "blurType": "블러 종류",
+ "typeBlur": "블러",
+ "blurIntensity": "블러 강도",
+ "selectStyle": "스타일 선택",
+ "textContent": "텍스트 내용",
+ "typeArrow": "화살표",
+ "none": "없음",
"blurColor": "블러 색상",
- "blurColorWhite": "흰색",
- "blurColorBlack": "검정",
+ "customFonts": "커스텀 폰트",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "type": "유형",
+ "arrowColor": "화살표 색상",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
"blurShape": "블러 모양",
- "blurIntensity": "블러 강도",
+ "uploadImage": "이미지 업로드",
+ "blurTypeBlur": "가우시안",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "shortcutsAndTips": "단축키 및 팁",
+ "deleteAnnotation": "주석 삭제",
+ "fontStyle": "폰트 스타일",
+ "defaultText": "안녕하세요",
"mosaicBlockSize": "모자이크 블록 크기",
- "blurShapeRectangle": "사각형",
+ "blurColorBlack": "검정",
+ "strokeWidth": "선 두께: {{width}}px",
"blurShapeOval": "타원",
- "blurShapeFreehand": "자유 곡선",
- "deleteAnnotation": "주석 삭제",
- "shortcutsAndTips": "단축키 및 팁",
+ "blurColorWhite": "흰색",
"tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
- "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
- "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
- "invalidImageType": "지원하지 않는 파일 형식입니다",
- "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
- "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
- "type": "유형"
- },
- "textAnimation": {
- "title": "텍스트 애니메이션",
- "selectAnimation": "애니메이션 선택",
- "none": "없음",
- "fade": "페이드",
- "rise": "상승",
- "pop": "팝",
- "slideLeft": "왼쪽 슬라이드",
- "typewriter": "타자기",
- "pulse": "펄스"
- },
- "customFont": {
- "dialogTitle": "Google 폰트 추가",
- "urlLabel": "Google Fonts 가져오기 URL",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
- "nameLabel": "표시 이름",
- "namePlaceholder": "내 커스텀 폰트",
- "nameHelp": "폰트 선택기에서 표시될 이름입니다",
- "addButton": "폰트 추가",
- "addingButton": "추가 중...",
- "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
- "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
- "errorEmptyName": "폰트 이름을 입력해 주세요",
- "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
- "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
- "failedToAdd": "폰트 추가에 실패했습니다",
- "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
- "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요."
+ "typeImage": "이미지"
},
- "cursor": {
- "title": "커서",
- "theme": "커서 스타일",
- "themeDefault": "기본",
- "show": "커서 표시",
- "size": "크기",
- "smoothing": "부드러움",
+ "effects": {
+ "fitClipFew": "{{count}}개 클립",
+ "title": "컴포지션",
+ "shadow": "그림자",
+ "off": "끄기",
+ "on": "켜기",
+ "blurBg": "배경 흐림",
+ "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백.",
+ "fitClipOne": "{{count}}개 클립",
+ "formatOriginal": "원본",
+ "fitClipMany": "{{count}}개 클립",
+ "frame": "프레임",
+ "motion": "모션",
+ "padding": "여백",
+ "format": "형식",
+ "fitClip": "맞추기",
"motionBlur": "모션 블러",
- "clickBounce": "클릭 바운스",
- "clipToBounds": "캔버스에 맞춰 자르기",
- "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.",
- "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스."
- },
- "language": {
- "title": "언어"
- },
- "facets": {
- "captions": "자막",
- "transcript": "대본"
- },
- "panes": {
- "help": "도움말"
+ "roundness": "모서리 둥글기"
},
"transcript": {
- "title": "현재 전사",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 건너뛴 것으로 표시됩니다(빨간색). 빨간 부분에 마우스를 올리면 복원할 수 있습니다.",
- "noClips": "아직 클립이 없습니다",
+ "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": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "editorAria": "{{filename}}의 전사",
- "silence": "[무음 {{duration}}초]",
- "restoreSilence": "무음 복원 ({{duration}}초)",
- "trimSilence": "무음 자르기 ({{duration}}초)",
- "restoreWord": "\"{{word}}\" 복원",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다"
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "blankedWord": "비움"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "고화질 비디오 파일",
+ "gifAnimation": "GIF 애니메이션",
+ "mp4Video": "MP4 비디오",
+ "gifDescription": "공유용 애니메이션 이미지",
+ "gif": "GIF"
},
"captions": {
- "show": "자막 표시",
- "noTranscript": "자막은 미디어 전사에서 읽어옵니다. 켜려면 이 동영상을 전사하세요.",
- "transcribe": "동영상 전사하기",
- "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
- "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
- "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
- "removeLegacyAnnotations": "이전 자막 주석 제거",
- "language": "언어",
- "displayLanguage": "표시",
- "original": "원본 (전사)",
- "translate": "번역",
- "translating": "번역 중…",
- "translateHint": "설정된 AI 제공자로 전사를 번역합니다",
- "translateFailed": "번역에 실패했습니다.",
+ "showBackground": "배경 표시",
"deleteTranslation": "이 번역 삭제",
+ "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.",
+ "backgroundOpacity": "불투명도",
+ "backgroundColor": "배경 색",
+ "alignCenter": "가운데",
"translationIsNonDestructive": "번역은 전사 안이 아니라 옆에 저장됩니다 — 원본 텍스트와 타이밍은 그대로 유지됩니다.",
+ "distanceFromRight": "오른쪽에서의 거리",
+ "language": "언어",
"text": "텍스트",
- "font": "글꼴",
- "fontSize": "크기",
- "bold": "굵게",
- "textColor": "글자 색",
- "background": "배경",
- "showBackground": "배경 표시",
- "backgroundColor": "배경 색",
- "backgroundOpacity": "불투명도",
- "position": "위치",
- "anchorBottom": "아래",
- "anchorTop": "위",
"anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.",
- "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.",
- "distanceFromBottom": "아래에서의 거리",
"distanceFromTop": "위에서의 거리",
- "distanceFromLeft": "왼쪽에서의 거리",
- "distanceFromRight": "오른쪽에서의 거리",
+ "translateFailed": "번역에 실패했습니다.",
"alignLeft": "왼쪽",
- "alignCenter": "가운데",
+ "distanceFromBottom": "아래에서의 거리",
+ "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
+ "translate": "번역",
+ "position": "위치",
+ "fontSize": "크기",
+ "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
+ "noTranscript": "자막은 미디어 전사에서 읽어옵니다. 켜려면 이 동영상을 전사하세요.",
+ "distanceFromLeft": "왼쪽에서의 거리",
+ "anchorBottom": "아래",
+ "transcribe": "동영상 전사하기",
+ "bold": "굵게",
"alignRight": "오른쪽",
- "lineLength": "줄 길이",
+ "anchorTop": "위",
"minWords": "줄당 최소 단어 수",
- "maxWords": "줄당 최대 단어 수"
+ "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": {
+ "slideLeft": "왼쪽 슬라이드",
+ "pulse": "펄스",
+ "typewriter": "타자기",
+ "selectAnimation": "애니메이션 선택",
+ "fade": "페이드",
+ "title": "텍스트 애니메이션",
+ "none": "없음",
+ "pop": "팝",
+ "rise": "상승"
+ },
+ "facets": {
+ "transcript": "대본",
+ "captions": "자막"
+ },
+ "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": {
- "title": "오디오",
"outputGain": "출력 레벨",
+ "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다.",
"reset": "오디오 재설정",
- "help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다."
+ "title": "오디오"
+ },
+ "language": {
+ "title": "언어"
+ },
+ "project": {
+ "new": "새 프로젝트",
+ "load": "프로젝트 불러오기",
+ "save": "프로젝트 저장"
+ },
+ "support": {
+ "starOnGithub": "GitHub에 Star 남기기",
+ "saveDiagnostics": "Save Diagnostics",
+ "reportBug": "버그 신고"
+ },
+ "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/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json
index 00e8f689d..86f0693ea 100644
--- a/src/i18n/locales/ko-KR/shortcuts.json
+++ b/src/i18n/locales/ko-KR/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "트림 추가",
"addSpeed": "속도 추가",
"addAnnotation": "주석 추가",
+ "addAudio": "오디오 추가",
+ "addVoiceover": "보이스오버 녹음",
"addKeyframe": "키프레임 추가",
"addCameraFullscreen": "전체 화면 카메라 추가",
"deleteSelected": "선택 항목 삭제",
diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json
index 8a100ee6d..8d1ec2e88 100644
--- a/src/i18n/locales/ko-KR/timeline.json
+++ b/src/i18n/locales/ko-KR/timeline.json
@@ -15,6 +15,7 @@
"pressZoom": "Z를 눌러 줌 추가",
"pressTrim": "T를 눌러 트림 추가",
"pressAnnotation": "A를 눌러 주석 추가",
+ "pressAudio": "M 키로 오디오 추가, V 키로 보이스오버 녹음",
"pressSpeed": "S를 눌러 속도 추가",
"pressCameraFullscreen": "C를 눌러 전체 화면 카메라 구간을 추가하세요"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "받아쓰기가 필요합니다",
"smartCutsNoAudio": "이 미디어에는 오디오가 없습니다",
"smartCutsNoSpeech": "음성이 감지되지 않음",
- "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요"
+ "smartCutsFailed": "받아쓰기에 실패했습니다 — 미디어에서 다시 시도하세요",
+ "addAudioTooltip": "오디오 추가",
+ "addedWord": "추가한 단어: \"{{word}}\" — 뒤에 오디오가 없습니다"
+ },
+ "audio": {
+ "addVoiceover": "내레이션 추가",
+ "addVoiceoverHint": "영상 위에 내레이션을 녹음",
+ "subtitle": "타임라인에 내레이션 또는 배경 음악 레이어를 배치합니다",
+ "record": "내레이션 녹음",
+ "importFile": "오디오 파일 가져오기",
+ "importFileHint": "음악이나 오디오 파일 가져오기",
+ "recording": "녹음 중",
+ "recordingHint": "영상에 맞춰 말하세요 — 녹음하는 동안 재생됩니다",
+ "stop": "중지",
+ "micDenied": "마이크 접근이 거부되었습니다",
+ "recordingUnavailable": "여기에서는 녹음할 수 없습니다",
+ "saveFailed": "녹음을 저장하지 못했습니다",
+ "importFailed": "오디오 파일을 가져오지 못했습니다"
}
}
diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json
index ba77d90a9..88f163d99 100644
--- a/src/i18n/locales/pt-BR/dialogs.json
+++ b/src/i18n/locales/pt-BR/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Salvar GIF Exportado",
"saveVideo": "Salvar Vídeo Exportado",
"selectVideo": "Selecionar Arquivo de Vídeo",
+ "selectAudio": "Selecionar arquivo de áudio",
"saveProject": "Salvar Projeto OpenScreen",
"openProject": "Abrir Projeto OpenScreen",
"gifImage": "Imagem GIF",
"mp4Video": "Vídeo MP4",
"videoFiles": "Arquivos de Vídeo",
+ "audioFiles": "Arquivos de áudio",
"openscreenProject": "Projeto OpenScreen",
"allFiles": "Todos os Arquivos"
}
diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json
index 799f3be74..5a6219e6c 100644
--- a/src/i18n/locales/pt-BR/editor.json
+++ b/src/i18n/locales/pt-BR/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "Carregando vídeo...",
"loadingEditor": "Carregando editor...",
"errors": {
- "noVideoLoaded": "Nenhum vídeo carregado",
- "videoNotReady": "Vídeo não está pronto",
- "unableToDetermineSourcePath": "Não foi possível determinar o caminho do vídeo de origem",
- "failedToSaveGif": "Falha ao salvar GIF",
- "gifExportFailed": "Falha na exportação do GIF",
- "failedToSaveVideo": "Falha ao salvar vídeo",
+ "exportBackgroundLoadFailed": "Falha na exportação: não foi possível carregar a imagem de fundo ({{url}})",
"exportFailed": "Falha na exportação",
"exportFailedWithError": "Falha na exportação: {{error}}",
- "exportBackgroundLoadFailed": "Falha na exportação: não foi possível carregar a imagem de fundo ({{url}})",
+ "failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}",
"failedToSaveExport": "Falha ao salvar exportação",
"failedToSaveExportedVideo": "Falha ao salvar vídeo exportado",
- "failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}",
- "previewCompositorUnavailable": "Pré-visualização indisponível neste computador"
+ "failedToSaveGif": "Falha ao salvar GIF",
+ "failedToSaveVideo": "Falha ao salvar vídeo",
+ "gifExportFailed": "Falha na exportação do GIF",
+ "noVideoLoaded": "Nenhum vídeo carregado",
+ "previewCompositorUnavailable": "Pré-visualização indisponível neste computador",
+ "trimNoFilm": "Não há o que cortar aí — não existe imagem sob essas palavras.",
+ "unableToDetermineSourcePath": "Não foi possível determinar o caminho do vídeo de origem",
+ "videoNotReady": "Vídeo não está pronto",
+ "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",
@@ -71,6 +75,7 @@
"pasted": "Atributos de {{region}} colados",
"nothingToCopy": "Selecione uma região para copiar seus atributos",
"nothingToPaste": "Nenhum atributo copiado ainda",
+ "pasteAssetMissing": "O arquivo dessa faixa de áudio não está neste projeto",
"kinds": {
"zoom": "Zoom",
"speed": "Velocidade",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 1218d1f85..b70b4d05e 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
- "level": "Nível de Zoom",
- "customScale": "Zoom Personalizado",
- "selectRegion": "Selecione uma região de zoom para ajustar",
- "deleteZoom": "Excluir Zoom",
- "focusMode": {
- "title": "Modo de Foco",
- "manual": "Manual",
- "auto": "Automático",
- "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."
- },
- "threeD": {
- "title": "Rotação 3D",
- "preset": {
- "iso": "Iso",
- "left": "Esquerda",
- "right": "Direita"
- },
- "none": "Nenhuma"
- },
- "position": {
- "title": "Posição do Foco",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior"
- }
- },
- "speed": {
- "playbackSpeed": "Velocidade de Reprodução",
- "selectRegion": "Selecione uma região de velocidade para ajustar",
- "deleteRegion": "Excluir Região de Velocidade",
- "customPlaybackSpeed": "Velocidade Personalizada",
- "maxSpeedError": "A velocidade não pode ser superior a {{max}}×",
- "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"
- },
- "layout": {
- "title": "Layout da câmera",
- "preset": "Predefinição",
- "selectPreset": "Selecionar predefinição",
- "pictureInPicture": "Picture in Picture",
- "verticalStack": "Empilhamento Vertical",
- "dualFrame": "Quadro Duplo",
- "noWebcam": "Sem Webcam",
- "webcamShape": "Formato da Câmera",
- "webcamSize": "Tamanho da Webcam",
- "mirrorWebcam": "Espelhar Webcam",
- "reactiveWebcam": "Encolher ao ampliar",
- "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
- "help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
- "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.",
- "webcamFraming": "Enquadramento da webcam",
- "webcamCropZoom": "Zoom do recorte",
- "webcamCropX": "Deslocamento horizontal",
- "webcamCropY": "Deslocamento vertical",
- "shapes": {
- "rectangle": "Ret.",
- "circle": "Círculo",
- "square": "Quadrado",
- "rounded": "Arredondado"
- },
- "webcamBackground": "Plano de fundo da câmera",
- "webcamBlurIntensity": "Intensidade do desfoque",
- "bgModes": {
- "none": "Original",
- "transparent": "Recorte",
- "blur": "Desfocado",
- "custom": "Personalizado"
- }
- },
- "effects": {
- "title": "Composição",
- "blurBg": "Desfocar Fundo",
- "motionBlur": "Desfoque de Movimento",
- "off": "desativado",
- "on": "ativado",
- "shadow": "Sombra",
- "roundness": "Arredondamento",
- "padding": "Espaçamento",
- "frame": "Moldura",
- "format": "Formato",
- "formatOriginal": "Original",
- "fitClip": "Ajustar",
- "fitClipOne": "{{count}} clipe",
- "fitClipFew": "{{count}} clipes",
- "fitClipMany": "{{count}} clipes",
- "motion": "Movimento",
- "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo."
- },
"background": {
+ "gradientLabel": "Gradiente {{index}}",
+ "uploadCustom": "Enviar Personalizada",
+ "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
"title": "Fundo",
- "image": "Imagem",
- "color": "Cor",
- "gradient": "Gradiente",
+ "imageLabel": "Fundo {{index}}",
"custom": "Personalizado",
- "uploadCustom": "Enviar Personalizada",
- "gradientLabel": "Gradiente {{index}}",
- "colorWheel": "Roda de Cores",
- "colorPalette": "Paleta de Cores",
- "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.",
+ "gradient": "Gradiente",
+ "colorLabel": "Cor {{color}}",
"customWallpaper": "Papel de parede personalizado",
- "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.",
+ "colorPalette": "Paleta de Cores",
"imageReadFailed": "Não foi possível ler esse arquivo de imagem.",
- "imageLabel": "Fundo {{index}}",
- "colorLabel": "Cor {{color}}"
- },
- "crop": {
- "title": "Cortar",
- "cropVideo": "Cortar Vídeo",
- "dragInstruction": "Arraste cada lado para ajustar a área de corte",
- "ratio": "Proporção",
- "free": "Livre",
- "done": "Concluir",
- "lockAspectRatio": "Bloquear proporção",
- "unlockAspectRatio": "Desbloquear proporção"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "Vídeo MP4",
- "mp4Description": "Arquivo de vídeo de alta qualidade",
- "gifAnimation": "Animação GIF",
- "gifDescription": "Imagem animada para compartilhamento"
- },
- "exportQuality": {
- "title": "Qualidade de Exportação",
- "low": "Baixa",
- "medium": "Média",
- "high": "Alta"
- },
- "gifSettings": {
- "frameRate": "Taxa de Quadros do GIF",
- "size": "Tamanho do GIF",
- "loop": "Loop no GIF"
- },
- "project": {
- "save": "Salvar Projeto",
- "load": "Carregar Projeto",
- "new": "Novo Projeto"
- },
- "export": {
- "videoButton": "Exportar Vídeo",
- "gifButton": "Exportar GIF",
- "chooseSaveLocation": "Escolher Local para Salvar"
+ "image": "Imagem",
+ "presets": "Predefinições",
+ "color": "Cor",
+ "colorWheel": "Roda de Cores"
},
- "support": {
- "reportBug": "Relatar Bug",
- "saveDiagnostics": "Salvar Diagnósticos",
- "starOnGithub": "Dar Estrela no GitHub"
+ "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",
+ "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",
+ "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",
+ "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!",
- "failedToUpload": "Falha ao enviar imagem",
"errorReading": "Ocorreu um erro ao ler o arquivo."
},
"annotation": {
- "title": "Configurações de Anotação",
- "active": "Ativo",
- "typeText": "Texto",
- "typeImage": "Imagem",
- "typeArrow": "Seta",
- "typeBlur": "Desfoque",
- "textContent": "Conteúdo do Texto",
- "textPlaceholder": "Digite seu texto...",
- "defaultText": "Olá",
- "fontStyle": "Estilo da Fonte",
- "selectStyle": "Selecionar estilo",
+ "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Retângulo",
"size": "Tamanho",
- "customFonts": "Fontes Personalizadas",
- "textColor": "Cor do Texto",
+ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
+ "clearBackground": "Limpar Fundo",
+ "colorPalette": "Paleta de Cores",
+ "invalidImageType": "Tipo de imagem inválido",
"background": "Fundo",
- "none": "Nenhum",
+ "typeText": "Texto",
+ "active": "Ativo",
"color": "Cor",
- "colorWheel": "Roda de Cores",
- "colorPalette": "Paleta de Cores",
- "clearBackground": "Limpar Fundo",
- "uploadImage": "Enviar Imagem",
- "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "Mão Livre",
"arrowDirection": "Direção da Seta",
- "strokeWidth": "Largura do Traço: {{width}}px",
- "arrowColor": "Cor da Seta",
- "blurType": "Tipo de Desfoque",
- "blurTypeBlur": "Gaussiano",
"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",
+ "textContent": "Conteúdo do Texto",
+ "typeArrow": "Seta",
+ "none": "Nenhum",
"blurColor": "Cor do Desfoque",
- "blurColorWhite": "Branco",
- "blurColorBlack": "Preto",
+ "customFonts": "Fontes Personalizadas",
+ "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",
- "blurIntensity": "Intensidade 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",
+ "defaultText": "Olá",
"mosaicBlockSize": "Tamanho do Bloco do Mosaico",
- "blurShapeRectangle": "Retângulo",
+ "blurColorBlack": "Preto",
+ "strokeWidth": "Largura do Traço: {{width}}px",
"blurShapeOval": "Oval",
- "blurShapeFreehand": "Mão Livre",
- "deleteAnnotation": "Excluir Anotação",
- "shortcutsAndTips": "Atalhos e Dicas",
+ "blurColorWhite": "Branco",
"tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
- "tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
- "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
- "invalidImageType": "Tipo de imagem inválido",
- "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
- "imageUploadSuccess": "Imagem enviada com sucesso!",
- "type": "Tipo"
- },
- "textAnimation": {
- "title": "Animação de Texto",
- "selectAnimation": "Selecionar animação",
- "none": "Nenhuma",
- "fade": "Esmaecer",
- "rise": "Subir",
- "pop": "Aparecer",
- "slideLeft": "Deslizar à Esquerda",
- "typewriter": "Máquina de Escrever",
- "pulse": "Pulsar"
- },
- "customFont": {
- "dialogTitle": "Adicionar Google Font",
- "urlLabel": "URL de Importação do Google Fonts",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
- "nameLabel": "Nome de Exibição",
- "namePlaceholder": "Minha Fonte Personalizada",
- "nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
- "addButton": "Adicionar Fonte",
- "addingButton": "Adicionando...",
- "errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
- "errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
- "errorEmptyName": "Por favor, insira um nome para a fonte",
- "errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
- "successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
- "failedToAdd": "Falha ao adicionar fonte",
- "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."
- },
- "cursor": {
- "title": "Cursor",
- "theme": "Estilo do cursor",
- "themeDefault": "Padrão",
- "show": "Mostrar cursor",
- "size": "Tamanho",
- "smoothing": "Suavização",
- "motionBlur": "Desfoque de movimento",
- "clickBounce": "Rebote ao clicar",
- "clipToBounds": "Recortar à tela",
- "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.",
- "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar."
+ "typeImage": "Imagem"
},
- "language": {
- "title": "Idioma"
- },
- "facets": {
- "captions": "Legendas",
- "transcript": "Transcrição"
- },
- "panes": {
- "help": "Ajuda"
+ "effects": {
+ "fitClipFew": "{{count}} clipes",
+ "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",
+ "fitClipMany": "{{count}} clipes",
+ "frame": "Moldura",
+ "motion": "Movimento",
+ "padding": "Espaçamento",
+ "format": "Formato",
+ "fitClip": "Ajustar",
+ "motionBlur": "Desfoque de Movimento",
+ "roundness": "Arredondamento"
},
"transcript": {
- "title": "Transcrição atual",
- "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção marca como ignorada (em vermelho). Passe o mouse sobre um trecho vermelho para restaurá-lo.",
- "noClips": "Nenhum clipe ainda",
+ "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.",
- "editorAria": "Transcrição de {{filename}}",
- "silence": "[silêncio {{duration}} s]",
- "restoreSilence": "Restaurar silêncio ({{duration}} s)",
- "trimSilence": "Cortar silêncio ({{duration}} s)",
- "restoreWord": "Restaurar \"{{word}}\"",
- "noAudio": "Esta mídia não tem faixa de áudio"
+ "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": {
+ "mp4": "MP4",
+ "mp4Description": "Arquivo de vídeo de alta qualidade",
+ "gifAnimation": "Animação GIF",
+ "mp4Video": "Vídeo MP4",
+ "gifDescription": "Imagem animada para compartilhamento",
+ "gif": "GIF"
},
"captions": {
- "show": "Mostrar legendas",
- "noTranscript": "As legendas são lidas da transcrição da mídia. Transcreva este vídeo para ativá-las.",
- "transcribe": "Transcrever vídeo",
- "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.",
- "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.",
- "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",
- "language": "Idioma",
- "displayLanguage": "Exibição",
- "original": "Original (transcrição)",
- "translate": "Traduzir",
- "translating": "Traduzindo…",
- "translateHint": "Traduzir a transcrição com o provedor de IA configurado",
- "translateFailed": "A tradução falhou.",
+ "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",
- "font": "Fonte",
- "fontSize": "Tamanho",
- "bold": "Negrito",
- "textColor": "Cor do texto",
- "background": "Fundo",
- "showBackground": "Mostrar fundo",
- "backgroundColor": "Cor do fundo",
- "backgroundOpacity": "Opacidade",
- "position": "Posição",
- "anchorBottom": "Base",
- "anchorTop": "Topo",
"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.",
- "distanceFromBottom": "Distância da base",
"distanceFromTop": "Distância do topo",
- "distanceFromLeft": "Distância da esquerda",
- "distanceFromRight": "Distância da direita",
+ "translateFailed": "A tradução falhou.",
"alignLeft": "Esquerda",
- "alignCenter": "Centro",
+ "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",
- "lineLength": "Comprimento da linha",
+ "anchorTop": "Topo",
"minWords": "Mín. de palavras por linha",
- "maxWords": "Máx. 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"
+ },
+ "panes": {
+ "help": "Ajuda"
+ },
+ "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."
+ },
+ "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": {
+ "defaultLabel": "Faixa de áudio",
+ "importFailed": "Não foi possível adicionar o áudio",
+ "fadeOut": "Fade out",
+ "fadeIn": "Fade in",
+ "remove": "Excluir faixa",
+ "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.",
+ "add": "Adicionar faixa de áudio",
+ "mute": "Silenciar"
+ },
+ "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"
+ },
+ "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"
+ },
+ "facets": {
+ "transcript": "Transcrição",
+ "captions": "Legendas"
+ },
+ "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": {
- "title": "Á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.",
"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"
+ },
+ "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"
+ },
+ "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/shortcuts.json b/src/i18n/locales/pt-BR/shortcuts.json
index a21fe7bed..5f5506aec 100644
--- a/src/i18n/locales/pt-BR/shortcuts.json
+++ b/src/i18n/locales/pt-BR/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Adicionar Recorte",
"addSpeed": "Adicionar Velocidade",
"addAnnotation": "Adicionar Anotação",
+ "addAudio": "Adicionar áudio",
+ "addVoiceover": "Gravar narração",
"addKeyframe": "Adicionar Quadro-chave",
"addCameraFullscreen": "Adicionar Câmera em Tela Cheia",
"deleteSelected": "Excluir Selecionado",
diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json
index 5359feba9..2c0669cbb 100644
--- a/src/i18n/locales/pt-BR/timeline.json
+++ b/src/i18n/locales/pt-BR/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"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"
+ "smartCutsFailed": "Falha na transcrição — tente de novo em Mídia",
+ "addAudioTooltip": "Adicionar áudio",
+ "addedWord": "Palavra adicionada: \"{{word}}\" — sem áudio por trás"
+ },
+ "audio": {
+ "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",
+ "record": "Gravar narração",
+ "importFile": "Importar arquivo de áudio",
+ "importFileHint": "Importe música ou um arquivo de áudio",
+ "recording": "Gravando",
+ "recordingHint": "Narre junto com o vídeo — ele continua tocando enquanto você grava",
+ "stop": "Parar",
+ "micDenied": "Acesso ao microfone negado",
+ "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"
}
}
diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json
index a821771e9..1f46a14fc 100644
--- a/src/i18n/locales/ru/dialogs.json
+++ b/src/i18n/locales/ru/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Сохранить экспортированный GIF",
"saveVideo": "Сохранить экспортированное видео",
"selectVideo": "Выбрать видеофайл",
+ "selectAudio": "Выбрать аудиофайл",
"saveProject": "Сохранить проект OpenScreen",
"openProject": "Открыть проект OpenScreen",
"gifImage": "GIF изображение",
"mp4Video": "MP4 видео",
"videoFiles": "Видеофайлы",
+ "audioFiles": "Аудиофайлы",
"openscreenProject": "Проект OpenScreen",
"allFiles": "Все файлы"
}
diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json
index db5216291..86830571b 100644
--- a/src/i18n/locales/ru/editor.json
+++ b/src/i18n/locales/ru/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "Загрузка видео...",
"loadingEditor": "Загрузка редактора...",
"errors": {
- "noVideoLoaded": "Видео не загружено",
- "videoNotReady": "Видео не готово",
- "unableToDetermineSourcePath": "Не удалось определить путь к исходному видео",
- "failedToSaveGif": "Не удалось сохранить GIF",
- "gifExportFailed": "Экспорт GIF не удался",
- "failedToSaveVideo": "Не удалось сохранить видео",
+ "exportBackgroundLoadFailed": "Экспорт не удался: не удалось загрузить фоновое изображение ({{url}})",
"exportFailed": "Экспорт не удался",
"exportFailedWithError": "Экспорт не удался: {{error}}",
- "exportBackgroundLoadFailed": "Экспорт не удался: не удалось загрузить фоновое изображение ({{url}})",
+ "failedToRevealInFolder": "Ошибка при показе в папке: {{error}}",
"failedToSaveExport": "Не удалось сохранить экспорт",
"failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео",
- "failedToRevealInFolder": "Ошибка при показе в папке: {{error}}",
- "previewCompositorUnavailable": "Предпросмотр недоступен на этом компьютере"
+ "failedToSaveGif": "Не удалось сохранить GIF",
+ "failedToSaveVideo": "Не удалось сохранить видео",
+ "gifExportFailed": "Экспорт GIF не удался",
+ "noVideoLoaded": "Видео не загружено",
+ "previewCompositorUnavailable": "Предпросмотр недоступен на этом компьютере",
+ "trimNoFilm": "Здесь нечего вырезать — под этими словами нет видео.",
+ "unableToDetermineSourcePath": "Не удалось определить путь к исходному видео",
+ "videoNotReady": "Видео не готово",
+ "wordEditFailed": "Не удалось изменить это слово",
+ "wordInsertFailed": "Не удалось добавить слово",
+ "wordRemoveFailed": "Не удалось удалить слово"
},
"export": {
"canceled": "Экспорт отменён",
@@ -71,6 +75,7 @@
"pasted": "Атрибуты «{{region}}» вставлены",
"nothingToCopy": "Выберите регион, чтобы скопировать его атрибуты",
"nothingToPaste": "Атрибуты ещё не скопированы",
+ "pasteAssetMissing": "Файл этой аудиодорожки отсутствует в проекте",
"kinds": {
"zoom": "Масштаб",
"speed": "Скорость",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index 5b235bf76..c390819f5 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Удерживайте для предпросмотра эффекта зума",
- "level": "Уровень масштабирования",
- "selectRegion": "Выберите область масштабирования для настройки",
- "deleteZoom": "Удалить масштабирование",
- "focusMode": {
- "title": "Режим фокуса",
- "manual": "Ручной",
- "auto": "Авто",
- "autoDescription": "Камера следует за записанной позицией курсора",
- "lockedDisclaimer": "Управляется глобальным переключателем автофокуса на таймлайне. Отключите его, чтобы задавать режим фокуса для каждого зума отдельно."
- },
- "threeD": {
- "title": "3D вращение",
- "preset": {
- "iso": "Изометрия",
- "left": "Слева",
- "right": "Справа"
- },
- "none": "Нет"
- },
- "customScale": "Пользовательский масштаб",
- "position": {
- "title": "Положение фокуса",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = край слева / сверху, 100 = край справа / снизу"
- }
- },
- "speed": {
- "playbackSpeed": "Скорость воспроизведения",
- "selectRegion": "Выберите область скорости для настройки",
- "deleteRegion": "Удалить область скорости",
- "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
- "maxSpeedError": "Скорость не может быть выше {{max}}×",
- "previewFrameSteppingHint": "Выше {{native}}× предпросмотр идёт покадрово и без звука. На экспорт это не влияет."
- },
- "trim": {
- "deleteRegion": "Удалить область обрезки"
- },
- "layout": {
- "title": "Расположение камеры",
- "preset": "Пресет",
- "selectPreset": "Выбрать пресет",
- "pictureInPicture": "Картинка в картинке",
- "verticalStack": "Вертикальный стек",
- "dualFrame": "Двойной кадр",
- "webcamShape": "Форма камеры",
- "webcamSize": "Размер веб-камеры",
- "noWebcam": "Без веб-камеры",
- "mirrorWebcam": "Зеркалить веб-камеру",
- "reactiveWebcam": "Уменьшать при зуме",
- "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
- "help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
- "helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
- "webcamFraming": "Кадрирование веб-камеры",
- "webcamCropZoom": "Масштаб обрезки",
- "webcamCropX": "Смещение по горизонтали",
- "webcamCropY": "Смещение по вертикали",
- "shapes": {
- "rectangle": "Прямоуг.",
- "circle": "Круг",
- "square": "Квадрат",
- "rounded": "Скруглённый"
- },
- "webcamBackground": "Фон камеры",
- "webcamBlurIntensity": "Интенсивность размытия",
- "bgModes": {
- "none": "Оригинал",
- "transparent": "Вырезка",
- "blur": "Размытие",
- "custom": "Пользовательский"
- }
- },
- "effects": {
- "title": "Композиция",
- "blurBg": "Размытие фона",
- "motionBlur": "Размытие движения",
- "off": "выкл",
- "on": "вкл",
- "shadow": "Тень",
- "roundness": "Скругление",
- "padding": "Отступ",
- "frame": "Рамка",
- "format": "Формат",
- "formatOriginal": "Исходный",
- "fitClip": "Подогнать",
- "fitClipOne": "{{count}} клип",
- "fitClipFew": "{{count}} клипа",
- "fitClipMany": "{{count}} клипов",
- "motion": "Движение",
- "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео."
- },
"background": {
+ "gradientLabel": "Градиент {{index}}",
+ "uploadCustom": "Загрузить свой",
+ "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
"title": "Фон",
- "image": "Изображение",
- "color": "Цвет",
- "gradient": "Градиент",
+ "imageLabel": "Фон {{index}}",
"custom": "Свой",
- "uploadCustom": "Загрузить свой",
- "gradientLabel": "Градиент {{index}}",
- "colorWheel": "Цветовой круг",
- "colorPalette": "Палитра цветов",
- "presets": "Пресеты",
"help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.",
+ "gradient": "Градиент",
+ "colorLabel": "Цвет {{color}}",
"customWallpaper": "Свои обои",
- "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.",
+ "colorPalette": "Палитра цветов",
"imageReadFailed": "Не удалось прочитать этот файл изображения.",
- "imageLabel": "Фон {{index}}",
- "colorLabel": "Цвет {{color}}"
- },
- "crop": {
- "title": "Обрезка",
- "cropVideo": "Обрезать видео",
- "dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
- "ratio": "Соотношение сторон",
- "free": "Свободно",
- "done": "Готово",
- "lockAspectRatio": "Заблокировать соотношение сторон",
- "unlockAspectRatio": "Разблокировать соотношение сторон"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 видео",
- "mp4Description": "Видеофайл высокого качества",
- "gifAnimation": "GIF анимация",
- "gifDescription": "Анимированное изображение для обмена"
- },
- "exportQuality": {
- "title": "Разрешение экспорта",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "Частота кадров GIF",
- "size": "Размер GIF",
- "loop": "Зациклить GIF"
- },
- "project": {
- "save": "Сохранить проект",
- "load": "Загрузить проект",
- "new": "Новый проект"
- },
- "export": {
- "videoButton": "Экспорт видео",
- "gifButton": "Экспорт GIF",
- "chooseSaveLocation": "Выбрать место сохранения"
+ "image": "Изображение",
+ "presets": "Пресеты",
+ "color": "Цвет",
+ "colorWheel": "Цветовой круг"
},
- "support": {
- "reportBug": "Сообщить об ошибке",
- "saveDiagnostics": "Сохранить диагностику",
- "starOnGithub": "Звезда на GitHub"
+ "customFont": {
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "failedToAdd": "Не удалось добавить шрифт",
+ "addingButton": "Добавление...",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
+ "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "urlLabel": "URL импорта Google Fonts",
+ "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
+ "nameLabel": "Отображаемое имя",
+ "errorEmptyName": "Пожалуйста, введите имя шрифта",
+ "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
+ "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
+ "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": "Пользовательское изображение успешно загружено!",
- "failedToUpload": "Не удалось загрузить изображение",
"errorReading": "Произошла ошибка при чтении файла."
},
"annotation": {
- "title": "Настройки аннотаций",
- "active": "Активно",
- "typeText": "Текст",
- "typeImage": "Изображение",
- "typeArrow": "Стрелка",
- "typeBlur": "Размытие",
- "textContent": "Содержание текста",
- "textPlaceholder": "Введите ваш текст...",
- "defaultText": "Привет",
- "fontStyle": "Стиль шрифта",
- "selectStyle": "Выбрать стиль",
+ "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Прямоугольник",
"size": "Размер",
- "customFonts": "Пользовательские шрифты",
- "textColor": "Цвет текста",
+ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
+ "clearBackground": "Очистить фон",
+ "colorPalette": "Палитра цветов",
+ "invalidImageType": "Неверный тип файла",
"background": "Фон",
- "none": "Нет",
+ "typeText": "Текст",
+ "active": "Активно",
"color": "Цвет",
- "colorWheel": "Цветовой круг",
- "colorPalette": "Палитра цветов",
- "clearBackground": "Очистить фон",
- "uploadImage": "Загрузить изображение",
- "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "От руки",
"arrowDirection": "Направление стрелки",
- "strokeWidth": "Толщина линии: {{width}}px",
- "arrowColor": "Цвет стрелки",
- "blurType": "Тип размытия",
- "blurTypeBlur": "Гауссово",
"blurTypeMosaic": "Мозаика",
+ "colorWheel": "Цветовой круг",
+ "textColor": "Цвет текста",
+ "title": "Настройки аннотаций",
+ "blurType": "Тип размытия",
+ "typeBlur": "Размытие",
+ "blurIntensity": "Интенсивность размытия",
+ "selectStyle": "Выбрать стиль",
+ "textContent": "Содержание текста",
+ "typeArrow": "Стрелка",
+ "none": "Нет",
"blurColor": "Цвет размытия",
- "blurColorWhite": "Белый",
- "blurColorBlack": "Чёрный",
+ "customFonts": "Пользовательские шрифты",
+ "imageUploadSuccess": "Изображение успешно загружено!",
+ "type": "Тип",
+ "arrowColor": "Цвет стрелки",
+ "textPlaceholder": "Введите ваш текст...",
+ "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
"blurShape": "Форма размытия",
- "blurIntensity": "Интенсивность размытия",
+ "uploadImage": "Загрузить изображение",
+ "blurTypeBlur": "Гауссово",
+ "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
+ "shortcutsAndTips": "Горячие клавиши и советы",
+ "deleteAnnotation": "Удалить аннотацию",
+ "fontStyle": "Стиль шрифта",
+ "defaultText": "Привет",
"mosaicBlockSize": "Размер блока мозаики",
- "blurShapeRectangle": "Прямоугольник",
+ "blurColorBlack": "Чёрный",
+ "strokeWidth": "Толщина линии: {{width}}px",
"blurShapeOval": "Овал",
- "blurShapeFreehand": "От руки",
- "deleteAnnotation": "Удалить аннотацию",
- "shortcutsAndTips": "Горячие клавиши и советы",
+ "blurColorWhite": "Белый",
"tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
- "tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
- "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
- "invalidImageType": "Неверный тип файла",
- "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
- "imageUploadSuccess": "Изображение успешно загружено!",
- "type": "Тип"
- },
- "textAnimation": {
- "title": "Анимация текста",
- "selectAnimation": "Выбрать анимацию",
- "none": "Нет",
- "fade": "Затухание",
- "rise": "Подъем",
- "pop": "Всплытие",
- "slideLeft": "Скольжение влево",
- "typewriter": "Пишущая машинка",
- "pulse": "Импульс"
+ "typeImage": "Изображение"
},
- "customFont": {
- "dialogTitle": "Добавить шрифт Google",
- "urlLabel": "URL импорта Google Fonts",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
- "nameLabel": "Отображаемое имя",
- "namePlaceholder": "Мой пользовательский шрифт",
- "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
- "addButton": "Добавить шрифт",
- "addingButton": "Добавление...",
- "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
- "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
- "errorEmptyName": "Пожалуйста, введите имя шрифта",
- "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
- "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
- "failedToAdd": "Не удалось добавить шрифт",
- "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
- "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts."
- },
- "cursor": {
- "title": "Курсор",
- "theme": "Стиль курсора",
- "themeDefault": "По умолчанию",
- "show": "Показывать курсор",
- "size": "Размер",
- "smoothing": "Сглаживание",
+ "effects": {
+ "fitClipFew": "{{count}} клипа",
+ "title": "Композиция",
+ "shadow": "Тень",
+ "off": "выкл",
+ "on": "вкл",
+ "blurBg": "Размытие фона",
+ "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео.",
+ "fitClipOne": "{{count}} клип",
+ "formatOriginal": "Исходный",
+ "fitClipMany": "{{count}} клипов",
+ "frame": "Рамка",
+ "motion": "Движение",
+ "padding": "Отступ",
+ "format": "Формат",
+ "fitClip": "Подогнать",
"motionBlur": "Размытие движения",
- "clickBounce": "Отскок при клике",
- "clipToBounds": "Обрезать по холсту",
- "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.",
- "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике."
- },
- "language": {
- "title": "Язык"
- },
- "facets": {
- "captions": "Субтитры",
- "transcript": "Транскрипт"
- },
- "panes": {
- "help": "Справка"
+ "roundness": "Скругление"
},
"transcript": {
- "title": "Текущая расшифровка",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению помечает его как пропущенное (красным). Наведите курсор на красный фрагмент, чтобы вернуть его.",
- "noClips": "Клипов пока нет",
+ "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": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
- "editorAria": "Расшифровка «{{filename}}»",
- "silence": "[тишина {{duration}} с]",
- "restoreSilence": "Вернуть тишину ({{duration}} с)",
- "trimSilence": "Вырезать тишину ({{duration}} с)",
- "restoreWord": "Вернуть «{{word}}»",
- "noAudio": "В этом медиафайле нет аудиодорожки"
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "blankedWord": "очищено"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "Видеофайл высокого качества",
+ "gifAnimation": "GIF анимация",
+ "mp4Video": "MP4 видео",
+ "gifDescription": "Анимированное изображение для обмена",
+ "gif": "GIF"
},
"captions": {
- "show": "Показывать субтитры",
- "noTranscript": "Субтитры берутся из расшифровки медиафайла. Расшифруйте это видео, чтобы включить их.",
- "transcribe": "Расшифровать видео",
- "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
- "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
- "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
- "removeLegacyAnnotations": "Удалить старые аннотации субтитров",
- "language": "Язык",
- "displayLanguage": "Отображение",
- "original": "Оригинал (расшифровка)",
- "translate": "Перевести",
- "translating": "Перевод…",
- "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
- "translateFailed": "Не удалось перевести.",
+ "showBackground": "Показывать фон",
"deleteTranslation": "Удалить этот перевод",
+ "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.",
+ "backgroundOpacity": "Непрозрачность",
+ "backgroundColor": "Цвет фона",
+ "alignCenter": "По центру",
"translationIsNonDestructive": "Переводы хранятся рядом с расшифровкой, а не внутри неё — исходный текст и его тайминги остаются нетронутыми.",
+ "distanceFromRight": "Отступ справа",
+ "language": "Язык",
"text": "Текст",
- "font": "Шрифт",
- "fontSize": "Размер",
- "bold": "Полужирный",
- "textColor": "Цвет текста",
- "background": "Фон",
- "showBackground": "Показывать фон",
- "backgroundColor": "Цвет фона",
- "backgroundOpacity": "Непрозрачность",
- "position": "Положение",
- "anchorBottom": "Снизу",
- "anchorTop": "Сверху",
"anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.",
- "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.",
- "distanceFromBottom": "Отступ снизу",
"distanceFromTop": "Отступ сверху",
- "distanceFromLeft": "Отступ слева",
- "distanceFromRight": "Отступ справа",
+ "translateFailed": "Не удалось перевести.",
"alignLeft": "Слева",
- "alignCenter": "По центру",
+ "distanceFromBottom": "Отступ снизу",
+ "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
+ "translate": "Перевести",
+ "position": "Положение",
+ "fontSize": "Размер",
+ "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
+ "noTranscript": "Субтитры берутся из расшифровки медиафайла. Расшифруйте это видео, чтобы включить их.",
+ "distanceFromLeft": "Отступ слева",
+ "anchorBottom": "Снизу",
+ "transcribe": "Расшифровать видео",
+ "bold": "Полужирный",
"alignRight": "Справа",
- "lineLength": "Длина строки",
+ "anchorTop": "Сверху",
"minWords": "Мин. слов в строке",
- "maxWords": "Макс. слов в строке"
+ "translateHint": "Перевести расшифровку с помощью настроенного ИИ-провайдера",
+ "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": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
+ "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": {
+ "slideLeft": "Скольжение влево",
+ "pulse": "Импульс",
+ "typewriter": "Пишущая машинка",
+ "selectAnimation": "Выбрать анимацию",
+ "fade": "Затухание",
+ "title": "Анимация текста",
+ "none": "Нет",
+ "pop": "Всплытие",
+ "rise": "Подъем"
+ },
+ "facets": {
+ "transcript": "Транскрипт",
+ "captions": "Субтитры"
+ },
+ "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": {
- "title": "Аудио",
"outputGain": "Уровень выхода",
+ "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте.",
"reset": "Сбросить аудио",
- "help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте."
+ "title": "Аудио"
+ },
+ "language": {
+ "title": "Язык"
+ },
+ "project": {
+ "new": "Новый проект",
+ "load": "Загрузить проект",
+ "save": "Сохранить проект"
+ },
+ "support": {
+ "starOnGithub": "Звезда на GitHub",
+ "saveDiagnostics": "Сохранить диагностику",
+ "reportBug": "Сообщить об ошибке"
+ },
+ "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/shortcuts.json b/src/i18n/locales/ru/shortcuts.json
index dabb06801..eb94af5fc 100644
--- a/src/i18n/locales/ru/shortcuts.json
+++ b/src/i18n/locales/ru/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Добавить обрезку",
"addSpeed": "Изменить скорость",
"addAnnotation": "Добавить аннотацию",
+ "addAudio": "Добавить аудио",
+ "addVoiceover": "Записать закадровый голос",
"addKeyframe": "Добавить ключевой кадр",
"addCameraFullscreen": "Добавить камеру на весь экран",
"deleteSelected": "Удалить выбранное",
diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json
index 387086953..e6281aaa8 100644
--- a/src/i18n/locales/ru/timeline.json
+++ b/src/i18n/locales/ru/timeline.json
@@ -15,6 +15,7 @@
"pressZoom": "Нажмите Z для добавления масштабирования",
"pressTrim": "Нажмите T для добавления обрезки",
"pressAnnotation": "Нажмите A для добавления аннотации",
+ "pressAudio": "Нажмите M, чтобы добавить аудио, V — чтобы записать закадровый голос",
"pressSpeed": "Нажмите S для изменения скорости",
"pressCameraFullscreen": "Нажмите C, чтобы добавить сегмент камеры на весь экран"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "Нужна расшифровка",
"smartCutsNoAudio": "В этом медиафайле нет звука",
"smartCutsNoSpeech": "Речь не обнаружена",
- "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»"
+ "smartCutsFailed": "Не удалось расшифровать — повторите из раздела «Медиа»",
+ "addAudioTooltip": "Добавить аудио",
+ "addedWord": "Добавленное слово: «{{word}}» — за ним нет звука"
+ },
+ "audio": {
+ "addVoiceover": "Добавить озвучку",
+ "addVoiceoverHint": "Запишите закадровый голос поверх видео",
+ "subtitle": "Разместите слой озвучки или фоновой музыки на таймлайне",
+ "record": "Записать озвучку",
+ "importFile": "Импортировать аудиофайл",
+ "importFileHint": "Импортируйте музыку или аудиофайл",
+ "recording": "Запись",
+ "recordingHint": "Говорите под видео — оно продолжает играть во время записи",
+ "stop": "Остановить",
+ "micDenied": "Доступ к микрофону запрещён",
+ "recordingUnavailable": "Запись здесь недоступна",
+ "saveFailed": "Не удалось сохранить запись",
+ "importFailed": "Не удалось импортировать аудиофайл"
}
}
diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json
index 196807fd3..6b01da744 100644
--- a/src/i18n/locales/tr/dialogs.json
+++ b/src/i18n/locales/tr/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Dışa Aktarılan GIF'i Kaydet",
"saveVideo": "Dışa Aktarılan Videoyu Kaydet",
"selectVideo": "Video Dosyası Seç",
+ "selectAudio": "Ses dosyası seç",
"saveProject": "OpenScreen Projesini Kaydet",
"openProject": "OpenScreen Projesini Aç",
"gifImage": "GIF Görüntüsü",
"mp4Video": "MP4 Video",
"videoFiles": "Video Dosyaları",
+ "audioFiles": "Ses dosyaları",
"openscreenProject": "OpenScreen Projesi",
"allFiles": "Tüm Dosyalar"
}
diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json
index 5b8e9922b..cb1c1906e 100644
--- a/src/i18n/locales/tr/editor.json
+++ b/src/i18n/locales/tr/editor.json
@@ -1,18 +1,22 @@
{
"errors": {
- "noVideoLoaded": "Video yüklenmedi",
- "videoNotReady": "Video hazır değil",
- "unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
- "failedToSaveGif": "GIF kaydedilemedi",
- "gifExportFailed": "GIF dışa aktarımı başarısız oldu",
- "failedToSaveVideo": "Video kaydedilemedi",
+ "exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})",
"exportFailed": "Dışa aktarım başarısız oldu",
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
- "exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})",
+ "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}",
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
- "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}",
- "previewCompositorUnavailable": "Bu makinede önizleme kullanılamıyor"
+ "failedToSaveGif": "GIF kaydedilemedi",
+ "failedToSaveVideo": "Video kaydedilemedi",
+ "gifExportFailed": "GIF dışa aktarımı başarısız oldu",
+ "noVideoLoaded": "Video yüklenmedi",
+ "previewCompositorUnavailable": "Bu makinede önizleme kullanılamıyor",
+ "trimNoFilm": "Orada kesilecek bir şey yok — o kelimelerin altında görüntü bulunmuyor.",
+ "unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
+ "videoNotReady": "Video hazır değil",
+ "wordEditFailed": "Bu kelime değiştirilemedi",
+ "wordInsertFailed": "Bu kelime eklenemedi",
+ "wordRemoveFailed": "Bu kelime silinemedi"
},
"export": {
"canceled": "Dışa aktarım iptal edildi",
@@ -71,6 +75,7 @@
"pasted": "{{region}} öznitelikleri yapıştırıldı",
"nothingToCopy": "Özniteliklerini kopyalamak için bir bölge seçin",
"nothingToPaste": "Henüz öznitelik kopyalanmadı",
+ "pasteAssetMissing": "Bu ses parçasının dosyası bu projede yok",
"kinds": {
"zoom": "Yakınlaştırma",
"speed": "Hız",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 05ec9bf03..6309560f7 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
- "level": "Yakınlaştırma Seviyesi",
- "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
- "deleteZoom": "Yakınlaştırmayı Sil",
- "focusMode": {
- "title": "Odak Modu",
- "manual": "Manuel",
- "auto": "Otomatik",
- "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."
- },
- "threeD": {
- "title": "3D Döndürme",
- "preset": {
- "iso": "Iso",
- "left": "Sol",
- "right": "Sağ"
- },
- "none": "Yok"
- },
- "customScale": "Özel Yakınlaştırma",
- "position": {
- "title": "Odak Konumu",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = en sol / en üst, 100 = en sağ / en alt"
- }
- },
- "speed": {
- "playbackSpeed": "Oynatma Hızı",
- "selectRegion": "Ayarlamak için bir hız bölgesi seçin",
- "deleteRegion": "Hız Bölgesini Sil",
- "customPlaybackSpeed": "Özel Oynatma Hızı",
- "maxSpeedError": "Hız {{max}}× değerinden yüksek olamaz",
- "previewFrameSteppingHint": "{{native}}× üzerinde önizleme kare kare ilerler ve sessizdir. Dışa aktarma etkilenmez."
- },
- "trim": {
- "deleteRegion": "Kırpma Bölgesini Sil"
- },
- "layout": {
- "title": "Kamera düzeni",
- "preset": "Ön Ayar",
- "selectPreset": "Ön ayar seçin",
- "pictureInPicture": "Resim İçinde Resim",
- "verticalStack": "Dikey Yığın",
- "webcamShape": "Kamera Şekli",
- "dualFrame": "Çift Kare",
- "webcamSize": "Webcam Boyutu",
- "noWebcam": "Web kamerası yok",
- "mirrorWebcam": "Web kamerasını aynala",
- "reactiveWebcam": "Yakınlaştırınca küçült",
- "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
- "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.",
- "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.",
- "webcamFraming": "Webcam kadrajı",
- "webcamCropZoom": "Kırpma yakınlaştırması",
- "webcamCropX": "Yatay kaydırma",
- "webcamCropY": "Dikey kaydırma",
- "shapes": {
- "rectangle": "Dikdörtgen",
- "circle": "Daire",
- "square": "Kare",
- "rounded": "Yuvarlatılmış"
- },
- "webcamBackground": "Kamera Arka Planı",
- "webcamBlurIntensity": "Bulanıklık Yoğunluğu",
- "bgModes": {
- "none": "Orijinal",
- "transparent": "Kesme",
- "blur": "Bulanık",
- "custom": "Özel"
- }
- },
- "effects": {
- "title": "Kompozisyon",
- "blurBg": "Arka Planı Bulanıklaştır",
- "motionBlur": "Hareket Bulanıklığı",
- "off": "kapalı",
- "shadow": "Gölge",
- "roundness": "Yuvarlaklık",
- "padding": "Dolgu",
- "frame": "Çerçeve",
- "format": "Biçim",
- "formatOriginal": "Orijinal",
- "fitClip": "Sığdır",
- "fitClipOne": "{{count}} klip",
- "fitClipFew": "{{count}} klip",
- "fitClipMany": "{{count}} klip",
- "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."
- },
"background": {
+ "gradientLabel": "Gradyan {{index}}",
+ "uploadCustom": "Özel Yükle",
+ "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
"title": "Arka Plan",
- "image": "Görüntü",
- "color": "Renk",
- "gradient": "Gradyan",
+ "imageLabel": "Arka plan {{index}}",
"custom": "Özel",
- "uploadCustom": "Özel Yükle",
- "gradientLabel": "Gradyan {{index}}",
- "colorWheel": "Renk çarkı",
- "colorPalette": "Renk paleti",
- "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.",
+ "gradient": "Gradyan",
+ "colorLabel": "Renk {{color}}",
"customWallpaper": "Özel duvar kâğıdı",
- "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.",
+ "colorPalette": "Renk paleti",
"imageReadFailed": "Bu görsel dosyası okunamadı.",
- "imageLabel": "Arka plan {{index}}",
- "colorLabel": "Renk {{color}}"
- },
- "crop": {
- "title": "Kırpma",
- "cropVideo": "Videoyu Kırp",
- "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
- "ratio": "Oran",
- "free": "Serbest",
- "done": "Tamam",
- "lockAspectRatio": "En boy oranını kilitle",
- "unlockAspectRatio": "En boy oranının kilidini aç"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 Video",
- "mp4Description": "Yüksek kaliteli video dosyası",
- "gifAnimation": "GIF Animasyon",
- "gifDescription": "Paylaşım için hareketli görüntü"
- },
- "exportQuality": {
- "title": "Dışa aktarma çözünürlüğü",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "GIF Kare Hızı",
- "size": "GIF Boyutu",
- "loop": "GIF Döngüsü"
- },
- "project": {
- "save": "Projeyi Kaydet",
- "load": "Proje Yükle",
- "new": "Yeni Proje"
- },
- "export": {
- "videoButton": "Videoyu Dışa Aktar",
- "gifButton": "GIF Olarak Dışa Aktar",
- "chooseSaveLocation": "Kayıt Konumu Seç"
+ "image": "Görüntü",
+ "presets": "Ön ayarlar",
+ "color": "Renk",
+ "colorWheel": "Renk çarkı"
},
- "support": {
- "reportBug": "Hata Bildir",
- "saveDiagnostics": "Teşhis Verilerini Kaydet",
- "starOnGithub": "GitHub'da Yıldızla"
+ "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",
+ "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",
+ "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ı",
+ "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!",
- "failedToUpload": "Görüntü yüklenemedi",
"errorReading": "Dosya okunurken bir hata oluştu."
},
"annotation": {
- "title": "Açıklama Ayarları",
- "active": "Aktif",
- "typeText": "Metin",
- "typeImage": "Görüntü",
- "typeArrow": "Ok",
- "typeBlur": "Bulanık",
- "textContent": "Metin İçeriği",
- "textPlaceholder": "Metninizi girin...",
- "defaultText": "Merhaba",
- "fontStyle": "Yazı Tipi Stili",
- "selectStyle": "Stil seçin",
+ "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Dikdörtgen",
"size": "Boyut",
- "customFonts": "Özel Yazı Tipleri",
- "textColor": "Metin Rengi",
+ "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",
- "none": "Yok",
+ "typeText": "Metin",
+ "active": "Aktif",
"color": "Renk",
- "colorWheel": "Renk çarkı",
- "colorPalette": "Renk paleti",
- "clearBackground": "Arka Planı Temizle",
- "uploadImage": "Görüntü Yükle",
- "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "Serbest",
"arrowDirection": "Ok Yönü",
- "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "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",
+ "textContent": "Metin İçeriği",
+ "typeArrow": "Ok",
+ "none": "Yok",
+ "blurColor": "Bulanıklık Rengi",
+ "customFonts": "Özel Yazı Tipleri",
+ "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",
- "blurIntensity": "Bulanıklık Yoğunluğu",
- "blurShapeRectangle": "Dikdörtgen",
- "blurShapeOval": "Oval",
- "blurShapeFreehand": "Serbest",
- "deleteAnnotation": "Açıklamayı Sil",
- "shortcutsAndTips": "Kısayollar ve İpuçları",
- "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "uploadImage": "Görüntü Yükle",
+ "blurTypeBlur": "Gauss",
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
- "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
- "invalidImageType": "Geçersiz dosya türü",
- "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
- "imageUploadSuccess": "Görüntü başarıyla yüklendi!",
- "blurColor": "Bulanıklık Rengi",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
+ "deleteAnnotation": "Açıklamayı Sil",
+ "fontStyle": "Yazı Tipi Stili",
+ "defaultText": "Merhaba",
+ "mosaicBlockSize": "Mozaik Blok Boyutu",
"blurColorBlack": "Siyah",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "blurShapeOval": "Oval",
"blurColorWhite": "Beyaz",
- "blurType": "Bulanıklık Türü",
- "blurTypeBlur": "Gauss",
- "blurTypeMosaic": "Mozaik",
- "mosaicBlockSize": "Mozaik Blok Boyutu",
- "type": "Tür"
- },
- "textAnimation": {
- "title": "Metin Animasyonu",
- "selectAnimation": "Animasyon seçin",
- "none": "Yok",
- "fade": "Belirme",
- "rise": "Yükselme",
- "pop": "Fırlama",
- "slideLeft": "Sola Kaydırma",
- "typewriter": "Daktilo",
- "pulse": "Nabız"
- },
- "customFont": {
- "dialogTitle": "Google Yazı Tipi Ekle",
- "urlLabel": "Google Fonts İçe Aktarım URL'si",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "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",
- "namePlaceholder": "Özel Yazı Tipim",
- "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
- "addButton": "Yazı Tipi Ekle",
- "addingButton": "Ekleniyor...",
- "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
- "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
- "errorEmptyName": "Lütfen bir yazı tipi adı girin",
- "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
- "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
- "failedToAdd": "Yazı tipi eklenemedi",
- "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."
+ "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "typeImage": "Görüntü"
},
- "cursor": {
- "title": "İmleç",
- "theme": "İmleç Stili",
- "themeDefault": "Varsayılan",
- "show": "İmleci Göster",
- "size": "Boyut",
- "smoothing": "Yumuşatma",
+ "effects": {
+ "fitClipFew": "{{count}} klip",
+ "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",
+ "fitClipMany": "{{count}} klip",
+ "frame": "Çerçeve",
+ "motion": "Hareket",
+ "padding": "Dolgu",
+ "format": "Biçim",
+ "fitClip": "Sığdır",
"motionBlur": "Hareket Bulanıklığı",
- "clickBounce": "Tıklama Sıçraması",
- "clipToBounds": "Tuvale Kırp",
- "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.",
- "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması."
- },
- "language": {
- "title": "Dil"
- },
- "facets": {
- "captions": "Altyazılar",
- "transcript": "Metin Dökümü"
- },
- "panes": {
- "help": "Yardım"
+ "roundness": "Yuvarlaklık"
},
"transcript": {
- "title": "Geçerli döküm",
- "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşuna basmak onu atlanmış (kırmızı) olarak işaretler. Kırmızı bölümün üzerine gelerek geri alabilirsiniz.",
- "noClips": "Henüz klip yok",
+ "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.",
- "editorAria": "{{filename}} dökümü",
- "silence": "[sessizlik {{duration}} sn]",
- "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
- "trimSilence": "Sessizliği kırp ({{duration}} sn)",
- "restoreWord": "\"{{word}}\" kelimesini geri al",
- "noAudio": "Bu medyada ses parçası yok"
+ "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": {
+ "mp4": "MP4",
+ "mp4Description": "Yüksek kaliteli video dosyası",
+ "gifAnimation": "GIF Animasyon",
+ "mp4Video": "MP4 Video",
+ "gifDescription": "Paylaşım için hareketli görüntü",
+ "gif": "GIF"
},
"captions": {
- "show": "Altyazıları göster",
- "noTranscript": "Altyazılar medyanın dökümünden okunur. Açmak için bu videonun dökümünü çıkarın.",
- "transcribe": "Videonun dökümünü çıkar",
- "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.",
- "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.",
- "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",
- "language": "Dil",
- "displayLanguage": "Görüntüleme",
- "original": "Özgün (döküm)",
- "translate": "Çevir",
- "translating": "Çevriliyor…",
- "translateHint": "Dökümü yapılandırılmış yapay zekâ sağlayıcısıyla çevir",
- "translateFailed": "Çeviri başarısız oldu.",
+ "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",
- "font": "Yazı tipi",
- "fontSize": "Boyut",
- "bold": "Kalın",
- "textColor": "Metin rengi",
- "background": "Arka plan",
- "showBackground": "Arka planı göster",
- "backgroundColor": "Arka plan rengi",
- "backgroundOpacity": "Saydamlık",
- "position": "Konum",
- "anchorBottom": "Alt",
- "anchorTop": "Üst",
"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.",
- "distanceFromBottom": "Alttan uzaklık",
"distanceFromTop": "Üstten uzaklık",
- "distanceFromLeft": "Soldan uzaklık",
- "distanceFromRight": "Sağdan uzaklık",
+ "translateFailed": "Çeviri başarısız oldu.",
"alignLeft": "Sol",
- "alignCenter": "Orta",
+ "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ğ",
- "lineLength": "Satır uzunluğu",
+ "anchorTop": "Üst",
"minWords": "Satır başına en az kelime",
- "maxWords": "Satır başına en çok 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"
+ },
+ "panes": {
+ "help": "Yardım"
+ },
+ "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."
+ },
+ "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": {
+ "defaultLabel": "Ses parçası",
+ "importFailed": "Ses eklenemedi",
+ "fadeOut": "Kararma",
+ "fadeIn": "Açılma",
+ "remove": "Parçayı sil",
+ "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.",
+ "add": "Ses parçası ekle",
+ "mute": "Sessiz"
+ },
+ "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"
+ },
+ "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"
+ },
+ "facets": {
+ "transcript": "Metin Dökümü",
+ "captions": "Altyazılar"
+ },
+ "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": {
- "title": "Ses",
"outputGain": "Çıkış seviyesi",
+ "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır.",
"reset": "Sesi sıfırla",
- "help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır."
+ "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"
+ },
+ "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/shortcuts.json b/src/i18n/locales/tr/shortcuts.json
index 6b1db110d..2da862f01 100644
--- a/src/i18n/locales/tr/shortcuts.json
+++ b/src/i18n/locales/tr/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Kırpma Ekle",
"addSpeed": "Hız Ekle",
"addAnnotation": "Açıklama Ekle",
+ "addAudio": "Ses ekle",
+ "addVoiceover": "Seslendirme kaydet",
"addKeyframe": "Anahtar Kare Ekle",
"addCameraFullscreen": "Tam Ekran Kamera Ekle",
"deleteSelected": "Seçileni Sil",
diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json
index d5a531f3e..2a40fd1f8 100644
--- a/src/i18n/locales/tr/timeline.json
+++ b/src/i18n/locales/tr/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"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"
+ "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": {
+ "addVoiceover": "Seslendirme ekle",
+ "addVoiceoverHint": "Videonuzun üzerine anlatım kaydedin",
+ "subtitle": "Zaman çizelgesine seslendirme veya fon müziği katmanı yerleştirin",
+ "record": "Seslendirme kaydet",
+ "importFile": "Ses dosyası içe aktar",
+ "importFileHint": "Müzik veya ses dosyası içe aktarın",
+ "recording": "Kaydediliyor",
+ "recordingHint": "Videoyla birlikte anlatın — kayıt sırasında oynamaya devam eder",
+ "stop": "Durdur",
+ "micDenied": "Mikrofon erişimi reddedildi",
+ "recordingUnavailable": "Burada kayıt kullanılamıyor",
+ "saveFailed": "Kayıt kaydedilemedi",
+ "importFailed": "Ses dosyası içe aktarılamadı"
}
}
diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json
index 644d2ba02..452e80e2f 100644
--- a/src/i18n/locales/vi/dialogs.json
+++ b/src/i18n/locales/vi/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "Lưu GIF đã xuất",
"saveVideo": "Lưu Video đã xuất",
"selectVideo": "Chọn tệp video",
+ "selectAudio": "Chọn tệp âm thanh",
"saveProject": "Lưu dự án OpenScreen",
"openProject": "Mở dự án OpenScreen",
"gifImage": "Hình ảnh GIF",
"mp4Video": "Video MP4",
"videoFiles": "Tệp Video",
+ "audioFiles": "Tệp âm thanh",
"openscreenProject": "Dự án OpenScreen",
"allFiles": "Tất cả các tệp"
}
diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json
index 821875edb..9f484688c 100644
--- a/src/i18n/locales/vi/editor.json
+++ b/src/i18n/locales/vi/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "Đang tải video...",
"loadingEditor": "Đang tải trình chỉnh sửa...",
"errors": {
- "noVideoLoaded": "Chưa tải video nào",
- "videoNotReady": "Video chưa sẵn sàng",
- "unableToDetermineSourcePath": "Không thể xác định đường dẫn video gốc",
- "failedToSaveGif": "Không thể lưu GIF",
- "gifExportFailed": "Xuất GIF thất bại",
- "failedToSaveVideo": "Không thể lưu video",
+ "exportBackgroundLoadFailed": "Xuất thất bại: không thể tải hình nền ({{url}})",
"exportFailed": "Xuất thất bại",
"exportFailedWithError": "Xuất thất bại: {{error}}",
- "exportBackgroundLoadFailed": "Xuất thất bại: không thể tải hình nền ({{url}})",
+ "failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}",
"failedToSaveExport": "Không thể lưu bản xuất",
"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"
+ "failedToSaveGif": "Không thể lưu GIF",
+ "failedToSaveVideo": "Không thể lưu video",
+ "gifExportFailed": "Xuất GIF thất bại",
+ "noVideoLoaded": "Chưa tải video nào",
+ "previewCompositorUnavailable": "Không thể xem trước trên máy này",
+ "trimNoFilm": "Không có gì để cắt ở đó — không có hình ảnh nào bên dưới những từ này.",
+ "unableToDetermineSourcePath": "Không thể xác định đường dẫn video gốc",
+ "videoNotReady": "Video chưa sẵn sàng",
+ "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",
@@ -71,6 +75,7 @@
"pasted": "Đã dán thuộc tính {{region}}",
"nothingToCopy": "Chọn một vùng để sao chép thuộc tính của nó",
"nothingToPaste": "Chưa sao chép thuộc tính nào",
+ "pasteAssetMissing": "Tệp của bản âm thanh này không có trong dự án",
"kinds": {
"zoom": "Thu phóng",
"speed": "Tốc độ",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 586d7f7b1..719f80e21 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "Giữ để xem trước hiệu ứng phóng to",
- "level": "Mức độ thu phóng",
- "selectRegion": "Chọn vùng thu phóng để điều chỉnh",
- "deleteZoom": "Xóa thu phóng",
- "focusMode": {
- "title": "Chế độ lấy nét",
- "manual": "Thủ công",
- "auto": "Tự độ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."
- },
- "customScale": "Thu phóng tùy chỉnh",
- "position": {
- "title": "Vị trí tiêu điểm",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới"
- },
- "threeD": {
- "title": "Xoay 3D",
- "preset": {
- "iso": "Đẳng phối",
- "left": "Trái",
- "right": "Phải"
- },
- "none": "Không"
- }
- },
- "speed": {
- "playbackSpeed": "Tốc độ phát",
- "selectRegion": "Chọn vùng tốc độ để điều chỉnh",
- "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}}×",
- "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"
- },
- "layout": {
- "title": "Bố cục camera",
- "preset": "Cài đặt sẵn",
- "selectPreset": "Chọn cài đặt sẵn",
- "pictureInPicture": "Hình trong hình",
- "verticalStack": "Xếp chồng dọc",
- "dualFrame": "Khung kép",
- "webcamShape": "Hình dạng máy ảnh",
- "webcamSize": "Kích thước Webcam",
- "noWebcam": "Không có webcam",
- "mirrorWebcam": "Lật webcam",
- "reactiveWebcam": "Thu nhỏ khi phóng to",
- "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
- "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.",
- "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.",
- "webcamFraming": "Khung hình webcam",
- "webcamCropZoom": "Thu phóng vùng cắt",
- "webcamCropX": "Dịch chuyển ngang",
- "webcamCropY": "Dịch chuyển dọc",
- "shapes": {
- "rectangle": "Chữ nhật",
- "circle": "Tròn",
- "square": "Vuông",
- "rounded": "Bo góc"
- },
- "webcamBackground": "Nền máy ảnh",
- "webcamBlurIntensity": "Độ mờ",
- "bgModes": {
- "none": "Gốc",
- "transparent": "Tách nền",
- "blur": "Làm mờ",
- "custom": "Tùy chỉnh"
- }
- },
- "effects": {
- "title": "Bố cục hình ảnh",
- "blurBg": "Làm mờ nền",
- "motionBlur": "Làm mờ chuyển động",
- "off": "tắt",
- "shadow": "Bóng đổ",
- "roundness": "Độ bo tròn",
- "padding": "Phần đệm",
- "frame": "Khung",
- "format": "Định dạng",
- "formatOriginal": "Gốc",
- "fitClip": "Vừa khít",
- "fitClipOne": "{{count}} clip",
- "fitClipFew": "{{count}} clip",
- "fitClipMany": "{{count}} clip",
- "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."
- },
"background": {
+ "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",
- "image": "Hình ảnh",
- "color": "Màu sắc",
- "gradient": "Dải màu",
+ "imageLabel": "Nền {{index}}",
"custom": "Tùy chỉnh",
- "uploadCustom": "Tải lên tùy chỉnh",
- "gradientLabel": "Dải màu {{index}}",
- "colorPalette": "Bảng màu",
- "colorWheel": "Vòng màu",
- "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.",
+ "gradient": "Dải màu",
+ "colorLabel": "Màu {{color}}",
"customWallpaper": "Ảnh nền tùy chỉnh",
- "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.",
+ "colorPalette": "Bảng màu",
"imageReadFailed": "Không thể đọc tệp ảnh này.",
- "imageLabel": "Nền {{index}}",
- "colorLabel": "Màu {{color}}"
- },
- "crop": {
- "title": "Cắt xén",
- "cropVideo": "Cắt xén video",
- "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
- "ratio": "Tỷ lệ",
- "free": "Tự do",
- "done": "Hoàn tất",
- "lockAspectRatio": "Khóa tỷ lệ khung hình",
- "unlockAspectRatio": "Mở khóa tỷ lệ khung hình"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "Video MP4",
- "mp4Description": "Tệp video chất lượng cao",
- "gifAnimation": "Ảnh động GIF",
- "gifDescription": "Hình ảnh động để chia sẻ"
- },
- "exportQuality": {
- "title": "Độ phân giải xuất",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "Tốc độ khung hình GIF",
- "size": "Kích thước GIF",
- "loop": "Lặp lại GIF"
- },
- "project": {
- "save": "Lưu dự án",
- "load": "Tải dự án",
- "new": "Dự án mới"
- },
- "export": {
- "videoButton": "Xuất Video",
- "gifButton": "Xuất GIF",
- "chooseSaveLocation": "Chọn vị trí lưu"
+ "image": "Hình ảnh",
+ "presets": "Có sẵn",
+ "color": "Màu sắc",
+ "colorWheel": "Vòng màu"
},
- "support": {
- "reportBug": "Báo cáo lỗi",
- "saveDiagnostics": "Lưu thông tin chẩn đoán",
- "starOnGithub": "Đánh giá sao trên GitHub"
+ "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",
+ "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ữ",
+ "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",
+ "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!",
- "failedToUpload": "Tải lên hình ảnh thất bại",
"errorReading": "Đã xảy ra lỗi khi đọc tệp."
},
"annotation": {
- "title": "Cài đặt chú thích",
- "active": "Hoạt động",
- "typeText": "Văn bản",
- "typeImage": "Hình ảnh",
- "typeArrow": "Mũi tên",
- "typeBlur": "Làm mờ",
- "textContent": "Nội dung văn bản",
- "textPlaceholder": "Nhập văn bản của bạn...",
- "defaultText": "Xin chào",
- "fontStyle": "Kiểu phông chữ",
- "selectStyle": "Chọn kiểu",
+ "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
+ "blurShapeRectangle": "Chữ nhật",
"size": "Kích thước",
- "customFonts": "Phông chữ tùy chỉnh",
- "textColor": "Màu văn bản",
+ "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",
- "none": "Không có",
+ "typeText": "Văn bản",
+ "active": "Hoạt động",
"color": "Màu sắc",
- "clearBackground": "Xóa nền",
- "uploadImage": "Tải lên hình ảnh",
- "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
+ "blurShapeFreehand": "Vẽ tự do",
"arrowDirection": "Hướng mũi tên",
- "strokeWidth": "Độ dày nét: {{width}}px",
- "arrowColor": "Màu mũi tên",
- "blurType": "Loại làm mờ",
- "blurTypeBlur": "Gaussian",
"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",
+ "textContent": "Nội dung văn bản",
+ "typeArrow": "Mũi tên",
+ "none": "Không có",
"blurColor": "Màu làm mờ",
- "blurColorWhite": "Trắng",
- "blurColorBlack": "Đen",
+ "customFonts": "Phông chữ tùy chỉnh",
+ "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ờ",
- "blurIntensity": "Cườ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ữ",
+ "defaultText": "Xin chào",
"mosaicBlockSize": "Kích thước khối khảm",
- "blurShapeRectangle": "Chữ nhật",
+ "blurColorBlack": "Đen",
+ "strokeWidth": "Độ dày nét: {{width}}px",
"blurShapeOval": "Bầu dục",
- "blurShapeFreehand": "Vẽ tự do",
- "deleteAnnotation": "Xóa chú thích",
- "shortcutsAndTips": "Phím tắt & Mẹo",
+ "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.",
- "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
- "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
- "invalidImageType": "Loại tệp không hợp lệ",
- "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
- "imageUploadSuccess": "Tải lên hình ảnh thành công!",
- "colorPalette": "Bảng màu",
- "colorWheel": "Vòng màu",
- "type": "Loại"
- },
- "textAnimation": {
- "title": "Hoạt ảnh văn bản",
- "selectAnimation": "Chọn hoạt ảnh",
- "none": "Không có",
- "fade": "Mờ dần",
- "rise": "Trồi lên",
- "pop": "Bật lên",
- "slideLeft": "Trượt sang trái",
- "typewriter": "Máy đánh chữ",
- "pulse": "Nhấp nháy"
- },
- "customFont": {
- "dialogTitle": "Thêm Google Font",
- "urlLabel": "URL nhập Google Fonts",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "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ị",
- "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ữ",
- "addButton": "Thêm phông chữ",
- "addingButton": "Đang thêm...",
- "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
- "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
- "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",
- "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
- "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.",
- "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác."
+ "typeImage": "Hình ảnh"
},
- "cursor": {
- "title": "Con trỏ",
- "theme": "Kiểu con trỏ",
- "themeDefault": "Mặc định",
- "show": "Hiện con trỏ",
- "size": "Kích thước",
- "smoothing": "Làm mượt",
+ "effects": {
+ "fitClipFew": "{{count}} clip",
+ "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",
+ "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",
- "clickBounce": "Nảy khi nhấp",
- "clipToBounds": "Cắt theo khung",
- "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.",
- "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."
- },
- "language": {
- "title": "Ngôn ngữ"
- },
- "facets": {
- "captions": "Phụ đề",
- "transcript": "Bản ghi lời thoại"
- },
- "panes": {
- "help": "Trợ giúp"
+ "roundness": "Độ bo tròn"
},
"transcript": {
- "title": "Bản chép lời hiện tại",
- "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 để đánh dấu là bỏ qua (màu đỏ). Di chuột lên đoạn màu đỏ để khôi phục.",
- "noClips": "Chưa có clip nào",
+ "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.",
- "editorAria": "Bản chép lời của {{filename}}",
- "silence": "[khoảng lặng {{duration}} giây]",
- "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
- "trimSilence": "Cắt khoảng lặng ({{duration}} giây)",
- "restoreWord": "Khôi phục \"{{word}}\"",
- "noAudio": "Media này không có bản âm thanh"
+ "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": {
+ "mp4": "MP4",
+ "mp4Description": "Tệp video chất lượng cao",
+ "gifAnimation": "Ảnh động GIF",
+ "mp4Video": "Video MP4",
+ "gifDescription": "Hình ảnh động để chia sẻ",
+ "gif": "GIF"
},
"captions": {
- "show": "Hiện phụ đề",
- "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ụ đề.",
- "transcribe": "Chép lời video",
- "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lờ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.",
- "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ũ",
- "language": "Ngôn ngữ",
- "displayLanguage": "Hiển thị",
- "original": "Gốc (bản chép lời)",
- "translate": "Dịch",
- "translating": "Đang dịch…",
- "translateHint": "Dịch bản chép lời bằng nhà cung cấp AI đã cấu hình",
- "translateFailed": "Dịch thất bại.",
+ "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",
- "font": "Phông chữ",
- "fontSize": "Cỡ chữ",
- "bold": "Đậm",
- "textColor": "Màu chữ",
- "background": "Nền",
- "showBackground": "Hiện nền",
- "backgroundColor": "Màu nền",
- "backgroundOpacity": "Độ mờ",
- "position": "Vị trí",
- "anchorBottom": "Dưới",
- "anchorTop": "Trên",
"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.",
- "distanceFromBottom": "Khoảng cách từ dưới",
"distanceFromTop": "Khoảng cách từ trên",
- "distanceFromLeft": "Khoảng cách từ trái",
- "distanceFromRight": "Khoảng cách từ phải",
+ "translateFailed": "Dịch thất bại.",
"alignLeft": "Trái",
- "alignCenter": "Giữa",
+ "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",
- "lineLength": "Độ dài dòng",
+ "anchorTop": "Trên",
"minWords": "Số từ tối thiểu mỗi dòng",
- "maxWords": "Số từ tối đa 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ữ"
+ },
+ "panes": {
+ "help": "Trợ giúp"
+ },
+ "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."
+ },
+ "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": {
+ "defaultLabel": "Bản âm thanh",
+ "importFailed": "Không thể thêm âm thanh",
+ "fadeOut": "Mờ ra",
+ "fadeIn": "Mờ vào",
+ "remove": "Xóa bản nhạc",
+ "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.",
+ "add": "Thêm bản âm thanh",
+ "mute": "Tắt tiếng"
+ },
+ "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"
+ },
+ "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"
+ },
+ "facets": {
+ "transcript": "Bản ghi lời thoại",
+ "captions": "Phụ đề"
+ },
+ "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": {
- "title": "Â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.",
"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"
+ },
+ "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"
+ },
+ "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/shortcuts.json b/src/i18n/locales/vi/shortcuts.json
index cf49f2526..448de3cfe 100644
--- a/src/i18n/locales/vi/shortcuts.json
+++ b/src/i18n/locales/vi/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "Thêm Cắt",
"addSpeed": "Thêm Tốc độ",
"addAnnotation": "Thêm Chú thích",
+ "addAudio": "Thêm âm thanh",
+ "addVoiceover": "Ghi âm lời thuyết minh",
"addKeyframe": "Thêm Khung hình chính",
"addCameraFullscreen": "Thêm Camera Toàn màn hình",
"deleteSelected": "Xóa mục đã chọn",
diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json
index 1d963e585..9b15c0e39 100644
--- a/src/i18n/locales/vi/timeline.json
+++ b/src/i18n/locales/vi/timeline.json
@@ -15,6 +15,7 @@
"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"
},
@@ -85,6 +86,23 @@
"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"
+ "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": {
+ "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",
+ "record": "Ghi âm thuyết minh",
+ "importFile": "Nhập tệp âm thanh",
+ "importFileHint": "Nhập nhạc hoặc tệp âm thanh",
+ "recording": "Đang 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",
+ "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"
}
}
diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json
index 645ebf9d8..db4f12730 100644
--- a/src/i18n/locales/zh-CN/dialogs.json
+++ b/src/i18n/locales/zh-CN/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "保存导出的 GIF",
"saveVideo": "保存导出的视频",
"selectVideo": "选择视频文件",
+ "selectAudio": "选择音频文件",
"saveProject": "保存 OpenScreen 项目",
"openProject": "打开 OpenScreen 项目",
"gifImage": "GIF 图片",
"mp4Video": "MP4 视频",
"videoFiles": "视频文件",
+ "audioFiles": "音频文件",
"openscreenProject": "OpenScreen 项目",
"allFiles": "所有文件"
}
diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json
index 7723d101d..0405b5c13 100644
--- a/src/i18n/locales/zh-CN/editor.json
+++ b/src/i18n/locales/zh-CN/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "正在加载视频...",
"loadingEditor": "正在加载编辑器...",
"errors": {
- "noVideoLoaded": "未加载视频",
- "videoNotReady": "视频未就绪",
- "unableToDetermineSourcePath": "无法确定源视频路径",
- "failedToSaveGif": "保存 GIF 失败",
- "gifExportFailed": "GIF 导出失败",
- "failedToSaveVideo": "保存视频失败",
+ "exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})",
"exportFailed": "导出失败",
"exportFailedWithError": "导出失败:{{error}}",
- "exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})",
+ "failedToRevealInFolder": "在文件夹中显示时出错:{{error}}",
"failedToSaveExport": "保存导出文件失败",
"failedToSaveExportedVideo": "保存导出的视频失败",
- "failedToRevealInFolder": "在文件夹中显示时出错:{{error}}",
- "previewCompositorUnavailable": "此设备无法使用预览"
+ "failedToSaveGif": "保存 GIF 失败",
+ "failedToSaveVideo": "保存视频失败",
+ "gifExportFailed": "GIF 导出失败",
+ "noVideoLoaded": "未加载视频",
+ "previewCompositorUnavailable": "此设备无法使用预览",
+ "trimNoFilm": "这里没有可剪的内容——这些词下面没有画面。",
+ "unableToDetermineSourcePath": "无法确定源视频路径",
+ "videoNotReady": "视频未就绪",
+ "wordEditFailed": "无法修改该词",
+ "wordInsertFailed": "无法添加该词",
+ "wordRemoveFailed": "无法删除该词"
},
"export": {
"canceled": "导出已取消",
@@ -71,6 +75,7 @@
"pasted": "已粘贴{{region}}属性",
"nothingToCopy": "选择一个区域以复制其属性",
"nothingToPaste": "尚未复制任何属性",
+ "pasteAssetMissing": "该音频轨道的文件不在此项目中",
"kinds": {
"zoom": "缩放",
"speed": "速度",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index fafb66f7e..fb5033f53 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -1,330 +1,355 @@
{
- "zoom": {
- "previewHold": "按住预览放大效果",
- "level": "缩放级别",
- "selectRegion": "选择要调整的缩放区域",
- "deleteZoom": "删除缩放",
- "focusMode": {
- "title": "对焦模式",
- "manual": "手动",
- "auto": "自动",
- "autoDescription": "摄像头跟随录制时的光标位置",
- "lockedDisclaimer": "由时间线中的全局自动对焦开关控制。关闭后可为每个缩放单独设置对焦模式。"
- },
- "threeD": {
- "title": "3D 旋转",
- "preset": {
- "iso": "Iso",
- "left": "左",
- "right": "右"
- },
- "none": "无"
- },
- "customScale": "自定义缩放",
- "position": {
- "title": "焦点位置",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下"
- }
- },
- "speed": {
- "playbackSpeed": "播放速度",
- "selectRegion": "选择要调整的速度区域",
- "deleteRegion": "删除速度区域",
- "customPlaybackSpeed": "自定义播放速度",
- "maxSpeedError": "速度不能超过 {{max}}×",
- "previewFrameSteppingHint": "超过 {{native}}× 时,预览为逐帧跳转且静音,导出不受影响。"
- },
- "trim": {
- "deleteRegion": "删除剪辑区域"
- },
- "layout": {
- "title": "摄像头布局",
- "preset": "预设",
- "selectPreset": "选择预设",
- "pictureInPicture": "画中画",
- "verticalStack": "垂直堆叠",
- "dualFrame": "双画框",
- "webcamShape": "摄像头形状",
- "webcamSize": "摄像头大小",
- "noWebcam": "无摄像头",
- "mirrorWebcam": "镜像摄像头",
- "reactiveWebcam": "缩放时缩小",
- "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
- "help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
- "helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
- "webcamFraming": "摄像头构图",
- "webcamCropZoom": "裁剪缩放",
- "webcamCropX": "水平移动",
- "webcamCropY": "垂直移动",
- "shapes": {
- "rectangle": "矩形",
- "circle": "圆形",
- "square": "正方形",
- "rounded": "圆角"
- },
- "webcamBackground": "摄像头背景",
- "webcamBlurIntensity": "模糊强度",
- "bgModes": {
- "none": "原画",
- "transparent": "抠图",
- "blur": "模糊",
- "custom": "自定义"
- }
- },
- "effects": {
- "title": "画面合成",
- "blurBg": "模糊背景",
- "motionBlur": "运动模糊",
- "off": "关",
- "shadow": "阴影",
- "roundness": "圆角",
- "padding": "内边距",
- "frame": "画框",
- "format": "格式",
- "formatOriginal": "原始",
- "fitClip": "适配",
- "fitClipOne": "{{count}} 个片段",
- "fitClipFew": "{{count}} 个片段",
- "fitClipMany": "{{count}} 个片段",
- "motion": "运动",
- "on": "开",
- "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。"
- },
"background": {
+ "gradientLabel": "渐变 {{index}}",
+ "uploadCustom": "上传自定义",
+ "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
"title": "背景",
- "image": "图片",
- "color": "颜色",
- "gradient": "渐变",
+ "imageLabel": "背景 {{index}}",
"custom": "自定义",
- "uploadCustom": "上传自定义",
- "gradientLabel": "渐变 {{index}}",
- "colorWheel": "颜色轮",
- "colorPalette": "颜色调色板",
- "presets": "预设",
"help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。",
+ "gradient": "渐变",
+ "colorLabel": "颜色 {{color}}",
"customWallpaper": "自定义壁纸",
- "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。",
+ "colorPalette": "颜色调色板",
"imageReadFailed": "无法读取该图片文件。",
- "imageLabel": "背景 {{index}}",
- "colorLabel": "颜色 {{color}}"
- },
- "crop": {
- "title": "裁剪",
- "cropVideo": "裁剪视频",
- "dragInstruction": "拖动每一侧来调整裁剪区域",
- "ratio": "比例",
- "free": "自由",
- "done": "完成",
- "lockAspectRatio": "锁定宽高比",
- "unlockAspectRatio": "解锁宽高比"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 视频",
- "mp4Description": "高质量视频文件",
- "gifAnimation": "GIF 动画",
- "gifDescription": "可分享的动态图片"
- },
- "exportQuality": {
- "title": "导出分辨率",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "GIF 帧率",
- "size": "GIF 尺寸",
- "loop": "循环 GIF"
- },
- "project": {
- "save": "保存项目",
- "load": "加载项目",
- "new": "新建项目"
- },
- "export": {
- "videoButton": "导出视频",
- "gifButton": "导出 GIF",
- "chooseSaveLocation": "选择保存位置"
+ "image": "图片",
+ "presets": "预设",
+ "color": "颜色",
+ "colorWheel": "颜色轮"
},
- "support": {
- "reportBug": "报告错误",
- "saveDiagnostics": "保存诊断信息",
- "starOnGithub": "在 GitHub 上加星"
+ "customFont": {
+ "namePlaceholder": "我的自定义字体",
+ "failedToAdd": "添加字体失败",
+ "addingButton": "添加中...",
+ "errorInvalidUrl": "请输入有效的 Google Fonts URL",
+ "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
+ "urlLabel": "Google Fonts 导入 URL",
+ "successMessage": "字体 \"{{fontName}}\" 添加成功",
+ "nameLabel": "显示名称",
+ "errorEmptyName": "请输入字体名称",
+ "nameHelp": "这是字体在字体选择器中显示的名称",
+ "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": "自定义图片上传成功!",
- "failedToUpload": "上传图片失败",
"errorReading": "读取文件时出错。"
},
"annotation": {
- "title": "标注设置",
- "active": "活动",
- "typeText": "文本",
- "typeImage": "图片",
- "typeArrow": "箭头",
- "typeBlur": "模糊",
- "textContent": "文本内容",
- "textPlaceholder": "输入您的文本...",
- "defaultText": "你好",
- "fontStyle": "字体样式",
- "selectStyle": "选择样式",
+ "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
+ "blurShapeRectangle": "矩形",
"size": "大小",
- "customFonts": "自定义字体",
- "textColor": "文本颜色",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
+ "clearBackground": "清除背景",
+ "colorPalette": "颜色调色板",
+ "invalidImageType": "无效的文件类型",
"background": "背景",
- "none": "无",
+ "typeText": "文本",
+ "active": "活动",
"color": "颜色",
- "colorWheel": "颜色轮",
- "colorPalette": "颜色调色板",
- "clearBackground": "清除背景",
- "uploadImage": "上传图片",
- "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
+ "blurShapeFreehand": "自由手绘",
"arrowDirection": "箭头方向",
- "strokeWidth": "描边宽度:{{width}}px",
+ "blurTypeMosaic": "马赛克",
+ "colorWheel": "颜色轮",
+ "textColor": "文本颜色",
+ "title": "标注设置",
+ "blurType": "模糊类型",
+ "typeBlur": "模糊",
+ "blurIntensity": "模糊强度",
+ "selectStyle": "选择样式",
+ "textContent": "文本内容",
+ "typeArrow": "箭头",
+ "none": "无",
+ "blurColor": "模糊颜色",
+ "customFonts": "自定义字体",
+ "imageUploadSuccess": "图片上传成功!",
+ "type": "类型",
"arrowColor": "箭头颜色",
+ "textPlaceholder": "输入您的文本...",
+ "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
"blurShape": "模糊形状",
- "blurIntensity": "模糊强度",
- "blurShapeRectangle": "矩形",
- "blurShapeOval": "椭圆",
- "blurShapeFreehand": "自由手绘",
- "deleteAnnotation": "删除标注",
- "shortcutsAndTips": "快捷键与提示",
- "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+ "uploadImage": "上传图片",
+ "blurTypeBlur": "高斯",
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
- "invalidImageType": "无效的文件类型",
- "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
- "imageUploadSuccess": "图片上传成功!",
- "blurColor": "模糊颜色",
+ "shortcutsAndTips": "快捷键与提示",
+ "deleteAnnotation": "删除标注",
+ "fontStyle": "字体样式",
+ "defaultText": "你好",
+ "mosaicBlockSize": "马赛克块大小",
"blurColorBlack": "黑色",
+ "strokeWidth": "描边宽度:{{width}}px",
+ "blurShapeOval": "椭圆",
"blurColorWhite": "白色",
- "blurType": "模糊类型",
- "blurTypeBlur": "高斯",
- "blurTypeMosaic": "马赛克",
- "mosaicBlockSize": "马赛克块大小",
- "type": "类型"
- },
- "textAnimation": {
- "title": "文本动画",
- "selectAnimation": "选择动画",
- "none": "无",
- "fade": "淡入淡出",
- "rise": "上升",
- "pop": "弹出",
- "slideLeft": "向左滑动",
- "typewriter": "打字机",
- "pulse": "脉动"
- },
- "customFont": {
- "dialogTitle": "添加 Google 字体",
- "urlLabel": "Google Fonts 导入 URL",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
- "nameLabel": "显示名称",
- "namePlaceholder": "我的自定义字体",
- "nameHelp": "这是字体在字体选择器中显示的名称",
- "addButton": "添加字体",
- "addingButton": "添加中...",
- "errorEmptyUrl": "请输入 Google Fonts 导入 URL",
- "errorInvalidUrl": "请输入有效的 Google Fonts URL",
- "errorEmptyName": "请输入字体名称",
- "errorExtractFailed": "无法从 URL 中提取字体系列",
- "successMessage": "字体 \"{{fontName}}\" 添加成功",
- "failedToAdd": "添加字体失败",
- "errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
- "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。"
+ "tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+ "typeImage": "图片"
},
- "cursor": {
- "title": "光标",
- "theme": "光标样式",
- "themeDefault": "默认",
- "show": "显示光标",
- "size": "大小",
- "smoothing": "平滑",
+ "effects": {
+ "fitClipFew": "{{count}} 个片段",
+ "title": "画面合成",
+ "shadow": "阴影",
+ "off": "关",
+ "on": "开",
+ "blurBg": "模糊背景",
+ "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。",
+ "fitClipOne": "{{count}} 个片段",
+ "formatOriginal": "原始",
+ "fitClipMany": "{{count}} 个片段",
+ "frame": "画框",
+ "motion": "运动",
+ "padding": "内边距",
+ "format": "格式",
+ "fitClip": "适配",
"motionBlur": "运动模糊",
- "clickBounce": "点击弹跳",
- "clipToBounds": "裁剪到画布",
- "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。",
- "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。"
- },
- "language": {
- "title": "语言"
- },
- "facets": {
- "captions": "字幕",
- "transcript": "转录文本"
- },
- "panes": {
- "help": "帮助"
+ "roundness": "圆角"
},
"transcript": {
- "title": "当前转录",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其标记为跳过(红色)。将鼠标悬停在红色片段上可恢复。",
- "noClips": "暂无片段",
+ "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": "该片段没有转录 — 请打开素材卡片并重新生成。",
- "editorAria": "{{filename}} 的转录",
- "silence": "[静音 {{duration}} 秒]",
- "restoreSilence": "恢复静音({{duration}} 秒)",
- "trimSilence": "修剪静音({{duration}} 秒)",
- "restoreWord": "恢复“{{word}}”",
- "noAudio": "此媒体没有音频轨道"
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "noAudio": "此媒体没有音频轨道",
+ "blankedWord": "已清空"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "高质量视频文件",
+ "gifAnimation": "GIF 动画",
+ "mp4Video": "MP4 视频",
+ "gifDescription": "可分享的动态图片",
+ "gif": "GIF"
},
"captions": {
- "show": "显示字幕",
- "noTranscript": "字幕来自媒体的转录。请先转录此视频以启用字幕。",
- "transcribe": "转录视频",
- "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
- "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
- "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
- "removeLegacyAnnotations": "移除旧的字幕批注",
- "language": "语言",
- "displayLanguage": "显示",
- "original": "原文(转录)",
- "translate": "翻译",
- "translating": "翻译中…",
- "translateHint": "使用已配置的 AI 提供方翻译转录",
- "translateFailed": "翻译失败。",
+ "showBackground": "显示背景",
"deleteTranslation": "删除此翻译",
+ "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。",
+ "backgroundOpacity": "不透明度",
+ "backgroundColor": "背景颜色",
+ "alignCenter": "居中",
"translationIsNonDestructive": "翻译保存在转录旁边,绝不写入其中——原文及其时间轴保持不变。",
+ "distanceFromRight": "距右侧",
+ "language": "语言",
"text": "文本",
- "font": "字体",
- "fontSize": "字号",
- "bold": "粗体",
- "textColor": "文字颜色",
- "background": "背景",
- "showBackground": "显示背景",
- "backgroundColor": "背景颜色",
- "backgroundOpacity": "不透明度",
- "position": "位置",
- "anchorBottom": "底部",
- "anchorTop": "顶部",
"anchorHintBottom": "较长的字幕向上延伸——底边保持不动。",
- "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。",
- "distanceFromBottom": "距底部",
"distanceFromTop": "距顶部",
- "distanceFromLeft": "距左侧",
- "distanceFromRight": "距右侧",
+ "translateFailed": "翻译失败。",
"alignLeft": "左对齐",
- "alignCenter": "居中",
+ "distanceFromBottom": "距底部",
+ "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
+ "translate": "翻译",
+ "position": "位置",
+ "fontSize": "字号",
+ "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
+ "noTranscript": "字幕来自媒体的转录。请先转录此视频以启用字幕。",
+ "distanceFromLeft": "距左侧",
+ "anchorBottom": "底部",
+ "transcribe": "转录视频",
+ "bold": "粗体",
"alignRight": "右对齐",
- "lineLength": "行长",
+ "anchorTop": "顶部",
"minWords": "每行最少词数",
- "maxWords": "每行最多词数"
+ "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": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
+ "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": {
+ "slideLeft": "向左滑动",
+ "pulse": "脉动",
+ "typewriter": "打字机",
+ "selectAnimation": "选择动画",
+ "fade": "淡入淡出",
+ "title": "文本动画",
+ "none": "无",
+ "pop": "弹出",
+ "rise": "上升"
+ },
+ "facets": {
+ "transcript": "转录文本",
+ "captions": "字幕"
+ },
+ "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": {
- "title": "音频",
"outputGain": "输出电平",
+ "help": "调整音频输出电平。它在预览和导出中的效果完全一致。",
"reset": "重置音频",
- "help": "调整音频输出电平。它在预览和导出中的效果完全一致。"
+ "title": "音频"
+ },
+ "language": {
+ "title": "语言"
+ },
+ "project": {
+ "new": "新建项目",
+ "load": "加载项目",
+ "save": "保存项目"
+ },
+ "support": {
+ "starOnGithub": "在 GitHub 上加星",
+ "saveDiagnostics": "保存诊断信息",
+ "reportBug": "报告错误"
+ },
+ "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/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json
index 95ec1c07e..033f80e7d 100644
--- a/src/i18n/locales/zh-CN/shortcuts.json
+++ b/src/i18n/locales/zh-CN/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "添加剪辑",
"addSpeed": "添加速度",
"addAnnotation": "添加标注",
+ "addAudio": "添加音频",
+ "addVoiceover": "录制配音",
"addKeyframe": "添加关键帧",
"addCameraFullscreen": "添加全屏摄像头",
"deleteSelected": "删除所选",
diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json
index 1451e6d31..a5d4f897a 100644
--- a/src/i18n/locales/zh-CN/timeline.json
+++ b/src/i18n/locales/zh-CN/timeline.json
@@ -15,6 +15,7 @@
"pressZoom": "按 Z 添加缩放",
"pressTrim": "按 T 添加剪辑",
"pressAnnotation": "按 A 添加标注",
+ "pressAudio": "按 M 添加音频,按 V 录制配音",
"pressSpeed": "按 S 添加速度",
"pressCameraFullscreen": "按 C 添加一个全屏摄像头片段"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "需要转录文本",
"smartCutsNoAudio": "此媒体没有音频",
"smartCutsNoSpeech": "未检测到语音",
- "smartCutsFailed": "转录失败 — 请在“媒体”中重试"
+ "smartCutsFailed": "转录失败 — 请在“媒体”中重试",
+ "addAudioTooltip": "添加音频",
+ "addedWord": "已添加的词:“{{word}}” — 背后没有声音"
+ },
+ "audio": {
+ "addVoiceover": "添加配音",
+ "addVoiceoverHint": "为视频录制旁白",
+ "subtitle": "在时间轴上放置配音或背景音乐图层",
+ "record": "录制配音",
+ "importFile": "导入音频文件",
+ "importFileHint": "导入音乐或音频文件",
+ "recording": "正在录制",
+ "recordingHint": "跟着视频讲解 — 录制时视频会继续播放",
+ "stop": "停止",
+ "micDenied": "麦克风访问被拒绝",
+ "recordingUnavailable": "此处无法录音",
+ "saveFailed": "无法保存录音",
+ "importFailed": "无法导入音频文件"
}
}
diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json
index 14fc364a0..f4830a611 100644
--- a/src/i18n/locales/zh-TW/dialogs.json
+++ b/src/i18n/locales/zh-TW/dialogs.json
@@ -79,11 +79,13 @@
"saveGif": "儲存匯出的 GIF",
"saveVideo": "儲存匯出的影片",
"selectVideo": "選擇影片檔案",
+ "selectAudio": "選擇音訊檔案",
"saveProject": "儲存 OpenScreen 專案",
"openProject": "開啟 OpenScreen 專案",
"gifImage": "GIF 圖片",
"mp4Video": "MP4 影片",
"videoFiles": "影片檔案",
+ "audioFiles": "音訊檔案",
"openscreenProject": "OpenScreen 專案",
"allFiles": "所有檔案"
}
diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json
index 3edf5f320..f4d10694f 100644
--- a/src/i18n/locales/zh-TW/editor.json
+++ b/src/i18n/locales/zh-TW/editor.json
@@ -8,19 +8,23 @@
"loadingVideo": "正在載入影片...",
"loadingEditor": "正在載入編輯器...",
"errors": {
- "noVideoLoaded": "未載入影片",
- "videoNotReady": "影片未就緒",
- "unableToDetermineSourcePath": "無法確定來源影片路徑",
- "failedToSaveGif": "儲存 GIF 失敗",
- "gifExportFailed": "GIF 匯出失敗",
- "failedToSaveVideo": "儲存影片失敗",
+ "exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})",
"exportFailed": "匯出失敗",
"exportFailedWithError": "匯出失敗:{{error}}",
- "exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})",
+ "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}",
"failedToSaveExport": "儲存匯出檔案失敗",
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
- "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}",
- "previewCompositorUnavailable": "此裝置無法使用預覽"
+ "failedToSaveGif": "儲存 GIF 失敗",
+ "failedToSaveVideo": "儲存影片失敗",
+ "gifExportFailed": "GIF 匯出失敗",
+ "noVideoLoaded": "未載入影片",
+ "previewCompositorUnavailable": "此裝置無法使用預覽",
+ "trimNoFilm": "這裡沒有可剪的內容——這些字詞下方沒有畫面。",
+ "unableToDetermineSourcePath": "無法確定來源影片路徑",
+ "videoNotReady": "影片未就緒",
+ "wordEditFailed": "無法修改這個字",
+ "wordInsertFailed": "無法加入這個字詞",
+ "wordRemoveFailed": "無法刪除這個字詞"
},
"export": {
"canceled": "匯出已取消",
@@ -71,6 +75,7 @@
"pasted": "已貼上{{region}}屬性",
"nothingToCopy": "選擇一個區域以複製其屬性",
"nothingToPaste": "尚未複製任何屬性",
+ "pasteAssetMissing": "該音訊軌道的檔案不在此專案中",
"kinds": {
"zoom": "縮放",
"speed": "速度",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index f09730292..cb02cc88f 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -1,331 +1,355 @@
{
- "zoom": {
- "previewHold": "按住預覽放大效果",
- "level": "縮放級別",
- "selectRegion": "選擇要調整的縮放區域",
- "deleteZoom": "刪除縮放",
- "focusMode": {
- "title": "對焦模式",
- "manual": "手動",
- "auto": "自動",
- "autoDescription": "攝影機跟隨錄製時的游標位置",
- "lockedDisclaimer": "由時間軸中的全域自動對焦開關控制。關閉後可為每個縮放個別設定對焦模式。"
- },
- "speed": {},
- "threeD": {
- "title": "3D 旋轉",
- "preset": {
- "iso": "Iso",
- "left": "左",
- "right": "右"
- },
- "none": "無"
- },
- "customScale": "自訂縮放",
- "position": {
- "title": "焦點位置",
- "x": "X (%)",
- "y": "Y (%)",
- "hint": "0 = 最左 / 最上,100 = 最右 / 最下"
- }
- },
- "speed": {
- "playbackSpeed": "播放速度",
- "selectRegion": "選擇要調整的速度區域",
- "deleteRegion": "刪除速度區域",
- "customPlaybackSpeed": "自訂播放速度",
- "maxSpeedError": "速度不能超過 {{max}}×",
- "previewFrameSteppingHint": "超過 {{native}}× 時,預覽為逐幀跳轉且靜音,匯出不受影響。"
- },
- "trim": {
- "deleteRegion": "刪除剪輯區域"
- },
- "layout": {
- "title": "攝影機版面",
- "preset": "預設",
- "selectPreset": "選擇預設",
- "pictureInPicture": "子母畫面",
- "verticalStack": "垂直堆疊",
- "dualFrame": "雙畫框",
- "webcamShape": "攝影機形狀",
- "webcamSize": "攝影機大小",
- "noWebcam": "無網路攝影機",
- "mirrorWebcam": "鏡像攝影機",
- "reactiveWebcam": "縮放時縮小",
- "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
- "help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
- "helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
- "webcamFraming": "攝影機構圖",
- "webcamCropZoom": "裁切縮放",
- "webcamCropX": "水平移動",
- "webcamCropY": "垂直移動",
- "shapes": {
- "rectangle": "矩形",
- "circle": "圓形",
- "square": "正方形",
- "rounded": "圓角"
- },
- "webcamBackground": "攝影機背景",
- "webcamBlurIntensity": "模糊強度",
- "bgModes": {
- "none": "原畫",
- "transparent": "去背",
- "blur": "模糊",
- "custom": "自訂"
- }
- },
- "effects": {
- "title": "畫面合成",
- "blurBg": "模糊背景",
- "motionBlur": "動態模糊",
- "off": "關",
- "shadow": "陰影",
- "roundness": "圓角",
- "padding": "內邊距",
- "frame": "外框",
- "format": "格式",
- "formatOriginal": "原始",
- "fitClip": "符合",
- "fitClipOne": "{{count}} 個片段",
- "fitClipFew": "{{count}} 個片段",
- "fitClipMany": "{{count}} 個片段",
- "motion": "動態",
- "on": "開",
- "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。"
- },
"background": {
+ "gradientLabel": "漸層 {{index}}",
+ "uploadCustom": "上傳自訂",
+ "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
"title": "背景",
- "image": "圖片",
- "color": "顏色",
- "gradient": "漸層",
+ "imageLabel": "背景 {{index}}",
"custom": "自訂",
- "uploadCustom": "上傳自訂",
- "gradientLabel": "漸層 {{index}}",
- "colorWheel": "色輪",
- "colorPalette": "調色盤",
- "presets": "預設",
"help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。",
+ "gradient": "漸層",
+ "colorLabel": "顏色 {{color}}",
"customWallpaper": "自訂桌布",
- "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。",
+ "colorPalette": "調色盤",
"imageReadFailed": "無法讀取該圖片檔案。",
- "imageLabel": "背景 {{index}}",
- "colorLabel": "顏色 {{color}}"
- },
- "crop": {
- "title": "裁剪",
- "cropVideo": "裁剪影片",
- "dragInstruction": "拖動每一側來調整裁剪區域",
- "ratio": "比例",
- "free": "自由",
- "done": "完成",
- "lockAspectRatio": "鎖定長寬比",
- "unlockAspectRatio": "解鎖長寬比"
- },
- "exportFormat": {
- "mp4": "MP4",
- "gif": "GIF",
- "mp4Video": "MP4 影片",
- "mp4Description": "高品質影片檔案",
- "gifAnimation": "GIF 動畫",
- "gifDescription": "可分享的動態圖片"
- },
- "exportQuality": {
- "title": "匯出解析度",
- "low": "720p",
- "medium": "1080p",
- "high": "Source"
- },
- "gifSettings": {
- "frameRate": "GIF 影格率",
- "size": "GIF 尺寸",
- "loop": "循環 GIF"
- },
- "project": {
- "save": "儲存專案",
- "load": "載入專案",
- "new": "新增專案"
- },
- "export": {
- "videoButton": "匯出影片",
- "gifButton": "匯出 GIF",
- "chooseSaveLocation": "選擇儲存位置"
+ "image": "圖片",
+ "presets": "預設",
+ "color": "顏色",
+ "colorWheel": "色輪"
},
- "support": {
- "reportBug": "回報錯誤",
- "saveDiagnostics": "儲存診斷資料",
- "starOnGithub": "在 GitHub 上加星"
+ "customFont": {
+ "namePlaceholder": "我的自訂字體",
+ "failedToAdd": "新增字體失敗",
+ "addingButton": "新增中...",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
+ "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "urlLabel": "Google Fonts 匯入 URL",
+ "successMessage": "字體 \"{{fontName}}\" 新增成功",
+ "nameLabel": "顯示名稱",
+ "errorEmptyName": "請輸入字體名稱",
+ "nameHelp": "這是字體在字體選擇器中顯示的名稱",
+ "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": "自訂圖片上傳成功!",
- "failedToUpload": "上傳圖片失敗",
"errorReading": "讀取檔案時出錯。"
},
"annotation": {
- "title": "標註設定",
- "active": "啟用",
- "typeText": "文字",
- "typeImage": "圖片",
- "typeArrow": "箭頭",
- "typeBlur": "模糊",
- "textContent": "文字內容",
- "textPlaceholder": "輸入您的文字...",
- "defaultText": "你好",
- "fontStyle": "字體樣式",
- "selectStyle": "選擇樣式",
+ "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
+ "blurShapeRectangle": "矩形",
"size": "大小",
- "customFonts": "自訂字體",
- "textColor": "文字顏色",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "clearBackground": "清除背景",
+ "colorPalette": "調色盤",
+ "invalidImageType": "無效的檔案類型",
"background": "背景",
- "none": "無",
+ "typeText": "文字",
+ "active": "啟用",
"color": "顏色",
- "colorWheel": "色輪",
- "colorPalette": "調色盤",
- "clearBackground": "清除背景",
- "uploadImage": "上傳圖片",
- "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
+ "blurShapeFreehand": "自由手繪",
"arrowDirection": "箭頭方向",
- "strokeWidth": "描邊寬度:{{width}}px",
+ "blurTypeMosaic": "馬賽克",
+ "colorWheel": "色輪",
+ "textColor": "文字顏色",
+ "title": "標註設定",
+ "blurType": "模糊類型",
+ "typeBlur": "模糊",
+ "blurIntensity": "模糊強度",
+ "selectStyle": "選擇樣式",
+ "textContent": "文字內容",
+ "typeArrow": "箭頭",
+ "none": "無",
+ "blurColor": "模糊顏色",
+ "customFonts": "自訂字體",
+ "imageUploadSuccess": "圖片上傳成功!",
+ "type": "類型",
"arrowColor": "箭頭顏色",
+ "textPlaceholder": "輸入您的文字...",
+ "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
"blurShape": "模糊形狀",
- "blurIntensity": "模糊強度",
- "blurShapeRectangle": "矩形",
- "blurShapeOval": "橢圓",
- "blurShapeFreehand": "自由手繪",
- "deleteAnnotation": "刪除標註",
- "shortcutsAndTips": "快捷鍵與提示",
- "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "uploadImage": "上傳圖片",
+ "blurTypeBlur": "高斯",
"tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
- "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
- "invalidImageType": "無效的檔案類型",
- "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
- "imageUploadSuccess": "圖片上傳成功!",
- "blurColor": "模糊顏色",
+ "shortcutsAndTips": "快捷鍵與提示",
+ "deleteAnnotation": "刪除標註",
+ "fontStyle": "字體樣式",
+ "defaultText": "你好",
+ "mosaicBlockSize": "馬賽克區塊大小",
"blurColorBlack": "黑色",
+ "strokeWidth": "描邊寬度:{{width}}px",
+ "blurShapeOval": "橢圓",
"blurColorWhite": "白色",
- "blurType": "模糊類型",
- "blurTypeBlur": "高斯",
- "blurTypeMosaic": "馬賽克",
- "mosaicBlockSize": "馬賽克區塊大小",
- "type": "類型"
- },
- "textAnimation": {
- "title": "文字動畫",
- "selectAnimation": "選擇動畫",
- "none": "無",
- "fade": "淡入淡出",
- "rise": "上升",
- "pop": "彈出",
- "slideLeft": "向左滑動",
- "typewriter": "打字機",
- "pulse": "脈動"
- },
- "customFont": {
- "dialogTitle": "新增 Google 字體",
- "urlLabel": "Google Fonts 匯入 URL",
- "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
- "nameLabel": "顯示名稱",
- "namePlaceholder": "我的自訂字體",
- "nameHelp": "這是字體在字體選擇器中顯示的名稱",
- "addButton": "新增字體",
- "addingButton": "新增中...",
- "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
- "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
- "errorEmptyName": "請輸入字體名稱",
- "errorExtractFailed": "無法從 URL 中提取字體系列",
- "successMessage": "字體 \"{{fontName}}\" 新增成功",
- "failedToAdd": "新增字體失敗",
- "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
- "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。"
+ "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "typeImage": "圖片"
},
- "cursor": {
- "title": "游標",
- "theme": "游標樣式",
- "themeDefault": "預設",
- "show": "顯示游標",
- "size": "大小",
- "smoothing": "平滑",
+ "effects": {
+ "fitClipFew": "{{count}} 個片段",
+ "title": "畫面合成",
+ "shadow": "陰影",
+ "off": "關",
+ "on": "開",
+ "blurBg": "模糊背景",
+ "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。",
+ "fitClipOne": "{{count}} 個片段",
+ "formatOriginal": "原始",
+ "fitClipMany": "{{count}} 個片段",
+ "frame": "外框",
+ "motion": "動態",
+ "padding": "內邊距",
+ "format": "格式",
+ "fitClip": "符合",
"motionBlur": "動態模糊",
- "clickBounce": "點擊彈跳",
- "clipToBounds": "裁切至畫布",
- "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。",
- "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。"
- },
- "language": {
- "title": "語言"
- },
- "facets": {
- "captions": "字幕",
- "transcript": "逐字稿"
- },
- "panes": {
- "help": "說明"
+ "roundness": "圓角"
},
"transcript": {
- "title": "目前的逐字稿",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會標記為略過(紅色)。將滑鼠移到紅色片段上即可還原。",
- "noClips": "尚無片段",
+ "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": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
- "editorAria": "{{filename}} 的逐字稿",
- "silence": "[靜音 {{duration}} 秒]",
- "restoreSilence": "還原靜音({{duration}} 秒)",
- "trimSilence": "修剪靜音({{duration}} 秒)",
- "restoreWord": "還原「{{word}}」",
- "noAudio": "此媒體沒有音訊軌道"
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "noAudio": "此媒體沒有音訊軌道",
+ "blankedWord": "已清空"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "mp4Description": "高品質影片檔案",
+ "gifAnimation": "GIF 動畫",
+ "mp4Video": "MP4 影片",
+ "gifDescription": "可分享的動態圖片",
+ "gif": "GIF"
},
"captions": {
- "show": "顯示字幕",
- "noTranscript": "字幕取自媒體的逐字稿。請先為這部影片產生逐字稿以啟用字幕。",
- "transcribe": "為影片產生逐字稿",
- "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
- "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
- "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
- "removeLegacyAnnotations": "移除舊的字幕註解",
- "language": "語言",
- "displayLanguage": "顯示",
- "original": "原文(逐字稿)",
- "translate": "翻譯",
- "translating": "翻譯中…",
- "translateHint": "使用已設定的 AI 供應商翻譯逐字稿",
- "translateFailed": "翻譯失敗。",
+ "showBackground": "顯示背景",
"deleteTranslation": "刪除這個翻譯",
+ "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。",
+ "backgroundOpacity": "不透明度",
+ "backgroundColor": "背景顏色",
+ "alignCenter": "置中",
"translationIsNonDestructive": "翻譯會存放在逐字稿旁邊,絕不寫入其中——原文與時間軸維持不變。",
+ "distanceFromRight": "距右緣",
+ "language": "語言",
"text": "文字",
- "font": "字型",
- "fontSize": "大小",
- "bold": "粗體",
- "textColor": "文字顏色",
- "background": "背景",
- "showBackground": "顯示背景",
- "backgroundColor": "背景顏色",
- "backgroundOpacity": "不透明度",
- "position": "位置",
- "anchorBottom": "下",
- "anchorTop": "上",
"anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。",
- "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。",
- "distanceFromBottom": "距下緣",
"distanceFromTop": "距上緣",
- "distanceFromLeft": "距左緣",
- "distanceFromRight": "距右緣",
+ "translateFailed": "翻譯失敗。",
"alignLeft": "靠左",
- "alignCenter": "置中",
+ "distanceFromBottom": "距下緣",
+ "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
+ "translate": "翻譯",
+ "position": "位置",
+ "fontSize": "大小",
+ "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
+ "noTranscript": "字幕取自媒體的逐字稿。請先為這部影片產生逐字稿以啟用字幕。",
+ "distanceFromLeft": "距左緣",
+ "anchorBottom": "下",
+ "transcribe": "為影片產生逐字稿",
+ "bold": "粗體",
"alignRight": "靠右",
- "lineLength": "行長",
+ "anchorTop": "上",
"minWords": "每行最少字數",
- "maxWords": "每行最多字數"
+ "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": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
+ "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": {
+ "slideLeft": "向左滑動",
+ "pulse": "脈動",
+ "typewriter": "打字機",
+ "selectAnimation": "選擇動畫",
+ "fade": "淡入淡出",
+ "title": "文字動畫",
+ "none": "無",
+ "pop": "彈出",
+ "rise": "上升"
+ },
+ "facets": {
+ "transcript": "逐字稿",
+ "captions": "字幕"
+ },
+ "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": {
- "title": "音訊",
"outputGain": "輸出音量",
+ "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。",
"reset": "重設音訊",
- "help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。"
+ "title": "音訊"
+ },
+ "language": {
+ "title": "語言"
+ },
+ "project": {
+ "new": "新增專案",
+ "load": "載入專案",
+ "save": "儲存專案"
+ },
+ "support": {
+ "starOnGithub": "在 GitHub 上加星",
+ "saveDiagnostics": "儲存診斷資料",
+ "reportBug": "回報錯誤"
+ },
+ "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/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json
index fd8c6434b..2daf7eb6d 100644
--- a/src/i18n/locales/zh-TW/shortcuts.json
+++ b/src/i18n/locales/zh-TW/shortcuts.json
@@ -20,6 +20,8 @@
"addTrim": "新增剪輯",
"addSpeed": "新增速度",
"addAnnotation": "新增標註",
+ "addAudio": "新增音訊",
+ "addVoiceover": "錄製配音",
"addKeyframe": "新增關鍵影格",
"addCameraFullscreen": "新增全螢幕攝影機",
"deleteSelected": "刪除所選",
diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json
index 7f4ba9874..7f5d90beb 100644
--- a/src/i18n/locales/zh-TW/timeline.json
+++ b/src/i18n/locales/zh-TW/timeline.json
@@ -15,6 +15,7 @@
"pressZoom": "按 Z 新增縮放",
"pressTrim": "按 T 新增剪輯",
"pressAnnotation": "按 A 新增標註",
+ "pressAudio": "按 M 新增音訊,按 V 錄製配音",
"pressSpeed": "按 S 新增速度",
"pressCameraFullscreen": "按 C 新增一個全螢幕攝影機片段"
},
@@ -85,6 +86,23 @@
"smartCutsNeedsTranscript": "需要轉錄文字",
"smartCutsNoAudio": "此媒體沒有音訊",
"smartCutsNoSpeech": "未偵測到語音",
- "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試"
+ "smartCutsFailed": "轉錄失敗 — 請在「媒體」中重試",
+ "addAudioTooltip": "新增音訊",
+ "addedWord": "已加入的字詞:「{{word}}」— 背後沒有聲音"
+ },
+ "audio": {
+ "addVoiceover": "新增旁白",
+ "addVoiceoverHint": "為影片錄製旁白",
+ "subtitle": "在時間軸上放置旁白或背景音樂圖層",
+ "record": "錄製旁白",
+ "importFile": "匯入音訊檔案",
+ "importFileHint": "匯入音樂或音訊檔案",
+ "recording": "錄製中",
+ "recordingHint": "跟著影片講解 — 錄製時影片會繼續播放",
+ "stop": "停止",
+ "micDenied": "麥克風存取遭拒絕",
+ "recordingUnavailable": "此處無法錄音",
+ "saveFailed": "無法儲存錄音",
+ "importFailed": "無法匯入音訊檔案"
}
}
diff --git a/src/lib/ai-edition/captions/captionLane.test.ts b/src/lib/ai-edition/captions/captionLane.test.ts
new file mode 100644
index 000000000..dfc3f5d88
--- /dev/null
+++ b/src/lib/ai-edition/captions/captionLane.test.ts
@@ -0,0 +1,144 @@
+// Issue #560, step 5. The lane the captions are read from is a DOCUMENT fact, because it
+// decides the text burnt into the exported file — and the path that burns it never runs
+// React. These pin that, and the fallback that keeps the pane and the exporter from
+// disagreeing about which lane a project even has.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutAudioTrack, AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
+import { deriveCaptionCues } from "./cues";
+import {
+ DEFAULT_CAPTION_SETTINGS,
+ getCaptionSettings,
+ patchCaptionSettings,
+ resolveCaptionLane,
+} from "./settings";
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "rec",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+const VOICE = {
+ id: "vo",
+ trackId: "vo",
+ assetId: "aud",
+ kind: "voiceover",
+ startMs: 0,
+ endMs: 6000,
+ durationSec: 6,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+} as unknown as AxcutAudioTrack;
+
+function words(assetId: string, texts: string[]): AxcutTranscript {
+ return {
+ assetId,
+ language: "en",
+ segments: [],
+ words: texts.map((text, i) => ({
+ id: `${assetId}_w${i}`,
+ segmentId: "s",
+ text,
+ startSec: i,
+ endSec: i + 0.9,
+ })),
+ } as unknown as AxcutTranscript;
+}
+
+function doc(over: Partial = {}): AxcutDocument {
+ return {
+ schemaVersion: 7,
+ project: {
+ id: "p",
+ title: "T",
+ createdAt: "2026-06-25T10:00:00.000Z",
+ updatedAt: "2026-06-25T10:00:00.000Z",
+ primaryAssetId: "rec",
+ },
+ assets: [],
+ transcript: null,
+ transcripts: [words("rec", ["filmed", "words"]), words("aud", ["narrated", "words"])],
+ timeline: {
+ clips: CLIPS,
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks: [VOICE],
+ legacyEditor: null,
+ ...over,
+ } as unknown as AxcutDocument;
+}
+
+const on = (lane: "recording" | "voiceover") => ({
+ ...DEFAULT_CAPTION_SETTINGS,
+ enabled: true,
+ captionLane: lane,
+});
+
+const texts = (d: AxcutDocument, lane: "recording" | "voiceover") =>
+ deriveCaptionCues(d, on(lane), {}).map((c) => c.text);
+
+describe("captionLane", () => {
+ it("defaults to the recording, and survives a round trip through the document", () => {
+ expect(DEFAULT_CAPTION_SETTINGS.captionLane).toBe("recording");
+ const next = patchCaptionSettings(doc(), { captionLane: "voiceover" });
+ expect(getCaptionSettings(next).captionLane).toBe("voiceover");
+ });
+
+ it("refuses a lane the placements would not recognise", () => {
+ // A hand-edited passthrough blob cannot inject one: `legacyEditor` is untyped.
+ const poisoned = patchCaptionSettings(doc(), {
+ captionLane: "sideways" as unknown as "recording",
+ });
+ expect(getCaptionSettings(poisoned).captionLane).toBe("recording");
+ });
+
+ it("reads the chosen lane's own words", () => {
+ expect(texts(doc(), "recording")).toContain("filmed words");
+ expect(texts(doc(), "voiceover")).toContain("narrated words");
+ });
+
+ it("leaves the recording lane's cues untouched by the change", () => {
+ // The default path is byte-identical: a project that never opts in sees nothing.
+ const withoutAudio = doc({ audioTracks: [] });
+ expect(texts(withoutAudio, "recording")).toEqual(texts(doc(), "recording"));
+ });
+
+ it("falls back to the recording when the stored lane no longer names anything", () => {
+ // The pane used to carry this fallback in React state, and the export path never
+ // runs React: this project would have exported ZERO captions while the pane showed
+ // the recording's.
+ const orphaned = doc({ audioTracks: [] });
+ expect(resolveCaptionLane(orphaned, on("voiceover"))).toBe("recording");
+ expect(texts(orphaned, "voiceover")).toContain("filmed words");
+ // And it is not a blanket fallback: with a take present the choice stands.
+ expect(resolveCaptionLane(doc(), on("voiceover"))).toBe("voiceover");
+ });
+
+ it("carries a corrected word into the caption", () => {
+ const corrected = doc({
+ transcripts: [words("rec", ["filmed", "words"]), words("aud", ["Kubernetes", "words"])],
+ });
+ expect(texts(corrected, "voiceover")).toContain("Kubernetes words");
+ });
+});
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index ce3d4a961..a1321e491 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { insertGeneratedClip } from "../document/insertion";
import type { AxcutDocument, AxcutTranscript } from "../schema";
import { captionCuesToTextRegions, deriveCaptionCues } from "./cues";
import type { CaptionSettings } from "./settings";
@@ -63,7 +64,16 @@ function doc(overrides: Partial = {}): 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: {
@@ -650,3 +660,29 @@ describe("translated caption layout", () => {
);
});
});
+
+// 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 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 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 clip that plays it", () => {
+ const wait = deriveCaptionCues(inserted(), ON, {}).find((c) => c.text === "wait");
+ expect(wait?.startMs).toBe(2000);
+ expect(wait?.endMs).toBeCloseTo(2000 + GEN_MS, 0);
+ });
+
+ it("leaves the cues before it exactly where they were", () => {
+ 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 c3287ebcb..bde765a46 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -22,12 +22,19 @@ import {
splitMergedCaptionsByWordBounds,
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
-import type { AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
+import type { AxcutDocument, AxcutTranscript } from "../schema";
+import {
+ lanePlacements,
+ placementRawSec,
+ type TranscriptPlacement,
+} from "../timeline/aggregated-transcript";
+import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
type CaptionSettings,
captionBackgroundCss,
captionBoxRect,
+ resolveCaptionLane,
} from "./settings";
import { type CaptionTranslations, captionTranslationUnits } from "./translations";
@@ -156,7 +163,10 @@ export function sourceSpanToTimelineSpans(
assetId: string,
startSec: number,
endSec: number,
- clips: AxcutClip[],
+ /** Clips, or a voiceover lane's placements — this reads only `assetId`, the source
+ * window and the ruler head, which both providers carry (issue #560). `AxcutClip`
+ * stays structurally assignable, so every existing caller is unaffected. */
+ clips: TranscriptPlacement[],
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -165,11 +175,13 @@ 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;
}
@@ -185,17 +197,29 @@ export function deriveCaptionCues(
translations: CaptionTranslations,
): CaptionCue[] {
if (!document || !settings.enabled) return [];
- const clips = document.timeline.clips;
- if (clips.length === 0) return [];
+ // The lane the captions are read FROM — resolved, so a stored "voiceover" whose last
+ // pill has been deleted falls back here rather than exporting nothing (issue #560).
+ const placements = lanePlacements(
+ resolveCaptionLane(document, settings),
+ document.timeline.clips,
+ // `?? []` 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]));
+ // 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();
const cues: CaptionCue[] = [];
let n = 0;
- for (const assetId of new Set(clips.map((c) => c.assetId))) {
+ for (const assetId of new Set(placements.map((c) => c.assetId))) {
const transcript = transcripts.get(assetId);
if (!transcript) continue;
linesByAsset.set(assetId, captionLinesForAsset(transcript, settings, translations));
@@ -205,7 +229,12 @@ 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,
+ placements,
+ )) {
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 });
@@ -213,6 +242,10 @@ export function deriveCaptionCues(
}
}
+ // No removed-word filter. A cue inside a cut maps to source time inside frames
+ // `resolvePlaybackSegments` never emits, so it is already invisible in the preview and
+ // the export; dropping the words instead would re-flow every line boundary on any
+ // project with a trim — a visible change to output, bought for nothing.
cues.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
// Lines from one asset can't overlap, but two clips playing overlapping
// source ranges can put two cues on the same instant. Keep the ruler honest
diff --git a/src/lib/ai-edition/captions/settings.ts b/src/lib/ai-edition/captions/settings.ts
index e50d2e9c1..8f5fae3a2 100644
--- a/src/lib/ai-edition/captions/settings.ts
+++ b/src/lib/ai-edition/captions/settings.ts
@@ -11,6 +11,7 @@
import { clamp } from "@/utils/math";
import type { AxcutDocument } from "../schema";
+import { type TranscriptLane, voiceoverPlacements } from "../timeline/aggregated-transcript";
/**
* Which frame edge the caption block is pinned to. The block grows AWAY from it:
@@ -52,6 +53,20 @@ export interface CaptionSettings {
* layer (see `translations.ts`) — the transcript is never rewritten.
*/
language: string | null;
+ /**
+ * Which lane's transcript the captions are read from (issue #560).
+ *
+ * A DOCUMENT fact, not a view preference, for the same reason `language` is one: it
+ * decides the text that gets burnt into the exported file. `buildSceneDescription`
+ * takes the document as its only input and one of its callers is the headless CLI
+ * exporter — a lane living in React state would caption the preview from one lane and
+ * the exported file from the other, with nothing to notice the difference.
+ *
+ * Read it through {@link resolveCaptionLane}, never directly: a stored "voiceover" on
+ * a project whose last voiceover pill has been deleted has to fall back, and the
+ * fallback has to happen where both the pane and the exporter can see it.
+ */
+ captionLane: TranscriptLane;
/** Pixels at a 1080-high frame, the same convention as `AnnotationTextStyle.fontSize`
* — both the preview overlay and the compositor scale it by the height of the box
* they draw into (see `annotationScale.ts`), so it is resolution-free. */
@@ -89,6 +104,7 @@ export interface CaptionSettings {
export const DEFAULT_CAPTION_SETTINGS: CaptionSettings = {
enabled: false,
language: null,
+ captionLane: "recording",
fontSize: 48,
fontFamily: "Inter",
fontWeight: "bold",
@@ -443,6 +459,9 @@ export function getCaptionSettings(
// `null` is a meaningful value here ("show the original"), so an explicit
// null must survive; only a missing/garbage entry falls back to the default.
language: raw.language === null || typeof raw.language === "string" ? raw.language : d.language,
+ // Through the enum guard, so a hand-edited passthrough blob cannot inject a lane
+ // that `lanePlacements` would not recognise.
+ captionLane: readEnum(raw.captionLane, CAPTION_LANES, d.captionLane),
fontSize,
fontFamily: readString(raw.fontFamily, d.fontFamily),
fontWeight: readEnum(raw.fontWeight, ["normal", "bold"] as const, d.fontWeight),
@@ -456,6 +475,25 @@ export function getCaptionSettings(
};
}
+const CAPTION_LANES = ["recording", "voiceover"] as const;
+
+/**
+ * The lane the captions are ACTUALLY read from — the stored choice, or "recording" when
+ * that choice no longer names anything.
+ *
+ * In the pure layer on purpose. The transcript pane had this fallback in React state,
+ * and `buildSceneDescription` never runs React: a document that stored "voiceover" after
+ * its last voiceover pill was deleted would have exported zero captions while the pane
+ * quietly showed the recording's.
+ */
+export function resolveCaptionLane(
+ doc: AxcutDocument | null | undefined,
+ settings: CaptionSettings,
+): TranscriptLane {
+ if (settings.captionLane !== "voiceover") return "recording";
+ return voiceoverPlacements(doc?.audioTracks ?? []).length > 0 ? "voiceover" : "recording";
+}
+
export type CaptionSettingsPatch = Partial;
/**
diff --git a/src/lib/ai-edition/document/audioLanes.test.ts b/src/lib/ai-edition/document/audioLanes.test.ts
new file mode 100644
index 000000000..53df11c9d
--- /dev/null
+++ b/src/lib/ai-edition/document/audioLanes.test.ts
@@ -0,0 +1,232 @@
+// Issue #560, step 6. "The voiceover" has to name ONE thing for the transcript tab's lane
+// switch to mean anything, so each kind keeps one row. Enforced at the single placement
+// door every writer goes through, and repaired — never refused — after a structural edit.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutAudioTrack, AxcutClip, AxcutDocument } from "../schema";
+import {
+ audioLanePills,
+ collapseTracksToPills,
+ firstFreeHeadMs,
+ placeAudioTrackInDocument,
+ separateAudioLanes,
+} from "./audioTracks";
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "rec",
+ sourceStartSec: 0,
+ sourceEndSec: 60,
+ timelineStartSec: 0,
+ timelineEndSec: 60,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+function track(over: Partial & { id: string }): AxcutAudioTrack {
+ return {
+ trackId: over.id,
+ assetId: "aud",
+ kind: "voiceover",
+ startMs: 0,
+ endMs: 4000,
+ durationSec: 30,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+ clipId: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 4,
+ ...over,
+ } as unknown as AxcutAudioTrack;
+}
+
+function doc(audioTracks: AxcutAudioTrack[]): AxcutDocument {
+ return {
+ schemaVersion: 7,
+ project: { id: "p", title: "T", createdAt: "", updatedAt: "" },
+ assets: [],
+ transcript: null,
+ transcripts: [],
+ timeline: {
+ clips: CLIPS,
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks,
+ legacyEditor: null,
+ } as unknown as AxcutDocument;
+}
+
+let n = 0;
+const ids = () => `f${n++}`;
+const pills = (d: AxcutDocument, kind: AxcutAudioTrack["kind"] = "voiceover") =>
+ audioLanePills(d.audioTracks, kind).map((p) => [p.startMs, p.endMs]);
+
+describe("one row per kind", () => {
+ it("queues a second take behind the first instead of on top of it", () => {
+ // Two takes recorded from the same playhead. This is what forced a second voiceover
+ // row into existence, and with it a lane switch that could not name what it meant.
+ const first = doc([track({ id: "a", startMs: 2000, endMs: 6000 })]);
+ const next = placeAudioTrackInDocument(
+ first,
+ track({ id: "b", startMs: 2000, endMs: 5000 }),
+ ids,
+ "create",
+ );
+ expect(pills(next)).toEqual([
+ [2000, 6000],
+ [6000, 9000],
+ ]);
+ });
+
+ it("parks a moved take against the wall with its duration intact", () => {
+ const before = doc([
+ track({ id: "a", startMs: 0, endMs: 4000 }),
+ track({ id: "b", startMs: 8000, endMs: 12_000 }),
+ ]);
+ // Dragged from 8s back to 2s, where "a" already sits. A move must never CROP a
+ // take: the user asked to move it, not to shorten it.
+ const next = placeAudioTrackInDocument(
+ before,
+ track({ id: "b", startMs: 2000, endMs: 6000 }),
+ ids,
+ "move",
+ );
+ const moved = audioLanePills(next.audioTracks, "voiceover").find(
+ (p) => (p.trackId ?? p.id) === "b",
+ );
+ expect(moved?.endMs && moved.endMs - moved.startMs).toBe(4000);
+ expect(moved?.startMs).toBe(4000);
+ });
+
+ it("stops a resized edge at the neighbour", () => {
+ const before = doc([
+ track({ id: "a", startMs: 0, endMs: 4000 }),
+ track({ id: "b", startMs: 8000, endMs: 12_000 }),
+ ]);
+ // Dragging "b"'s left edge back to 1s: it stops where "a" ends, and the head is
+ // what moves, not the whole pill.
+ const next = placeAudioTrackInDocument(
+ before,
+ track({ id: "b", startMs: 1000, endMs: 12_000 }),
+ ids,
+ "resize",
+ );
+ const resized = audioLanePills(next.audioTracks, "voiceover").find(
+ (p) => (p.trackId ?? p.id) === "b",
+ );
+ expect([resized?.startMs, resized?.endMs]).toEqual([4000, 12_000]);
+ });
+
+ it("leaves a voiceover over a music bed alone", () => {
+ // Different kinds, different rows: the normal case, and it must not clamp.
+ const before = doc([track({ id: "bed", kind: "music", startMs: 0, endMs: 20_000 })]);
+ const next = placeAudioTrackInDocument(
+ before,
+ track({ id: "vo", startMs: 3000, endMs: 7000 }),
+ ids,
+ "create",
+ );
+ expect(pills(next, "voiceover")).toEqual([[3000, 7000]]);
+ expect(pills(next, "music")).toEqual([[0, 20_000]]);
+ });
+
+ it("does not merge two different files that happen to match", () => {
+ // `regionIdentityKey` puts `assetId` in NON_IDENTITY_FIELDS, so two takes with the
+ // same payload hash identically — reusing it here would splice them into one pill.
+ const before = doc([track({ id: "a", assetId: "one", startMs: 0, endMs: 4000 })]);
+ const next = placeAudioTrackInDocument(
+ before,
+ track({ id: "b", assetId: "two", startMs: 4000, endMs: 8000 }),
+ ids,
+ "create",
+ );
+ expect(collapseTracksToPills(next.audioTracks)).toHaveLength(2);
+ });
+});
+
+describe("firstFreeHeadMs", () => {
+ it("takes the head as given when nothing is in the way", () => {
+ expect(firstFreeHeadMs([{ startMs: 10_000, endMs: 12_000 }], 2000, 4000)).toBe(2000);
+ });
+
+ it("slides past every pill that would overlap, in order", () => {
+ const busy = [
+ { startMs: 0, endMs: 3000 },
+ { startMs: 3000, endMs: 5000 },
+ ];
+ expect(firstFreeHeadMs(busy, 1000, 2000)).toBe(5000);
+ });
+
+ it("fits a pill into a gap big enough for it", () => {
+ const busy = [
+ { startMs: 0, endMs: 2000 },
+ { startMs: 9000, endMs: 12_000 },
+ ];
+ expect(firstFreeHeadMs(busy, 2000, 3000)).toBe(2000);
+ });
+});
+
+describe("separateAudioLanes", () => {
+ it("pushes a pill whose head fell inside its predecessor forward", () => {
+ // What a clip reorder can do with no audio code running.
+ const overlapped = [
+ track({ id: "a", startMs: 0, endMs: 5000 }),
+ track({ id: "b", startMs: 3000, endMs: 6000 }),
+ ];
+ expect(separateAudioLanes(overlapped).map((t) => [t.startMs, t.endMs])).toEqual([
+ [0, 5000],
+ [5000, 8000],
+ ]);
+ });
+
+ it("is idempotent, and leaves a document that is already separated alone", () => {
+ const fine = [
+ track({ id: "a", startMs: 0, endMs: 4000 }),
+ track({ id: "b", startMs: 4000, endMs: 8000 }),
+ ];
+ expect(separateAudioLanes(fine)).toBe(fine); // same reference: nothing to do
+ const once = separateAudioLanes([
+ track({ id: "a", startMs: 0, endMs: 5000 }),
+ track({ id: "b", startMs: 1000, endMs: 4000 }),
+ ]);
+ expect(separateAudioLanes(once)).toBe(once);
+ });
+
+ it("separates each kind on its own, never against the other", () => {
+ const mixed = [
+ track({ id: "vo", startMs: 0, endMs: 5000 }),
+ track({ id: "bed", kind: "music", startMs: 1000, endMs: 9000 }),
+ ];
+ // They overlap, and they should: they are different rows.
+ expect(separateAudioLanes(mixed)).toBe(mixed);
+ });
+
+ it("keeps every fragment of a split take moving together", () => {
+ const split = [
+ track({ id: "a", startMs: 0, endMs: 6000 }),
+ track({ id: "b1", trackId: "b", startMs: 2000, endMs: 4000 }),
+ track({ id: "b2", trackId: "b", startMs: 4000, endMs: 7000 }),
+ ];
+ const out = separateAudioLanes(split);
+ // The pill moves as one thing; its halves do not drift apart.
+ expect(out.filter((t) => t.trackId === "b").map((t) => [t.startMs, t.endMs])).toEqual([
+ [6000, 8000],
+ [8000, 11_000],
+ ]);
+ });
+});
diff --git a/src/lib/ai-edition/document/audioTracks.test.ts b/src/lib/ai-edition/document/audioTracks.test.ts
new file mode 100644
index 000000000..064771150
--- /dev/null
+++ b/src/lib/ai-edition/document/audioTracks.test.ts
@@ -0,0 +1,358 @@
+import { describe, expect, it } from "vitest";
+import { type AxcutAsset, type AxcutClip, createAudioTrack, createEmptyDocument } from "../schema";
+import {
+ anchorAudioTrackFragments,
+ audioGhostExtent,
+ collapseTracksToPills,
+ packAudioTrackRows,
+ patchAudioTrack,
+ removeAudioTrack,
+ resolveFadeSecs,
+ slipAudioOffsetMs,
+ trackGroupId,
+} from "./audioTracks";
+
+const emptyDoc = () => createEmptyDocument({ projectId: "p", title: "t" });
+
+function clip(id: string, timelineStartSec: number, lengthSec: number): AxcutClip {
+ return {
+ id,
+ assetId: "video_1",
+ sourceStartSec: 0,
+ sourceEndSec: lengthSec,
+ timelineStartSec,
+ timelineEndSec: timelineStartSec + lengthSec,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ };
+}
+
+// Two 10s clips back to back: a track crossing second 10 is ventilated in two.
+const twoClips = [clip("c1", 0, 10), clip("c2", 10, 10)];
+
+let seq = 0;
+const makeId = () => `frag_${++seq}`;
+
+const track = (over: Partial> = {}) => ({
+ ...createAudioTrack({ assetId: "asset_1", durationSec: 30, timelineStartSec: 5, spanSec: 10 }),
+ ...over,
+});
+
+const audioAsset: AxcutAsset = {
+ id: "asset_1",
+ kind: "audio",
+ label: "BGM",
+ originalPath: "/bgm.mp3",
+ cameraTrack: null,
+};
+
+describe("anchorAudioTrackFragments", () => {
+ it("leaves a track that fits inside one clip as a single fragment", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 1000, endMs: 6000 }),
+ twoClips,
+ makeId,
+ );
+ expect(frags).toHaveLength(1);
+ expect(frags[0].clipId).toBe("c1");
+ expect(frags[0].offsetMs).toBe(0);
+ });
+
+ it("advances each fragment's source offset by the time its predecessors played", () => {
+ // 5s..15s spans the c1/c2 boundary at 10s: 5s of source, then the next 5s.
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000, offsetMs: 2000 }),
+ twoClips,
+ makeId,
+ );
+ expect(frags).toHaveLength(2);
+ expect(frags.map((f) => f.clipId)).toEqual(["c1", "c2"]);
+ // Fragment 1 starts the file at the track's own offset...
+ expect(frags[0].offsetMs).toBe(2000);
+ // ...and fragment 2 picks up where it left off, rather than restarting
+ // there — that restart is what made a bed audibly repeat at every cut.
+ expect(frags[1].offsetMs).toBe(7000);
+ });
+
+ it("keeps the fades on the outer edges only", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000, fadeInMs: 500, fadeOutMs: 800 }),
+ twoClips,
+ makeId,
+ );
+ expect(frags.map((f) => [f.fadeInMs, f.fadeOutMs])).toEqual([
+ [500, 0],
+ [0, 800],
+ ]);
+ });
+
+ it("does not advance the offset of a looping track", () => {
+ // Looping folds within `duration - offset`, which every fragment shares;
+ // an advanced offset would shorten the window and drift out of phase.
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000, offsetMs: 1000, loop: true }),
+ twoClips,
+ makeId,
+ );
+ expect(frags.map((f) => f.offsetMs)).toEqual([1000, 1000]);
+ });
+
+ it("ties every fragment to one group id", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000 }),
+ twoClips,
+ makeId,
+ );
+ const groups = new Set(frags.map(trackGroupId));
+ expect(groups.size).toBe(1);
+ });
+
+ it("returns the track unanchored when no clip is under it", () => {
+ const frags = anchorAudioTrackFragments(track({ startMs: 5000, endMs: 15_000 }), [], makeId);
+ expect(frags).toHaveLength(1);
+ expect(frags[0].clipId).toBeUndefined();
+ });
+});
+
+describe("collapseTracksToPills", () => {
+ it("folds a ventilated track back into one span with its real offset", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000, offsetMs: 2000, fadeInMs: 400, fadeOutMs: 600 }),
+ twoClips,
+ makeId,
+ );
+ const [pill] = collapseTracksToPills(frags);
+ expect(pill.startMs).toBe(5000);
+ expect(pill.endMs).toBe(15_000);
+ // The FIRST fragment's offset is the track's own; the later ones hold
+ // advanced copies that must not leak back into the pill.
+ expect(pill.offsetMs).toBe(2000);
+ expect([pill.fadeInMs, pill.fadeOutMs]).toEqual([400, 600]);
+ expect(pill.clipId).toBeUndefined();
+ });
+
+ it("round-trips through anchoring unchanged", () => {
+ const original = track({ startMs: 5000, endMs: 15_000, offsetMs: 2000 });
+ const once = anchorAudioTrackFragments(original, twoClips, makeId);
+ const twice = anchorAudioTrackFragments(collapseTracksToPills(once)[0], twoClips, makeId);
+ expect(twice.map((f) => [f.startMs, f.endMs, f.offsetMs])).toEqual(
+ once.map((f) => [f.startMs, f.endMs, f.offsetMs]),
+ );
+ });
+});
+
+describe("removeAudioTrack", () => {
+ it("drops every fragment of the track and is a no-op for unknown ids", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000 }),
+ twoClips,
+ makeId,
+ );
+ const doc = { ...emptyDoc(), audioTracks: frags };
+ expect(removeAudioTrack(doc, trackGroupId(frags[0])).audioTracks).toEqual([]);
+ expect(removeAudioTrack(doc, "nope").audioTracks).toEqual(frags);
+ });
+
+ it("also drops the track's asset when nothing else references it", () => {
+ const t = track();
+ const doc = { ...emptyDoc(), audioTracks: [t], assets: [audioAsset] };
+ const next = removeAudioTrack(doc, t.id);
+ expect(next.audioTracks).toEqual([]);
+ expect(next.assets).toEqual([]);
+ });
+
+ it("keeps the asset when another track still references it", () => {
+ const t1 = track();
+ const t2 = track({ id: "audio_other" });
+ const doc = { ...emptyDoc(), audioTracks: [t1, t2], assets: [audioAsset] };
+ expect(removeAudioTrack(doc, t1.id).assets).toEqual([audioAsset]);
+ });
+});
+
+describe("patchAudioTrack", () => {
+ it("applies a payload edit to every fragment", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000 }),
+ twoClips,
+ makeId,
+ );
+ const doc = { ...emptyDoc(), audioTracks: frags };
+ 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", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000 }),
+ twoClips,
+ makeId,
+ );
+ const doc = { ...emptyDoc(), audioTracks: frags };
+ const next = patchAudioTrack(doc, trackGroupId(frags[0]), { fadeInMs: 300, fadeOutMs: 400 });
+ expect(next.audioTracks.map((t) => [t.fadeInMs, t.fadeOutMs])).toEqual([
+ [300, 0],
+ [0, 400],
+ ]);
+ });
+
+ it("shifts the whole track by the offset delta, preserving each advance", () => {
+ const frags = anchorAudioTrackFragments(
+ track({ startMs: 5000, endMs: 15_000, offsetMs: 2000 }),
+ twoClips,
+ makeId,
+ );
+ const doc = { ...emptyDoc(), audioTracks: frags };
+ // 2000 → 3000 is +1000 everywhere; fragment 2 keeps its 5000ms advance.
+ const next = patchAudioTrack(doc, trackGroupId(frags[0]), { offsetMs: 3000 });
+ expect(next.audioTracks.map((t) => t.offsetMs)).toEqual([3000, 8000]);
+ });
+
+ it("leaves other tracks untouched", () => {
+ const a = track({ id: "audio_a" });
+ const b = track({ id: "audio_b" });
+ const doc = { ...emptyDoc(), audioTracks: [a, b] };
+ const next = patchAudioTrack(doc, "audio_b", { gainDb: -6 });
+ expect(next.audioTracks[0]).toEqual(a);
+ expect(next.audioTracks[1]?.gainDb).toBe(-6);
+ });
+});
+
+describe("resolveFadeSecs", () => {
+ it("passes fades that fit through untouched", () => {
+ expect(resolveFadeSecs(1000, 2000, 10)).toEqual({ fadeInSec: 1, fadeOutSec: 2 });
+ });
+
+ it("shrinks a fade-in longer than the span to the span", () => {
+ // Unreduced this holds the gain at zero for the whole track.
+ expect(resolveFadeSecs(5000, 0, 2)).toEqual({ fadeInSec: 2, fadeOutSec: 0 });
+ });
+
+ it("shares the span in proportion when both fades overflow", () => {
+ const { fadeInSec, fadeOutSec } = resolveFadeSecs(6000, 4000, 2);
+ expect(fadeInSec).toBeCloseTo(1.2);
+ expect(fadeOutSec).toBeCloseTo(0.8);
+ });
+});
+
+describe("packAudioTrackRows", () => {
+ const t = (id: string, startMs: number, endMs: number) => ({ id, startMs, endMs });
+
+ it("keeps tracks that never overlap on one row", () => {
+ // The common case stays a single-line lane.
+ const { rowOf, rowCount } = packAudioTrackRows([
+ t("a", 0, 1000),
+ t("b", 1000, 2000),
+ t("c", 5000, 6000),
+ ]);
+ expect(rowCount).toBe(1);
+ expect([rowOf.get("a"), rowOf.get("b"), rowOf.get("c")]).toEqual([0, 0, 0]);
+ });
+
+ it("stacks tracks that overlap, so neither hides the other", () => {
+ const { rowOf, rowCount } = packAudioTrackRows([t("a", 0, 5000), t("b", 1000, 2000)]);
+ expect(rowCount).toBe(2);
+ expect(rowOf.get("a")).toBe(0);
+ expect(rowOf.get("b")).toBe(1);
+ });
+
+ it("reuses a row as soon as it frees up", () => {
+ // b overlaps a and goes to row 1; c starts after a ends, so it drops back
+ // to row 0 rather than opening a third row.
+ const { rowOf, rowCount } = packAudioTrackRows([
+ t("a", 0, 3000),
+ t("b", 1000, 9000),
+ t("c", 4000, 5000),
+ ]);
+ expect(rowCount).toBe(2);
+ expect(rowOf.get("c")).toBe(0);
+ });
+
+ it("treats touching tracks as non-overlapping", () => {
+ // One ending exactly where the next begins is a sequence, not a pile.
+ const { rowCount } = packAudioTrackRows([t("a", 0, 1000), t("b", 1000, 2000)]);
+ expect(rowCount).toBe(1);
+ });
+
+ it("always reports at least one row, even with nothing to place", () => {
+ expect(packAudioTrackRows([]).rowCount).toBe(1);
+ });
+});
+
+describe("audioGhostExtent", () => {
+ // A 4s pill starting at ruler 10, showing the file from 2s, on a 60s file.
+ const base = () => audioGhostExtent(2, 4, 60, 10, 14, 100);
+
+ it("reaches back by the in-point and forward by what is left of the file", () => {
+ const g = base();
+ expect(g).not.toBeNull();
+ // 2s of head before the pill, 54s of tail after it.
+ expect(g?.startT).toBeCloseTo(8, 6);
+ expect(g?.endT).toBeCloseTo(68, 6);
+ // And the window it draws is the file's own, not the pill's.
+ expect(g?.sourceStartSec).toBeCloseTo(0, 6);
+ expect(g?.sourceEndSec).toBeCloseTo(60, 6);
+ });
+
+ it("stays inside the programme however long the file is", () => {
+ // A four-minute bed under a short programme would otherwise ask for an element
+ // tens of screens wide. Clamped at both ends.
+ const g = audioGhostExtent(2, 4, 600, 10, 14, 20);
+ expect(g?.startT).toBeCloseTo(8, 6);
+ expect(g?.endT).toBe(20);
+ });
+
+ it("refuses when there is nothing around the pill to show", () => {
+ // The pill already shows the whole file.
+ expect(audioGhostExtent(0, 60, 60, 0, 60, 100)).toBeNull();
+ });
+
+ it("refuses an unknown duration rather than drawing a bound it cannot measure", () => {
+ // Same rule as the edge stops: a failed probe must never invent a limit.
+ expect(audioGhostExtent(0, 4, null, 0, 4, 100)).toBeNull();
+ expect(audioGhostExtent(0, 4, undefined, 0, 4, 100)).toBeNull();
+ expect(audioGhostExtent(0, 4, 0, 0, 4, 100)).toBeNull();
+ });
+});
+
+describe("slipAudioOffsetMs", () => {
+ it("slides the in-point by the delta it is given", () => {
+ expect(slipAudioOffsetMs(10_000, 4_000, 60, 5_000)).toBe(15_000);
+ expect(slipAudioOffsetMs(10_000, 4_000, 60, -5_000)).toBe(5_000);
+ });
+
+ it("never windows past either end of the file", () => {
+ // Past the head is negative source time; past the tail is silence nobody asked
+ // for. The last legal in-point is duration - span.
+ expect(slipAudioOffsetMs(2_000, 4_000, 60, -10_000)).toBe(0);
+ expect(slipAudioOffsetMs(50_000, 4_000, 60, 999_000)).toBe(56_000);
+ });
+
+ it("refuses a file no longer than the window onto it", () => {
+ expect(slipAudioOffsetMs(0, 60_000, 60, 5_000)).toBeNull();
+ expect(slipAudioOffsetMs(0, 90_000, 60, 5_000)).toBeNull();
+ });
+
+ 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", () => {
+ // `offsetMs` is `z.number().int()`; a fractional slip would fail the parse on
+ // the next save rather than at the gesture.
+ expect(Number.isInteger(slipAudioOffsetMs(0, 4_000, 60, 1234.567) ?? 0)).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/document/audioTracks.ts b/src/lib/ai-edition/document/audioTracks.ts
new file mode 100644
index 000000000..19ca98964
--- /dev/null
+++ b/src/lib/ai-edition/document/audioTracks.ts
@@ -0,0 +1,468 @@
+// Placement and payload edits for timeline audio tracks (issue #350).
+//
+// Audio tracks are CLIP-ANCHORED, so unlike the array-only ops this module used
+// to hold, placement goes through the shared pill helpers in
+// `timeline/timelineMap.ts` — the same machinery zoom, annotation, speed and
+// camera-fullscreen regions already use. One user-visible track is one PILL;
+// underneath it is one anchored fragment per clip it covers.
+//
+// The one thing audio needs that no other region kind does is `offsetMs`
+// advancement. `anchorRawRegionsToClips` copies a region's payload verbatim
+// into every fragment, which is right for value-per-span effects (both halves
+// of a split zoom are still "depth 3") and wrong for continuous media: two
+// fragments each carrying `offsetMs: 2000` would both restart the file two
+// seconds in, so a bed spanning a cut audibly restarts at the boundary, and
+// each fragment would re-run the layer's fades. `anchorAudioTrackFragments`
+// fixes the payload up afterwards: every fragment's `offsetMs` is advanced by
+// the source time its predecessors consumed, and the fades are kept on the
+// outer edges only.
+
+import type { AxcutAudioTrack, AxcutClip, AxcutDocument } from "../schema";
+import { anchorRegionsWithDerivedMs, clampSpanAgainstNeighbours } from "../timeline/timelineMap";
+
+/** Every fragment of one user-visible track shares this key. */
+export function trackGroupId(track: AxcutAudioTrack): string {
+ return track.trackId ?? track.id;
+}
+
+/**
+ * Anchor a track's raw span to the clips it covers, then repair the payload so
+ * the fragments play as ONE continuous take:
+ *
+ * - `offsetMs` advances by the elapsed source time, so fragment 2 picks up the
+ * file where fragment 1 left off instead of restarting at the track offset.
+ * - `fadeInMs` stays on the first fragment and `fadeOutMs` on the last, so a
+ * split track fades once at each real edge rather than at every cut.
+ * - `trackId` ties the fragments together for the lane, the inspector and
+ * delete.
+ *
+ * A track that overlaps no clip is returned unanchored (one fragment, the input
+ * span), matching how `anchorRegionsWithDerivedMs` treats every other kind.
+ */
+export function anchorAudioTrackFragments(
+ track: AxcutAudioTrack,
+ clips: AxcutClip[],
+ makeId: () => string,
+): AxcutAudioTrack[] {
+ const groupId = trackGroupId(track);
+ const anchored = anchorRegionsWithDerivedMs([track], clips, makeId) as AxcutAudioTrack[];
+ if (anchored.length === 0) return [];
+ // Fragments come back in clip order, which is the order they play.
+ let elapsedMs = 0;
+ const last = anchored.length - 1;
+ return anchored.map((fragment, index) => {
+ const spanMs = Math.max(0, fragment.endMs - fragment.startMs);
+ const next: AxcutAudioTrack = {
+ ...fragment,
+ trackId: groupId,
+ // Looping restarts the window on its own, so an advanced offset would
+ // double-count the fold; the mixer and the preview both wrap within
+ // `durationSec - offsetMs`, which every fragment shares.
+ offsetMs: track.loop ? track.offsetMs : track.offsetMs + elapsedMs,
+ fadeInMs: index === 0 ? track.fadeInMs : 0,
+ fadeOutMs: index === last ? track.fadeOutMs : 0,
+ };
+ elapsedMs += spanMs;
+ return next;
+ });
+}
+
+/** Re-anchor every track in the document — used after a structural clip edit
+ * reshuffles what each fragment sits over. */
+export function reanchorAudioTracks(
+ tracks: AxcutAudioTrack[],
+ clips: AxcutClip[],
+ makeId: () => string,
+): AxcutAudioTrack[] {
+ // Coalesce back to one raw span per track FIRST: re-anchoring the stored
+ // fragments individually would re-ventilate each one and multiply them.
+ //
+ // 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
+ * offset — later fragments hold advanced copies) and the outer fades.
+ */
+export function collapseTracksToPills(tracks: AxcutAudioTrack[]): AxcutAudioTrack[] {
+ const groups = new Map();
+ for (const track of tracks) {
+ const key = trackGroupId(track);
+ const bucket = groups.get(key);
+ if (bucket) bucket.push(track);
+ else groups.set(key, [track]);
+ }
+ return [...groups.values()].map((fragments) => {
+ const ordered = [...fragments].sort((a, b) => a.startMs - b.startMs);
+ const head = ordered[0];
+ const tail = ordered[ordered.length - 1];
+ return {
+ ...head,
+ id: trackGroupId(head),
+ trackId: undefined,
+ clipId: undefined,
+ sourceStartSec: undefined,
+ sourceEndSec: undefined,
+ startMs: head.startMs,
+ endMs: tail.endMs,
+ fadeInMs: head.fadeInMs,
+ fadeOutMs: tail.fadeOutMs,
+ };
+ });
+}
+
+/** Drop every fragment of a track, and its asset when nothing else needs it.
+ *
+ * An imported audio asset is only ever reachable through its track — audio is
+ * filtered out of the clip lists, so it never becomes a clip — so a deleted
+ * track orphans it, and it would otherwise linger in the document forever,
+ * invisible in every asset list (issue #350). */
+export function removeAudioTrack(doc: AxcutDocument, trackId: string): AxcutDocument {
+ const doomed = doc.audioTracks.filter((t) => trackGroupId(t) === trackId);
+ if (doomed.length === 0) return doc;
+ const audioTracks = doc.audioTracks.filter((t) => trackGroupId(t) !== trackId);
+ const assetId = doomed[0].assetId;
+ const stillReferenced =
+ audioTracks.some((t) => t.assetId === assetId) ||
+ doc.timeline.clips.some((c) => c.assetId === assetId);
+ const assets = stillReferenced ? doc.assets : doc.assets.filter((a) => a.id !== assetId);
+ return { ...doc, audioTracks, assets };
+}
+
+/** Patch the shared payload of every fragment of one track. Payload edits (gain,
+ * mute, loop, offset) must hit ALL fragments or the halves of a split track
+ * disagree; `offsetMs` keeps its per-fragment advance. */
+export function patchAudioTrack(
+ doc: AxcutDocument,
+ trackId: string,
+ patch: Partial> & {
+ offsetMs?: number;
+ },
+): AxcutDocument {
+ const fragments = doc.audioTracks.filter((t) => trackGroupId(t) === trackId);
+ if (fragments.length === 0) return doc;
+ const ordered = [...fragments].sort((a, b) => a.startMs - b.startMs);
+ const baseOffset = ordered[0].offsetMs;
+ const last = ordered[ordered.length - 1].id;
+ return {
+ ...doc,
+ audioTracks: doc.audioTracks.map((t) => {
+ if (trackGroupId(t) !== trackId) return t;
+ const isFirst = t.id === ordered[0].id;
+ const isLast = t.id === last;
+ return {
+ ...t,
+ ...(patch.gainDb === undefined ? {} : { gainDb: patch.gainDb }),
+ ...(patch.muted === undefined ? {} : { muted: patch.muted }),
+ ...(patch.loop === undefined ? {} : { loop: patch.loop }),
+ // Fades live on the outer edges; an interior fragment keeps none.
+ ...(patch.fadeInMs === undefined ? {} : { fadeInMs: isFirst ? patch.fadeInMs : 0 }),
+ ...(patch.fadeOutMs === undefined ? {} : { fadeOutMs: isLast ? patch.fadeOutMs : 0 }),
+ // Shift the whole track by the delta so each fragment keeps the
+ // advance that makes it continuous with its predecessor.
+ ...(patch.offsetMs === undefined
+ ? {}
+ : { offsetMs: Math.max(0, t.offsetMs + (patch.offsetMs - baseOffset)) }),
+ };
+ }),
+ };
+}
+
+/**
+ * Fade lengths in seconds, reduced to fit inside `spanSec`.
+ *
+ * Fades that do not fit share the span in proportion rather than being clamped
+ * independently: clamping each to the span first would turn an asymmetric pair
+ * into a symmetric one, losing the shape the user asked for. An unreduced
+ * fade-in longer than the span is worse than cosmetic — it holds the gain at
+ * zero for the whole track.
+ *
+ * Mirrored by `resolve_fade_samples` in `crates/compositor/src/audio.rs`; the
+ * preview reads this one, the render reads that one, and they must agree.
+ */
+export function resolveFadeSecs(
+ fadeInMs: number,
+ fadeOutMs: number,
+ spanSec: number,
+): { fadeInSec: number; fadeOutSec: number } {
+ const fadeInSec = Math.max(0, fadeInMs / 1000);
+ const fadeOutSec = Math.max(0, fadeOutMs / 1000);
+ const total = fadeInSec + fadeOutSec;
+ if (total <= spanSec) return { fadeInSec, fadeOutSec };
+ const scale = Math.max(0, spanSec) / total;
+ return { fadeInSec: fadeInSec * scale, fadeOutSec: fadeOutSec * scale };
+}
+
+/**
+ * Assign each track a ROW in the audio lane, so two tracks that overlap in time
+ * never sit on top of each other.
+ *
+ * Greedy first-fit over tracks in start order: a track takes the topmost row
+ * whose last occupant has already finished, and opens a new row only when every
+ * existing one is still busy. Tracks that do not overlap therefore keep sharing
+ * one row — the lane stays a single line for the common case, and grows only as
+ * far as the actual overlap demands.
+ *
+ * Stacking is the whole point: a lane that draws every track at the same height
+ * turns three voiceovers into one illegible pile where the user cannot tell
+ * which pill they are about to drag.
+ *
+ * Returns the row index per track id, plus how many rows the lane needs.
+ */
+/**
+ * The rest of the tape a pill is a window onto: where the file's own content still
+ * sits to the left and right of it, in timeline seconds.
+ *
+ * An audio pill is the only timeline object that edits media you cannot see. Every
+ * other pill holds a value over a span, and a clip's crop produces a clip that is
+ * right there on screen; resizing an audio pill crops an invisible file, and nothing
+ * said where in that file the edges had landed. The edges already stop at the
+ * content (see the `lowerLeft` / `maxEnd` clamps in the lane drag) — this is what
+ * makes the stop legible before you hit it.
+ *
+ * Clamped to the programme, so the element stays bounded however long the file is:
+ * a four-minute bed under a five-second view would otherwise ask for a box tens of
+ * screens wide. Returns null when there is nothing to show — no known duration (a
+ * failed probe must never draw a bound it cannot measure), or a file no longer than
+ * the window onto it.
+ */
+export function audioGhostExtent(
+ offsetSec: number,
+ spanSec: number,
+ durationSec: number | null | undefined,
+ pillStartT: number,
+ pillEndT: number,
+ totalT: number,
+): { startT: number; endT: number; sourceStartSec: number; sourceEndSec: number } | null {
+ if (durationSec == null || !(durationSec > 0)) return null;
+ const startT = Math.max(0, pillStartT - Math.max(0, offsetSec));
+ const endT = Math.min(totalT, pillEndT + Math.max(0, durationSec - (offsetSec + spanSec)));
+ if (endT - startT <= pillEndT - pillStartT + 1e-6) return null;
+ return {
+ startT,
+ endT,
+ sourceStartSec: offsetSec - (pillStartT - startT),
+ sourceEndSec: offsetSec + (endT - pillStartT),
+ };
+}
+
+/**
+ * Slip: slide the media under a pill whose span does not move.
+ *
+ * The gesture the ghost makes necessary rather than optional. An edge drag sets the
+ * in-point at TIMELINE scale, which is unusable the moment the file is much longer
+ * than the region it fills — reaching 3:00 inside a four-minute bed on a five-second
+ * view means dragging three minutes of ruler. Slip separates the two questions a
+ * pill conflates: *where it plays* (the span) and *what plays* (`offsetMs`).
+ *
+ * The RATE is the caller's business, not this function's — it takes a delta already
+ * in source ms, because the timeline's own scale is the wrong one here and that is
+ * the whole point. What belongs here is the clamp: an offset outside
+ * `[0, duration - span]` windows past one end of the file, which is silence nobody
+ * asked for.
+ *
+ * Returns null when there is nothing to slip, on the same two conditions the ghost
+ * refuses on.
+ */
+export function slipAudioOffsetMs(
+ offsetMs: number,
+ spanMs: number,
+ durationSec: number | null | undefined,
+ deltaMs: number,
+): number | null {
+ if (durationSec == null || !(durationSec > 0)) return null;
+ const slackMs = durationSec * 1000 - spanMs;
+ if (!(slackMs > 0)) return null;
+ return Math.round(Math.min(slackMs, Math.max(0, offsetMs + deltaMs)));
+}
+
+/** The document's user-visible pills of one kind, in ruler order. */
+export function audioLanePills(
+ tracks: AxcutAudioTrack[],
+ kind: AxcutAudioTrack["kind"],
+): AxcutAudioTrack[] {
+ return collapseTracksToPills(tracks)
+ .filter((pill) => pill.kind === kind)
+ .sort((a, b) => a.startMs - b.startMs);
+}
+
+/**
+ * The first head at or after `headMs` where a `spanMs` pill fits between its neighbours.
+ *
+ * For CREATING one. Two takes recorded from the same playhead used to land on top of each
+ * other, which is what forced a second voiceover row into existence; the later one now
+ * queues behind the first instead.
+ */
+export function firstFreeHeadMs(
+ pills: Array<{ startMs: number; endMs: number }>,
+ headMs: number,
+ spanMs: number,
+): number {
+ let head = Math.max(0, headMs);
+ for (const pill of [...pills].sort((a, b) => a.startMs - b.startMs)) {
+ if (pill.endMs <= head) continue;
+ if (pill.startMs >= head + spanMs) break; // it fits in front of this one
+ head = pill.endMs;
+ }
+ return head;
+}
+
+/**
+ * Lay a pill down in the document, clamped so its own kind keeps ONE row (issue #560).
+ *
+ * The single door every writer goes through. There were seven hand-rolled
+ * `[...others, ...fragments]` splices before this, and each one was a way to end up with
+ * two voiceover rows — which is what made the transcript tab's lane switch incoherent:
+ * "the voiceover" has to name one thing.
+ *
+ * The mode is the gesture, and each resolves an overlap differently:
+ * - "resize" stops the dragged EDGE at the neighbour, keeping the head still.
+ * - "move" keeps the DURATION and parks the pill against the wall. A take must never be
+ * silently cropped because it was dragged somewhere crowded.
+ * - "create" queues behind whatever is already there.
+ *
+ * Deliberately NOT `regionIdentityKey`: `assetId` is in `NON_IDENTITY_FIELDS`, so two
+ * different voiceover files with matching payload hash the same and would MERGE into one
+ * pill — silently splicing two takes together.
+ *
+ * Only same-kind pills clamp. A voiceover over a music bed is the normal case.
+ */
+export function placeAudioTrackInDocument(
+ doc: AxcutDocument,
+ pill: AxcutAudioTrack,
+ makeId: () => string,
+ mode: "move" | "resize" | "create",
+): AxcutDocument {
+ const groupId = trackGroupId(pill);
+ const others = audioLanePills(doc.audioTracks, pill.kind).filter(
+ (other) => trackGroupId(other) !== groupId,
+ );
+ const spanMs = Math.max(0, pill.endMs - pill.startMs);
+
+ let startMs = pill.startMs;
+ let endMs = pill.endMs;
+ if (mode === "create") {
+ startMs = firstFreeHeadMs(others, pill.startMs, spanMs);
+ endMs = startMs + spanMs;
+ } else if (mode === "move") {
+ startMs = firstFreeHeadMs(others, pill.startMs, spanMs);
+ endMs = startMs + spanMs;
+ } else {
+ const clamped = clampSpanAgainstNeighbours(
+ { start: pill.startMs, end: pill.endMs },
+ `lane:${pill.kind}:${groupId}`,
+ others.map((other) => ({
+ id: other.id,
+ identity: `lane:${other.kind}:${trackGroupId(other)}`,
+ start: other.startMs,
+ end: other.endMs,
+ })),
+ );
+ startMs = clamped.start;
+ endMs = clamped.end;
+ }
+
+ const placed = { ...pill, startMs, endMs };
+ const fragments = anchorAudioTrackFragments(placed, doc.timeline.clips, makeId);
+ if (fragments.length === 0) return doc;
+ const kept = doc.audioTracks.filter((track) => trackGroupId(track) !== groupId);
+ return { ...doc, audioTracks: [...kept, ...fragments] };
+}
+
+/**
+ * Push any same-kind pill whose head fell inside its predecessor forward to that
+ * predecessor's end, so each kind is back to one row.
+ *
+ * REPAIR, not refusal. The generic region pipeline re-derives audio spans with no audio
+ * code running — a clip reorder or a removed clip can slide two disjoint takes into
+ * overlap — and a schema refine there would surface as a thrown save and a "failed to
+ * save" toast on an ordinary clip drag, and would make existing documents unloadable.
+ *
+ * Deterministic, order-preserving and idempotent. It cannot lose audio: a pill pushed
+ * past the end of the programme still plays, because removal is defined by trims and
+ * gaps only and the projection is the identity out there.
+ */
+export function separateAudioLanes(tracks: AxcutAudioTrack[]): AxcutAudioTrack[] {
+ const shift = new Map();
+ for (const kind of ["voiceover", "music"] as const) {
+ let cursor = Number.NEGATIVE_INFINITY;
+ for (const pill of audioLanePills(tracks, kind)) {
+ const spanMs = Math.max(0, pill.endMs - pill.startMs);
+ const startMs = Math.max(pill.startMs, cursor);
+ if (startMs !== pill.startMs) shift.set(trackGroupId(pill), startMs - pill.startMs);
+ cursor = startMs + spanMs;
+ }
+ }
+ if (shift.size === 0) return tracks;
+ return tracks.map((track) => {
+ const by = shift.get(trackGroupId(track));
+ return by === undefined
+ ? track
+ : { ...track, startMs: track.startMs + by, endMs: track.endMs + by };
+ });
+}
+
+export function packAudioTrackRows(tracks: Array<{ id: string; startMs: number; endMs: number }>): {
+ rowOf: Map;
+ rowCount: number;
+} {
+ const ordered = [...tracks].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+ // The end of the last track placed in each row, in the same index order.
+ const rowEnds: number[] = [];
+ const rowOf = new Map();
+ for (const track of ordered) {
+ let row = rowEnds.findIndex((end) => end <= track.startMs);
+ if (row === -1) {
+ row = rowEnds.length;
+ rowEnds.push(track.endMs);
+ } else {
+ rowEnds[row] = track.endMs;
+ }
+ rowOf.set(track.id, row);
+ }
+ return { rowOf, rowCount: Math.max(1, rowEnds.length) };
+}
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..ec0192829
--- /dev/null
+++ b/src/lib/ai-edition/document/insertion.test.ts
@@ -0,0 +1,308 @@
+// 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 { moveClip, removeClip, resolvePlaybackSegments } from "./timeline";
+import { setDocumentWordText } from "./transcript";
+
+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("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 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,
+ {
+ 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"]);
+ expect(after.timeline.clips[0]).toMatchObject({ sourceStartSec: 0, sourceEndSec: 10 });
+ });
+
+ it("leaves them alone when the media does not continue across the join", () => {
+ const base = doc();
+ 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"]);
+ });
+});
+
+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("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(
+ `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");
+ 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..32a726294
--- /dev/null
+++ b/src/lib/ai-edition/document/insertion.ts
@@ -0,0 +1,297 @@
+// 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 {
+ extensionAssetId,
+ extensionClipPath,
+ extensionDurationSec,
+ isGeneratedAssetId,
+} 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";
+
+export { isGeneratedAssetId };
+
+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. */
+export 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. */
+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;
+}
+
+export 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,
+ };
+}
+
+export 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
+ ? // 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,
+ ),
+ ),
+ },
+ };
+ return rederiveRegionMs(next, next.timeline.clips);
+}
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/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts
index 49308129d..984faa795 100644
--- a/src/lib/ai-edition/document/migrate.test.ts
+++ b/src/lib/ai-edition/document/migrate.test.ts
@@ -475,3 +475,117 @@ describe("migrateRawDocumentToCurrent", () => {
expect(() => documentSchema.parse(upgraded)).not.toThrow();
});
});
+
+// ─── The ghost trims of b9e0f1ff ─────────────────────────────────────────────
+// That build let a cut authored from the voiceover lane be anchored on the AUDIO asset
+// the words belonged to. It removed nothing from the film, the preview or the export —
+// it only struck the word through. Now that both lanes read one removed set, leaving
+// those rows behind would keep striking words through for a cut that never happened.
+
+describe("dropping trims anchored to audio", () => {
+ const createdAt = "2024-01-01T00:00:00.000Z";
+
+ function docWith(trimRanges: unknown[], assets?: unknown[]) {
+ return {
+ schemaVersion: 7,
+ project: { id: "p", title: "t", createdAt, updatedAt: createdAt },
+ assets: assets ?? [
+ { id: "vid", kind: "video", label: "v", originalPath: "/v.mp4", cameraTrack: null },
+ { id: "aud", kind: "audio", label: "a", originalPath: "/a.mp3", cameraTrack: null },
+ ],
+ transcript: null,
+ transcripts: [],
+ timeline: {
+ clips: [
+ {
+ id: "c1",
+ assetId: "vid",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ gaps: [],
+ trimRanges,
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks: [],
+ legacyEditor: null,
+ };
+ }
+
+ const trims = (raw: unknown) =>
+ (
+ (raw as Record>).timeline.trimRanges as Array<{
+ id: string;
+ }>
+ ).map((t) => t.id);
+
+ const ghost = {
+ id: "ghost",
+ assetId: "aud",
+ clipId: "vo_frag",
+ startSec: 1,
+ endSec: 2,
+ origin: "user",
+ reason: "",
+ };
+ const legacy = {
+ id: "legacy",
+ assetId: "vid",
+ startSec: 1,
+ endSec: 2,
+ origin: "user",
+ reason: "",
+ };
+ const orphan = {
+ id: "orphan",
+ assetId: "vid",
+ clipId: "deleted",
+ startSec: 3,
+ endSec: 4,
+ origin: "user",
+ reason: "",
+ };
+
+ it("drops a trim anchored to an audio asset", () => {
+ expect(trims(migrateRawDocumentToCurrent(docWith([ghost, legacy])))).toEqual(["legacy"]);
+ });
+
+ it("keeps a pre-v7 trim that names no clip, and one whose clip is gone", () => {
+ // The first is asset-wide back-compat; the second can still come back through undo.
+ expect(trims(migrateRawDocumentToCurrent(docWith([legacy, orphan])))).toEqual([
+ "legacy",
+ "orphan",
+ ]);
+ });
+
+ it("is idempotent, and returns the document untouched when there is nothing to sweep", () => {
+ const clean = docWith([legacy]);
+ expect(migrateRawDocumentToCurrent(clean)).toBe(clean);
+ const swept = migrateRawDocumentToCurrent(docWith([ghost, legacy]));
+ expect(migrateRawDocumentToCurrent(swept)).toBe(swept);
+ });
+
+ it("leaves a project with no audio asset entirely alone", () => {
+ const noAudio = docWith(
+ [legacy],
+ [{ id: "vid", kind: "video", label: "v", originalPath: "/v.mp4", cameraTrack: null }],
+ );
+ expect(migrateRawDocumentToCurrent(noAudio)).toBe(noAudio);
+ });
+
+ it("still parses after the sweep", () => {
+ expect(() =>
+ documentSchema.parse(migrateRawDocumentToCurrent(docWith([ghost, legacy]))),
+ ).not.toThrow();
+ });
+});
diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts
index 2b360806a..9315eb8c7 100644
--- a/src/lib/ai-edition/document/migrate.ts
+++ b/src/lib/ai-edition/document/migrate.ts
@@ -63,7 +63,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;
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..1e5517394 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -13,6 +13,7 @@ import {
normalizeIntervals,
planTimelineReplacement,
primaryAssetDuration,
+ projectRawTimelineSecToPlayback,
rederiveRegionMs,
removeClip,
removeRegion,
@@ -57,6 +58,7 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
...overrides,
};
@@ -385,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: {
@@ -393,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,
}),
@@ -420,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",
@@ -430,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"],
});
@@ -507,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,
});
});
});
@@ -840,6 +846,90 @@ describe("resolvePlaybackSegments", () => {
});
});
+describe("projectRawTimelineSecToPlayback (issue #350 audio-track/trim sync)", () => {
+ // One 10s clip, an interior trim removing raw 2..4 (2s). Output programme is 8s long.
+ const clip = makeClip({
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ });
+ const trim = makeTrim({ startSec: 2, endSec: 4 });
+
+ it("is the identity when there are no trims", () => {
+ 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);
+ });
+
+ it("is unaffected for a position before the cut", () => {
+ 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);
+ });
+
+ it("counts overlapping trims once (union, not sum)", () => {
+ // Trims [2,5] and [3,4] — the second nested in the first — remove 3s total, not 4.
+ // Raw 6 → output 3. The old per-trim accumulation double-counted and returned 2.
+ const trims = [
+ makeTrim({ id: "t1", startSec: 2, endSec: 5 }),
+ makeTrim({ id: "t2", startSec: 3, endSec: 4 }),
+ ];
+ expect(projectRawTimelineSecToPlayback([clip], trims, 6)).toBeCloseTo(3, 6);
+ });
+
+ it("removes a raw gap between clips (concatenated, like the programme)", () => {
+ // Clip A ends at raw 10; clip B starts at raw 15 — a 5s gap with no content. The
+ // programme concatenates B straight after A, so raw 20 (5s into B) → output 15, NOT 20.
+ const clipA = makeClip({
+ id: "clip_a",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ });
+ const clipB = makeClip({
+ id: "clip_b",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 15,
+ timelineEndSec: 25,
+ });
+ expect(projectRawTimelineSecToPlayback([clipA, clipB], [], 20)).toBeCloseTo(15, 6);
+ });
+
+ it("sums cuts across multiple clips", () => {
+ const clipA = makeClip({
+ id: "clip_a",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ });
+ const clipB = makeClip({
+ id: "clip_b",
+ sourceStartSec: 10,
+ sourceEndSec: 20,
+ timelineStartSec: 10,
+ timelineEndSec: 20,
+ });
+ // Remove 1s from clip A (raw 5..6) and 2s from clip B (raw 12..14) → 3s total.
+ const trims = [
+ makeTrim({ id: "t1", startSec: 5, endSec: 6 }),
+ 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);
+ });
+});
+
describe("duplicateClip / moveClip", () => {
it("duplicateClip gives the copy a fresh, collision-free id even when called repeatedly", () => {
// Regression test: this used to id the copy as `clip_${clips.length + 1}_copy`,
@@ -1597,3 +1687,56 @@ describe("a malformed legacyEditor envelope", () => {
expect(next.legacyEditor).toEqual({ speedRegions: null, cameraFullscreenRegions: 42 });
});
});
+
+describe("projectRawTimelineSecToPlayback with speed regions", () => {
+ const clip: AxcutClip = {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ };
+
+ it("is the identity when nothing is sped up", () => {
+ expect(projectRawTimelineSecToPlayback([clip], [], 8, [])).toBeCloseTo(8, 6);
+ });
+
+ 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);
+ // Everything after carries the compression with it.
+ 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);
+ });
+
+ it("composes with trims", () => {
+ // Raw 2..4 cut, then raw 6..10 at 2x. Raw 12 = 2 kept + 2 kept + 2 (4s at 2x)
+ // + 2 = output 8.
+ const trim: AxcutTrimRange = {
+ id: "t1",
+ assetId: "a1",
+ startSec: 2,
+ endSec: 4,
+ origin: "user",
+ reason: "",
+ };
+ const speed = [{ startMs: 6000, endMs: 10_000, speed: 2 }];
+ 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);
+ });
+});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 232bcb6ee..3bfe91fbc 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -4,6 +4,18 @@
// or a new document with updated clips.
import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "../schema";
+
+/**
+ * What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film.
+ *
+ * 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;
+
+import { type Interval, subtractInterval } from "../timeline/intervals";
+import { keptRawSpans } from "../timeline/programme-time";
import {
anchoredToRawSpanSec,
anchorRegionsWithDerivedMs,
@@ -11,13 +23,14 @@ import {
hasCompleteClipAnchor,
} from "../timeline/timelineMap";
import { dropTrimPillsByIds, trimAppliesToClip } from "../timeline/trim-mapping";
+import { reanchorAudioTracks, removeAudioTrack, separateAudioLanes } from "./audioTracks";
import { createId } from "./ids";
/** The region families a delete can target by id. Shared with the store so "which kinds
* exist" has exactly one definition. `trim` is a source-time cut; the rest are pill-merged
* effects (zoom / speed / annotation / camera-fullscreen). Clips are removed via
* {@link removeClip}, not here — deleting a clip reflows the whole timeline. */
-export type RegionKind = "zoom" | "trim" | "annotation" | "speed" | "cameraFullscreen";
+export type RegionKind = "zoom" | "trim" | "annotation" | "speed" | "cameraFullscreen" | "audio";
/** Length a clip is given before its media has been probed. Lives here, in the pure
* document layer, because that layer decides which clips are still waiting for a real
@@ -28,10 +41,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
@@ -115,6 +128,7 @@ 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.
+
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
return clips.map((c) => {
@@ -127,23 +141,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
@@ -163,9 +160,9 @@ export function subtractInterval(intervals: Interval[], cut: Interval): Interval
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
-): AxcutClip[] {
+): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const result: AxcutClip[] = [];
+ const result: PlaybackSegment[] = [];
let timelineCursor = 0;
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
@@ -185,14 +182,14 @@ 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;
+ const pieces = kept.filter((piece) => piece.endSec > piece.startSec);
+ pieces.forEach((piece, i) => {
+ const dur = piece.endSec - piece.startSec;
result.push({
...clip,
- id: kept.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
- sourceStartSec: iv.startSec,
- sourceEndSec: iv.endSec,
+ id: pieces.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
+ sourceStartSec: piece.startSec,
+ sourceEndSec: piece.endSec,
timelineStartSec: timelineCursor,
timelineEndSec: timelineCursor + dur,
});
@@ -202,6 +199,108 @@ export function resolvePlaybackSegments(
return result;
}
+/**
+ * Project a RAW/document-timeline second (the ruler where trims still occupy their space)
+ * onto the trim-COMPRESSED output programme — the concatenation of the kept segments that
+ * {@link resolvePlaybackSegments} produces and that `audio::mix_external_tracks` overlays on.
+ *
+ * Built from the SAME kept intervals as `resolvePlaybackSegments` (trims subtracted per clip
+ * via `subtractInterval`, then concatenated with a shared output cursor), so it agrees with the
+ * assembled programme in the two cases a naïve "raw − Σ trimmed-before" got wrong: OVERLAPPING
+ * trims (set subtraction counts the union once, not each trim) and RAW GAPS between clips (the
+ * cursor only advances on kept content, so a gap is removed just as the programme removes it).
+ *
+ * `output(T)` = how much kept content precedes `T`. A `T` inside a trimmed span (or an inter-clip
+ * gap) collapses to the output edge of the kept content just before it; a `T` past the last kept
+ * frame carries its raw overhang through unchanged, so a project with no clips is the identity and
+ * a track parked past the programme stays past it (the mixer then skips it). EXACT for trims; like
+ * the rest of the audio-track export path it does not model speed regions, which stay an approximation.
+ *
+ * Issue #350: imported audio tracks store their head in RAW seconds (seeded from the playhead),
+ * but the export mixes onto the compressed programme — passing the raw head through verbatim
+ * delayed every track by the total trim duration ahead of it. The preview already lands them
+ * correctly because its playhead jumps across trims; this makes the render agree.
+ */
+export interface PlaybackSpeedRegion {
+ startMs: number;
+ endMs: number;
+ speed: number;
+}
+
+/**
+ * Output seconds a raw interval `[fromSec, toSec)` occupies once the speed
+ * regions covering it are applied: a 2x stretch of raw time takes half as long
+ * to play, so it contributes half its raw length to the programme.
+ *
+ * Subdivides at every speed boundary the interval crosses and integrates
+ * `1 / speed` piecewise. Regions are matched on the raw ruler, the same
+ * coordinate their pills are drawn in.
+ */
+function outputDurationOfRawSpan(
+ fromSec: number,
+ toSec: number,
+ speedRegions: PlaybackSpeedRegion[],
+): number {
+ if (toSec <= fromSec) return 0;
+ if (speedRegions.length === 0) return toSec - fromSec;
+ // Every boundary inside the span, so each piece has one constant speed.
+ const cuts = new Set([fromSec, toSec]);
+ for (const region of speedRegions) {
+ for (const edge of [region.startMs / 1000, region.endMs / 1000]) {
+ if (edge > fromSec && edge < toSec) cuts.add(edge);
+ }
+ }
+ const edges = [...cuts].sort((a, b) => a - b);
+ let out = 0;
+ for (let i = 0; i < edges.length - 1; i++) {
+ const start = edges[i];
+ const end = edges[i + 1];
+ const mid = (start + end) / 2;
+ const region = speedRegions.find(
+ (r) => mid >= r.startMs / 1000 && mid < r.endMs / 1000 && r.speed > 0,
+ );
+ out += (end - start) / (region?.speed ?? 1);
+ }
+ return out;
+}
+
+export function projectRawTimelineSecToPlayback(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[],
+ rawSec: number,
+ /**
+ * 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
+ * clock; omitted by callers that only care about trims. Left out, the
+ * projection behaves exactly as it did before speed was modelled.
+ */
+ speedRegions: PlaybackSpeedRegion[] = [],
+): number {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ let outCursor = 0; // output length of the kept content walked so far
+ 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
+
+ // 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.
+ return landed ?? outCursor + Math.max(0, rawSec - lastRawEnd);
+}
+
export function invertIntervals(intervals: Interval[], durationSec: number): Interval[] {
const cuts: Interval[] = [];
let cursor = 0;
@@ -259,6 +358,28 @@ function mapAllRegionCollections(
document.annotations as unknown as StoredRegion[],
"ann",
) as unknown as AxcutDocument["annotations"],
+ // Repaired here rather than at each of the four call sites, so no structural edit
+ // can skip it (issue #560).
+ //
+ // `reanchorAudioTracks` first: the generic pipeline copies `offsetMs` verbatim into
+ // every fragment, which corrupts a split take's offsets — a live bug, unrelated to
+ // lanes, that this walk was already causing. Then `separateAudioLanes`, because the
+ // same pipeline can slide two disjoint takes into overlap with no audio code
+ // running, and each kind has to keep ONE row.
+ //
+ // Repair, never refusal: a schema refine here would turn an ordinary clip drag into
+ // a thrown save, and would make every existing document with overlapping same-kind
+ // pills unloadable.
+ audioTracks: separateAudioLanes(
+ reanchorAudioTracks(
+ fn(
+ document.audioTracks as unknown as StoredRegion[],
+ "audio",
+ ) as unknown as AxcutDocument["audioTracks"],
+ document.timeline.clips,
+ () => createId("audio"),
+ ),
+ ),
legacyEditor:
legacy && (speedRegions || cameraFullscreenRegions)
? {
@@ -766,15 +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)];
- const newClips = resequenceClips(reordered);
- const next: AxcutDocument = {
- ...document,
- timeline: {
- ...document.timeline,
- clips: newClips,
- },
- };
- return rederiveRegionMs(next, newClips);
+ return withClipsChanged(document, reordered);
}
// ponytail: duplicate a clip (preserves the original). Used for "split this
@@ -808,19 +921,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,
+ );
}
/**
@@ -850,12 +963,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);
}
/**
@@ -879,6 +987,11 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri
};
case "annotation":
return { ...document, annotations: dropPillById(document.annotations, id) };
+ case "audio":
+ // Not `dropPillById`: an audio track's fragments are grouped by
+ // `trackId`, and deleting the pill has to take the asset with it when
+ // nothing else references it.
+ return removeAudioTrack(document, id);
case "trim":
return {
...document,
@@ -915,6 +1028,109 @@ 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 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 { clips: joined, absorbed } = joinContiguous(clips);
+ const laid = resequenceClips(joined);
+ const reanchored = absorbed.size === 0 ? document : reanchorRows(document, absorbed);
+ const next: AxcutDocument = {
+ ...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);
+}
+
+/** 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);
+ }
+ return { clips: out, absorbed };
+}
+
+/** 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 &&
+ 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
@@ -929,12 +1145,10 @@ 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 next: AxcutDocument = {
...document,
timeline: {
...document.timeline,
- clips: newClips,
trimRanges: document.timeline.trimRanges.filter((t) => t.clipId !== clipId),
},
};
@@ -963,12 +1177,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)),
);
}
- return rederiveRegionMs(next, newClips);
+ return withClipsChanged(next, arr);
}
export function restoreFullTimeline(document: AxcutDocument): AxcutDocument {
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 648fed50b..0ca221a8c 100644
--- a/src/lib/ai-edition/document/transcribe.test.ts
+++ b/src/lib/ai-edition/document/transcribe.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { STT_NATIVE_EXTRACTION_UNAVAILABLE } from "../../../../electron/stt/transcriptionContract";
import { type AxcutDocument, axcutSchemaVersion } from "../schema";
import { transcribeAsset } from "./transcribe";
@@ -12,10 +13,17 @@ vi.mock("@/lib/captioning", () => ({
sampleRate: 16_000,
})),
transcribeMono16kToSegments: vi.fn(),
+ transcribeSourceFileToSegments: vi.fn(),
}));
-const { transcribeMono16kToSegments } = await import("@/lib/captioning");
-const transcribeMock = vi.mocked(transcribeMono16kToSegments);
+const { extractMono16kFromVideoUrl, transcribeMono16kToSegments, transcribeSourceFileToSegments } =
+ await import("@/lib/captioning");
+// `transcribeAsset` sends the PATH now and lets the main process decode; the samples
+// entry point is only reached when no ffmpeg can be resolved. The assertions below
+// therefore target the native call, and the fallback has tests of its own at the end.
+const transcribeMock = vi.mocked(transcribeSourceFileToSegments);
+const rendererMock = vi.mocked(transcribeMono16kToSegments);
+const extractMock = vi.mocked(extractMono16kFromVideoUrl);
function makeDoc(): AxcutDocument {
return {
@@ -49,6 +57,7 @@ function makeDoc(): AxcutDocument {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
@@ -166,3 +175,52 @@ describe("transcribeAsset language handling", () => {
expect(t.language).toBe("auto");
});
});
+
+describe("transcribeAsset native extraction", () => {
+ // Call counts are the assertion here, so they start from zero every test —
+ // `mockResolvedValueOnce` queues a result, it does not clear the history.
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("hands the main process a path instead of decoding in the renderer", async () => {
+ // The point of the change: the renderer must not touch the audio at all on the
+ // happy path. `extractMono16kFromVideoUrl` reads the whole file, copies it twice
+ // and resamples on the UI thread — that is the freeze this avoids.
+ transcribeMock.mockResolvedValueOnce({
+ segments: [{ startSec: 0, endSec: 1, text: "hi" }],
+ granularity: "word",
+ detectedLanguage: "en",
+ });
+ await transcribeAsset(makeDoc(), "asset_1");
+ expect(transcribeMock).toHaveBeenCalledWith("/tmp/demo.mp4", expect.anything());
+ expect(extractMock).not.toHaveBeenCalled();
+ expect(rendererMock).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the renderer decode when the install has no ffmpeg", async () => {
+ // A dev checkout that never fetched ffmpeg, or a platform build missing it, must
+ // still transcribe rather than lose the feature.
+ transcribeMock.mockRejectedValueOnce(
+ new Error(`${STT_NATIVE_EXTRACTION_UNAVAILABLE}: no ffmpeg binary`),
+ );
+ rendererMock.mockResolvedValueOnce({
+ segments: [{ startSec: 0, endSec: 1, text: "hi" }],
+ granularity: "word",
+ detectedLanguage: "en",
+ });
+ const transcript = await transcribeAsset(makeDoc(), "asset_1");
+ expect(extractMock).toHaveBeenCalled();
+ expect(rendererMock).toHaveBeenCalled();
+ expect(transcript.segments.length).toBeGreaterThan(0);
+ });
+
+ it("does NOT fall back on any other failure", async () => {
+ // "This file has no audio" is a verdict. Re-deriving it in the renderer would buy
+ // the same answer for the price of the decode this change exists to avoid.
+ transcribeMock.mockRejectedValueOnce(new Error("No decodable audio in /tmp/demo.mp4"));
+ await expect(transcribeAsset(makeDoc(), "asset_1")).rejects.toThrow("No decodable audio");
+ expect(extractMock).not.toHaveBeenCalled();
+ expect(rendererMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/ai-edition/document/transcribe.ts b/src/lib/ai-edition/document/transcribe.ts
index dcdfbc650..3300b0caf 100644
--- a/src/lib/ai-edition/document/transcribe.ts
+++ b/src/lib/ai-edition/document/transcribe.ts
@@ -6,7 +6,13 @@
// verbatim. No Python, no faster-whisper, no network calls. Privacy-safe.
import { toFileUrl } from "@/components/video-editor/projectPersistence";
-import { extractMono16kFromVideoUrl, transcribeMono16kToSegments } from "@/lib/captioning";
+import {
+ extractMono16kFromVideoUrl,
+ transcribeMono16kToSegments,
+ transcribeSourceFileToSegments,
+} from "@/lib/captioning";
+import type { SttRendererStatus } from "@/lib/captioning/transcribe";
+import { STT_NATIVE_EXTRACTION_UNAVAILABLE } from "../../../../electron/stt/transcriptionContract";
import type { AxcutDocument, AxcutTranscript, AxcutTranscriptSegment, AxcutWord } from "../schema";
/**
@@ -47,9 +53,6 @@ export async function transcribeAsset(
const videoUrl = toFileUrl(asset.originalPath);
options.onStatus?.({ phase: "extracting-audio" });
- const audioResult = await extractMono16kFromVideoUrl(videoUrl, {
- signal: options.signal,
- });
// Stay on loading-model until the main process reports inference chunks.
// Emitting "transcribing" here used to label the cold `server.start()` wait
@@ -62,28 +65,54 @@ export async function transcribeAsset(
// so the stored transcript reflects reality, not the input option.
const forcedLanguage =
options.language && options.language !== "auto" ? options.language : undefined;
- const result = await transcribeMono16kToSegments(audioResult.samples, {
+
+ // Forward the main process's per-chunk progress. Without this the status
+ // callback only ever fired the two coarse phases above, so a 30-minute
+ // recording showed one static "transcribing" for ten minutes.
+ const forwardStatus = (status: SttRendererStatus) =>
+ options.onStatus?.({
+ phase: status.phase === "model" ? "loading-model" : "transcribing",
+ completedSec: status.completedSec,
+ totalSec: status.totalSec,
+ // Which device is doing the work, and how fast. The main process is the
+ // only place that knows either, and a silent CPU fallback is exactly the
+ // case a user cannot otherwise diagnose.
+ backend: status.backend,
+ rtf: status.rtf,
+ ...(status.downloadedBytes !== undefined ? { downloadedBytes: status.downloadedBytes } : {}),
+ ...(status.totalBytes !== undefined ? { totalBytes: status.totalBytes } : {}),
+ });
+
+ // Native first. `extractMono16kFromVideoUrl` runs in the RENDERER: it reads the
+ // whole media into memory, copies it twice and resamples on the UI thread, which
+ // is what froze the editor at project open on a long import (measured on a
+ // four-minute bed: ~86 MB of decoded float32 there against 15.7 MB in the main
+ // process). Handing the path over keeps every byte on the other side of the IPC,
+ // and the whisper helper it feeds was already a separate process.
+ //
+ // The fallback is not decoration: an install with no resolvable ffmpeg — a dev
+ // checkout that never fetched it, a platform build missing the binary — must still
+ // transcribe rather than lose the feature. Only THAT case falls back. "This file
+ // has no audio" is a verdict, and re-deriving it in the renderer would buy the same
+ // answer for the price of the decode this exists to avoid.
+ const result = await transcribeSourceFileToSegments(asset.originalPath, {
trimRegions: [],
signal: options.signal,
language: forcedLanguage,
- // Forward the main process's per-chunk progress. Without this the status
- // callback only ever fired the two coarse phases above, so a 30-minute
- // recording showed one static "transcribing" for ten minutes.
- onStatus: (status) =>
- options.onStatus?.({
- phase: status.phase === "model" ? "loading-model" : "transcribing",
- completedSec: status.completedSec,
- totalSec: status.totalSec,
- // Which device is doing the work, and how fast. The main process is the
- // only place that knows either, and a silent CPU fallback is exactly the
- // case a user cannot otherwise diagnose.
- backend: status.backend,
- rtf: status.rtf,
- ...(status.downloadedBytes !== undefined
- ? { downloadedBytes: status.downloadedBytes }
- : {}),
- ...(status.totalBytes !== undefined ? { totalBytes: status.totalBytes } : {}),
- }),
+ onStatus: forwardStatus,
+ }).catch(async (error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error);
+ if (!message.includes(STT_NATIVE_EXTRACTION_UNAVAILABLE)) throw error;
+ const audioResult = await extractMono16kFromVideoUrl(videoUrl, {
+ signal: options.signal,
+ });
+ options.onStatus?.({ phase: "transcribing" });
+ return transcribeMono16kToSegments(audioResult.samples, {
+ trimRegions: [],
+ signal: options.signal,
+ language: forcedLanguage,
+ onStatus: forwardStatus,
+ });
});
const segments: AxcutTranscriptSegment[] = [];
@@ -133,18 +162,6 @@ export async function transcribeAsset(
};
}
-export function withTranscript(
- document: AxcutDocument,
- transcript: AxcutTranscript,
-): AxcutDocument {
- const transcripts = [
- ...document.transcripts.filter((t) => t.assetId !== transcript.assetId),
- transcript,
- ];
- return {
- ...document,
- transcript:
- document.project.primaryAssetId === transcript.assetId ? transcript : document.transcript,
- transcripts,
- };
-}
+// `withTranscript` used to live here. It moved to `document/transcript.ts`, next to
+// the other writers of the same object: it is a pure document operation, and the
+// Whisper adapter is not where a caller should have to look for it.
diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts
new file mode 100644
index 000000000..79e1397aa
--- /dev/null
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -0,0 +1,541 @@
+import { describe, expect, it } from "vitest";
+import { type AxcutDocument, type AxcutTranscript, createEmptyDocument } from "../schema";
+import { carryOverWordEdits, setDocumentWordText, setWordText, withTranscript } from "./transcript";
+
+function fixture(language = "en"): AxcutTranscript {
+ return {
+ assetId: "asset_1",
+ language,
+ sourceDslPath: "transcript.dsl",
+ sourceJsonPath: "transcript.json",
+ segments: [
+ {
+ id: "segment_1",
+ kind: "speech",
+ startSec: 1,
+ endSec: 4,
+ text: "I use OpenScreen",
+ wordIds: ["word_1", "word_2", "word_3"],
+ },
+ {
+ id: "segment_2",
+ kind: "speech",
+ startSec: 5,
+ endSec: 6,
+ text: "Untouched segment",
+ wordIds: ["word_4", "word_5"],
+ },
+ ],
+ // Deliberately shuffled: segment.wordIds, not this array, defines segment order.
+ words: [
+ { id: "word_3", segmentId: "segment_1", startSec: 3, endSec: 4, text: "OpenScreen" },
+ { id: "word_1", segmentId: "segment_1", startSec: 1, endSec: 2, text: "I" },
+ { id: "word_5", segmentId: "segment_2", startSec: 5.5, endSec: 6, text: "segment" },
+ { id: "word_2", segmentId: "segment_1", startSec: 2, endSec: 3, text: "use" },
+ { id: "word_4", segmentId: "segment_2", startSec: 5, endSec: 5.5, text: "Untouched" },
+ ],
+ };
+}
+
+function transcriptForTokens(language: string, tokens: string[]): AxcutTranscript {
+ const wordIds = tokens.map((_, index) => `word_${index + 1}`);
+ return {
+ assetId: "asset_tokens",
+ language,
+ segments: [
+ {
+ id: "segment_tokens",
+ kind: "speech",
+ startSec: 0,
+ endSec: tokens.length,
+ text: tokens.join(" "),
+ wordIds,
+ },
+ {
+ id: "segment_other",
+ kind: "speech",
+ startSec: 20,
+ endSec: 21,
+ text: "other",
+ wordIds: ["word_other"],
+ },
+ ],
+ words: [
+ ...tokens.map((text, index) => ({
+ id: wordIds[index],
+ segmentId: "segment_tokens",
+ startSec: index,
+ endSec: index + 1,
+ text,
+ })),
+ {
+ id: "word_other",
+ segmentId: "segment_other",
+ startSec: 20,
+ endSec: 21,
+ text: "other",
+ },
+ ],
+ };
+}
+
+describe("setWordText", () => {
+ it("immutably updates the exact word and rebuilds only its owning segment", () => {
+ const transcript = fixture();
+ const originalSnapshot = structuredClone(transcript);
+ const originalTarget = transcript.words.find((word) => word.id === "word_2");
+ const originalOtherWord = transcript.words.find((word) => word.id === "word_4");
+ const originalOtherSegment = transcript.segments[1];
+
+ const result = setWordText(transcript, "word_2", "prefer");
+
+ expect(result).not.toBe(transcript);
+ expect(result.words.map((word) => word.id)).toEqual(transcript.words.map((word) => word.id));
+ expect(result.segments.map((segment) => segment.id)).toEqual(
+ transcript.segments.map((segment) => segment.id),
+ );
+ // The provenance pair rides along with the new text — see "setWordText
+ // provenance" below for the rules it follows.
+ expect(result.words.find((word) => word.id === "word_2")).toEqual({
+ ...originalTarget,
+ text: "prefer",
+ originalText: "use",
+ source: "user",
+ });
+ expect(result.segments[0]).toEqual({
+ ...transcript.segments[0],
+ text: "I prefer OpenScreen",
+ });
+ for (const originalWord of transcript.words) {
+ if (originalWord.id !== "word_2") {
+ expect(result.words.find((word) => word.id === originalWord.id)).toBe(originalWord);
+ }
+ }
+ expect(result.words.find((word) => word.id === "word_4")).toBe(originalOtherWord);
+ expect(result.segments[1]).toBe(originalOtherSegment);
+ expect(result.assetId).toBe("asset_1");
+ expect(result.language).toBe("en");
+ expect(result.sourceDslPath).toBe("transcript.dsl");
+ expect(result.sourceJsonPath).toBe("transcript.json");
+ expect(transcript).toEqual(originalSnapshot);
+ });
+
+ it("uses segment.wordIds order even when transcript.words is shuffled", () => {
+ const result = setWordText(fixture(), "word_3", "Studio");
+
+ expect(result.segments[0].text).toBe("I use Studio");
+ expect(result.words.map((word) => word.id)).toEqual([
+ "word_3",
+ "word_1",
+ "word_5",
+ "word_2",
+ "word_4",
+ ]);
+ });
+
+ it("joins English words with one space", () => {
+ const result = setWordText(
+ transcriptForTokens("en", ["I", "use", "OpenScreen"]),
+ "word_2",
+ "prefer",
+ );
+
+ expect(result.segments[0].text).toBe("I prefer OpenScreen");
+ });
+
+ it("preserves the passed word text exactly while trimming its segment contribution", () => {
+ const result = setWordText(fixture(), "word_2", " prefer ");
+
+ expect(result.words.find((word) => word.id === "word_2")?.text).toBe(" prefer ");
+ expect(result.segments[0].text).toBe("I prefer OpenScreen");
+ });
+
+ it.each([
+ "zh",
+ "zh-CN",
+ "zh-TW",
+ "ZH-cn",
+ "auto",
+ "yue",
+ ])("does not add artificial spaces between adjacent Chinese content for %s", (language) => {
+ const result = setWordText(transcriptForTokens(language, ["你", "好", "世界"]), "word_2", "们");
+
+ expect(result.segments[0].text).toBe("你们世界");
+ });
+
+ it("does not add a space after a non-BMP Han word (edge read by code point)", () => {
+ const result = setWordText(transcriptForTokens("zh", ["\u{20000}", "好"]), "word_2", "世界");
+
+ expect(result.segments[0].text).toBe("\u{20000}世界");
+ });
+
+ it("does not add a space before a token starting with a non-BMP Han character", () => {
+ const result = setWordText(
+ transcriptForTokens("zh", ["好", "\u{20000}"]),
+ "word_2",
+ "\u{20000}",
+ );
+
+ expect(result.segments[0].text).toBe("好\u{20000}");
+ });
+
+ it.each([
+ "ja",
+ "ja-JP",
+ "JA-jp",
+ ])("does not add artificial spaces between adjacent Japanese content for %s", (language) => {
+ const result = setWordText(
+ transcriptForTokens(language, ["私", "は", "テスト", "です"]),
+ "word_3",
+ "開発者",
+ );
+
+ expect(result.segments[0].text).toBe("私は開発者です");
+ });
+
+ it("does not add a space after Chinese closing punctuation between CJK tokens", () => {
+ const result = setWordText(transcriptForTokens("zh-CN", ["你好,", "世"]), "word_2", "世界");
+
+ expect(result.segments[0].text).toBe("你好,世界");
+ });
+
+ it("does not add a space after Japanese closing punctuation between CJK tokens", () => {
+ const result = setWordText(
+ transcriptForTokens("ja-JP", ["これは。", "試験"]),
+ "word_2",
+ "テスト",
+ );
+
+ expect(result.segments[0].text).toBe("これは。テスト");
+ });
+
+ it("keeps readable boundaries in mixed CJK and Latin content", () => {
+ const result = setWordText(
+ transcriptForTokens("zh-CN", ["我们用", "GitHub", "Action", "部署"]),
+ "word_3",
+ "Actions",
+ );
+
+ expect(result.segments[0].text).toBe("我们用 GitHub Actions 部署");
+ });
+
+ it("does not put spaces before common closing punctuation", () => {
+ const result = setWordText(
+ transcriptForTokens("en", ["Hello", ",", "world", "?"]),
+ "word_4",
+ "!",
+ );
+
+ expect(result.segments[0].text).toBe("Hello, world!");
+ });
+
+ it("does not put spaces immediately after common opening punctuation", () => {
+ const result = setWordText(transcriptForTokens("en", ["(", "hello", ")"]), "word_2", "world");
+
+ expect(result.segments[0].text).toBe("(world)");
+ });
+
+ it.each([
+ { tokens: ["I", "use", "OpenScreen"], targetId: "word_2", expected: "I OpenScreen" },
+ { tokens: ["I", "use", "OpenScreen"], targetId: "word_1", expected: "use OpenScreen" },
+ { tokens: ["I", "use", "OpenScreen"], targetId: "word_3", expected: "I use" },
+ ])("keeps the emptied word but creates no duplicate or edge whitespace", ({
+ tokens,
+ targetId,
+ expected,
+ }) => {
+ const result = setWordText(transcriptForTokens("en", tokens), targetId, "");
+
+ expect(result.words.find((word) => word.id === targetId)?.text).toBe("");
+ expect(result.segments[0].text).toBe(expected);
+ });
+
+ it.each(["missing_word", "silence_1"])("rejects non-document word ID %s", (wordId) => {
+ expect(() => setWordText(fixture(), wordId, "replacement")).toThrowError(wordId);
+ });
+
+ it("rejects a target whose owning segment is missing", () => {
+ const transcript = fixture();
+ const target = transcript.words.find((word) => word.id === "word_2");
+ if (!target) throw new Error("fixture target missing");
+ target.segmentId = "segment_missing";
+
+ expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError(
+ /word_2.*segment_missing|segment_missing.*word_2/,
+ );
+ });
+
+ it("rejects an owning segment that references a missing word", () => {
+ const transcript = fixture();
+ transcript.segments[0].wordIds.splice(1, 0, "word_missing");
+
+ expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError(
+ /segment_1.*word_missing|word_missing.*segment_1/,
+ );
+ });
+
+ it("rejects an owning segment that references a word owned by another segment", () => {
+ const transcript = fixture();
+ transcript.segments[0].wordIds.push("word_4");
+
+ expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError(
+ /segment_1.*word_4.*segment_2/,
+ );
+ });
+
+ it("rejects an owning segment that omits the target word", () => {
+ const transcript = fixture();
+ transcript.segments[0].wordIds = ["word_1", "word_3"];
+
+ expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError(
+ /segment_1.*word_2|word_2.*segment_1/,
+ );
+ });
+});
+
+// ─── Provenance ──────────────────────────────────────────────────
+// Every field below is what makes a correction survivable: revertible by the
+// user, and carryable across a re-transcription. Without them a corrected word
+// is indistinguishable from a transcribed one the moment it is written.
+
+describe("setWordText provenance", () => {
+ it("records the transcriber's text the first time a word is rewritten", () => {
+ const word = setWordText(fixture(), "word_3", "OpenScreenApp").words.find(
+ (w) => w.id === "word_3",
+ );
+ expect(word).toMatchObject({
+ text: "OpenScreenApp",
+ originalText: "OpenScreen",
+ source: "user",
+ });
+ });
+
+ it("keeps the FIRST original across later edits, so revert reaches the transcriber's text", () => {
+ const once = setWordText(fixture(), "word_3", "OpenScreenApp");
+ const twice = setWordText(once, "word_3", "OpenScreen Studio");
+ expect(twice.words.find((w) => w.id === "word_3")).toMatchObject({
+ text: "OpenScreen Studio",
+ originalText: "OpenScreen",
+ });
+ });
+
+ it("clears the markers when the original is typed back — that round trip IS the revert", () => {
+ const edited = setWordText(fixture(), "word_3", "OpenScreenApp");
+ const reverted = setWordText(edited, "word_3", "OpenScreen");
+ const word = reverted.words.find((w) => w.id === "word_3");
+ expect(word?.text).toBe("OpenScreen");
+ expect(word).not.toHaveProperty("originalText");
+ expect(word).not.toHaveProperty("source");
+ });
+
+ it("leaves a synthesized word synthesized — it has no transcribed text to revert to", () => {
+ const base = fixture();
+ const synth: AxcutTranscript = {
+ ...base,
+ words: base.words.map((w) =>
+ w.id === "word_3" ? { ...w, source: "synth" as const, text: "spoken" } : w,
+ ),
+ };
+ const word = setWordText(synth, "word_3", "rewritten").words.find((w) => w.id === "word_3");
+ expect(word).toMatchObject({ text: "rewritten", source: "synth" });
+ expect(word).not.toHaveProperty("originalText");
+ });
+
+ it("does not mark the untouched words", () => {
+ const result = setWordText(fixture(), "word_3", "OpenScreenApp");
+ for (const word of result.words.filter((w) => w.id !== "word_3")) {
+ expect(word).not.toHaveProperty("source");
+ }
+ });
+});
+
+// ─── Document-level write ────────────────────────────────────────
+// The document carries the transcript twice. A word edit that writes only one
+// copy leaves the legacy mirror serving pre-edit text forever — the failure that
+// closed the standalone Python editor (#469).
+
+function makeDoc(primaryAssetId = "asset_1") {
+ const base = createEmptyDocument({ title: "Test", projectId: "proj_transcript" });
+ return withTranscript({ ...base, project: { ...base.project, primaryAssetId } }, fixture());
+}
+
+describe("setDocumentWordText", () => {
+ it("writes BOTH the per-asset transcript and the legacy mirror", () => {
+ const result = setDocumentWordText(makeDoc(), "asset_1", "word_3", "OpenScreenApp");
+ const stored = result.transcripts.find((t) => t.assetId === "asset_1");
+ expect(stored?.words.find((w) => w.id === "word_3")?.text).toBe("OpenScreenApp");
+ expect(result.transcript?.words.find((w) => w.id === "word_3")?.text).toBe("OpenScreenApp");
+ expect(result.transcript).toBe(stored);
+ });
+
+ it("leaves the mirror alone when the edited asset is not the primary one", () => {
+ const doc = makeDoc("asset_other");
+ const result = setDocumentWordText(doc, "asset_1", "word_3", "OpenScreenApp");
+ expect(result.transcript).toBe(doc.transcript);
+ expect(result.transcripts.find((t) => t.assetId === "asset_1")?.words).not.toBe(
+ doc.transcripts.find((t) => t.assetId === "asset_1")?.words,
+ );
+ });
+
+ it("rejects an asset with no transcript rather than writing a second one", () => {
+ expect(() => setDocumentWordText(makeDoc(), "asset_missing", "word_3", "x")).toThrow(
+ /no transcript/,
+ );
+ });
+
+ it("keeps the input document untouched", () => {
+ const doc = makeDoc();
+ const before = JSON.stringify(doc);
+ setDocumentWordText(doc, "asset_1", "word_3", "OpenScreenApp");
+ expect(JSON.stringify(doc)).toBe(before);
+ });
+});
+
+// ─── Carry-over across a re-transcription ────────────────────────
+
+function retranscribed(words: Array<[string, string, number, number]>): AxcutTranscript {
+ return {
+ assetId: "asset_1",
+ language: "en",
+ segments: [
+ {
+ id: "segment_1",
+ kind: "speech",
+ startSec: words[0][2],
+ endSec: words[words.length - 1][3],
+ text: words.map(([, text]) => text).join(" "),
+ wordIds: words.map(([id]) => id),
+ },
+ ],
+ words: words.map(([id, text, startSec, endSec]) => ({
+ id,
+ segmentId: "segment_1",
+ startSec,
+ endSec,
+ text,
+ })),
+ };
+}
+
+describe("carryOverWordEdits", () => {
+ const corrected = () => setWordText(fixture(), "word_3", "OpenScreenApp");
+
+ it("re-applies a correction when the run repeats the same mistake at the same moment", () => {
+ const next = retranscribed([
+ ["w1", "I", 1, 2],
+ ["w2", "use", 2, 3],
+ ["w3", "OpenScreen", 3.1, 3.9],
+ ]);
+ const result = carryOverWordEdits(corrected(), next);
+ expect(result.carried).toBe(1);
+ expect(result.dropped).toBe(0);
+ expect(result.transcript.words.find((w) => w.id === "w3")).toMatchObject({
+ text: "OpenScreenApp",
+ originalText: "OpenScreen",
+ source: "user",
+ });
+ // The segment text is rebuilt too, so the captions follow.
+ expect(result.transcript.segments[0].text).toBe("I use OpenScreenApp");
+ });
+
+ it("drops the correction when the run heard something else there", () => {
+ const next = retranscribed([["w3", "Open Screen", 3, 4]]);
+ const result = carryOverWordEdits(corrected(), next);
+ expect(result).toMatchObject({ carried: 0, dropped: 1 });
+ expect(result.transcript).toBe(next);
+ });
+
+ it("drops the correction when the same word lands somewhere else entirely", () => {
+ const next = retranscribed([["w3", "OpenScreen", 40, 41]]);
+ expect(carryOverWordEdits(corrected(), next)).toMatchObject({ carried: 0, dropped: 1 });
+ });
+
+ it("never lands two corrections on the same new word", () => {
+ // Both corrections have the SAME original text and both spans overlap the one
+ // word the new run produced. Without the claim, the second would overwrite the
+ // first and the count would claim two were saved.
+ const previous = setWordText(
+ setWordText(
+ retranscribed([
+ ["p1", "the", 1, 2],
+ ["p2", "the", 2, 3],
+ ]),
+ "p1",
+ "a",
+ ),
+ "p2",
+ "an",
+ );
+ const result = carryOverWordEdits(previous, retranscribed([["w1", "the", 1, 3]]));
+ expect(result).toMatchObject({ carried: 1, dropped: 1 });
+ expect(result.transcript.words[0].text).toBe("a");
+ });
+
+ it("returns the new transcript untouched when nothing was ever corrected", () => {
+ const next = retranscribed([["w1", "I", 1, 2]]);
+ const result = carryOverWordEdits(fixture(), next);
+ expect(result.transcript).toBe(next);
+ expect(result).toMatchObject({ carried: 0, dropped: 0 });
+ });
+
+ it("handles a first-ever transcription (no previous transcript)", () => {
+ const next = retranscribed([["w1", "I", 1, 2]]);
+ expect(carryOverWordEdits(null, next).transcript).toBe(next);
+ });
+});
+
+// ─── The clip grows with the word ───────────────────────────────────────────
+// 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("correcting a word leaves the timeline alone", () => {
+ 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("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
new file mode 100644
index 000000000..1bfdb7ccf
--- /dev/null
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -0,0 +1,259 @@
+import type { AxcutDocument, AxcutTranscript, AxcutWord } from "../schema";
+import {
+ type InsertSide,
+ insertGeneratedClip,
+ isGeneratedAssetId,
+ 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;
+const TRAILING_CLOSING_PUNCTUATION = /[,.;:!?%。,、;:!?…))\]}>》」』】〕]+$/u;
+const OPENING_PUNCTUATION = /[([<{《「『【〔(]$/u;
+
+// The CJK-compaction rule is deliberately LANGUAGE-AGNOSTIC: two adjacent Han /
+// Hiragana / Katakana characters never carry a space between them in any script
+// that uses them. Gating it on the `language` tag would corrupt transcripts whose
+// stored tag is "auto" (a real persisted value — see transcribe.ts's language
+// fallback) or "yue": the join would inject ASCII spaces between Chinese runs.
+function joinSegmentText(texts: string[]): string {
+ const tokens = texts.map((text) => text.trim()).filter((text) => text.length > 0);
+ return tokens.reduce((joined, token) => {
+ if (joined.length === 0) return token;
+ if (CLOSING_PUNCTUATION.test(token) || OPENING_PUNCTUATION.test(joined)) {
+ return joined + token;
+ }
+ const leftContentEdge = [...joined.replace(TRAILING_CLOSING_PUNCTUATION, "")].at(-1) ?? "";
+ // Spread reads the edges by CODE POINT: `.at(-1)` / `[0]` would return half a
+ // surrogate pair, so a non-BMP Han edge (e.g. U+20000) would miss CJK_EDGE
+ // and receive an ASCII space.
+ if (CJK_EDGE.test(leftContentEdge) && CJK_EDGE.test([...token][0] ?? "")) {
+ return joined + token;
+ }
+ return `${joined} ${token}`;
+ }, "");
+}
+
+/**
+ * Apply the new text to ONE word, keeping its provenance straight.
+ *
+ * `originalText` is the transcriber's own text, captured the first time the user
+ * rewrites the word and never overwritten afterwards — a second edit still reverts
+ * to what Whisper said, not to the first correction. Typing the original back
+ * clears the pair, so a round trip leaves no word flagged as corrected whose
+ * correction is a no-op.
+ */
+function rewriteWord(word: AxcutWord, text: string): AxcutWord {
+ // A synthesized word has no transcribed text behind it, so there is nothing to
+ // revert to and nothing to record: rewriting one leaves it synthesized.
+ if (word.source === "synth") return { ...word, text };
+ const original = word.originalText ?? word.text;
+ if (text === original) {
+ const { originalText: _reverted, source: _wasUser, ...rest } = word;
+ return { ...rest, text };
+ }
+ return { ...word, text, originalText: original, source: "user" };
+}
+
+export function setWordText(
+ transcript: AxcutTranscript,
+ wordId: string,
+ text: string,
+): AxcutTranscript {
+ const targetWord = transcript.words.find((word) => word.id === wordId);
+ if (!targetWord) {
+ throw new Error(`Cannot set text for missing transcript word "${wordId}"`);
+ }
+
+ const owningSegment = transcript.segments.find((segment) => segment.id === targetWord.segmentId);
+ if (!owningSegment) {
+ throw new Error(
+ `Transcript word "${wordId}" references missing segment "${targetWord.segmentId}"`,
+ );
+ }
+ if (!owningSegment.wordIds.includes(wordId)) {
+ throw new Error(`Segment "${owningSegment.id}" does not reference target word "${wordId}"`);
+ }
+
+ const wordsById = new Map(transcript.words.map((word) => [word.id, word]));
+ for (const referencedWordId of owningSegment.wordIds) {
+ const referencedWord = wordsById.get(referencedWordId);
+ if (!referencedWord) {
+ throw new Error(
+ `Segment "${owningSegment.id}" references missing word "${referencedWordId}"`,
+ );
+ }
+ if (referencedWord.segmentId !== owningSegment.id) {
+ throw new Error(
+ `Segment "${owningSegment.id}" references word "${referencedWordId}" which belongs to segment "${referencedWord.segmentId}"`,
+ );
+ }
+ }
+
+ const words = transcript.words.map((word) =>
+ word.id === wordId ? rewriteWord(word, text) : word,
+ );
+ const updatedWordsById = new Map(words.map((word) => [word.id, word]));
+ const segmentText = joinSegmentText(
+ owningSegment.wordIds.map(
+ (referencedWordId) => updatedWordsById.get(referencedWordId)?.text ?? "",
+ ),
+ );
+ const segments = transcript.segments.map((segment) =>
+ segment.id === owningSegment.id ? { ...segment, text: segmentText } : segment,
+ );
+
+ return { ...transcript, words, segments };
+}
+
+/**
+ * Write a transcript into the document — the ONLY safe way to do it.
+ *
+ * The document carries the same transcript twice: the per-asset `transcripts[]`
+ * entry, and the legacy `transcript` mirror that a couple of readers still fall
+ * back to. Writing one without the other leaves two divergent copies on disk,
+ * where the mirror keeps serving the pre-edit text forever. Nothing outside this
+ * function may assemble that pair.
+ *
+ * Lives here rather than in `transcribe.ts` (which re-exports it for its existing
+ * importers): it is a pure document operation, and the Whisper adapter is not the
+ * place a caller should have to look for it.
+ */
+export function withTranscript(
+ document: AxcutDocument,
+ transcript: AxcutTranscript,
+): AxcutDocument {
+ const transcripts = [
+ ...document.transcripts.filter((t) => t.assetId !== transcript.assetId),
+ transcript,
+ ];
+ return {
+ ...document,
+ transcript:
+ document.project.primaryAssetId === transcript.assetId ? transcript : document.transcript,
+ transcripts,
+ };
+}
+
+/**
+ * {@link setWordText}, addressed the way the UI has it: an asset and a word, not a
+ * transcript object. Goes through `withTranscript`, so a caller cannot forget the
+ * legacy mirror.
+ */
+export function setDocumentWordText(
+ document: AxcutDocument,
+ assetId: string,
+ wordId: string,
+ text: string,
+): AxcutDocument {
+ // 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`);
+ }
+ return withTranscript(document, setWordText(transcript, wordId, text));
+}
+
+export type { InsertSide } from "./insertion";
+
+/** 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,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutDocument {
+ // 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
+ * 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,
+ wordIds: readonly string[],
+): AxcutDocument {
+ // 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. */
+export interface WordEditCarryOver {
+ transcript: AxcutTranscript;
+ /** Corrections and insertions re-applied to the new transcript. */
+ carried: number;
+ /** Edits the new transcript left no place for. These are lost. */
+ dropped: number;
+}
+
+/**
+ * Re-apply the user's word corrections onto a freshly transcribed transcript.
+ *
+ * A transcription run REPLACES the asset's transcript wholesale, so without this a
+ * user who fixed twenty proper nouns and then regenerated lost all twenty, silently.
+ *
+ * The match is deliberately strict — same original text, overlapping span, one new
+ * word per correction. A correction is carried only when the new run reproduced the
+ * very same mistake at the very same moment; re-transcribing in another language
+ * therefore carries nothing rather than stamping French corrections onto Spanish
+ * words. What could not be carried is counted, not guessed at, so the caller can say
+ * so.
+ */
+export function carryOverWordEdits(
+ previous: AxcutTranscript | null | undefined,
+ next: AxcutTranscript,
+): WordEditCarryOver {
+ const edits = (previous?.words ?? []).filter(
+ (word) => word.source === "user" && word.originalText !== undefined,
+ );
+ // 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
+ // the text the next one matches on, and `claimed` is what stops two corrections
+ // from landing on the same word.
+ const claimed = new Set();
+ let transcript = next;
+ let carried = 0;
+ for (const edit of edits) {
+ const match = next.words.find(
+ (word) =>
+ !claimed.has(word.id) &&
+ word.text === edit.originalText &&
+ word.endSec > edit.startSec &&
+ word.startSec < edit.endSec,
+ );
+ if (!match) continue;
+ claimed.add(match.id);
+ transcript = setWordText(transcript, match.id, edit.text);
+ carried += 1;
+ }
+
+ return { transcript, carried, dropped: edits.length - carried };
+}
diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts
index bbba94fd8..c53bfae9f 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,110 @@ describe("v6 -> v7 trim clip-anchor migration", () => {
]);
});
});
+
+describe("audio tracks (issue #350)", () => {
+ it("applies defaults for kind, gain, offset, fades and label", () => {
+ const track = audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 42,
+ startMs: 0,
+ endMs: 42_000,
+ });
+ expect(track.kind).toBe("music");
+ expect(track.offsetMs).toBe(0);
+ expect(track.gainDb).toBe(0);
+ expect(track.loop).toBe(false);
+ expect(track.fadeInMs).toBe(0);
+ expect(track.fadeOutMs).toBe(0);
+ expect(track.muted).toBe(false);
+ expect(track.label).toBe("");
+ // Unanchored until a caller places it — same contract as every other
+ // clip-anchored region kind.
+ expect(track.clipId).toBeUndefined();
+ });
+
+ it("rejects a span whose end precedes its start", () => {
+ expect(() =>
+ audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 10,
+ startMs: 5000,
+ endMs: 2000,
+ }),
+ ).toThrow();
+ });
+
+ it("rejects a negative head", () => {
+ expect(() =>
+ audioTrackSchema.parse({
+ id: "audio_1",
+ assetId: "asset_1",
+ durationSec: 10,
+ startMs: -1,
+ endMs: 1000,
+ }),
+ ).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);
+ // The span runs from the head for the source duration by default.
+ expect(track.startMs).toBe(3000);
+ expect(track.endMs).toBe(15_500);
+ expect(track.label).toBe("voiceover.mp3");
+ // The factory output must itself round-trip through the schema.
+ expect(() => audioTrackSchema.parse(track)).not.toThrow();
+ });
+
+ it("createAudioTrack takes a shorter span than the source when asked", () => {
+ // A voiceover recorded over a 4s tail of the timeline should not lay a
+ // 30s pill down just because its file is 30s long.
+ const track = createAudioTrack({
+ assetId: "asset_1",
+ durationSec: 30,
+ kind: "voiceover",
+ timelineStartSec: 2,
+ spanSec: 4,
+ });
+ expect(track.kind).toBe("voiceover");
+ expect(track.startMs).toBe(2000);
+ expect(track.endMs).toBe(6000);
+ });
+
+ it("createAudioTrack still gives a grabbable span to a zero-duration source", () => {
+ const track = createAudioTrack({ assetId: "asset_1", durationSec: 0 });
+ expect(track.endMs).toBeGreaterThan(track.startMs);
+ });
+
+ 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..d37f500b3 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -63,6 +63,23 @@ export const wordSchema = z
startSec: z.number().nonnegative(),
endSec: z.number().nonnegative(),
text: z.string(),
+ // Provenance of the TEXT, so a hand-corrected word can be told from a
+ // transcribed one. Both fields are additive and absent on every document
+ // written before them (like `cameraTrack.width`), so no schema bump: an
+ // older build simply drops them on save.
+ //
+ // `document/transcript.ts` is the only writer, and it keeps the pair
+ // consistent: `originalText` is set from the ASR text the first time a user
+ // rewrites the word and never overwritten afterwards, so it stays the revert
+ // target however many times the word is edited; typing the original back
+ // clears both, which IS the revert.
+ //
+ // Absent `source` means the word came from the transcriber. It is what makes
+ // a re-transcription able to carry the user's corrections forward
+ // (`carryOverWordEdits`) instead of silently discarding them — and what a
+ // future TTS pass will read to know which words it has to speak.
+ originalText: z.string().optional(),
+ source: z.enum(["asr", "user", "synth"]).optional(),
})
.refine((data) => data.endSec >= data.startSec, {
message: "endSec must be greater than or equal to startSec",
@@ -150,7 +167,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(),
@@ -269,6 +291,9 @@ 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.
muteRanges: z.array(rangeSchema).default([]),
speedRanges: z.array(rangeSchema).default([]),
captionRanges: z.array(rangeSchema).default([]),
@@ -471,6 +496,73 @@ export const zoomRegionSchema = endGteStart(
"startMs",
);
+// External audio import (issue #350) — voiceover / BGM / SFX layered over the
+// programme. Unlike zoom/speed/annotation/trim, an audio track is NOT
+// clip-anchored: it floats over the whole timeline, addressed in RAW/document
+// timeline seconds — the same clock the ruler, playhead and clip
+// `timelineStartSec`/`timelineEndSec` use, and the one `addAudioTrack` seeds from
+// the playhead. The preview positions the track on exactly this clock (see
+// `resolveTimelineAudioPlayback` in VirtualPreview). The export's OUTPUT programme
+// is trim-compressed, so the renderer maps this position to output time when
+// building the scene — an identity map when the project has no trims/speed (the
+// common case), an accepted approximation otherwise, the same way the preview
+// approximates trims by re-seeking. See `SceneAudioTrack` (sceneDescription.ts,
+// audio.rs).
+//
+// `assetId` points at an asset with `kind: "audio"`. `timelineStartSec` places
+// the track's head; `trimStartSec`/`trimEndSec` window the source file (both in
+// source seconds); `gainDb` sets its level.
+// An imported or recorded audio track (voiceover / BGM / SFX) placed on the
+// timeline (issue #350).
+//
+// CLIP-ANCHORED, on the same v5 contract as zoom/annotation: `{clipId,
+// sourceStartSec, sourceEndSec}` is the source of truth and `startMs`/`endMs`
+// is a derived ruler cache, so a track travels with the content it was placed
+// over instead of sitting still while a reorder or trim slides the programme
+// underneath it. Positions are RAW ruler ms; the export projects them onto the
+// trim-compressed programme (`projectRawTimelineSecToPlayback`).
+//
+// `offsetMs` skips INTO the source file — start the music at its chorus. It
+// replaces #502's `trimStartSec`/`trimEndSec` pair: the track's own span
+// (`startMs`..`endMs`) is where it plays, so the tail trim is implied by the
+// span and does not need storing twice. A file longer than its span is cut off
+// at the span unless `loop` is set, in which case it repeats.
+//
+// The anchor ventilates one user-visible track into one fragment PER CLIP it
+// covers. Fragments of the same track share `trackId`, and each carries its own
+// `offsetMs` advanced by the source time its predecessors consumed — see
+// `anchorAudioTrackFragments`. Without that every fragment would restart the
+// file at the same offset and re-run the fades, so a bed spanning a cut would
+// audibly restart at the boundary.
+export const audioTrackSchema = endGteStart(
+ z.object({
+ id: z.string().min(1),
+ // Shared by every fragment of one user-visible track: what the lane draws
+ // as a single pill, what the inspector edits, and what delete removes.
+ // Absent on tracks written before ventilation existed — they are their own
+ // single fragment, so `trackId ?? id` is always the group key.
+ trackId: z.string().min(1).optional(),
+ startMs: z.number().nonnegative(),
+ endMs: z.number().nonnegative(),
+ ...clipAnchorShape,
+ assetId: z.string().min(1),
+ kind: z.enum(["voiceover", "music"]).default("music"),
+ // 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),
+ offsetMs: z.number().int().nonnegative().default(0),
+ gainDb: z.number().min(-60).max(12).default(0),
+ loop: z.boolean().default(false),
+ fadeInMs: z.number().int().nonnegative().default(0),
+ fadeOutMs: z.number().int().nonnegative().default(0),
+ muted: z.boolean().default(false),
+ label: z.string().default(""),
+ origin: z.enum(["system", "agent", "user"]).default("user"),
+ }),
+ "endMs",
+ "startMs",
+);
+
// 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 +595,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),
});
@@ -781,9 +876,59 @@ export function upgradeV6DocumentToV7(raw: unknown): unknown {
* (`PROJECT_VERSION`) through the `@/` alias, which `vite-plugin-electron` does
* not configure for the main bundle. Keep this module alias-free.
*/
+/**
+ * Drop the ghost trims commit `b9e0f1ff` wrote (issue #560).
+ *
+ * That build let the transcript pane author a cut from the voiceover lane while still
+ * anchoring it on whatever the words belonged to — an AUDIO asset and an audio fragment.
+ * `resolvePlaybackSegments` matches no clip for such a row, so it removed nothing from the
+ * film, the preview or the export; all it did was strike the word through. Now that both
+ * lanes read the same removed set, leaving those rows behind would keep striking words
+ * through for a cut that never existed.
+ *
+ * The test is exact and needs no clip lookup: an audio asset is never a clip's `assetId`
+ * (audio is filtered out of the lists that make clips), so a trim naming one can only have
+ * come from that build. An un-anchored pre-v7 trim names a VIDEO asset and is untouched;
+ * so is a trim whose clip was deleted, which in-session undo can still bring back.
+ *
+ * No `schemaVersion` bump: nothing about the format changed, and no output moves — these
+ * rows were already inert. What changes is that words the user "deleted" on that build
+ * come back as kept, which is the correction, and belongs in the release note.
+ *
+ * Runs on RAW, untrusted input like the rest of the chain, so every read is guarded.
+ */
+function dropAudioAnchoredTrims(raw: unknown): unknown {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
+ const doc = raw as Record;
+ const assets = Array.isArray(doc.assets) ? doc.assets : null;
+ if (!assets) return raw;
+ const audioAssetIds = new Set();
+ for (const asset of assets) {
+ if (!asset || typeof asset !== "object" || Array.isArray(asset)) continue;
+ const entry = asset as Record;
+ if (entry.kind === "audio" && typeof entry.id === "string") audioAssetIds.add(entry.id);
+ }
+ if (audioAssetIds.size === 0) return raw;
+
+ const timeline =
+ doc.timeline && typeof doc.timeline === "object" && !Array.isArray(doc.timeline)
+ ? (doc.timeline as Record)
+ : null;
+ const trims = timeline && Array.isArray(timeline.trimRanges) ? timeline.trimRanges : null;
+ if (!trims) return raw;
+
+ const kept = trims.filter((entry) => {
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return true;
+ const trim = entry as Record;
+ return !(typeof trim.assetId === "string" && audioAssetIds.has(trim.assetId));
+ });
+ if (kept.length === trims.length) return raw;
+ return { ...doc, timeline: { ...timeline, trimRanges: kept } };
+}
+
export function migrateRawDocumentToCurrent(raw: unknown): unknown {
- return upgradeV6DocumentToV7(
- upgradeV5DocumentToV6(upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw))),
+ return dropAudioAnchoredTrims(
+ upgradeV6DocumentToV7(upgradeV5DocumentToV6(upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw)))),
);
}
@@ -803,6 +948,10 @@ export const createProjectInputSchema = z.object({
export const addAssetInputSchema = z.object({
path: z.string().trim().min(1),
label: z.string().trim().optional(),
+ // "audio" imports an external voiceover / BGM / SFX file (issue #350); it has
+ // no video stream and never becomes the project's primary asset. Defaults to
+ // "video" so every existing caller keeps its current behaviour.
+ kind: z.enum(["video", "audio"]).default("video"),
autoTranscribe: z.boolean().default(true),
});
@@ -942,6 +1091,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 +1127,7 @@ export function createEmptyDocument(
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
});
}
@@ -984,3 +1135,40 @@ export function createEmptyDocument(
export function ensureDocument(value: unknown): AxcutDocument {
return documentSchema.parse(value);
}
+
+/**
+ * Build a timeline audio track for an imported or recorded audio asset
+ * (issue #350). The head is placed at `timelineStartSec` (RAW/document timeline
+ * seconds — the same clock the ruler, playhead and clip `timelineStartSec` use,
+ * NOT the trim-compressed output programme; the export projects it with
+ * `projectRawTimelineSecToPlayback`) and the track spans the whole source file
+ * unless the caller asks for a shorter `spanSec`. Parsed through the schema so
+ * every default (gain, fades, loop) is applied in one place.
+ *
+ * The result is UNANCHORED — `clipId` is absent. Callers place it through
+ * `anchorAudioTrackFragments`, which ventilates it across the clips it covers.
+ */
+export function createAudioTrack(input: {
+ assetId: string;
+ durationSec: number;
+ kind?: "voiceover" | "music";
+ /** Raw ruler head. The span runs from here for `durationSec`, or for
+ * `spanSec` when the caller wants a shorter placement than the file. */
+ timelineStartSec?: number;
+ spanSec?: number;
+ label?: string;
+}): AxcutAudioTrack {
+ const startMs = Math.round(Math.max(0, input.timelineStartSec ?? 0) * 1000);
+ // A track with no measurable source still needs a visible span, or the pill
+ // is zero-width and cannot be grabbed to fix.
+ const spanMs = Math.max(1, Math.round((input.spanSec ?? input.durationSec) * 1000));
+ return audioTrackSchema.parse({
+ id: createId("audio"),
+ assetId: input.assetId,
+ kind: input.kind ?? "music",
+ durationSec: input.durationSec,
+ startMs,
+ endMs: startMs + spanMs,
+ label: input.label ?? "",
+ });
+}
diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts
index 6a76e8013..9a832b356 100644
--- a/src/lib/ai-edition/store/documentWriteAudit.test.ts
+++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts
@@ -131,10 +131,26 @@ 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"),
+ // The transcript lane, chosen in the pane and stored on the document because it decides
+ // the captions burnt into the export (#560). Written through `useCaptions.set`, which
+ // is already in the table under its own name.
+ // A cut made in the transcript pane, and its restore. Both moved off `applyTimelineOp`
+ // onto the write chain in #560: they read the document inside it, so a word edit landing
+ // between the read and the save can no longer overwrite the cut.
+ w("src/components/ai-edition/NewEditorShell.tsx", "handleRemoveTrimRanges", "save", "gesture"),
+ w("src/components/ai-edition/NewEditorShell.tsx", "handleTrimTimelineSpan", "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"),
// "Save" chosen on the way out of Ctrl+N and Ctrl+O.
w("src/components/ai-edition/NewEditorShell.tsx", "onKey", "save", "gesture"),
w("src/components/ai-edition/NewEditorShell.tsx", "onKey", "save", "gesture"),
- // Ctrl+V of a copied region: zoom, annotation, or a legacy span.
+ // Ctrl+V of a copied region: an audio track, zoom, annotation, or a legacy span.
+ w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"),
w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"),
w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"),
w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"),
@@ -168,6 +184,12 @@ const DECLARED: WritePath[] = [
// Linking the camera track found next to a newly added asset. Part of the
// import, not an edit of its own.
w("src/lib/ai-edition/store/projectStore.ts", "addAsset", "save", "automatic"),
+ // Folding an imported audio file's probed duration onto its asset (issue #350).
+ // Part of the import, like the camera link above — not an edit of its own.
+ w("src/lib/ai-edition/store/projectStore.ts", "addAudioAsset", "save", "automatic"),
+ // Placing an imported audio track on the timeline. The user asked for it, via the
+ // media panel's "Import audio" or the timeline.
+ w("src/lib/ai-edition/store/projectStore.ts", "addAudioTrack", "save", "gesture"),
// THE round-3 fix. This is the shape that defeated round 2: a store action that
// writes on someone else's behalf. It forwards now, so its callers decide.
w("src/lib/ai-edition/store/projectStore.ts", "replaceTimeline", "save", "forwarded"),
@@ -234,6 +256,19 @@ const DECLARED: WritePath[] = [
w("src/lib/ai-edition/store/useTimeline.ts", "duplicateClip", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "insertClipAt", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "moveClip", "save", "gesture"),
+ // Timeline audio tracks (issue #350). Each is a direct user edit — drag or resize
+ // the track (placeAudioTrack), change its payload (updateAudioTrack, which
+ // setAudioTrackGain routes through), or delete it — one undo step apiece.
+ w("src/lib/ai-edition/store/useTimeline.ts", "placeAudioTrack", "save", "gesture"),
+ w("src/lib/ai-edition/store/useTimeline.ts", "removeAudioTrack", "save", "gesture"),
+ // Three exits, one gesture: the toggle writes the flag alone when there is
+ // nothing to fill, and the flag plus the filled span when there is. Either
+ // way it is one undo step (see setAudioTrackLoop).
+ // Two, not three: the fill and its no-op fallback collapsed into one call when the
+ // placement door took over the clamping (#560).
+ w("src/lib/ai-edition/store/useTimeline.ts", "setAudioTrackLoop", "save", "gesture"),
+ w("src/lib/ai-edition/store/useTimeline.ts", "setAudioTrackLoop", "save", "gesture"),
+ w("src/lib/ai-edition/store/useTimeline.ts", "updateAudioTrack", "save", "gesture"),
// The round-2 defect: a background duration probe every freshly imported asset
// fires, because `addAsset` never populates `durationSec`.
w("src/lib/ai-edition/store/useTimeline.ts", "probeAndCorrectClip", "save", "automatic"),
@@ -256,6 +291,9 @@ const DECLARED: WritePath[] = [
// Source-dimension backfill for assets a migration left unprobed. On load, for
// every project, whether or not the user touches anything.
w("src/lib/ai-edition/store/useTimeline.ts", "useTimeline", "save", "automatic"),
+ // Audio-duration backfill (issue #350) — the same on-load, un-asked-for probe
+ // for imported audio assets whose duration didn't stamp at import.
+ w("src/lib/ai-edition/store/useTimeline.ts", "useTimeline", "save", "automatic"),
];
function w(
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..2b75f0a04 100644
--- a/src/lib/ai-edition/store/projectStore.test.ts
+++ b/src/lib/ai-edition/store/projectStore.test.ts
@@ -16,6 +16,17 @@ const toastMocks = vi.hoisted(() => ({
error: vi.fn(),
}));
+// Stub only the audio duration probe (issue #350): mounting a real in
+// jsdom never fires loadedmetadata, so an unmocked probe would block on its
+// timeout. Everything else in the module (probeVideoDimensions) stays real so
+// the video-import tests above are untouched.
+const durationMocks = vi.hoisted(() => ({ probeAudioDuration: vi.fn() }));
+
+vi.mock("../timeline/duration", async (importOriginal) => ({
+ ...(await importOriginal()),
+ probeAudioDuration: durationMocks.probeAudioDuration,
+}));
+
vi.mock("@/native/client", () => ({
nativeBridgeClient: {
aiEdition: {
@@ -62,6 +73,7 @@ const sampleDoc = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
@@ -72,6 +84,7 @@ describe("useProjectStore", () => {
mock.mockReset();
}
toastMocks.error.mockReset();
+ durationMocks.probeAudioDuration.mockReset();
// biome-ignore lint/suspicious/noExplicitAny: test-only stub of the legacy contextBridge surface
(window as any).electronAPI = { findRecordingCamera: vi.fn() };
});
@@ -298,6 +311,147 @@ describe("useProjectStore", () => {
expect(toastMocks.error.mock.calls[0][0]).toContain("video.mp4");
});
+ // Issue #350 — external audio import.
+ it("addAudioAsset passes kind 'audio', skips the camera lookup, and returns the asset", async () => {
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: sampleDoc,
+ revision: 1,
+ status: "ready",
+ error: null,
+ });
+ durationMocks.probeAudioDuration.mockResolvedValue(null);
+ const audioDoc = {
+ ...sampleDoc,
+ assets: [
+ { id: "audio_asset", kind: "audio", label: "voiceover.mp3", originalPath: "/tmp/vo.mp3" },
+ ],
+ };
+ bridgeMocks.addAsset.mockResolvedValue({ assetId: "audio_asset", document: audioDoc });
+
+ const asset = await useProjectStore.getState().addAudioAsset("/tmp/vo.mp3");
+
+ expect(asset?.id).toBe("audio_asset");
+ expect(asset?.kind).toBe("audio");
+ // The bridge must be told this is an audio import (4th arg).
+ expect(bridgeMocks.addAsset).toHaveBeenCalledWith(
+ "proj_test",
+ "/tmp/vo.mp3",
+ undefined,
+ "audio",
+ );
+ // Audio has no camera sidecar — the lookup that addAsset does must not run.
+ expect(vi.mocked(window.electronAPI.findRecordingCamera)).not.toHaveBeenCalled();
+ // Probe returned null, so nothing to stamp: no extra save.
+ expect(bridgeMocks.save).not.toHaveBeenCalled();
+ });
+
+ it("addAudioAsset stamps the probed duration onto the asset", async () => {
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: sampleDoc,
+ revision: 1,
+ status: "ready",
+ error: null,
+ });
+ durationMocks.probeAudioDuration.mockResolvedValue(8.25);
+ const audioDoc = {
+ ...sampleDoc,
+ assets: [
+ { id: "audio_asset", kind: "audio", label: "bgm.wav", originalPath: "/tmp/bgm.wav" },
+ ],
+ };
+ bridgeMocks.addAsset.mockResolvedValue({ assetId: "audio_asset", document: audioDoc });
+ bridgeMocks.save.mockImplementation((document: unknown) =>
+ Promise.resolve({ success: true, document }),
+ );
+
+ const asset = await useProjectStore.getState().addAudioAsset("/tmp/bgm.wav");
+
+ expect(asset?.durationSec).toBe(8.25);
+ expect(bridgeMocks.save).toHaveBeenCalledTimes(1);
+ expect(useProjectStore.getState().document?.assets[0]?.durationSec).toBe(8.25);
+ });
+
+ // Placement + selection for imported audio tracks (issue #350).
+ const audioAsset = {
+ id: "audio_1",
+ kind: "audio" as const,
+ label: "voiceover.mp3",
+ originalPath: "/tmp/vo.mp3",
+ durationSec: 12,
+ cameraTrack: null,
+ };
+
+ it("addAudioTrack places a track at the playhead for an audio asset and selects it", async () => {
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: { ...sampleDoc, assets: [audioAsset] },
+ revision: 1,
+ status: "ready",
+ error: null,
+ currentTimeSec: 5,
+ });
+ bridgeMocks.save.mockImplementation((document: unknown) =>
+ Promise.resolve({ success: true, document }),
+ );
+
+ const id = await useProjectStore.getState().addAudioTrack("audio_1");
+
+ const tracks = useProjectStore.getState().document?.audioTracks ?? [];
+ expect(tracks).toHaveLength(1);
+ // Head at the playhead (5s), in raw ruler ms.
+ expect(tracks[0]).toMatchObject({ assetId: "audio_1", startMs: 5000, durationSec: 12 });
+ expect(id).toBe(tracks[0]?.id);
+ // Placing a track selects it so the inspector opens on its controls.
+ expect(useProjectStore.getState().selectedAudioTrackId).toBe(id);
+ });
+
+ it("addAudioTrack refuses a non-audio (or unknown) asset and selects nothing", async () => {
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: sampleDoc, // its only asset, if any, is not audio
+ revision: 1,
+ status: "ready",
+ error: null,
+ });
+ expect(await useProjectStore.getState().addAudioTrack("nope")).toBeNull();
+ expect(useProjectStore.getState().selectedAudioTrackId).toBeNull();
+ });
+
+ it("importAudioAsset adds the asset then places and selects a track in one action", async () => {
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: sampleDoc,
+ revision: 1,
+ status: "ready",
+ error: null,
+ currentTimeSec: 0,
+ });
+ durationMocks.probeAudioDuration.mockResolvedValue(12);
+ bridgeMocks.addAsset.mockResolvedValue({
+ assetId: "audio_1",
+ document: { ...sampleDoc, assets: [audioAsset] },
+ });
+ bridgeMocks.save.mockImplementation((document: unknown) =>
+ Promise.resolve({ success: true, document }),
+ );
+
+ const asset = await useProjectStore.getState().importAudioAsset("/tmp/vo.mp3");
+
+ expect(asset?.id).toBe("audio_1");
+ const tracks = useProjectStore.getState().document?.audioTracks ?? [];
+ expect(tracks).toHaveLength(1);
+ expect(tracks[0]?.assetId).toBe("audio_1");
+ expect(useProjectStore.getState().selectedAudioTrackId).toBe(tracks[0]?.id);
+ });
+
+ it("clear() resets the audio-track selection", () => {
+ useProjectStore.setState({ selectedAudioTrackId: "audio_x" });
+ useProjectStore.getState().clear();
+ expect(useProjectStore.getState().selectedAudioTrackId).toBeNull();
+ });
+
// The save boundary. Every write in the app funnels through `saveDocument`, and
// almost every caller `void`s it from a click handler, so what this function does
// with a failure IS what the user sees.
diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts
index 48dad73e2..fd47da9cc 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -3,9 +3,11 @@ import { create } from "zustand";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { toastText } from "@/i18n/toastText";
import { nativeBridgeClient } from "@/native/client";
+import { placeAudioTrackInDocument } from "../document/audioTracks";
+import { createId } from "../document/ids";
import { type Interval, replaceTimeline as replaceTimelineOp } from "../document/timeline";
-import { type AxcutAsset, type AxcutDocument, documentSchema } from "../schema";
-import { probeVideoDimensions } from "../timeline/duration";
+import { type AxcutAsset, type AxcutDocument, createAudioTrack, documentSchema } from "../schema";
+import { probeAudioDuration, probeVideoDimensions } from "../timeline/duration";
import { clearHistory, currentWriteEpoch, pushHistory } from "./undoStack";
// ponytail: thin Zustand wrapper over the native-bridge client. Keeps the
@@ -58,6 +60,11 @@ export interface ProjectState {
error: string | null;
sourceDurationSec: number;
currentTimeSec: 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
+ * and both need to read/set it; the region/clip selection stays hook-local. */
+ selectedAudioTrackId: string | null;
/** Single source of truth for "is the timeline transport playing?" — previously
* duplicated as separate local state in NewEditorShell AND VirtualPreview, each
* independently wired to the same raw DOM events, which let one advance
@@ -72,6 +79,37 @@ export interface ProjectState {
createProject: (title: string) => Promise;
refresh: () => Promise;
addAsset: (path: string, label?: string) => Promise;
+ /**
+ * Import an external audio file (voiceover / BGM / SFX) as a `kind: "audio"`
+ * asset — issue #350. Unlike {@link addAsset} it never looks for a camera
+ * sidecar, and it probes the file's duration up front so the timeline can lay
+ * out its track (added separately, see the timeline store). Returns the added
+ * asset, or null if the write was superseded.
+ */
+ addAudioAsset: (path: string, label?: string) => Promise;
+ /**
+ * One-shot "Import audio" for the media panel (issue #350): {@link addAudioAsset}
+ * then place a track for it at the current playhead and select it, so the file
+ * lands visibly on the timeline in a single user action. Returns the asset (or
+ * null if the import was superseded). The timeline's own {@link addAudioTrack}
+ * covers placing an already-imported asset.
+ */
+ importAudioAsset: (path: string, label?: string) => Promise;
+ /** Place a track for an already-imported audio asset at `timelineStartSec`
+ * (default: the playhead) and select it. Returns the new track id, or null. */
+ addAudioTrack: (
+ assetId: string,
+ timelineStartSec?: number,
+ options?: {
+ kind?: "voiceover" | "music";
+ /** Real source duration, when the caller measured it (a fresh recording
+ * knows its own length before the asset is probed). */
+ durationSec?: number;
+ /** Timeline span, when it should differ from the source duration. */
+ spanSec?: number;
+ },
+ ) => Promise;
+ setSelectedAudioTrackId: (id: string | null) => void;
removeAsset: (assetId: string) => Promise;
/**
* Write the document to disk. Resolves `true` when it took effect, `false` when it
@@ -159,6 +197,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ selectedAudioTrackId: null,
playing: false,
dirty: false,
lastSavedAt: null,
@@ -179,6 +218,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
dirty: false,
lastSavedAt: new Date(),
+ selectedAudioTrackId: null,
});
clearHistory();
} catch (error) {
@@ -325,6 +365,96 @@ export const useProjectStore = create((set, get) => ({
return addedAsset;
},
+ async addAudioAsset(path, label) {
+ const { projectId } = get();
+ if (!projectId) throw new Error("No project loaded");
+ // Same superseded guard as addAsset: the native add, a duration probe and a
+ // save all await, and a project switch / clear can land in between.
+ const epoch = currentWriteEpoch();
+ const superseded = () => get().projectId !== projectId || currentWriteEpoch() !== epoch;
+ const result = await nativeBridgeClient.aiEdition.addAsset(projectId, path, label, "audio");
+ if (superseded()) return null;
+ let document = parseDocument(result.document);
+ const addedAsset =
+ document.assets.find(
+ (a) => a.kind === "audio" && a.originalPath === path && (label ? a.label === label : true),
+ ) ??
+ document.assets.at(-1) ??
+ null;
+ if (!addedAsset) return null;
+
+ // Probe the real length so the timeline can size the track pill immediately
+ // on add. Non-fatal: an unreadable file just leaves durationSec unset and the
+ // track store falls back to a placeholder. No camera lookup — audio has none.
+ const durationSec = await probeAudioDuration(toFileUrl(addedAsset.originalPath)).catch(
+ () => null,
+ );
+ if (superseded()) return null;
+ if (durationSec != null) {
+ const next: AxcutDocument = {
+ ...document,
+ assets: document.assets.map((a) => (a.id === addedAsset.id ? { ...a, durationSec } : a)),
+ };
+ // history: false — probing a duration is part of the import, not an edit
+ // of its own, so it must not become the thing the next Ctrl+Z reverses.
+ if (await get().saveDocument(next, { history: false })) document = parseDocument(next);
+ }
+
+ if (superseded()) return null;
+ set({
+ document,
+ revision: get().revision + 1,
+ dirty: false,
+ lastSavedAt: new Date(),
+ });
+ return document.assets.find((a) => a.id === addedAsset.id) ?? addedAsset;
+ },
+
+ setSelectedAudioTrackId(id) {
+ set({ selectedAudioTrackId: id });
+ },
+
+ async addAudioTrack(assetId, timelineStartSec, options) {
+ const document = get().document;
+ if (!document) return null;
+ const asset = document.assets.find((a) => a.id === assetId);
+ if (!asset || asset.kind !== "audio") return null;
+ const track = createAudioTrack({
+ assetId,
+ durationSec: options?.durationSec ?? asset.durationSec ?? 0,
+ kind: options?.kind,
+ spanSec: options?.spanSec,
+ // Default to the playhead (RAW/document timeline seconds — the clock the
+ // ruler and playhead use, NOT the trim-compressed output programme),
+ // matching the timeline hook's placement. A voiceover passes the playhead
+ // captured when RECORDING STARTED — by the time the take ends the live
+ // playhead has run on by the take's own length.
+ timelineStartSec: timelineStartSec ?? get().currentTimeSec,
+ label: asset.label,
+ });
+ // Through the placement door: it anchors the track into one fragment per clip it
+ // covers AND queues it behind whatever already occupies its kind's row, so two
+ // takes recorded from the same playhead no longer land on top of each other
+ // (issue #560).
+ const next = placeAudioTrackInDocument(document, track, () => createId("audio"), "create");
+ if (next === document) return null;
+ if (!(await get().saveDocument(next, { history: true }))) return null;
+ set({ selectedAudioTrackId: track.id });
+ return track.id;
+ },
+
+ async importAudioAsset(path, label) {
+ const asset = await get().addAudioAsset(path, label);
+ if (!asset) return null;
+ // addAudioAsset already committed the asset (with its probed duration), so
+ // the current document is the one to place the track on. If the placement
+ // write fails or is superseded, the import did NOT succeed as a one-shot —
+ // report failure rather than claim success with an asset but no track.
+ const trackId = await get().addAudioTrack(asset.id);
+ if (!trackId) return null;
+ return asset;
+ },
+
async removeAsset(assetId) {
const { projectId } = get();
if (!projectId) throw new Error("No project loaded");
@@ -451,6 +581,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ selectedAudioTrackId: null,
playing: false,
dirty: false,
lastSavedAt: null,
diff --git a/src/lib/ai-edition/store/regionClipboard.ts b/src/lib/ai-edition/store/regionClipboard.ts
index ef1116ff8..f0d6d7d10 100644
--- a/src/lib/ai-edition/store/regionClipboard.ts
+++ b/src/lib/ai-edition/store/regionClipboard.ts
@@ -11,6 +11,10 @@ export type RegionSnapshot =
| { kind: "annotation"; region: Record }
| { kind: "speed"; region: Record }
| { kind: "cameraFullscreen"; region: Record }
+ // An audio track copies its whole payload (asset, gain, fades, loop) so a
+ // paste is a second placement of the same audio, like every other kind. The
+ // snapshot is the COLLAPSED pill, never a stored fragment.
+ | { kind: "audio"; region: Record }
// A trim carries no user-visible properties, so all there is to copy is how
// LONG it was — `{ durationSec }`. That is not a special case so much as the
// general one made obvious: every paste keeps the copied properties and takes
diff --git a/src/lib/ai-edition/store/transcriptionStore.ts b/src/lib/ai-edition/store/transcriptionStore.ts
index 4e55bca3e..c0baaac48 100644
--- a/src/lib/ai-edition/store/transcriptionStore.ts
+++ b/src/lib/ai-edition/store/transcriptionStore.ts
@@ -26,10 +26,12 @@ import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { create } from "zustand";
import { toastText as translateToast } from "@/i18n/toastText";
-import { transcribeAsset, withTranscript } from "../document/transcribe";
+import { transcribeAsset } from "../document/transcribe";
+import { carryOverWordEdits, withTranscript } from "../document/transcript";
import type { AxcutDocument } from "../schema";
import {
type AssetTranscriptionView,
+ assetCanCarrySpeech,
classifyTranscriptionError,
deriveAssetStatus,
findAssetTranscript,
@@ -130,6 +132,10 @@ export const useTranscriptionStore = create((set, get) => ({
if (jobs[asset.id]) continue;
if (findAssetTranscript(document, asset.id)) continue;
if (asset.transcriptionFailure) continue;
+ // Music is not speech, and finding that out costs a whole inference pass —
+ // 35s at editor open for a four-minute bed. The manual regenerate in the
+ // media stage stays available for anything this refuses.
+ if (!assetCanCarrySpeech(document, asset.id)) continue;
patch()[asset.id] = { status: "queued", language: "auto", manual: false };
}
}
@@ -405,6 +411,20 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise {
dropJob(assetId, runId);
return;
}
+ // A run REPLACES the asset's transcript, so any word the user had corrected by
+ // hand would go with it. Carry those corrections onto the new words first —
+ // strictly, so nothing is invented (see `carryOverWordEdits`). What could not be
+ // carried is lost; telling the user so is the UI's job, and there is no surface
+ // for it yet.
+ const merged = carryOverWordEdits(
+ current.transcripts.find((t) => t.assetId === assetId),
+ transcript,
+ );
+ if (merged.dropped > 0) {
+ console.warn(
+ `[transcription] ${merged.dropped} word correction(s) on asset ${assetId} could not be carried over to the new transcript.`,
+ );
+ }
// One save: the transcript, and (on a successful retry) the removal of
// the verdict remembered on the asset.
// `history: false`: a transcript landing from a background job is not an edit
@@ -418,7 +438,7 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise {
a.id === assetId && a.transcriptionFailure ? { ...a, transcriptionFailure: null } : a,
),
},
- transcript,
+ merged.transcript,
),
{ history: false },
);
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/useCaptions.ts b/src/lib/ai-edition/store/useCaptions.ts
index 5238fa150..0bfb709f0 100644
--- a/src/lib/ai-edition/store/useCaptions.ts
+++ b/src/lib/ai-edition/store/useCaptions.ts
@@ -17,8 +17,10 @@ import {
putCaptionTranslation,
removeCaptionTranslation,
} from "../captions";
+import { resolveCaptionLane } from "../captions/settings";
import { resolveAspectRatioValue } from "../document/outputFormat";
import type { AxcutDocument } from "../schema";
+import { lanePlacements } from "../timeline/aggregated-transcript";
import { useProjectStore } from "./projectStore";
import { useEditorSettings } from "./useEditorSettings";
@@ -73,11 +75,18 @@ export function useCaptions(): UseCaptionsResult {
[document, settings, translations],
);
+ // Asked of the lane the captions actually come from: a voiceover-only project has a
+ // transcript to caption even though no CLIP does, and a recording project with a
+ // freshly imported take does not yet (issue #560).
const hasTranscript = useMemo(() => {
if (!document) return false;
const withTranscript = new Set(document.transcripts.map((t) => t.assetId));
- return document.timeline.clips.some((clip) => withTranscript.has(clip.assetId));
- }, [document]);
+ return lanePlacements(
+ resolveCaptionLane(document, settings),
+ document.timeline.clips,
+ document.audioTracks ?? [],
+ ).some((placement) => withTranscript.has(placement.assetId));
+ }, [document, settings]);
const set = useCallback(
async (patch: CaptionSettingsPatch) => {
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..2bc430311 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -20,6 +20,7 @@ const probeVideoDurationMock = vi.hoisted(() => vi.fn());
const probeVideoDimensionsMock = vi.hoisted(() =>
vi.fn().mockResolvedValue({ width: 1920, height: 1080 }),
);
+const probeAudioDurationMock = vi.hoisted(() => vi.fn().mockResolvedValue(null));
const toastErrorMock = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({ toast: { error: toastErrorMock } }));
@@ -30,6 +31,7 @@ vi.mock("../timeline/duration", async (importOriginal) => {
...actual,
probeVideoDuration: probeVideoDurationMock,
probeVideoDimensions: probeVideoDimensionsMock,
+ probeAudioDuration: probeAudioDurationMock,
};
});
@@ -110,6 +112,7 @@ const sampleDoc: AxcutDocument = {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
@@ -188,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: [],
@@ -1285,3 +1291,308 @@ describe("useTimeline drag snapshots", () => {
expect(useProjectStore.getState().document?.annotations[0].content).toBe("before");
});
});
+
+// Issue #350 — imported audio tracks. The hook wraps the pure ops in
+// document/audioTracks.ts (unit-tested separately); these cover the wiring:
+// asset lookup, playhead placement, the save, and undo.
+describe("useTimeline audio tracks", () => {
+ const audioDoc: AxcutDocument = {
+ ...sampleDoc,
+ assets: [
+ ...sampleDoc.assets,
+ {
+ id: "audio_1",
+ kind: "audio",
+ label: "voiceover.mp3",
+ originalPath: "/tmp/vo.mp3",
+ durationSec: 30,
+ cameraTrack: null,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ useProjectStore.getState().clear();
+ clearHistory();
+ for (const mock of Object.values(bridgeMocks)) mock.mockReset();
+ probeAudioDurationMock.mockReset();
+ probeAudioDurationMock.mockResolvedValue(null);
+ bridgeMocks.save.mockImplementation(async (doc: typeof sampleDoc) => ({
+ success: true,
+ document: doc,
+ }));
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: audioDoc,
+ revision: 1,
+ status: "ready",
+ error: null,
+ currentTimeSec: 4,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("addAudioTrack places a track for the asset at the playhead and returns its id", async () => {
+ const { result } = renderTimeline();
+ let id: string | null = null;
+ await act(async () => {
+ id = await result.current.addAudioTrack("audio_1");
+ });
+ const tracks = useProjectStore.getState().document?.audioTracks ?? [];
+ expect(tracks).toHaveLength(1);
+ expect(id).toBe(tracks[0]?.id);
+ expect(tracks[0]).toMatchObject({
+ assetId: "audio_1",
+ durationSec: 30,
+ // Head at the playhead (4s), span the source's own length.
+ startMs: 4000,
+ label: "voiceover.mp3",
+ });
+ });
+
+ it("addAudioTrack refuses a non-audio (or unknown) asset", async () => {
+ const { result } = renderTimeline();
+ let videoId: string | null = "x";
+ let missingId: string | null = "x";
+ await act(async () => {
+ videoId = await result.current.addAudioTrack("asset_1"); // a video asset
+ missingId = await result.current.addAudioTrack("nope");
+ });
+ expect(videoId).toBeNull();
+ expect(missingId).toBeNull();
+ expect(useProjectStore.getState().document?.audioTracks).toEqual([]);
+ });
+
+ it("place / gain update the track and each is one undo step", async () => {
+ const { result } = renderTimeline();
+ let id = "";
+ await act(async () => {
+ id = (await result.current.addAudioTrack("audio_1", 2)) ?? "";
+ });
+ // A lane drag commits the whole span in one write and re-ventilates it.
+ await act(async () => {
+ await result.current.placeAudioTrack(id, { startMs: 3000, endMs: 8000 });
+ });
+ await act(async () => {
+ await result.current.setAudioTrackGain(id, -6);
+ });
+
+ const track = useProjectStore.getState().document?.audioTracks[0];
+ expect(track).toMatchObject({
+ startMs: 3000,
+ endMs: 8000,
+ gainDb: -6,
+ });
+
+ // Three writes (add + place + gain) → the gain edit undoes first.
+ act(() => {
+ expect(undo()).toBe(true);
+ });
+ expect(useProjectStore.getState().document?.audioTracks[0]?.gainDb).toBe(0);
+ });
+
+ it("clamps a track to the content under it, like every other anchored region", async () => {
+ // The sample timeline is one 0..10s clip. A track dragged past the end has
+ // nothing to anchor to out there — and the exported programme stops at the
+ // last clip regardless — so the span is cut at the content, not stored
+ // hanging off the end where it could never play.
+ const { result } = renderTimeline();
+ let id = "";
+ await act(async () => {
+ id = (await result.current.addAudioTrack("audio_1", 2)) ?? "";
+ });
+ await act(async () => {
+ await result.current.placeAudioTrack(id, { startMs: 9000, endMs: 16_000 });
+ });
+ const track = useProjectStore.getState().document?.audioTracks[0];
+ expect(track).toMatchObject({ startMs: 9000, endMs: 10_000 });
+ });
+
+ it("turning loop on fills the rest of the programme, in one undo step", async () => {
+ // Looping only means anything when the span exceeds the source, so a toggle
+ // that changed nothing else did nothing at all. The sample timeline is one
+ // 0..10s clip and the asset is 30s, so the track is created 2..10 (clamped
+ // to the content) and filling is a no-op — place it short first.
+ const { result } = renderTimeline();
+ let id = "";
+ await act(async () => {
+ id = (await result.current.addAudioTrack("audio_1", 2)) ?? "";
+ });
+ await act(async () => {
+ await result.current.placeAudioTrack(id, { startMs: 2000, endMs: 4000 });
+ });
+ await act(async () => {
+ await result.current.setAudioTrackLoop(id, true);
+ });
+ const tracks = useProjectStore.getState().document?.audioTracks ?? [];
+ expect(tracks[0]).toMatchObject({ startMs: 2000, endMs: 10_000, loop: true });
+
+ // One step: the flag and the fill undo together.
+ act(() => {
+ expect(undo()).toBe(true);
+ });
+ const back = useProjectStore.getState().document?.audioTracks[0];
+ expect(back).toMatchObject({ endMs: 4000, loop: false });
+ });
+
+ it("turning loop off leaves the span alone", async () => {
+ // Shrinking back would throw away a length the user may have set by hand.
+ const { result } = renderTimeline();
+ let id = "";
+ await act(async () => {
+ id = (await result.current.addAudioTrack("audio_1", 2)) ?? "";
+ });
+ await act(async () => {
+ await result.current.setAudioTrackLoop(id, true);
+ });
+ const filled = useProjectStore.getState().document?.audioTracks[0]?.endMs;
+ await act(async () => {
+ await result.current.setAudioTrackLoop(id, false);
+ });
+ const track = useProjectStore.getState().document?.audioTracks[0];
+ expect(track?.loop).toBe(false);
+ expect(track?.endMs).toBe(filled);
+ });
+
+ it("removeAudioTrack deletes the track", async () => {
+ const { result } = renderTimeline();
+ let id = "";
+ await act(async () => {
+ id = (await result.current.addAudioTrack("audio_1")) ?? "";
+ });
+ await act(async () => {
+ await result.current.removeAudioTrack(id);
+ });
+ expect(useProjectStore.getState().document?.audioTracks).toEqual([]);
+ });
+
+ // #350: the toolbar button and the `M` shortcut both call `tl.addAudio`, which opens
+ // the OS file picker and hands the result to `importAudioAsset`. Spy on the store's
+ // import so these assert the wiring (picker → import), not the import itself.
+ it("addAudio imports the picked file, and is a no-op when the picker is cancelled", async () => {
+ const importSpy = vi.fn().mockResolvedValue(null);
+ useProjectStore.setState({ importAudioAsset: importSpy });
+ const pickerMock = vi.fn();
+ Object.defineProperty(window, "electronAPI", {
+ configurable: true,
+ value: { openAudioFilePicker: pickerMock },
+ });
+ const { result } = renderTimeline();
+
+ // Cancelled picker → nothing imported.
+ pickerMock.mockResolvedValueOnce({ success: false });
+ await act(async () => {
+ await result.current.addAudio();
+ });
+ expect(importSpy).not.toHaveBeenCalled();
+
+ // Picked a file → imported with its path and display name.
+ pickerMock.mockResolvedValueOnce({ success: true, path: "/tmp/bgm.mp3", name: "bgm.mp3" });
+ await act(async () => {
+ await result.current.addAudio();
+ });
+ expect(importSpy).toHaveBeenCalledWith("/tmp/bgm.mp3", "bgm.mp3");
+ });
+
+ it("addAudio clears region/clip selections after a successful import", async () => {
+ // importAudioAsset must resolve an asset for the success path to run.
+ useProjectStore.setState({ importAudioAsset: vi.fn().mockResolvedValue({ id: "audio_1" }) });
+ Object.defineProperty(window, "electronAPI", {
+ configurable: true,
+ value: {
+ openAudioFilePicker: vi
+ .fn()
+ .mockResolvedValue({ success: true, path: "/tmp/bgm.mp3", name: "bgm.mp3" }),
+ },
+ });
+ const { result } = renderTimeline();
+
+ // A clip selected before the import (selectClip and selectRegion are mutually
+ // exclusive, so a clip is enough to prove the import wipes the local selection)…
+ act(() => result.current.selectClip("clip_1"));
+ expect(result.current.clipSelection).toBe("clip_1");
+
+ await act(async () => {
+ await result.current.addAudio();
+ });
+ // …is gone after it (the imported track becomes the sole selection).
+ expect(result.current.selection).toBeNull();
+ expect(result.current.multiSelection).toEqual([]);
+ expect(result.current.clipSelection).toBeNull();
+ });
+
+ it("addAudio toasts when the file picker itself rejects", async () => {
+ toastErrorMock.mockClear();
+ const importSpy = vi.fn();
+ useProjectStore.setState({ importAudioAsset: importSpy });
+ Object.defineProperty(window, "electronAPI", {
+ configurable: true,
+ value: { openAudioFilePicker: vi.fn().mockRejectedValueOnce(new Error("ipc down")) },
+ });
+ const { result } = renderTimeline();
+
+ await act(async () => {
+ await result.current.addAudio();
+ });
+ // A picker rejection reaches the localized toast, not an unhandled rejection, and never
+ // attempts an import.
+ expect(importSpy).not.toHaveBeenCalled();
+ expect(toastErrorMock).toHaveBeenCalledTimes(1);
+ });
+
+ // #350 regression: a failed import-time probe leaves durationSec at 0, which
+ // makes the playback window zero-length. The on-load backfill re-probes and
+ // stamps the real duration onto the asset AND the track, so it can play again.
+ it("backfills a missing audio duration on load", async () => {
+ probeAudioDurationMock.mockResolvedValue(12.5);
+ // Asset imported with an unknown duration (probe failed), and a track that
+ // cached the resulting 0.
+ useProjectStore.setState({
+ projectId: "proj_test",
+ document: {
+ ...sampleDoc,
+ assets: [
+ ...sampleDoc.assets,
+ {
+ id: "audio_2",
+ kind: "audio",
+ label: "bgm.mp3",
+ originalPath: "/tmp/bgm.mp3",
+ cameraTrack: null,
+ },
+ ],
+ audioTracks: [
+ {
+ id: "trk_2",
+ assetId: "audio_2",
+ kind: "music" as const,
+ startMs: 0,
+ endMs: 1,
+ durationSec: 0,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "bgm.mp3",
+ origin: "user" as const,
+ },
+ ],
+ },
+ revision: 1,
+ status: "ready",
+ error: null,
+ });
+ renderTimeline();
+ await waitFor(() => {
+ const doc = useProjectStore.getState().document;
+ expect(doc?.assets.find((a) => a.id === "audio_2")?.durationSec).toBe(12.5);
+ expect(doc?.audioTracks[0]?.durationSec).toBe(12.5);
+ });
+ expect(probeAudioDurationMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 7823459ec..40e2408fd 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -4,24 +4,32 @@
// (a reasonable default for the user to then resize).
import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import type { AnnotationRegion, AnnotationType } from "@/components/video-editor/types";
import { useScopedT } from "@/contexts/I18nContext";
+import {
+ collapseTracksToPills,
+ patchAudioTrack,
+ placeAudioTrackInDocument,
+ removeAudioTrack as removeAudioTrackInDocument,
+ trackGroupId,
+} from "../document/audioTracks";
import { createId } from "../document/ids";
import {
duplicateClip as duplicateClipInDocument,
moveClip as moveClipInDocument,
PLACEHOLDER_DURATION_SEC,
type RegionKind,
- rederiveRegionMs,
removeClip as removeClipInDocument,
removeRegion as removeRegionInDocument,
resequenceClips,
setClipSourceRange,
+ withClipsChanged,
} from "../document/timeline";
-import type { AxcutClipCropRegion, AxcutDocument } from "../schema";
+import type { AxcutAudioTrack, AxcutClipCropRegion, AxcutDocument } from "../schema";
import { hasAnyClipWithCamera } from "../timeline/camera";
-import { probeVideoDimensions, probeVideoDuration } from "../timeline/duration";
+import { probeAudioDuration, probeVideoDimensions, probeVideoDuration } from "../timeline/duration";
import {
anchorRegionsWithDerivedMs,
dropPillsByIds,
@@ -105,6 +113,15 @@ export function useTimeline() {
// the Delete key operates on.
const [multiSelection, setMultiSelection] = useState([]);
const [clipSelection, setClipSelection] = useState(null);
+ // The selected imported audio track (issue #350) lives in the project store —
+ // not here — because the media panel and the inspector, in different subtrees,
+ // both touch it (see projectStore). It shares "this is the thing I mean"
+ // exclusivity with the region/clip selection above, so the selects below clear
+ // it and it clears them, but it carries none of the region delete/anchor logic.
+ const selectedAudioTrackId = useProjectStore((s) => s.selectedAudioTrackId);
+ const setSelectedAudioTrackId = useProjectStore((s) => s.setSelectedAudioTrackId);
+ const storeAddAudioTrack = useProjectStore((s) => s.addAudioTrack);
+ const importAudioAsset = useProjectStore((s) => s.importAudioAsset);
// Pre-drag snapshots for the two optimistic paths (zoom focus, annotations), so a
// failed commit can put the document back instead of leaving an edit on screen that
// was never written.
@@ -129,6 +146,17 @@ export function useTimeline() {
const hasDoc = document !== null && projectId !== null;
+ // Clear a stale audio-track selection. `removeAudioTrack` clears it on an explicit
+ // delete, but an undo (or any document swap) can drop the selected track WITHOUT
+ // going through that op — and then `selectedAudioTrackId` points at nothing while the
+ // inspector stays open on an empty AudioTrackPane, recoverable only by clicking a facet.
+ useEffect(() => {
+ if (selectedAudioTrackId === null) return;
+ if (!document?.audioTracks.some((t) => trackGroupId(t) === selectedAudioTrackId)) {
+ setSelectedAudioTrackId(null);
+ }
+ }, [document, selectedAudioTrackId, setSelectedAudioTrackId]);
+
// Backfill missing source dimensions for any USED asset whose `video` was never probed.
// `probeAndCorrectClip` only populates dims on INSERT, gated on a null duration, so an asset
// saved with a duration but no dims (e.g. a project migrated from before dims were probed
@@ -213,6 +241,60 @@ export function useTimeline() {
};
}, [document]);
+ // Backfill the real duration of imported audio assets (issue #350), the audio
+ // counterpart of the dimension backfill above. `addAudioAsset` probes once at
+ // import; a transient failure (timeout, a file still being written) would
+ // otherwise leave `durationSec` at 0 forever, and a 0-length window is a track
+ // that never plays and a pill with no width. Re-probe on load — once per asset
+ // per session, success or not — and stamp both the asset AND every track that
+ // caches its duration, with `history: false` so the fix is not an undo step.
+ const probedAudioAssetIdsRef = useRef>(new Set());
+ useEffect(() => {
+ if (!document) return;
+ const usedAssetIds = new Set(document.audioTracks.map((t) => t.assetId));
+ const missing = document.assets.filter(
+ (a) =>
+ a.kind === "audio" &&
+ a.originalPath &&
+ usedAssetIds.has(a.id) &&
+ !(a.durationSec && a.durationSec > 0) &&
+ !probedAudioAssetIdsRef.current.has(a.id),
+ );
+ if (missing.length === 0) return;
+ // Mark every candidate BEFORE the first await. Marking each only as its turn
+ // came meant a document change that re-entered this effect while asset #1 was
+ // still awaiting found #2+ unmarked and probed them a second time.
+ for (const a of missing) probedAudioAssetIdsRef.current.add(a.id);
+ let cancelled = false;
+ void (async () => {
+ const probed: Record = {};
+ for (const a of missing) {
+ const durationSec = await probeAudioDuration(toFileUrl(a.originalPath));
+ if (durationSec != null && durationSec > 0) probed[a.id] = durationSec;
+ }
+ if (cancelled || Object.keys(probed).length === 0) return;
+ const current = useProjectStore.getState().document;
+ if (!current) return;
+ await useProjectStore.getState().saveDocument(
+ {
+ ...current,
+ assets: current.assets.map((a) =>
+ probed[a.id] ? { ...a, durationSec: probed[a.id] } : a,
+ ),
+ audioTracks: current.audioTracks.map((t) =>
+ probed[t.assetId] && !(t.durationSec > 0)
+ ? { ...t, durationSec: probed[t.assetId] }
+ : t,
+ ),
+ },
+ { history: false },
+ );
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [document]);
+
// Every add* below anchors the new region to the clip(s) it covers before storing it.
// A modifier MUST own a clip anchor to survive reorder/trim (see
// technical-documentation/architecture/timeline-model.md) — writing only startMs/endMs
@@ -894,6 +976,7 @@ export function useTimeline() {
(kind: RegionKind, id: string, opts?: { additive?: boolean }) => {
const handle = { kind, id };
setClipSelection(null);
+ setSelectedAudioTrackId(null);
if (opts?.additive) {
// Shift-click toggles membership; the focused region follows the click.
setMultiSelection((prev) => {
@@ -906,14 +989,15 @@ export function useTimeline() {
setMultiSelection([handle]);
setSelection(handle);
},
- [],
+ [setSelectedAudioTrackId],
);
const clearSelection = useCallback(() => {
setSelection(null);
setMultiSelection([]);
setClipSelection(null);
- }, []);
+ setSelectedAudioTrackId(null);
+ }, [setSelectedAudioTrackId]);
// The Edit Clip dialog's Apply, as ONE document and ONE save.
//
@@ -1081,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);
@@ -1149,11 +1228,26 @@ export function useTimeline() {
);
// Mirror of selectRegion: picking a clip retires the pill selection.
- const selectClip = useCallback((id: string) => {
- setClipSelection(id);
- setSelection(null);
- setMultiSelection([]);
- }, []);
+ const selectClip = useCallback(
+ (id: string) => {
+ setClipSelection(id);
+ setSelection(null);
+ setMultiSelection([]);
+ setSelectedAudioTrackId(null);
+ },
+ [setSelectedAudioTrackId],
+ );
+
+ // Picking an audio track retires every other selection, same exclusivity rule.
+ const selectAudioTrack = useCallback(
+ (id: string) => {
+ setSelectedAudioTrackId(id);
+ setSelection(null);
+ setMultiSelection([]);
+ setClipSelection(null);
+ },
+ [setSelectedAudioTrackId],
+ );
const speedRegions = hasDoc
? (((document.legacyEditor as Record | null)?.speedRegions as Array<{
@@ -1173,14 +1267,197 @@ export function useTimeline() {
}>) ?? [])
: [];
+ // --- Timeline audio tracks (issue #350) -------------------------------------
+ // CLIP-ANCHORED like every region above: one user-visible track is one pill
+ // over one-or-more stored fragments, so these ops go through the shared pill
+ // helpers and address a track by its group id, never a fragment id.
+
+ // Place a new track for an imported audio asset, its head at the playhead (in
+ // RAW/document timeline seconds — the clock the ruler and playhead use, NOT the
+ // trim-compressed output programme the export mixes onto) unless the caller says
+ // otherwise. Delegates to the store op, which also selects the new track and
+ // returns its id (or null). On success, retire the hook-local region/clip
+ // selection so the new audio-track selection isn't held CONCURRENTLY with a
+ // stale region/clip one.
+ const addAudioTrack = useCallback(
+ async (
+ assetId: string,
+ timelineStartSec?: number,
+ options?: { kind?: "voiceover" | "music"; durationSec?: number; spanSec?: number },
+ ): Promise => {
+ const id = await storeAddAudioTrack(assetId, timelineStartSec ?? playheadSec(), options);
+ if (id) {
+ setSelection(null);
+ setMultiSelection([]);
+ setClipSelection(null);
+ }
+ return id;
+ },
+ [storeAddAudioTrack],
+ );
+
+ // Import an audio file and drop it on the timeline (issue #350). Lives here — not in
+ // the timeline toolbar — so the toolbar button and the keyboard shortcut (both call
+ // through `tl`) share one path. Opens a file picker, so unlike the region adds it takes
+ // no playhead duration; `importAudioAsset` places the track at the current playhead.
+ const addAudio = useCallback(async () => {
+ try {
+ // Inside the try so a rejected picker (an IPC failure, not a cancel) still reaches the
+ // localized toast instead of surfacing as an unhandled rejection. A cancel resolves with
+ // `success: false` and is a silent early return, not an error.
+ const picker = await window.electronAPI?.openAudioFilePicker?.();
+ if (!picker?.success || !picker.path) return;
+ const label = picker.name || picker.path.split(/[\\/]/).pop() || "Audio";
+ const asset = await importAudioAsset(picker.path, label);
+ // `importAudioAsset` selects the new track in the store, but the region/clip
+ // selections are hook-local state it can't touch — clear them here so an import
+ // doesn't leave a stale annotation/clip selected alongside the new track (the same
+ // exclusivity `addAudioTrack` keeps). Only on success: a failed import changes nothing.
+ if (asset) {
+ setSelection(null);
+ setMultiSelection([]);
+ setClipSelection(null);
+ }
+ } catch (err) {
+ toast.error(ts("audioTrack.importFailed"), {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }, [importAudioAsset, ts]);
+
+ const removeAudioTrack = useCallback(
+ async (trackId: string) => {
+ if (!document) return;
+ // Clear the inspector selection only AFTER the delete commits. A failed
+ // write leaves the track in the document, so it must keep its selection.
+ const ok = await saveDocument(removeAudioTrackInDocument(document, trackId), {
+ history: true,
+ });
+ if (ok && selectedAudioTrackId === trackId) setSelectedAudioTrackId(null);
+ },
+ [document, saveDocument, selectedAudioTrackId, setSelectedAudioTrackId],
+ );
+
+ // The commit for a lane drag or edge-resize: move the pill's whole span and
+ // re-ventilate it, so a track dragged across a cut becomes the right set of
+ // fragments in one write (one undo step). `offsetMs` is preserved as the
+ // track's own — `anchorAudioTrackFragments` re-derives each fragment's
+ // advance from the new geometry.
+ const placeAudioTrack = useCallback(
+ async (trackId: string, span: { startMs: number; endMs: number; offsetMs?: number }) => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ // The door replaces the whole group, so the survivors no longer need naming here.
+ const [pill] = collapseTracksToPills(
+ doc.audioTracks.filter((t) => trackGroupId(t) === trackId),
+ );
+ if (!pill) return;
+ const moved = {
+ ...pill,
+ startMs: Math.max(0, Math.round(span.startMs)),
+ endMs: Math.max(Math.round(span.startMs) + 1, Math.round(span.endMs)),
+ // A left-edge drag is a trim IN: the head moves right and the same
+ // amount is skipped in the source, so the audio under the pill stays
+ // put instead of sliding with it. Omitted by a plain move, which
+ // keeps the offset it already had.
+ offsetMs:
+ span.offsetMs === undefined ? pill.offsetMs : Math.max(0, Math.round(span.offsetMs)),
+ };
+ // A resize stops the dragged edge at the neighbour; a move keeps the take's
+ // duration and parks it against the wall. Cropping a take because it was
+ // dragged somewhere crowded would lose audio the user never asked to lose.
+ const next = placeAudioTrackInDocument(
+ doc,
+ moved,
+ () => createId("audio"),
+ span.offsetMs === undefined ? "move" : "resize",
+ );
+ if (next === doc) return;
+ await saveDocument(next, { history: true });
+ },
+ [saveDocument],
+ );
+
+ // Payload edits hit every fragment of the track — the halves of a split take
+ // must not disagree about gain, mute or loop.
+ const updateAudioTrack = useCallback(
+ async (
+ trackId: string,
+ patch: Partial<
+ Pick
+ >,
+ ) => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ await saveDocument(patchAudioTrack(doc, trackId, patch), { history: true });
+ },
+ [saveDocument],
+ );
+
+ // Turning loop ON fills the rest of the programme with the track.
+ //
+ // Looping only means anything when the span EXCEEDS the source, so a toggle
+ // that changed nothing else did nothing at all — the user had to know to then
+ // drag the pill's right edge out, which is not a thing anyone guesses. Filling
+ // is what "loop" is for, it is one undo away, and the edge still trims it back
+ // to any length. Turning loop OFF deliberately leaves the span alone: shrinking
+ // it would throw away a length the user may have set by hand.
+ const setAudioTrackLoop = useCallback(
+ async (trackId: string, loop: boolean) => {
+ const doc = useProjectStore.getState().document;
+ if (!doc) return;
+ const fragments = doc.audioTracks.filter((t) => trackGroupId(t) === trackId);
+ const [pill] = collapseTracksToPills(fragments);
+ if (!pill) return;
+ // Refused on a voiceover. `anchorAudioTrackFragments` does not advance `offsetMs`
+ // across a looping track's fragments, so its words map to raw moments they do not
+ // occupy — the transcript lane drops it, and a cut authored from it would land in
+ // the wrong place. Music loops; narration does not (issue #560).
+ if (loop && pill.kind === "voiceover") return;
+ const programmeEndMs = Math.round(
+ doc.timeline.clips.reduce((max, c) => Math.max(max, c.timelineEndSec), 0) * 1000,
+ );
+ // One write, so the fill and the flag are a single undo step.
+ const patched = patchAudioTrack(doc, trackId, { loop });
+ if (!loop || programmeEndMs <= pill.endMs) {
+ await saveDocument(patched, { history: true });
+ return;
+ }
+ // The fill stops at the next pill of its own kind, not at the programme end: a
+ // bed filling the timeline must not swallow a second bed that comes after it.
+ const filled = placeAudioTrackInDocument(
+ patched,
+ { ...pill, loop, endMs: programmeEndMs },
+ () => createId("audio"),
+ "resize",
+ );
+ await saveDocument(filled === patched ? patched : filled, { history: true });
+ },
+ [saveDocument],
+ );
+
+ const setAudioTrackGain = useCallback(
+ async (trackId: string, gainDb: number) => {
+ await updateAudioTrack(trackId, { gainDb });
+ },
+ [updateAudioTrack],
+ );
+
return {
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`).
annotationRegions: (document?.annotations ?? []) as unknown as AnnotationRegion[],
speedRegions,
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,
@@ -1193,6 +1470,15 @@ export function useTimeline() {
addCameraFullscreen,
removeRegion,
removeRegions,
+ addAudioTrack,
+ addAudio,
+ removeAudioTrack,
+ updateAudioTrack,
+ setAudioTrackLoop,
+ placeAudioTrack,
+ setAudioTrackGain,
+ selectedAudioTrackId,
+ selectAudioTrack,
selectRegion,
clearSelection,
applyClipEdit,
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
new file mode 100644
index 000000000..9cfebde27
--- /dev/null
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -0,0 +1,299 @@
+// Issue #560: the transcript tab was wired to `timeline.clips`, so a voiceover —
+// speech, with words, on the timeline — could not be read, trimmed or grounded
+// against. The aggregation is now parameterised by lane, and these hold the two
+// providers to the same contract.
+
+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 {
+ startMs: 0,
+ endMs: 4000,
+ clipId: "clip_1",
+ sourceStartSec: 0,
+ sourceEndSec: 4,
+ assetId: "asset_vo",
+ kind: "voiceover",
+ durationSec: 30,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+ ...over,
+ } as unknown as AxcutAudioTrack;
+}
+
+describe("voiceoverPlacements", () => {
+ it("windows the source by the fragment's own offset, not the file's head", () => {
+ // A fragment that starts 6s into its file and plays for 4s is 6s..10s of
+ // speech. Reading from 0 would caption the wrong sentence entirely.
+ const [placement] = voiceoverPlacements([
+ track({ id: "t1", offsetMs: 6000, startMs: 2000, endMs: 6000 }),
+ ]);
+ expect(placement.sourceStartSec).toBe(6);
+ expect(placement.sourceEndSec).toBe(10);
+ expect(placement.timelineStartSec).toBe(2);
+ });
+
+ it("leaves music out — it is never transcribed, so it is never a lane", () => {
+ const placements = voiceoverPlacements([
+ track({ id: "t1", kind: "music" }),
+ track({ id: "t2", kind: "voiceover" }),
+ ]);
+ expect(placements.map((p) => p.id)).toEqual(["t2"]);
+ });
+
+ it("orders by the ruler, not by the order the tracks were written", () => {
+ const placements = voiceoverPlacements([
+ track({ id: "late", startMs: 9000, endMs: 12000 }),
+ track({ id: "early", startMs: 1000, endMs: 3000 }),
+ ]);
+ expect(placements.map((p) => p.id)).toEqual(["early", "late"]);
+ });
+
+ it("folds a ventilated take back into one placement", () => {
+ // The fragments exist because the take spans a cut in the FILM, not because the
+ // narration is in two pieces. The walk recomputes the source advance, so collapsing
+ // them is safe now — and necessary, because a fragment's own source window knows
+ // nothing about an insertion before it.
+ const placements = voiceoverPlacements([
+ track({ id: "f1", trackId: "T", startMs: 0, endMs: 3000, offsetMs: 0 }),
+ track({ id: "f2", trackId: "T", startMs: 3000, endMs: 5000, offsetMs: 3000 }),
+ ]);
+ expect(placements.map((p) => [p.sourceStartSec, p.sourceEndSec])).toEqual([[0, 5]]);
+ });
+
+ it("splits at the CUTS, not at the fragment boundaries", () => {
+ const placements = voiceoverPlacements(
+ [track({ id: "f1", trackId: "T", startMs: 0, endMs: 6000, offsetMs: 0 })],
+ [{ startSec: 2, endSec: 4, trimIds: ["t1"] }],
+ );
+ // The take is heard 0..2 and 4..6 of the ruler, reading source 0..2 and 4..6 — its
+ // own clock ran through the cut, so the words after it stay on their picture.
+ expect(placements.map((p) => [p.timelineStartSec, p.sourceStartSec, p.sourceEndSec])).toEqual([
+ [0, 0, 2],
+ [4, 4, 6],
+ ]);
+ });
+});
+
+describe("lanePlacements", () => {
+ const CLIPS = [
+ {
+ id: "clip_1",
+ assetId: "asset_rec",
+ sourceStartSec: 0,
+ sourceEndSec: 12,
+ timelineStartSec: 0,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ];
+
+ it("reads the recording by default and the voiceover on request", () => {
+ const tracks = [track({ id: "t1" })];
+ expect(lanePlacements("recording", CLIPS, tracks).map((p) => p.assetId)).toEqual(["asset_rec"]);
+ expect(lanePlacements("voiceover", CLIPS, tracks).map((p) => p.assetId)).toEqual(["asset_vo"]);
+ });
+
+ it("gives the aggregator sections it can key words on", () => {
+ // The whole point of the parameterisation: everything downstream consumes
+ // sections, and a voiceover section has to be indistinguishable from a clip's.
+ const transcript = {
+ assetId: "asset_vo",
+ language: "en",
+ words: [
+ { id: "w1", segmentId: "s", text: "bonjour", startSec: 0.2, endSec: 0.8 },
+ { id: "w2", segmentId: "s", text: "tout", startSec: 0.8, endSec: 1.1 },
+ ],
+ segments: [],
+ };
+ const sections = buildAggregatedSections(
+ lanePlacements("voiceover", CLIPS, [track({ id: "t1" })]),
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ [transcript as any],
+ [],
+ [],
+ );
+ expect(sections).toHaveLength(1);
+ expect(sections[0].words.filter((w) => !w.word.id.startsWith("silence_"))).toHaveLength(2);
+ // Namespaced by placement id, so two placements over one asset never collide.
+ 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);
+ });
+});
+
+// ─── 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
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 1a35e9e61..1527a7f37 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -14,8 +14,59 @@
// 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, AxcutClip, AxcutTranscript, AxcutTrimRange, AxcutWord } from "../schema";
-import { trimAppliesToClip } from "./trim-mapping";
+import { collapseTracksToPills } from "../document/audioTracks";
+import type { AxcutAsset, AxcutAudioTrack, AxcutClip, AxcutTranscript, AxcutWord } from "../schema";
+import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
+import { takeProgramme } from "./take-programme";
+
+/**
+ * The unit the aggregation actually runs over: one stretch of ONE asset's source
+ * time, laid somewhere on the timeline (issue #560).
+ *
+ * `AxcutClip` is one provider of this and was, for a long time, the only one —
+ * which is why everything downstream is still named after clips. A voiceover is
+ * the second: speech that the transcript tab could not see, because the tab was
+ * wired to `timeline.clips` rather than to the shape clips happen to have.
+ *
+ * Deliberately structural rather than a union of the two record types. Nothing
+ * below this line needs to know which lane a section came from, and the moment it
+ * could ask, something would start behaving differently per lane — which is the
+ * one thing this parameterisation is meant to prevent.
+ */
+export interface TranscriptPlacement {
+ /** Unique on the timeline. Namespaces every rendered word (see {@link clipWordId}). */
+ id: string;
+ assetId: string;
+ sourceStartSec: number;
+ /** Open-ended when the placement runs to the end of its source. */
+ sourceEndSec?: number;
+ /** 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;
+}
+
+/** 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;
@@ -25,6 +76,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
@@ -38,7 +96,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;
@@ -66,8 +131,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`. */
@@ -98,15 +168,16 @@ 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 clip's contribution to the aggregated flow. */
+/** One placement's contribution to the aggregated flow. */
export interface ClipSection {
- clip: AxcutClip;
+ /** Named `clip` for its history, not its type — see {@link TranscriptPlacement}. */
+ clip: TranscriptPlacement;
asset: AxcutAsset | null;
transcript: AxcutTranscript | null;
words: ClipWord[];
@@ -114,40 +185,36 @@ export interface ClipSection {
}
function wordsInRange(transcript: AxcutTranscript, startSec: number, endSec: number): AxcutWord[] {
- return transcript.words.filter((w) => w.endSec > startSec && w.startSec < endSec);
-}
-
-/** 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;
+ 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,
+ );
}
/**
- * 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: AxcutClip,
+ 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),
@@ -156,25 +223,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,
@@ -183,23 +252,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);
@@ -215,10 +287,10 @@ export function buildClipSection(
* the clip exists but no transcript is available for it yet.
*/
export function buildAggregatedSections(
- clips: AxcutClip[],
+ 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]));
@@ -227,19 +299,57 @@ export function buildAggregatedSections(
clip,
transcriptById.get(clip.assetId) ?? null,
assetById.get(clip.assetId) ?? null,
- trimRanges,
+ removed,
),
);
}
-/** 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;
+/**
+ * The voiceover lane as placements, in timeline order.
+ *
+ * Music is excluded here rather than filtered downstream: it is not transcribed at
+ * 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: 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
+ * 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[],
+ /** 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[] = [],
+): 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)
+ .filter((piece) => piece.kind === "play")
+ .map((piece, i) => ({
+ // Namespaced by piece so two stretches of one take never collide on a word id.
+ id: i === 0 ? pill.id : `${pill.id}#${i}`,
+ assetId: pill.assetId,
+ sourceStartSec: piece.sourceStartSec,
+ sourceEndSec: piece.sourceEndSec,
+ timelineStartSec: piece.rawStartSec,
+ })),
+ );
+}
+
+/** The placements a lane contributes, in timeline order. */
+export function lanePlacements(
+ lane: TranscriptLane,
+ clips: AxcutClip[],
+ audioTracks: AxcutAudioTrack[],
+ removed: readonly RemovedRawSpan[] = [],
+): TranscriptPlacement[] {
+ return lane === "voiceover" ? voiceoverPlacements(audioTracks, removed) : clips;
}
/**
@@ -254,22 +364,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/clip-parts.test.ts b/src/lib/ai-edition/timeline/clip-parts.test.ts
new file mode 100644
index 000000000..447ac3dec
--- /dev/null
+++ b/src/lib/ai-edition/timeline/clip-parts.test.ts
@@ -0,0 +1,48 @@
+// 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 { 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 clip", () => {
+ expect(extensionDurationSec("a")).toBe(0.15);
+ });
+
+ it("is nothing at all for nothing at all", () => {
+ expect(extensionDurationSec(" ")).toBe(0);
+ });
+});
+
+/** 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 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",
+ );
+ });
+
+ 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("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("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
new file mode 100644
index 000000000..acc33bfab
--- /dev/null
+++ b/src/lib/ai-edition/timeline/clip-parts.ts
@@ -0,0 +1,60 @@
+// What an insertion is made of: a duration, a name, and a file.
+//
+// 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 { 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 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. */
+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";
+}
+
+/** 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:";
+
+/** 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}`;
+}
+
+/** Hidden, because it is derived: deleting it costs nothing but a regeneration. */
+export const EXTENSIONS_DIR = ".openscreen-extensions";
+
+/** Where the generated media for an added word lives.
+ *
+ * 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/duration.test.ts b/src/lib/ai-edition/timeline/duration.test.ts
index 528c3b910..ccc41cb5a 100644
--- a/src/lib/ai-edition/timeline/duration.test.ts
+++ b/src/lib/ai-edition/timeline/duration.test.ts
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { probeVideoDuration } from "./duration";
+import { probeAudioDuration, probeVideoDuration } from "./duration";
interface FakeVideo {
duration: number;
@@ -8,7 +8,16 @@ interface FakeVideo {
onerror: ((ev: Event) => unknown) | null;
}
-describe("probeVideoDuration", () => {
+// `probeVideoDuration` and `probeAudioDuration` are the same probe on a
+// different media element, so the fake-element harness and the cases are shared
+// and driven per tag. See the schema/store for why audio import needs its own
+// probe (issue #350).
+const PROBES = [
+ { label: "probeVideoDuration", tag: "video", probe: probeVideoDuration },
+ { label: "probeAudioDuration", tag: "audio", probe: probeAudioDuration },
+] as const;
+
+describe.each(PROBES)("$label", ({ tag, probe }) => {
let created: FakeVideo[];
let originalCreate: typeof document.createElement;
let appendSpy: ReturnType | null;
@@ -21,9 +30,9 @@ describe("probeVideoDuration", () => {
// `"webview"`-only overload — the one `.call` resolves to, which then rejects a
// generic string tag. Pin the plain `(tagName: string) => HTMLElement` overload.
const createReal: (this: Document, tag: string) => HTMLElement = originalCreate;
- document.createElement = ((tag: string) => {
- const node = createReal.call(document, tag);
- if (tag === "video") {
+ document.createElement = ((el: string) => {
+ const node = createReal.call(document, el);
+ if (el === tag) {
const fake: FakeVideo = {
duration: Number.NaN,
onloadedmetadata: null,
@@ -66,11 +75,11 @@ describe("probeVideoDuration", () => {
});
it("returns null when src is empty", async () => {
- await expect(probeVideoDuration("")).resolves.toBeNull();
+ await expect(probe("")).resolves.toBeNull();
});
it("returns duration on loadedmetadata", async () => {
- const p = probeVideoDuration("file:///tmp/clip.mp4");
+ const p = probe("file:///tmp/clip");
await vi.advanceTimersByTimeAsync(0);
const v = created[0];
v.duration = 12.5;
@@ -79,14 +88,14 @@ describe("probeVideoDuration", () => {
});
it("returns null on error", async () => {
- const p = probeVideoDuration("file:///missing.mp4");
+ const p = probe("file:///missing");
await vi.advanceTimersByTimeAsync(0);
created[0].onerror?.(new Event("error"));
await expect(p).resolves.toBeNull();
});
it("returns null on timeout", async () => {
- const p = probeVideoDuration("file:///slow.mp4", 1000);
+ const p = probe("file:///slow", 1000);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(2000);
await expect(p).resolves.toBeNull();
@@ -94,7 +103,7 @@ describe("probeVideoDuration", () => {
it("returns null for non-finite duration", async () => {
for (const d of [Number.POSITIVE_INFINITY, Number.NaN, -1, 0]) {
- const p = probeVideoDuration("file:///x.mp4");
+ const p = probe("file:///x");
await vi.advanceTimersByTimeAsync(0);
const v = created[created.length - 1];
v.duration = d;
diff --git a/src/lib/ai-edition/timeline/duration.ts b/src/lib/ai-edition/timeline/duration.ts
index 950d2ae3b..41f47dd33 100644
--- a/src/lib/ai-edition/timeline/duration.ts
+++ b/src/lib/ai-edition/timeline/duration.ts
@@ -1,47 +1,47 @@
-// Probe a video file's actual duration by mounting a hidden and
-// waiting for loadedmetadata. Used by the timeline store to size
-// freshly-inserted clips at the real source duration, so the user sees
-// the correct clip width immediately on drop instead of the placeholder.
-//
-// Falls back to null on error / timeout / non-finite duration. Caller
-// decides whether to fall back to a placeholder (60s) or surface an error.
-//
-// ponytail: probe via DOM rather than the existing VirtualPreview.
-// We need this BEFORE the clip is on the timeline (so insertClipAt can
-// size it correctly), but VirtualPreview only mounts once a clip exists.
-// A throwaway is the cleanest no-extra-component solution.
-
const DEFAULT_TIMEOUT_MS = 5000;
-export function probeVideoDuration(
+// The duration probe: mount a hidden media element and wait for loadedmetadata.
+// Falls back to null on error / timeout / non-finite duration; the caller decides
+// whether to use a placeholder (60s) or surface an error.
+//
+// One function for both and — only the tag differs, and every
+// property touched (preload, style, onloadedmetadata, onerror, duration,
+// removeAttribute, load, src) lives on HTMLMediaElement, which both are. Sharing
+// it keeps a fix to the settle/cleanup/timeout logic from drifting between the two.
+//
+// ponytail: probe via a throwaway DOM element rather than VirtualPreview, which
+// only mounts once a clip exists — we need the duration BEFORE that, so
+// insertClipAt can size the clip correctly on drop.
+function probeMediaDuration(
+ tag: "video" | "audio",
src: string,
- timeoutMs: number = DEFAULT_TIMEOUT_MS,
+ timeoutMs: number,
): Promise {
return new Promise((resolve) => {
if (typeof document === "undefined" || !src) {
resolve(null);
return;
}
- const video = document.createElement("video");
- video.preload = "metadata";
- video.style.position = "absolute";
- video.style.width = "1px";
- video.style.height = "1px";
- video.style.opacity = "0";
- video.style.pointerEvents = "none";
- video.style.left = "-9999px";
+ const el = document.createElement(tag);
+ el.preload = "metadata";
+ el.style.position = "absolute";
+ el.style.width = "1px";
+ el.style.height = "1px";
+ el.style.opacity = "0";
+ el.style.pointerEvents = "none";
+ el.style.left = "-9999px";
let settled = false;
const cleanup = () => {
- video.onloadedmetadata = null;
- video.onerror = null;
+ el.onloadedmetadata = null;
+ el.onerror = null;
clearTimeout(timer);
try {
- video.removeAttribute("src");
- video.load();
+ el.removeAttribute("src");
+ el.load();
} catch {
// ignore — browser may refuse if already detached
}
- if (video.parentNode) video.parentNode.removeChild(video);
+ if (el.parentNode) el.parentNode.removeChild(el);
};
const settle = (value: number | null) => {
if (settled) return;
@@ -50,18 +50,37 @@ export function probeVideoDuration(
resolve(value);
};
const timer = setTimeout(() => settle(null), timeoutMs);
- video.onloadedmetadata = () => {
- const d = video.duration;
+ el.onloadedmetadata = () => {
+ const d = el.duration;
settle(Number.isFinite(d) && d > 0 ? d : null);
};
- video.onerror = () => settle(null);
- // ponytail: append to body so some browsers (Firefox) actually fire
- // loadedmetadata for fully-detached elements.
- document.body.appendChild(video);
- video.src = src;
+ el.onerror = () => settle(null);
+ // Append to body so some browsers (Firefox) actually fire loadedmetadata
+ // for a fully-detached media element.
+ document.body.appendChild(el);
+ el.src = src;
});
}
+/** Duration of a video file, to size a freshly-inserted clip at its real length. */
+export function probeVideoDuration(
+ src: string,
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
+): Promise {
+ return probeMediaDuration("video", src, timeoutMs);
+}
+
+/**
+ * Duration of an imported audio file (issue #350) — the audio counterpart of
+ * `probeVideoDuration`, used to size a voiceover / BGM / SFX track pill on add.
+ */
+export function probeAudioDuration(
+ src: string,
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
+): Promise {
+ return probeMediaDuration("audio", src, timeoutMs);
+}
+
/** Native pixel dimensions, same probe shape as `probeVideoDuration` (separate DOM element —
* cheap, one-shot, not worth merging into a combined probe for the one extra caller that
* needs both). `asset.video` was otherwise left permanently unset for most recordings (nothing
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);
+}
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);
diff --git a/src/lib/ai-edition/timeline/take-programme.test.ts b/src/lib/ai-edition/timeline/take-programme.test.ts
new file mode 100644
index 000000000..def48cc48
--- /dev/null
+++ b/src/lib/ai-edition/timeline/take-programme.test.ts
@@ -0,0 +1,81 @@
+// Issue #560. A take loses time to a cut and gains it to an insertion, and the two must be
+// one walk: resolved in two passes, an insertion's raw moment would be computed without the
+// holds before it and land in the wrong place.
+//
+// The load-bearing property is the demotion: with no insertions this must agree with
+// `subtractRemoved` exactly, because that is what the export and the preview already do and
+// their answers must not move.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutAudioTrack, AxcutClip, AxcutTrimRange } from "../schema";
+import { removedRawSpans, subtractRemoved } from "./programme-time";
+import { takePlaybackAt, takeProgramme } from "./take-programme";
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "rec",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+const trim = (startSec: number, endSec: number, id = "t1"): AxcutTrimRange =>
+ ({
+ id,
+ assetId: "rec",
+ clipId: "c1",
+ startSec,
+ endSec,
+ origin: "user",
+ reason: "",
+ }) as AxcutTrimRange;
+
+/** A take from raw 0 to raw 10, reading its file from the head. */
+const TAKE = { startMs: 0, endMs: 10_000, offsetMs: 0 } as Pick<
+ AxcutAudioTrack,
+ "startMs" | "endMs" | "offsetMs"
+>;
+
+describe("takeProgramme", () => {
+ it("is exactly subtractRemoved when nothing is inserted", () => {
+ // The demotion. Every fixture the export and the preview already agree on has to
+ // keep its current answer.
+ for (const cuts of [
+ [] as AxcutTrimRange[],
+ [trim(3, 5)],
+ [trim(0, 2)],
+ [trim(8, 12)],
+ [trim(2, 3), trim(6, 7, "t2")],
+ ]) {
+ 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);
+ }
+ });
+});
+
+describe("takePlaybackAt", () => {
+ 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("has nothing to say outside the take", () => {
+ expect(takePlaybackAt(pieces, 12)).toBeNull();
+ });
+});
+
+// ─── The preview and the export, held to each other ─────────────────────────
+// 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.
diff --git a/src/lib/ai-edition/timeline/take-programme.ts b/src/lib/ai-edition/timeline/take-programme.ts
new file mode 100644
index 000000000..1d0017054
--- /dev/null
+++ b/src/lib/ai-edition/timeline/take-programme.ts
@@ -0,0 +1,108 @@
+// One walk over a voice-over take (issue #560).
+//
+// 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.
+//
+// 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 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";
+
+/** One stretch of a take, in playback order. */
+export interface TakePiece {
+ /**
+ * `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" | "removed";
+ /** Stored RAW ruler seconds. */
+ rawStartSec: number;
+ rawEndSec: number;
+ /** The take's own file. */
+ sourceStartSec: number;
+ sourceEndSec: number;
+}
+
+const EPSILON_SEC = 1e-9;
+
+/** The take, stretch by stretch, in playback order: what is heard, and what a cut mutes. */
+export function takeProgramme(
+ pill: Pick,
+ removed: readonly RemovedRawSpan[],
+): TakePiece[] {
+ const rawStart = pill.startMs / 1000;
+ const rawEnd = Math.max(rawStart, pill.endMs / 1000);
+ const sourceStart = Math.max(0, pill.offsetMs / 1000);
+
+ const cuts = [...removed]
+ .filter((span) => span.endSec > rawStart && span.startSec < rawEnd)
+ .sort((a, b) => a.startSec - b.startSec);
+
+ const pieces: TakePiece[] = [];
+ let raw = rawStart;
+ // 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: to,
+ sourceStartSec: sourceStart + (raw - rawStart),
+ sourceEndSec: sourceStart + (to - rawStart),
+ });
+ raw = to;
+ };
+
+ 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;
+}
+
+/** The take's stretch of raw ruler, or null when it has none. */
+export function takeRulerExtent(pieces: readonly TakePiece[]): {
+ startSec: number;
+ endSec: number;
+} | null {
+ if (pieces.length === 0) return null;
+ return { startSec: pieces[0].rawStartSec, endSec: pieces[pieces.length - 1].rawEndSec };
+}
+
+/**
+ * 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.
+ */
+export function consumedSourceSec(pieces: readonly TakePiece[]): number {
+ return pieces.reduce((sum, piece) => sum + (piece.sourceEndSec - piece.sourceStartSec), 0);
+}
+
+/** Where the voice is, and whether it is heard, at a raw moment. */
+export function takePlaybackAt(
+ pieces: readonly TakePiece[],
+ rawSec: number,
+): { targetTimeSec: number; shouldPlay: boolean } | null {
+ for (const piece of pieces) {
+ if (rawSec < piece.rawStartSec) break;
+ if (rawSec >= piece.rawEndSec) continue;
+
+ const target = piece.sourceStartSec + (rawSec - piece.rawStartSec);
+ return { targetTimeSec: target, shouldPlay: piece.kind === "play" };
+ }
+ return null;
+}
diff --git a/src/lib/ai-edition/timeline/trim-mapping.ts b/src/lib/ai-edition/timeline/trim-mapping.ts
index 98ba9f68a..0dee56d7d 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.ts
@@ -42,7 +42,10 @@ export type TrimAnchor = Pick
* keeps those rather than dropping them). It keeps the historical asset-wide meaning, so
* old documents render exactly as they did.
*/
-export function trimAppliesToClip(trim: TrimAnchor, clip: AxcutClip): boolean {
+export function trimAppliesToClip(
+ trim: TrimAnchor,
+ clip: Pick,
+): boolean {
if (trim.clipId !== undefined) return trim.clipId === clip.id;
return trim.assetId === clip.assetId;
}
diff --git a/src/lib/ai-edition/timeline/voiceoverCut.test.ts b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
new file mode 100644
index 000000000..7e121be61
--- /dev/null
+++ b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
@@ -0,0 +1,131 @@
+// Issue #560, step 4. A cut authored from the voiceover lane used to be anchored on an
+// AUDIO FRAGMENT — `resolvePlaybackSegments` matched nothing for it, so the word turned
+// red and the film, the preview and the export were all unchanged. A silent lie.
+//
+// The pane now emits a RAW span and the write site resolves the clips under it. These pin
+// the arithmetic that does it; the pane's own clamp is pinned in TranscriptPane.lanes.
+
+import { describe, expect, it } from "vitest";
+import { resolvePlaybackSegments } from "../document/timeline";
+import type { AxcutClip, AxcutTrimRange } from "../schema";
+import { placementRawSec, voiceoverPlacements } from "./aggregated-transcript";
+import {
+ coalescedTrimGroups,
+ dropTrimPillsByIds,
+ ventilateTimelineSpanToTrims,
+} from "./trim-mapping";
+
+/** Two 6s clips over one asset, laid end to end. */
+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: "",
+ },
+];
+
+/** The write site's arithmetic: a raw span becomes trim rows on the clips under it. */
+function cut(startSec: number, endSec: number): AxcutTrimRange[] {
+ return ventilateTimelineSpanToTrims(startSec, endSec, CLIPS).map((range, i) => ({
+ id: `t${i}`,
+ assetId: range.assetId,
+ clipId: range.clipId,
+ startSec: range.sourceStartSec,
+ endSec: range.sourceEndSec,
+ origin: "user" as const,
+ reason: "",
+ }));
+}
+
+const filmSec = (trims: AxcutTrimRange[]) =>
+ resolvePlaybackSegments(CLIPS, trims).reduce(
+ (sum, seg) => sum + ((seg.sourceEndSec ?? seg.sourceStartSec) - seg.sourceStartSec),
+ 0,
+ );
+
+const VOICE = {
+ id: "vo",
+ trackId: "vo",
+ assetId: "aud",
+ kind: "voiceover" as const,
+ startMs: 0,
+ endMs: 12_000,
+ durationSec: 12,
+ offsetMs: 0,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user" as const,
+};
+
+describe("a cut authored from the voiceover lane", () => {
+ it("lands on a real clip, and the film gets shorter", () => {
+ // A word at raw 2..3 of the take. The take carries no clip, so the old anchoring
+ // 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));
+ expect(rows).toHaveLength(1);
+ expect(CLIPS.map((c) => c.id)).toContain(rows[0].clipId);
+ expect(filmSec([])).toBeCloseTo(12, 6);
+ expect(filmSec(rows)).toBeCloseTo(11, 6);
+ });
+
+ it("becomes several rows and one pill when it crosses a clip boundary", () => {
+ const rows = cut(5, 7);
+ expect(rows).toHaveLength(2);
+ expect(rows.map((r) => r.clipId)).toEqual(["c1", "c2"]);
+ // Source time is per asset and the two clips draw from different positions, so the
+ // rows cannot be one range — but they are one thing on the ruler.
+ expect(rows.map((r) => [r.startSec, r.endSec])).toEqual([
+ [5, 6],
+ [20, 21],
+ ]);
+ expect(coalescedTrimGroups(rows, CLIPS)).toHaveLength(1);
+ expect(filmSec(rows)).toBeCloseTo(10, 6);
+ });
+
+ it("drops every row of the pill when one of them is restored", () => {
+ 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([]);
+ });
+
+ it("writes nothing where there is no film", () => {
+ const gapped = [CLIPS[0], { ...CLIPS[1], timelineStartSec: 9, timelineEndSec: 15 }];
+ // Over an inter-clip gap...
+ expect(ventilateTimelineSpanToTrims(7, 8, gapped)).toEqual([]);
+ // ...and past the end of the programme. The caller shows an error rather than
+ // falling back to the nearest clip: cutting the closest thing would remove
+ // something the user never pointed at.
+ expect(ventilateTimelineSpanToTrims(20, 22, CLIPS)).toEqual([]);
+ });
+
+ it("stays inside its own clip when a word straddles the edge", () => {
+ // A word from raw 5.5 to 6.5 clamped to c1's extent cuts only c1 — unclamped it
+ // would take the head of c2 with it, which the user never asked for.
+ expect(cut(5.5, 6).map((r) => r.clipId)).toEqual(["c1"]);
+ expect(cut(5.5, 6.5).map((r) => r.clipId)).toEqual(["c1", "c2"]);
+ });
+});
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index e40636dee..d453936bf 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { AxcutDocument, AxcutTranscript } from "../schema";
import {
type AssetTranscriptionView,
+ assetCanCarrySpeech,
classifyTranscriptionError,
deriveAssetStatus,
firstBusyView,
@@ -245,6 +246,7 @@ const base = {
transcripts: [],
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
@@ -393,3 +395,54 @@ describe("deriveAssetStatus carries the engine's own report", () => {
expect(derived.rtf).toBeUndefined();
});
});
+
+describe("assetCanCarrySpeech", () => {
+ /** A document with one video asset and one imported audio asset. */
+ const doc = (audioTracks: Array>) =>
+ ({
+ assets: [
+ { id: "vid", kind: "video" },
+ { id: "aud", kind: "audio" },
+ ],
+ audioTracks,
+ }) as unknown as Parameters[0];
+
+ const track = (kind: "voiceover" | "music", assetId = "aud") => ({
+ id: `t_${kind}`,
+ assetId,
+ kind,
+ });
+
+ it("says yes to footage without consulting the timeline", () => {
+ // Video is the case that always carried speech; the guard must not regress it.
+ expect(assetCanCarrySpeech(doc([]), "vid")).toBe(true);
+ });
+
+ it("says yes to an audio asset played on a voiceover lane", () => {
+ expect(assetCanCarrySpeech(doc([track("voiceover")]), "aud")).toBe(true);
+ });
+
+ it("says no to a music bed", () => {
+ // The whole point: 35s of inference at editor open, to transcribe music.
+ expect(assetCanCarrySpeech(doc([track("music")]), "aud")).toBe(false);
+ });
+
+ it("says yes when the same file is on both lanes", () => {
+ // One voiceover placement is enough — the file demonstrably carries speech,
+ // whatever else it is also used for.
+ expect(assetCanCarrySpeech(doc([track("music"), track("voiceover")]), "aud")).toBe(true);
+ });
+
+ it("says no to an audio asset no region plays", () => {
+ // Nothing is asking for it, so nothing should pay for it.
+ expect(assetCanCarrySpeech(doc([]), "aud")).toBe(false);
+ });
+
+ it("ignores regions playing a different file", () => {
+ expect(assetCanCarrySpeech(doc([track("voiceover", "other")]), "aud")).toBe(false);
+ });
+
+ it("says no to an asset that is not in the document", () => {
+ expect(assetCanCarrySpeech(doc([]), "ghost")).toBe(false);
+ });
+});
diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts
index 8bc98b489..f9df6a89f 100644
--- a/src/lib/ai-edition/transcription/status.ts
+++ b/src/lib/ai-edition/transcription/status.ts
@@ -7,6 +7,7 @@
// effects, this module owns the vocabulary.
import type { AxcutDocument, AxcutTranscript } from "../schema";
+import { voiceoverPlacements } from "../timeline/aggregated-transcript";
/** Why a transcription run could not produce anything. */
export type TranscriptionFailureKind = "no-audio" | "unsupported-audio" | "error";
@@ -314,12 +315,51 @@ export function resolveTranscriptGate(views: AssetTranscriptionView[]): Transcri
* make it look ready when the clip on screen has no transcript. Falls back to
* the whole bin while the timeline is still empty.
*/
+/**
+ * Can this asset plausibly carry speech?
+ *
+ * The background pass transcribes every asset in the document, which was harmless
+ * while every asset was footage. Imported audio broke that: a music bed is speech to
+ * nobody, and whisper spends real time discovering it. Measured on a four-minute bed:
+ * 35s of GPU inference at editor open, for 164 segments of transcribed music.
+ *
+ * "Can carry speech" is NOT a property of the asset — `AxcutAsset.kind` only knows
+ * `video | audio`. The voiceover/music distinction lives on the TRACK, so the question
+ * is answered from the timeline: an audio asset qualifies exactly when some track
+ * playing it sits on the voiceover lane.
+ *
+ * Stable under a lane change, which matters because the track's `kind` is editable:
+ *
+ * - music -> voiceover queues it, which is right: it is speech now.
+ * - voiceover -> music discards nothing. The transcript already exists, and the
+ * caller skips an asset that has one, so the round trip is lossless rather than
+ * paid for twice.
+ *
+ * An audio asset no track plays is not transcribed either: nothing is asking for it.
+ * See issue #560, where this rule was settled.
+ */
+export function assetCanCarrySpeech(document: AxcutDocument, assetId: string): boolean {
+ const asset = document.assets.find((a) => a.id === assetId);
+ if (!asset) return false;
+ if (asset.kind !== "audio") return true;
+ return document.audioTracks.some(
+ (track) => track.assetId === assetId && track.kind === "voiceover",
+ );
+}
+
export function transcriptRelevantAssetIds(document: AxcutDocument | null): string[] {
if (!document) return [];
+ // The UNION of both lanes. "Can this project be transcribed" is not a per-lane
+ // question — a voiceover-only project has speech to transcribe with no clip carrying
+ // it, and narrowing this to the selected lane would report "no transcript" on a
+ // project whose other lane is full of words (issue #560).
const onTimeline: string[] = [];
for (const clip of document.timeline.clips) {
if (!onTimeline.includes(clip.assetId)) onTimeline.push(clip.assetId);
}
+ for (const placement of voiceoverPlacements(document.audioTracks ?? [])) {
+ if (!onTimeline.includes(placement.assetId)) onTimeline.push(placement.assetId);
+ }
const known = new Set(document.assets.map((a) => a.id));
const filtered = onTimeline.filter((id) => known.has(id));
return filtered.length > 0 ? filtered : document.assets.map((a) => a.id);
diff --git a/src/lib/captioning/index.ts b/src/lib/captioning/index.ts
index 99da5a1f0..a5ad5dabd 100644
--- a/src/lib/captioning/index.ts
+++ b/src/lib/captioning/index.ts
@@ -12,4 +12,4 @@ export type {
CaptionTimestampGranularity,
TranscribeMono16kResult,
} from "./transcribe";
-export { transcribeMono16kToSegments } from "./transcribe";
+export { transcribeMono16kToSegments, transcribeSourceFileToSegments } from "./transcribe";
diff --git a/src/lib/captioning/transcribe.ts b/src/lib/captioning/transcribe.ts
index a8c6ec3e9..7b8473608 100644
--- a/src/lib/captioning/transcribe.ts
+++ b/src/lib/captioning/transcribe.ts
@@ -74,12 +74,40 @@ interface RendererSttApi {
*/
export function transcribeMono16kToSegments(
samples: Float32Array,
- options?: {
- trimRegions?: TrimRegion[];
- onStatus?: (status: SttRendererStatus) => void;
- signal?: AbortSignal;
- language?: string;
- },
+ options?: TranscribeOptions,
+): Promise {
+ return runTranscription({ samples }, options);
+}
+
+/**
+ * Same recognition, from a FILE the main process decodes itself.
+ *
+ * Preferred over `transcribeMono16kToSegments` wherever there is a path to point at.
+ * The samples entry point decodes in the renderer — whole file into memory, an
+ * `arrayBuffer()` copy, a `slice(0)` copy, then a resample loop on the UI thread —
+ * which is what froze the editor at open on a long import. Here the renderer sends a
+ * string and gets segments back.
+ *
+ * Rejects with a message carrying `STT_NATIVE_EXTRACTION_UNAVAILABLE` when the
+ * install has no ffmpeg, so the caller can fall back rather than lose the transcript.
+ */
+export function transcribeSourceFileToSegments(
+ sourcePath: string,
+ options?: TranscribeOptions,
+): Promise {
+ return runTranscription({ sourcePath }, options);
+}
+
+export interface TranscribeOptions {
+ trimRegions?: TrimRegion[];
+ onStatus?: (status: SttRendererStatus) => void;
+ signal?: AbortSignal;
+ language?: string;
+}
+
+function runTranscription(
+ payload: { samples: Float32Array } | { sourcePath: string },
+ options?: TranscribeOptions,
): Promise {
if (options?.signal?.aborted) {
return Promise.reject(new DOMException("Aborted", "AbortError"));
@@ -108,7 +136,7 @@ export function transcribeMono16kToSegments(
// iteration trimmed leading silence with a peak detector and got false
// positives on quiet music intros / room tone. VAD or nothing.
return api
- .transcribe({ samples, language: forcedLanguage })
+ .transcribe({ ...payload, language: forcedLanguage })
.then((result) => {
const words = result.wordSegments ?? [];
let segments: CaptionSegment[];
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
index 0487b91c7..94098a4c0 100644
--- a/src/lib/shortcuts.ts
+++ b/src/lib/shortcuts.ts
@@ -5,6 +5,8 @@ export const SHORTCUT_ACTIONS = [
"addSpeed",
"addCameraFullscreen",
"addAnnotation",
+ "addAudio",
+ "addVoiceover",
"deleteSelected",
"playPause",
"copySelected",
@@ -113,6 +115,9 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addSpeed: { key: "s" },
addCameraFullscreen: { key: "c" },
addAnnotation: { key: "a" },
+ addAudio: { key: "m" },
+ // Record a voiceover over the timeline from the playhead.
+ addVoiceover: { key: "v" },
deleteSelected: { key: "d", ctrl: true },
playPause: { key: " " },
copySelected: { key: "c", ctrl: true },
@@ -126,6 +131,8 @@ export const SHORTCUT_LABELS: Record = {
addSpeed: "Add Speed",
addCameraFullscreen: "Add Full Camera",
addAnnotation: "Add Annotation",
+ addAudio: "Add Audio",
+ addVoiceover: "Record Voiceover",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
copySelected: "Copy Selected",
diff --git a/src/native/browserShim.test.ts b/src/native/browserShim.test.ts
new file mode 100644
index 000000000..07f5966b8
--- /dev/null
+++ b/src/native/browserShim.test.ts
@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+// The shim persists projects to localStorage, so this needs a DOM.
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+import type { AxcutDocument } from "@/lib/ai-edition/schema";
+import { installBrowserShims } from "./browserShim";
+import { nativeBridgeClient } from "./client";
+
+// The bridge contract types `document` as `unknown`; the shim returns real
+// AxcutDocuments, so narrow here rather than reaching for `any`.
+const asDoc = (d: unknown) => d as AxcutDocument;
+
+// installBrowserShims patches the real nativeBridgeClient's methods in place, so
+// installing once is enough; each test starts from a clean localStorage. The
+// `?browser` query is what flips detectBrowserMode() on outside Electron.
+beforeAll(() => {
+ window.history.replaceState(null, "", "/?browser");
+ installBrowserShims();
+});
+beforeEach(() => {
+ localStorage.clear();
+});
+
+async function freshProjectId(): Promise {
+ const created = await nativeBridgeClient.aiEdition.create("P");
+ const id = asDoc(created.document).project.id;
+ if (!id) throw new Error("shim create returned no project");
+ return id;
+}
+
+describe("browserShim addAsset (issue #350)", () => {
+ it("keeps kind 'audio' and does not claim the empty primary slot", async () => {
+ const projectId = await freshProjectId();
+ const res = await nativeBridgeClient.aiEdition.addAsset(
+ projectId,
+ "/tmp/music.mp3",
+ "music",
+ "audio",
+ );
+ const doc = asDoc(res.document);
+ const asset = doc.assets.at(-1);
+ expect(asset?.kind).toBe("audio");
+ // An audio import must never become the primary asset (mirrors the main
+ // process's document-service.addAsset).
+ expect(doc.project.primaryAssetId).toBeUndefined();
+ });
+
+ it("still lets a video import claim the empty primary slot", async () => {
+ const projectId = await freshProjectId();
+ const res = await nativeBridgeClient.aiEdition.addAsset(
+ projectId,
+ "/tmp/screen.mp4",
+ "screen",
+ "video",
+ );
+ const doc = asDoc(res.document);
+ const asset = doc.assets.at(-1);
+ expect(asset?.kind).toBe("video");
+ expect(doc.project.primaryAssetId).toBe(asset?.id);
+ });
+});
diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts
index 8ef5989a8..c9e5d395b 100644
--- a/src/native/browserShim.ts
+++ b/src/native/browserShim.ts
@@ -186,7 +186,7 @@ function createShimBridgeClient() {
updatedAt: string;
primaryAssetId?: string;
};
- assets: Array<{ id: string; kind: "video"; label: string; originalPath: string }>;
+ assets: Array<{ id: string; kind: "video" | "audio"; label: string; originalPath: string }>;
[key: string]: unknown;
};
const projectsStorageKey = "browser-shim-projects-v2";
@@ -386,6 +386,7 @@ function createShimBridgeClient() {
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
documentsByProject[doc.project.id] = doc;
@@ -407,20 +408,27 @@ function createShimBridgeClient() {
saveProjectsState();
return Promise.resolve({ success: true });
},
- addAsset: (projectId: string, path: string, label?: string) => {
+ addAsset: (projectId: string, path: string, label?: string, kind?: "video" | "audio") => {
const doc = documentsByProject[projectId];
if (!doc) return Promise.resolve({ assetId: "", document: null });
const assetId = `asset_${Math.random().toString(36).slice(2, 10)}`;
+ const assetKind = kind ?? "video";
const asset = {
id: assetId,
- kind: "video" as const,
+ kind: assetKind,
label: label || path.split(/[\\/]/).pop() || "Recording",
originalPath: path,
};
+ // Mirror the main-process rule: an audio import never claims the empty
+ // primary slot (see document-service.addAsset).
+ const claimsPrimary = assetKind !== "audio" && !doc.project.primaryAssetId;
const next: ShimDocument = {
...doc,
assets: [...doc.assets, asset],
- project: { ...doc.project, primaryAssetId: doc.project.primaryAssetId ?? assetId },
+ project: {
+ ...doc.project,
+ primaryAssetId: claimsPrimary ? assetId : doc.project.primaryAssetId,
+ },
};
documentsByProject[projectId] = next;
saveProjectsState();
diff --git a/src/native/client.ts b/src/native/client.ts
index eed5d68d7..fce40fa6c 100644
--- a/src/native/client.ts
+++ b/src/native/client.ts
@@ -181,11 +181,11 @@ export const nativeBridgeClient = {
action: "document.delete",
payload: { projectId },
}),
- addAsset: (projectId: string, path: string, label?: string) =>
+ addAsset: (projectId: string, path: string, label?: string, kind?: "video" | "audio") =>
requireNativeBridgeData({
domain: "aiEdition",
action: "document.addAsset",
- payload: { projectId, path, label },
+ payload: { projectId, path, label, kind },
}),
removeAsset: (projectId: string, assetId: string) =>
requireNativeBridgeData({
diff --git a/src/native/contracts.ts b/src/native/contracts.ts
index 7e7fc11c9..54c1cc775 100644
--- a/src/native/contracts.ts
+++ b/src/native/contracts.ts
@@ -533,7 +533,7 @@ export type NativeBridgeRequest =
| {
domain: "aiEdition";
action: "document.addAsset";
- payload: { projectId: string; path: string; label?: string };
+ payload: { projectId: string; path: string; label?: string; kind?: "video" | "audio" };
requestId?: string;
}
| {
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index 4fc088e86..346c1af3c 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,
};
}
@@ -1990,3 +1991,343 @@ describe("buildSceneDescription.captions", () => {
expect(text?.color).toBe("#ffffff");
});
});
+
+// --- imported audio tracks (issue #350) ------------------------------------
+describe("buildSceneDescription.audioTracks", () => {
+ const audioAsset = makeAsset({
+ id: "aud",
+ kind: "audio",
+ originalPath: "/music.mp3",
+ durationSec: 30,
+ });
+ // A 10s span starting at raw 5s, playing the source from 2s in.
+ const track = {
+ id: "trk1",
+ assetId: "aud",
+ kind: "music" as const,
+ startMs: 5000,
+ endMs: 15_000,
+ durationSec: 30,
+ offsetMs: 2000,
+ gainDb: -3,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user" as const,
+ };
+
+ it("maps a track to the mix list with its resolved path and window", () => {
+ const doc = makeDoc({ assets: [audioAsset], audioTracks: [track] });
+ expect(buildSceneDescription(doc).audioTracks).toEqual([
+ {
+ path: "/music.mp3",
+ startSec: 5,
+ gainDb: -3,
+ trimStartSec: 2,
+ // The span is 10s and the file has 28s left after the offset, so the
+ // span is what runs out first.
+ trimEndSec: 12,
+ fadeInSec: 0,
+ fadeOutSec: 0,
+ },
+ ]);
+ });
+
+ it("caps the trim-out at the end of the file when the span outlasts it", () => {
+ const doc = makeDoc({
+ assets: [audioAsset],
+ // A 40s span over a 30s file, offset 2s: only 28s of source exist.
+ audioTracks: [{ ...track, endMs: 45_000 }],
+ });
+ expect(buildSceneDescription(doc).audioTracks[0]?.trimEndSec).toBe(30);
+ });
+
+ // ─── A cut under a voiceover ────────────────────────────────────────────────
+ // Issue #560. A trim removes the moment it covers; the transcript pane strikes the
+ // words said there through. If the mix played them anyway, shifted earlier, the red
+ // would be a lie — so a voiceover is SLICED by the cuts. Music is not: a bed plays
+ // through and ends early, deliberately, and has no words whose redness must be true.
+
+ /** One 10s clip, cut over raw 4..6. */
+ function cutDoc(tracks: Array & { kind: "music" | "voiceover" }>) {
+ return makeDoc({
+ assets: [
+ audioAsset,
+ makeAsset({ id: "scr", kind: "video", originalPath: "/screen.mp4", durationSec: 10 }),
+ ],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ }),
+ ],
+ timeline: {
+ trimRanges: [
+ {
+ id: "t1",
+ assetId: "scr",
+ clipId: "c1",
+ startSec: 4,
+ endSec: 6,
+ reason: "",
+ origin: "user",
+ },
+ ],
+ },
+ audioTracks: tracks,
+ });
+ }
+
+ /** A voiceover over the whole 10s, reading its file from the head. */
+ const voice = {
+ ...track,
+ kind: "voiceover" as const,
+ startMs: 0,
+ endMs: 10_000,
+ offsetMs: 0,
+ gainDb: 0,
+ };
+
+ it("splits a voiceover at the cut, skipping exactly the seconds the film lost", () => {
+ const entries = buildSceneDescription(cutDoc([voice])).audioTracks;
+ expect(entries).toHaveLength(2);
+ // Before the cut: raw 0..4 of the take, at output 0.
+ expect(entries[0]).toMatchObject({ startSec: 0, trimStartSec: 0, trimEndSec: 4 });
+ // After it: raw 6..10 of the take, at output 4 — the source jumps the two seconds
+ // the cut took. Today's music path would instead play 0..8 and stop early.
+ expect(entries[1]).toMatchObject({ startSec: 4, trimStartSec: 6, trimEndSec: 10 });
+ });
+
+ it("keeps the fades on the take's outer edges across a split", () => {
+ const entries = buildSceneDescription(
+ cutDoc([{ ...voice, fadeInMs: 500, fadeOutMs: 500 }]),
+ ).audioTracks;
+ expect(entries.map((e) => [e.fadeInSec, e.fadeOutSec])).toEqual([
+ [0.5, 0],
+ [0, 0.5],
+ ]);
+ });
+
+ it("drops a voiceover buried inside a cut, and keeps one that hangs past the film", () => {
+ expect(
+ buildSceneDescription(cutDoc([{ ...voice, startMs: 4200, endMs: 5800 }])).audioTracks,
+ ).toEqual([]);
+ // Raw time past the last clip is unfilmed, not removed: the narration plays on.
+ const over = buildSceneDescription(
+ cutDoc([{ ...voice, startMs: 10_000, endMs: 14_000 }]),
+ ).audioTracks;
+ expect(over).toHaveLength(1);
+ expect(over[0]).toMatchObject({ trimStartSec: 0, trimEndSec: 4 });
+ });
+
+ it("leaves a music bed under the same cut exactly as it was", () => {
+ const entries = buildSceneDescription(cutDoc([{ ...voice, kind: "music" }])).audioTracks;
+ // One contiguous entry, shortened at the tail by what the cut took — the behaviour
+ // `VirtualPreview` and `mix_external_tracks` have always had for a bed.
+ expect(entries).toEqual([
+ {
+ path: "/music.mp3",
+ startSec: 0,
+ gainDb: 0,
+ trimStartSec: 0,
+ trimEndSec: 8,
+ fadeInSec: 0,
+ fadeOutSec: 0,
+ },
+ ]);
+ });
+
+ it("leaves a LOOPING voiceover on the music path, which step 6 will forbid outright", () => {
+ // The window comes from the ASSET's duration, so the short file has to be there.
+ const doc = cutDoc([{ ...voice, loop: true, endMs: 10_000 }]);
+ const short = {
+ ...doc,
+ assets: doc.assets.map((a) => (a.id === "aud" ? { ...a, durationSec: 3 } : a)),
+ };
+ const entries = buildSceneDescription(short).audioTracks;
+ // Repeats, not slices: inventing semantics for a combination about to be banned
+ // would be the worse answer.
+ expect(entries.length).toBeGreaterThan(1);
+ expect(entries.every((e) => e.trimStartSec === 0)).toBe(true);
+ });
+
+ it("drops a muted track from the mix list", () => {
+ const doc = makeDoc({ assets: [audioAsset], audioTracks: [{ ...track, muted: true }] });
+ expect(buildSceneDescription(doc).audioTracks).toEqual([]);
+ });
+
+ it("emits one entry per repeat for a looping track", () => {
+ const doc = makeDoc({
+ assets: [audioAsset],
+ // 4s of source (offset 26 into a 30s file) under a 10s span → 3 repeats.
+ audioTracks: [{ ...track, offsetMs: 26_000, loop: true, fadeInMs: 500, fadeOutMs: 500 }],
+ });
+ const entries = buildSceneDescription(doc).audioTracks;
+ expect(entries.map((e) => [e.startSec, e.trimStartSec, e.trimEndSec])).toEqual([
+ [5, 26, 30],
+ [9, 26, 30],
+ [13, 26, 28],
+ ]);
+ // The fades belong to the track's edges, not to every repeat.
+ expect(entries.map((e) => [e.fadeInSec, e.fadeOutSec])).toEqual([
+ [0.5, 0],
+ [0, 0],
+ [0, 0.5],
+ ]);
+ });
+
+ it("projects the head onto the trim-compressed programme (issue #350)", () => {
+ // A 10s screen clip with an interior cut removing raw [2,4] (2s). The audio track's
+ // raw head is 5; on the compressed programme that is 3. Passing 5 through verbatim was
+ // the bug — the track played 2s (the trim) late in the render while the preview, whose
+ // playhead jumps the cut, had it on time.
+ const screen = makeAsset({ id: "scr", originalPath: "/screen.mp4", durationSec: 10 });
+ const doc = makeDoc({
+ assets: [screen, audioAsset],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ }),
+ ],
+ timeline: {
+ trimRanges: [
+ { id: "t1", assetId: "scr", startSec: 2, endSec: 4, reason: "", origin: "user" },
+ ],
+ },
+ audioTracks: [track],
+ });
+ expect(buildSceneDescription(doc).audioTracks[0]?.startSec).toBeCloseTo(3, 6);
+ });
+
+ it("drops a track that sits entirely inside a trimmed stretch", () => {
+ // The trim takes raw 4..8 out of the programme; a track living at raw 5..7
+ // has nowhere left to play. It used to project both ends onto the cut and
+ // then play its full raw length there — audible, and out of place, with
+ // nothing on screen to account for it.
+ const screen = makeAsset({ id: "scr", originalPath: "/screen.mp4", durationSec: 20 });
+ const doc = makeDoc({
+ assets: [screen, audioAsset],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ }),
+ ],
+ timeline: {
+ trimRanges: [
+ { id: "t1", assetId: "scr", startSec: 4, endSec: 8, reason: "", origin: "user" },
+ ],
+ },
+ audioTracks: [{ ...track, startMs: 5000, endMs: 7000 }],
+ });
+ expect(buildSceneDescription(doc).audioTracks).toEqual([]);
+ });
+
+ it("shortens a track by the trim it crosses", () => {
+ // Raw 2..12 with raw 4..8 cut is 6s of programme, not 10.
+ const screen = makeAsset({ id: "scr", originalPath: "/screen.mp4", durationSec: 20 });
+ const doc = makeDoc({
+ assets: [screen, audioAsset],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ }),
+ ],
+ timeline: {
+ trimRanges: [
+ { id: "t1", assetId: "scr", startSec: 4, endSec: 8, reason: "", origin: "user" },
+ ],
+ },
+ audioTracks: [{ ...track, startMs: 2000, endMs: 12_000, offsetMs: 0 }],
+ });
+ const [entry] = buildSceneDescription(doc).audioTracks;
+ expect(entry.startSec).toBeCloseTo(2, 6);
+ expect(entry.trimEndSec - entry.trimStartSec).toBeCloseTo(6, 6);
+ });
+
+ it("places a track after a speed region on the compressed clock", () => {
+ // The programme is time-stretched before the tracks are mixed onto it
+ // (`stretch_clip_pcm_by_speed` then `mix_external_tracks`), so raw 12 with
+ // raw 4..8 at 2x is output 10. Blind to speed the track landed at 12 —
+ // two seconds late, and later still the more the video is sped up.
+ const screen = makeAsset({ id: "scr", originalPath: "/screen.mp4", durationSec: 20 });
+ const doc = makeDoc({
+ assets: [screen, audioAsset],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ }),
+ ],
+ legacyEditor: { speedRegions: [{ id: "s1", startMs: 4000, endMs: 8000, speed: 2 }] },
+ audioTracks: [{ ...track, startMs: 12_000, endMs: 16_000, offsetMs: 0 }],
+ });
+ const [entry] = buildSceneDescription(doc).audioTracks;
+ expect(entry.startSec).toBeCloseTo(10, 6);
+ // ...and the track itself is NOT stretched: 4 raw seconds of audio stay 4
+ // seconds of source, whatever the video under it is doing.
+ expect(entry.trimEndSec - entry.trimStartSec).toBeCloseTo(4, 6);
+ });
+
+ it("does not shorten a track just because the video under it is sped up", () => {
+ // A speed region compresses the programme; it does not delete anything. The
+ // track still holds all its audio and still plays at 1x, so a 4s voiceover
+ // under a 2x region is still 4s of narration — measuring its length on the
+ // compressed clock silently cut it in half.
+ const screen = makeAsset({ id: "scr", originalPath: "/screen.mp4", durationSec: 20 });
+ const doc = makeDoc({
+ assets: [screen, audioAsset],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 20,
+ timelineStartSec: 0,
+ timelineEndSec: 20,
+ }),
+ ],
+ legacyEditor: { speedRegions: [{ id: "s1", startMs: 2000, endMs: 10_000, speed: 2 }] },
+ audioTracks: [{ ...track, startMs: 4000, endMs: 8000, offsetMs: 0 }],
+ });
+ const [entry] = buildSceneDescription(doc).audioTracks;
+ expect(entry.trimEndSec - entry.trimStartSec).toBeCloseTo(4, 6);
+ // Its head still moves onto the compressed clock: raw 4 is 1s into a 2x
+ // stretch that began at raw 2, so output 3.
+ expect(entry.startSec).toBeCloseTo(3, 6);
+ });
+
+ it("drops a track whose asset has no resolvable path", () => {
+ const doc = makeDoc({ assets: [], audioTracks: [track] });
+ expect(buildSceneDescription(doc).audioTracks).toEqual([]);
+ });
+
+ it("is empty for a project with no imported audio", () => {
+ const doc = makeDoc({ assets: [makeAsset({ id: "a", originalPath: "/a.mp4" })] });
+ expect(buildSceneDescription(doc).audioTracks).toEqual([]);
+ });
+});
diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts
index 376a8580a..ce22db0ed 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -28,13 +28,21 @@ import {
getCaptionSettings,
getCaptionTranslations,
} from "@/lib/ai-edition/captions";
+import { collapseTracksToPills, trackGroupId } from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
import { pickOutputDims } from "@/lib/ai-edition/document/outputFormat";
-import { resolvePlaybackSegments } from "@/lib/ai-edition/document/timeline";
+import {
+ type PlaybackSegment,
+ type PlaybackSpeedRegion,
+ projectRawTimelineSecToPlayback,
+ resolvePlaybackSegments,
+} from "@/lib/ai-edition/document/timeline";
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 { 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";
import {
computeCompositeLayout,
@@ -411,6 +419,31 @@ export interface SceneDescription {
audio: {
gainDb: number;
};
+ /**
+ * Timeline audio tracks (issue #350), mixed over the assembled programme by
+ * `audio::mix_external_tracks`. One entry per contiguous stretch: a track split by a
+ * clip boundary contributes one entry per fragment (each picking the source up where
+ * the last left off), and a looping track one entry per repeat.
+ *
+ * `startSec` is the head on the trim-COMPRESSED output programme: the track's raw
+ * timeline head projected through the trims via `projectRawTimelineSecToPlayback`, so
+ * a cut ahead of the track pulls it earlier by the removed duration (exactly as the
+ * preview already plays it). Exact for trims; speed regions stay an approximation.
+ * `trimEndSec` is always concrete — the compositor preallocates the decode window
+ * from it — so it is resolved from the span and the source duration.
+ */
+ audioTracks: Array<{
+ path: string;
+ startSec: number;
+ gainDb: number;
+ trimStartSec: number;
+ trimEndSec: number;
+ /** Ramp lengths at the entry's own edges, in seconds. A split or looping
+ * track carries them only on the pieces that touch the track's real
+ * start and end, so it fades once rather than at every cut or repeat. */
+ fadeInSec: number;
+ fadeOutSec: number;
+ }>;
/**
* Per-clip screen crop (fractions of the frame), or null for the identity
* (full-frame) crop. One entry per clip in the same order as `clips`, so a
@@ -472,11 +505,26 @@ function parseWallpaper(wallpaper: string) {
* `NativeCompositorOverlay.tsx`'s `nativeClips` (live preview) — previously these three each
* hand-rolled their own sort+filter, acknowledged as needing to be "kept in lock-step".
*/
-export function resolveVisibleClips(document: AxcutDocument): AxcutClip[] {
+/** Whether a clip's media can actually be read — the one rule that decides
+ * which clips make it into the programme. Shared with the audio-track
+ * projection, which must count exactly the clips the programme is built from
+ * or every track after a relinked-away clip lands late. */
+function clipAssetIsResolvable(
+ clip: { assetId: string },
+ assetById: Map,
+): boolean {
+ return Boolean(assetById.get(clip.assetId)?.originalPath);
+}
+
+/**
+ * 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[] {
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)
- .filter((clip) => assetById.get(clip.assetId)?.originalPath);
+ .filter((clip) => clipAssetIsResolvable(clip, assetById));
}
/** Serialize a document into a {@link SceneDescription}. Pure — no per-frame math. */
@@ -487,6 +535,165 @@ export function buildSceneDescription(
const settings = getEditorSettings(document);
const assetById = new Map(document.assets.map((a) => [a.id, a]));
+ // Timeline audio tracks (issue #350) → the compositor's mix list.
+ //
+ // Each STORED track is one clip-anchored fragment, already carrying its own
+ // advanced `offsetMs`, so a fragment maps to one contiguous decode window and
+ // the pieces of a split take play as one continuous take. Project each head
+ // onto the trim-compressed programme the mixer overlays on: the head is
+ // stored in RAW ruler seconds, and passing it verbatim delayed every track by
+ // the total trim duration ahead of it (issue #350).
+ //
+ // Projected onto the same clips the programme is assembled from —
+ // `resolveVisibleClips` drops clips whose asset has no resolvable
+ // `originalPath`, and a projection that counted a relinked-away clip the
+ // programme does not would land every following track past the real end.
+ // `projectRawTimelineSecToPlayback` subtracts the trims itself, so it needs
+ // the RAW clips behind that filter, not the already-compressed segments.
+ const projectedClips = document.timeline.clips.filter((clip) =>
+ clipAssetIsResolvable(clip, assetById),
+ );
+ // Speed regions on the RAW ruler. The programme these tracks mix onto has
+ // already been time-stretched by them (`stretch_clip_pcm_by_speed` runs before
+ // `mix_external_tracks`), so a projection blind to speed lands every track
+ // after a speed region at the wrong second. The tracks themselves are never
+ // stretched — a voiceover should not chipmunk because the video under it was
+ // sped up.
+ const rawSpeedRegions = (
+ ((document.legacyEditor as Record | null)?.speedRegions as
+ | PlaybackSpeedRegion[]
+ | undefined) ?? []
+ ).filter((r) => Number.isFinite(r.speed) && r.speed > 0);
+ // 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
+ 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 `+=`
+ // at an absolute offset — the export would contain the take playing on top of itself.
+ const voiceoverPills = new Map(
+ collapseTracksToPills(document.audioTracks)
+ .filter((pill) => pill.kind === "voiceover" && !pill.loop)
+ .map((pill) => [trackGroupId(pill), pill]),
+ );
+ const audioTracks = document.audioTracks.flatMap((track) => {
+ if (track.muted) return [];
+ const asset = assetById.get(track.assetId);
+ if (!asset?.originalPath) return [];
+ const sourceDurationSec = asset.durationSec ?? track.durationSec;
+ const offsetSec = track.offsetMs / 1000;
+ const startSec = projectRawTimelineSecToPlayback(
+ projectedClips,
+ document.timeline.trimRanges,
+ track.startMs / 1000,
+ rawSpeedRegions,
+ );
+ // Length is measured WITHOUT speed, position WITH it — the two do different
+ // things to a track and must not be conflated.
+ //
+ // A trim REMOVES timeline: a track inside removed time has nowhere left to
+ // be (zero length, dropped), and one crossing a cut loses what the cut took.
+ // A speed region only COMPRESSES: the track still holds all its audio and
+ // still plays at 1x, so speeding the video up must not quietly cut the
+ // narration short. It changes where the track STARTS, because the programme
+ // ahead of it got shorter, and nothing else.
+ const trimmedSpanSec =
+ projectRawTimelineSecToPlayback(
+ projectedClips,
+ document.timeline.trimRanges,
+ track.endMs / 1000,
+ ) -
+ projectRawTimelineSecToPlayback(
+ projectedClips,
+ document.timeline.trimRanges,
+ track.startMs / 1000,
+ );
+ const spanSec = trimmedSpanSec;
+ if (spanSec <= 0) return [];
+ const base = {
+ path: asset.originalPath,
+ gainDb: track.gainDb,
+ fadeInSec: track.fadeInMs / 1000,
+ fadeOutSec: track.fadeOutMs / 1000,
+ };
+ // The window the file has left after the offset. Without a probed duration
+ // there is nothing to loop over and nothing to cap the tail with, so the
+ // span itself is the window — the mixer stops at the real end of the file.
+ const windowSec = sourceDurationSec > 0 ? Math.max(0, sourceDurationSec - offsetSec) : spanSec;
+ if (windowSec <= 0) return [];
+
+ // A cut under a VOICEOVER removes the words that were said there, not the tail of
+ // the take (issue #560). The transcript pane strikes those words through; if the
+ // mix went on playing them, shifted earlier, the red would be a lie.
+ //
+ // Music deliberately keeps the branch below: a bed plays through a cut and ends
+ // early, because slicing it at every edit is a musical regression, and a bed has no
+ // words whose redness has to be true. A LOOPING voiceover keeps it too — step 6 of
+ // #560 refuses that combination outright, and inventing semantics for something
+ // about to be banned would be the worse answer.
+ if (track.kind === "voiceover" && !track.loop) {
+ const groupId = trackGroupId(track);
+ const pill = voiceoverPills.get(groupId);
+ // Emitted from the group's HEAD fragment only — every other fragment of the same
+ // take is already covered by the pill's own walk.
+ if (!pill || pill.id !== track.id) return [];
+ const rawSpanSec = Math.max(0, pill.endMs / 1000 - pill.startMs / 1000);
+ // Unprobed assets have no real duration to cap with; the RAW span is how much
+ // 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;
+ const kept = takeProgramme(pill, removed)
+ .filter((piece) => piece.kind === "play")
+ .map((piece) => ({
+ ...base,
+ startSec: projectRawTimelineSecToPlayback(
+ projectedClips,
+ document.timeline.trimRanges,
+ piece.rawStartSec,
+ rawSpeedRegions,
+ ),
+ trimStartSec: piece.sourceStartSec,
+ trimEndSec: Math.min(offsetSec + voWindowSec, piece.sourceEndSec),
+ }))
+ .filter((entry) => entry.trimEndSec > entry.trimStartSec);
+ // The fades belong to the TAKE's edges, not to every piece a cut left behind.
+ return kept.map((entry, i) => ({
+ ...entry,
+ fadeInSec: i === 0 ? base.fadeInSec : 0,
+ fadeOutSec: i === kept.length - 1 ? base.fadeOutSec : 0,
+ }));
+ }
+ if (!track.loop) {
+ return [
+ {
+ ...base,
+ startSec,
+ trimStartSec: offsetSec,
+ // Always concrete: the compositor preallocates its decode window
+ // from it. Whichever runs out first — the span or the file.
+ trimEndSec: offsetSec + Math.min(windowSec, spanSec),
+ },
+ ];
+ }
+ // A looping track is one mix entry per repeat: the mixer overlays entries
+ // independently, so the repeats are just more of them. The last one is cut
+ // short wherever the span ends.
+ const entries = [];
+ for (let played = 0; played < spanSec && entries.length < 1000; played += windowSec) {
+ const thisSec = Math.min(windowSec, spanSec - played);
+ entries.push({
+ ...base,
+ startSec: startSec + played,
+ trimStartSec: offsetSec,
+ trimEndSec: offsetSec + thisSec,
+ // The fades belong to the track's edges, not to every repeat.
+ fadeInSec: played === 0 ? base.fadeInSec : 0,
+ fadeOutSec: played + thisSec >= spanSec ? base.fadeOutSec : 0,
+ });
+ }
+ return entries;
+ });
const visibleClips = resolveVisibleClips(document);
const clips: CompositorClipInput[] = visibleClips.flatMap((clip) => {
const asset = assetById.get(clip.assetId);
@@ -507,6 +714,8 @@ export function buildSceneDescription(
sourceEndSec: resolveClipSourceEndSec(clip, asset),
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
+ // A held segment has an empty source window and exists only for the frames it
+ // holds; every other clip holds nothing.
},
];
});
@@ -821,6 +1030,7 @@ export function buildSceneDescription(
audio: {
gainDb: settings.audioGainDb,
},
+ audioTracks,
background: parseWallpaper(settings.wallpaper),
zoomRegions: projectedZoomRegions.map((region) => ({
id: region.id,
diff --git a/technical-documentation/architecture/document-model.md b/technical-documentation/architecture/document-model.md
index c312ad605..ea5e76c88 100644
--- a/technical-documentation/architecture/document-model.md
+++ b/technical-documentation/architecture/document-model.md
@@ -27,6 +27,7 @@ and anything unknown is rejected by the `z.literal(axcutSchemaVersion)` check in
| `timeline` | `{ clips[], gaps[], trimRanges[], muteRanges[], speedRanges[], captionRanges[] }` | Clips carry their own in/out (`sourceStartSec`/`sourceEndSec`); trims are anchored to a clip (`clipId?`) since v7. See [timeline-model.md](timeline-model.md). |
| `annotations[]` | `AxcutAnnotationRegion[]` | Text/image/figure/blur overlays, anchored to a clip (`clipId?`). |
| `zoomRanges[]` | `AxcutZoomRegion[]` | Zoom-in effects, depth 1–6, anchored to a clip (`clipId?`). |
+| `audioTracks[]` | `AxcutAudioTrack[]` | Imported audio (voiceover / BGM / SFX, issue #350) mixed over the programme. NOT clip-anchored — addressed in RAW/document timeline seconds (`timelineStartSec`), with `trimStartSec`/`trimEndSec` windowing the source and `gainDb` its level. Added from the timeline toolbar; the referenced asset has `kind: "audio"`. |
| `legacyEditor` | OpenScreen v2 `ProjectEditorState` passthrough | Appearance/cursor settings not yet first-class in the AI-edition schema. |
| `agent` | `{ baseIntent?, pendingQuestions[], suggestions[], lastAppliedOperations[], lastReasoningSummary? }` | LLM agent state. |
| `preview` | `{ strategy: "seek" \| "mse-proxy", revision: number }` | `revision` is the bump used to invalidate cached frames after an edit. |
diff --git a/technical-documentation/architecture/export-pipeline.md b/technical-documentation/architecture/export-pipeline.md
index fd2d7446f..6f44d3a9d 100644
--- a/technical-documentation/architecture/export-pipeline.md
+++ b/technical-documentation/architecture/export-pipeline.md
@@ -110,6 +110,19 @@ and **one** encoder + muxer pair:
table, asserted by `outputFrameCount.test.ts` and by
`speed_segments_match_the_exporter_frame_totals`.
+- **Imported audio tracks** (voiceover / BGM / SFX, issue #350) are mixed
+ on top of the assembled programme by `audio.rs::mix_external_tracks`,
+ between `assemble_concatenated_pcm` and `finish_audio`. Each track's
+ trim window is decoded through the same `decode_clip_audio` path a clip
+ uses, scaled by its per-track gain, and summed in at its `startSec`
+ offset; a track running past the video is truncated to it so the two
+ streams stay the same length. `startSec` is resolved renderer-side
+ (`buildSceneDescription`) from the track's raw timeline position — an
+ identity map without trims/speed, an accepted approximation otherwise,
+ matching how the preview approximates trims by re-seeking. The CLI's
+ `openscreen export --audio` remains a separate post-export remux
+ (`voiceoverMix.ts`) for a single track and is unaffected.
+
- **Output** honours the timeline's selected aspect ratio
(`resolveAspectRatioValue` over `getEditorSettings(document).aspectRatio` —
the same typed façade `buildSceneDescription` reads, so the dialog cannot
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.