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-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs
index 69476ffc2..95700a6cb 100644
--- a/crates/compositor-view-napi/src/lib.rs
+++ b/crates/compositor-view-napi/src/lib.rs
@@ -364,6 +364,10 @@ pub struct ClipInput {
pub webcam_offset_sec: f64,
/// `false` évite une ouverture ffmpeg vouée à échouer et réserve du silence à ce clip.
pub has_audio: bool,
+ /// Secondes de sortie pendant lesquelles ce clip TIENT sa dernière image, en silence.
+ /// Une pause achetée par un mot ajouté (issue #560) : le ruler et la preview la
+ /// respectaient déjà, l'export l'ignorait, faute d'une fenêtre source non vide.
+ pub hold_sec: f64,
}
/// Taille/cadence/codec de sortie voulus par l'app (modale d'export). Tous optionnels :
@@ -500,6 +504,7 @@ pub fn export_multi(
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
+ hold_sec: c.hold_sec,
})
.collect();
Ok(AsyncTask::new(ExportMultiTask {
@@ -657,6 +662,7 @@ pub fn export_gif(
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
+ hold_sec: c.hold_sec,
})
.collect();
let gif_params = params
diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs
index e51f0f8af..6d7c8d4ec 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,142 @@ 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);
+ 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),
+ );
+ }
+ 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,
+) {
+ 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 (fade_in, fade_out) = resolve_fade_samples(decoded_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, decoded_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 +1629,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 +1862,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);
+ 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);
+ 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);
+ 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 +2016,73 @@ 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);
+ 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_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);
+ 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 235a32917..46dcbe290 100644
--- a/crates/compositor/src/pipeline_linux.rs
+++ b/crates/compositor/src/pipeline_linux.rs
@@ -21,7 +21,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};
@@ -52,6 +52,8 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
+ /// Secondes de sortie tenues sur la dernière image, en silence (issue #560).
+ pub hold_sec: f64,
}
/// Codec cible. Memes variantes que `pipeline_macos::ExportCodec`.
@@ -459,6 +461,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.
@@ -552,7 +560,10 @@ pub fn run_composited_multi(
let declared_audio: Vec = clips.iter().map(|c| c.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,
)?;
crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?;
diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs
index c7cf571bc..e0c4eec34 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};
@@ -529,6 +529,8 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
+ /// Secondes de sortie tenues sur la dernière image, en silence (issue #560).
+ pub hold_sec: f64,
}
/// Codec cible pour l'export. Identique à `pipeline_windows::ExportCodec`.
@@ -1078,6 +1080,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,
@@ -1146,7 +1153,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 5fff82056..674b31984 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};
@@ -969,6 +969,8 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
+ /// Secondes de sortie tenues sur la dernière image, en silence (issue #560).
+ pub hold_sec: f64,
}
/// Export **multiclip** : rend la timeline (clips ordonnés, avec trims) en un seul MP4.
@@ -1344,6 +1346,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
@@ -1477,7 +1484,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..a53aba3cf 100644
--- a/crates/compositor/src/scene.rs
+++ b/crates/compositor/src/scene.rs
@@ -19,6 +19,11 @@ pub struct SceneClip {
/// Une source sans piste audio décodable garde sa durée via du silence natif.
#[serde(default)]
pub has_audio: bool,
+ /// Secondes de sortie pendant lesquelles ce clip TIENT sa dernière image, en silence.
+ /// `#[serde(default)]` comme `has_audio` juste au-dessus, et pour la même raison : un
+ /// document écrit avant ce champ se charge sans lui, à 0.0.
+ #[serde(default)]
+ pub hold_sec: f64,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -432,6 +437,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 +538,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 +618,26 @@ impl Scene {
mod tests {
use super::*;
+ /// Un document écrit avant `holdSec` doit se charger, à 0.0 — comme `hasAudio` avant
+ /// lui. Sans ce défaut, ouvrir un projet fait par une version antérieure échouerait au
+ /// parse au lieu de simplement ne rien tenir (issue #560).
+ #[test]
+ fn a_clip_without_hold_sec_deserializes_to_zero() {
+ let json = r##"{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":0,"hasAudio":true}"##;
+ let clip: SceneClip = serde_json::from_str(json).expect("clip sans holdSec");
+ assert_eq!(clip.hold_sec, 0.0);
+ }
+
+ #[test]
+ fn a_clip_carries_its_hold_when_the_document_states_one() {
+ let json = r##"{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":2,"sourceEndSec":2,"webcamOffsetSec":0,"hasAudio":true,"holdSec":1.5}"##;
+ let clip: SceneClip = serde_json::from_str(json).expect("clip tenu");
+ assert_eq!(clip.hold_sec, 1.5);
+ // Fenêtre source vide ET pause positive : c'est exactement la forme que la marche
+ // laisse désormais passer.
+ assert!(clip.source_end_sec <= clip.source_start_sec);
+ }
+
#[test]
fn parses_a_minimal_scene_json() {
let json = r##"{
diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs
index 5d95a81db..92cee3b76 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};
@@ -247,7 +248,11 @@ pub(crate) unsafe fn walk_composited_timeline(
clip.webcam,
);
}
- if source_end_sec <= clip.source_start_sec {
+ // Une fenêtre source vide ne veut plus dire « rien à faire » : un segment TENU
+ // (issue #560) n'a par construction aucune source à décoder et n'existe que pour
+ // ses images tenues. Sans cette porte, une pause achetée par un mot ajouté était
+ // honorée par le ruler et par la preview et n'exportait rien du tout.
+ if source_end_sec <= clip.source_start_sec && clip.hold_sec <= 0.0 {
continue;
}
@@ -301,6 +306,10 @@ pub(crate) unsafe fn walk_composited_timeline(
}
let frames_before_clip = frames;
+ // La dernière paire composée, gardée hors de la boucle : `'clip_frames` peut casser,
+ // et un clip qui n'existe QUE pour tenir une image n'entre jamais dedans — il faut
+ // pourtant une image à tenir dans les deux cas.
+ let mut last_pair: Option<(*mut AVFrame, *mut AVFrame)> = None;
'clip_frames: for segment in &speed_segments {
for segment_frame in 0..segment.frame_count {
let target_source_time =
@@ -322,11 +331,53 @@ pub(crate) unsafe fn walk_composited_timeline(
comp.set_cursor_time(Some(target_source_time as f32));
}
comp.compose_frame(sf, wf, frames as f32, cfg)?;
+ last_pair = Some((sf, wf));
on_frame(frames)?;
frames += 1;
}
}
+
+ // Les images tenues, APRÈS les segments de vitesse et volontairement en dehors
+ // d'eux. Dedans, `stretch_pcm_to_length` étirerait le vrai son du clip à travers le
+ // gel (WSOLA sur une voix, audible). Dehors, le créneau audio est plus long que le
+ // PCM et `min(source.len())` laisse des zéros : le silence est gratuit.
+ //
+ // `ceil`, pas `round` : arrondir perd jusqu'à une demi-image, prise sur la fin de la
+ // narration même que la pause existe pour loger.
+ if clip.hold_sec > 0.0 {
+ if last_pair.is_none() {
+ // Clip tenu seul : rien n'a été composé, alors on va chercher une image une
+ // fois. Un seek qui échoue laisse `last_pair` vide et on n'émet rien plutôt
+ // que de composer du vide.
+ if advance_decoder_to(sdec, clip.source_start_sec, 0.0)?
+ && advance_decoder_to(wdec, clip.source_start_sec, clip.webcam_offset_sec)?
+ {
+ let sf = sdec.cur_frame();
+ let wf = wdec.cur_frame();
+ if !sf.is_null() && !wf.is_null() {
+ last_pair = Some((sf, wf));
+ }
+ }
+ }
+ match last_pair {
+ Some((sf, wf)) => {
+ let held = ((clip.hold_sec * out_fps as f64).ceil()) as u64;
+ // Le temps timeline est ÉPINGLÉ sur l'instant tenu : c'est ce qui fige
+ // aussi les modificateurs (zoom, curseur) au lieu de les laisser courir.
+ comp.set_timeline_time(Some(clip.source_start_sec as f32));
+ for _ in 0..held {
+ comp.compose_frame(sf, wf, frames as f32, cfg)?;
+ on_frame(frames)?;
+ frames += 1;
+ }
+ }
+ None => eprintln!(
+ "[pipeline] warning: clip #{}: pause de {:.3}s ignorée, aucune image à tenir (screen=\"{}\")",
+ clip_index, clip.hold_sec, clip.screen,
+ ),
+ }
+ }
on_clip_end(
clip_index,
source_end_sec,
diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs
index 62361572d..22ea8bc3f 100644
--- a/crates/compositor/tests/compose_linux.rs
+++ b/crates/compositor/tests/compose_linux.rs
@@ -792,6 +792,7 @@ fn export_linux_mp4() {
source_end_sec: 1.0,
webcam_offset_sec: 0.0,
has_audio: true,
+ hold_sec: 0.0,
}];
let params = ExportParams {
width: 640,
diff --git a/crates/compositor/tests/export_timing.rs b/crates/compositor/tests/export_timing.rs
index f0070794b..11a100626 100644
--- a/crates/compositor/tests/export_timing.rs
+++ b/crates/compositor/tests/export_timing.rs
@@ -117,6 +117,7 @@ fn whole_clip(dir: &PathBuf) -> ClipSource {
source_end_sec: SOURCE_SEC,
webcam_offset_sec: 0.0,
has_audio: false,
+ hold_sec: 0.0,
}
}
diff --git a/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..f39510487 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,289 @@ 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 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..469b5d313 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,6 +32,7 @@ import {
replaceTimeline,
setClipSourceRange,
} from "../../src/lib/ai-edition/document/timeline";
+import { setDocumentWordText } from "../../src/lib/ai-edition/document/transcript";
import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
import { hasAnyClipWithCamera } from "../../src/lib/ai-edition/timeline/camera";
import {
@@ -337,6 +344,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 +491,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 +522,18 @@ export const setCameraFullscreenArgs = z.object({
endSec: secondsSchema.optional(),
});
+export const getTranscriptWordsArgs = z.object({
+ assetId: z.string().min(1).optional(),
+ startSec: secondsSchema.optional(),
+ endSec: secondsSchema.optional(),
+});
+
+export const setWordTextArgs = z.object({
+ wordId: z.string().min(1),
+ text: z.string(),
+ assetId: z.string().min(1).optional(),
+});
+
export const removeTrimArgs = z.object({
trimRangeId: z.string().min(1),
});
@@ -525,7 +569,9 @@ export const removeClipArgs = z.object({
export const OPENSCREEN_TOOL_NAMES = [
"getCurrentDocument",
"getTranscript",
+ "getTranscriptWords",
"getCursorTrack",
+ "setWordText",
"addTrim",
"addTrims",
"setTrim",
@@ -541,6 +587,8 @@ export const OPENSCREEN_TOOL_NAMES = [
"setAnnotation",
"addCameraFullscreen",
"setCameraFullscreen",
+ "addAudio",
+ "setAudio",
"removeTrim",
"removeModifier",
"removeClip",
@@ -592,6 +640,9 @@ export const PHANTOM_TOOL_NAMES = [
* remaining surfaces (descriptions, built tools, executor cases) to each other.
*/
export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([
+ // Writes the transcript, not the timeline — but it writes the document, so it is a
+ // consented edit like any other.
+ "setWordText",
"addTrim",
"addTrims",
"addZooms",
@@ -607,6 +658,8 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([
"setAnnotation",
"addCameraFullscreen",
"setCameraFullscreen",
+ "addAudio",
+ "setAudio",
"removeTrim",
"removeModifier",
"removeClip",
@@ -665,7 +718,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 +738,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 +814,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 +1278,96 @@ export function executeAgentTool(
};
}
+ // The word-level read. `getTranscript` answers in SEGMENTS, whose ids belong to a
+ // different namespace than the words — so on its own it cannot address anything
+ // `setWordText` takes. This is the one that can. It is separate rather than folded
+ // in because a whole transcript is already ~70k tokens and most turns never touch a
+ // word; the span filter is there so fixing one name costs one phrase, not the film.
+ case "getTranscriptWords": {
+ const parsed = getTranscriptWordsArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const assetId =
+ parsed.data.assetId ?? document.project.primaryAssetId ?? document.assets[0]?.id;
+ const transcript =
+ document.transcripts.find((t) => t.assetId === assetId) ??
+ (document.transcript?.assetId === assetId ? document.transcript : null);
+ if (!transcript) {
+ return failure(`No transcript for asset ${assetId ?? "(none)"}.`);
+ }
+ const from = parsed.data.startSec ?? Number.NEGATIVE_INFINITY;
+ const to = parsed.data.endSec ?? Number.POSITIVE_INFINITY;
+ const words = transcript.words
+ .filter((word) => word.endSec >= from && word.startSec <= to)
+ .map((word) => ({
+ id: word.id,
+ text: word.text,
+ startSec: word.startSec,
+ endSec: word.endSec,
+ // Only the words that are NOT plain transcription say so, so the common
+ // case costs nothing to read.
+ ...(word.source ? { source: word.source } : {}),
+ ...(word.originalText !== undefined ? { originalText: word.originalText } : {}),
+ }));
+ return {
+ ok: true,
+ resultJson: JSON.stringify({
+ assetId,
+ language: transcript.language,
+ total: transcript.words.length,
+ returned: words.length,
+ words,
+ }),
+ };
+ }
+
+ // Correcting what the transcriber HEARD. This writes text and nothing else: the
+ // captions follow it, the film does not move. The tool for making a spoken word go
+ // away is addTrim, which removes its audio with it.
+ case "setWordText": {
+ const parsed = setWordTextArgs.safeParse(args);
+ if (!parsed.success) return failure(parsed.error.message);
+ const assetId =
+ parsed.data.assetId ?? document.project.primaryAssetId ?? document.assets[0]?.id;
+ if (!assetId) return failure("Project has no assets — nothing to correct.");
+ const { wordId, text } = parsed.data;
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ const before = transcript?.words.find((word) => word.id === wordId);
+ if (!before) {
+ return failure(
+ `No word ${wordId} in the transcript for asset ${assetId}. ` +
+ `Call getTranscriptWords to read the ids.`,
+ );
+ }
+ if (before.text === text) {
+ return failure(`Word ${wordId} already reads "${text}" — nothing to change.`);
+ }
+ let next: AxcutDocument;
+ try {
+ next = setDocumentWordText(document, assetId, wordId, text);
+ } catch (error) {
+ return failure(error instanceof Error ? error.message : String(error));
+ }
+ const after = next.transcripts
+ .find((t) => t.assetId === assetId)
+ ?.words.find((word) => word.id === wordId);
+ return {
+ ok: true,
+ document: next,
+ resultJson: JSON.stringify({
+ wordId,
+ assetId,
+ text: after?.text ?? text,
+ was: before.text,
+ // Absent once the word is back to what the transcriber said — the pair is
+ // cleared on that round trip, and the model should be able to see it.
+ originalText: after?.originalText,
+ blanked: text.trim().length === 0,
+ }),
+ summary:
+ text.trim().length === 0 ? `blanked "${before.text}"` : `"${before.text}" → "${text}"`,
+ };
+ }
+
case "addTrim": {
const parsed = addTrimArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
@@ -1843,6 +2008,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,6 +2196,7 @@ 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}. ` +
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..bf3d12289 100644
--- a/electron/ai-edition/document-service.test.ts
+++ b/electron/ai-edition/document-service.test.ts
@@ -280,6 +280,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 +420,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..d3eaa01d3 100644
--- a/electron/ai-edition/document-service.ts
+++ b/electron/ai-edition/document-service.ts
@@ -13,6 +13,7 @@
import fs, { type FileHandle } from "node:fs/promises";
import path from "node:path";
import { createId } from "../../src/lib/ai-edition/document/ids";
+import { parseStoredDocument, reconcileInsertions } from "../../src/lib/ai-edition/document/load";
import { removeClip } from "../../src/lib/ai-edition/document/timeline";
import {
type AxcutAsset,
@@ -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.
@@ -90,7 +123,7 @@ function safeProjectId(raw: string): string {
// `getProject` spells the same two steps out inline because it relinks moved
// media between them; keep the order (upgrade, then validate) in step.
function parseLoadedDocument(raw: string): AxcutDocument {
- return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw)));
+ return parseStoredDocument(JSON.parse(raw));
}
/**
@@ -241,7 +274,9 @@ export class DocumentService {
// back, and it is not persisted from here: the renderer saves the document
// it was given, as it does for any other load-time repair.
const migrated = migrateRawDocumentToCurrent(JSON.parse(raw));
- return documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir));
+ return reconcileInsertions(
+ documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir)),
+ );
}
async createProject(title: string): Promise {
@@ -283,7 +318,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 +343,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 +373,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 +387,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..f404daa84 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -107,6 +107,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 +189,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 +313,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 +331,7 @@ async function approveReadableVideoPath(
return normalizedPath;
}
- if (!hasAllowedImportVideoExtension(normalizedPath)) {
+ if (!hasAllowedExtension(normalizedPath)) {
return null;
}
@@ -322,6 +358,29 @@ 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);
+}
+
+// For the generic media reads that accept either kind — NOT for the pickers,
+// which must stay type-specific (see `hasAllowedImportMediaExtension`).
+function approveReadableAvPath(
+ filePath?: string | null,
+ trustedDirs?: string[],
+): Promise {
+ return approveReadableMediaPath(filePath, hasAllowedImportMediaExtension, trustedDirs);
+}
+
function resolveRecordingOutputPath(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
@@ -3590,6 +3649,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 +3697,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 +3800,7 @@ export function registerIpcHandlers(
ipcMain.handle("read-binary-file", async (_, filePath: string) => {
try {
- const normalizedPath = await approveReadableVideoPath(filePath);
+ const normalizedPath = await approveReadableAvPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -3691,7 +3830,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 = await approveReadableAvPath(filePath);
if (!normalizedPath) {
return {
success: false,
@@ -3727,7 +3866,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 = await approveReadableAvPath(filePath);
if (!normalizedPath) {
return { success: false, message: "File path is not approved" };
}
@@ -3751,7 +3890,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 = await approveReadableAvPath(filePath);
if (!normalizedPath) {
return {
success: false,
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/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..1004726f5 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,
@@ -237,12 +238,26 @@ 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`");
+ }
+ return extractMono16kPcm(req.sourcePath);
+ }
+
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 +300,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/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx
index 9a7f1b590..5da415596 100644
--- a/src/cli/CliExportRunner.tsx
+++ b/src/cli/CliExportRunner.tsx
@@ -95,6 +95,7 @@ function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
+ holdSec: clip.heldSec ?? 0,
},
];
});
diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx
index cae76b59a..506404f2a 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,43 +97,35 @@ 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(
-
-
- ,
- );
- 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("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 6d6a776c9..a0ba29a76 100644
--- a/src/components/ai-edition/CaptionsPane.tsx
+++ b/src/components/ai-edition/CaptionsPane.tsx
@@ -19,10 +19,7 @@ import {
untranslatedUnits,
} from "@/lib/ai-edition/captions";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
-import {
- useTimelineTranscriptGate,
- useTranscriptionStore,
-} from "@/lib/ai-edition/store/transcriptionStore";
+import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionStore";
import { useCaptions } from "@/lib/ai-edition/store/useCaptions";
import { nativeBridgeClient } from "@/native";
import { ColorField } from "./ColorField";
@@ -90,14 +87,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 isTranscribing = gate.state === "pending";
const silentMedia = gate.state === "blocked" && gate.reason === "no-audio";
const engineError = gate.state === "blocked" && gate.reason === "failed" ? gate.message : null;
@@ -223,17 +220,26 @@ export function CaptionsPane() {
{engineError}
) : null}
- void requestTimelineTranscripts()}
- >
- {isTranscribing ? : null}
- {isTranscribing ? t("captions.transcribing") : 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 ? (
+
+
+ {t("captions.transcribing")}
+
+ ) : null}
) : (
resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
+ () =>
+ resolveNativePosition(
+ currentTimeSec,
+ nativeClips,
+ document?.timeline.clips ?? [],
+ document?.timeline.insertRanges ?? [],
+ ),
[nativeClips, currentTimeSec, document],
);
const activeClip = activePosition?.clip ?? null;
diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css
index 8078ca27b..e99482f7f 100644
--- a/src/components/ai-edition/NewEditorShell.module.css
+++ b/src/components/ai-edition/NewEditorShell.module.css
@@ -2122,6 +2122,66 @@
.iconBtn:hover { background: var(--surface-3); color: var(--fg); }
.iconBtn:disabled { opacity: 0.5; cursor: not-allowed; }
+/* A labelled sibling of .iconBtn. Captions lost their own tab (issue #560), so the
+ control that replaces it cannot be an icon you must hover to identify: the whole
+ point of folding it in here was that people find it. */
+.paneHeadBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 10px;
+ border-radius: var(--r-sm);
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--fg-2);
+ font: 500 12px/1 var(--font-body);
+ white-space: nowrap;
+ cursor: pointer;
+}
+.paneHeadBtn:hover { background: var(--surface-3); color: var(--fg); }
+
+/* Two mutually exclusive readings of the same tab, so: one track, one lit half.
+ Not tabs — tabs would say the two transcripts sit side by side, and only one of
+ them exists as far as everything downstream is concerned (issue #560). */
+.laneSwitch {
+ display: flex;
+ gap: 2px;
+ margin: 0 var(--sp-4) 6px;
+ padding: 2px;
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ background: var(--surface-2);
+}
+.laneSwitchBtn {
+ flex: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 26px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: calc(var(--r-sm) - 2px);
+ background: transparent;
+ color: var(--muted);
+ font: 500 12px/1 var(--font-body);
+ white-space: nowrap;
+ cursor: pointer;
+}
+.laneSwitchBtn:hover { color: var(--fg-2); }
+.laneSwitchBtn.isActive {
+ background: var(--surface);
+ color: var(--fg);
+ box-shadow: var(--elev-1, 0 1px 2px rgb(0 0 0 / 0.12));
+}
+/* The choice reaches past this tab — it decides the text burnt into the export. */
+.laneSwitchNote {
+ margin: 0 var(--sp-4) 12px;
+ font: 400 11.5px/1.45 var(--font-body);
+ color: var(--meta);
+}
+
.backBtn {
display: inline-flex;
align-items: center;
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index b371d100c..2d1f5fbb1 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -5,16 +5,25 @@ import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
-import {
- migrateProjectDataToAxcutDocument,
- migrateRawDocumentToCurrent,
-} from "@/lib/ai-edition/document/migrate";
+import { createId } from "@/lib/ai-edition/document/ids";
+import { parseStoredDocument } from "@/lib/ai-edition/document/load";
+import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
import {
applyProbedDuration,
replaceTimeline as replaceTimelineOp,
} from "@/lib/ai-edition/document/timeline";
+import {
+ type InsertSide,
+ insertDocumentWord,
+ removeDocumentWords,
+ setDocumentWordText,
+} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
-import { type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
+import {
+ type AxcutAudioTrack,
+ type AxcutClip,
+ type AxcutInsertRange,
+} from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
@@ -26,6 +35,10 @@ import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
+import {
+ dropTrimPillsByIds,
+ ventilateTimelineSpanToTrims,
+} from "@/lib/ai-edition/timeline/trim-mapping";
import { matchesShortcut } from "@/lib/shortcuts";
import { nativeBridgeClient } from "@/native";
import type { AiEditionProjectSummary } from "@/native/contracts";
@@ -42,8 +55,8 @@ import {
type UnsavedChoice,
} from "./Modals";
import { Preview } from "./Preview";
-import type { TrimTarget } from "./RightPanes";
import { importPendingRecording } from "./recordingImport";
+import { AddAudioLayerDialog } from "./v4/AddAudioLayerDialog";
import v4 from "./v4/EditorShellV4.module.css";
import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar";
import { type Facet, FloatingInspector } from "./v4/FloatingInspector";
@@ -73,18 +86,24 @@ interface SeekTarget {
* handlers that need it. The store write cadence is unchanged — `currentTimeSec`
* is still the source of truth, still updated every frame.
*/
+// Stable empty list: a fresh `[]` each render would churn the preview's audio
+// element set on every playhead tick.
+const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
+
function NativePlaybackSync({
visibleClips,
clips,
+ insertRanges,
}: {
visibleClips: AxcutClip[];
clips: AxcutClip[];
+ insertRanges: readonly AxcutInsertRange[];
}) {
const playing = useProjectStore((s) => s.playing);
const currentTimeSec = useProjectStore((s) => s.currentTimeSec);
// visibleClips = trim-compressed native stream; `clips` = RAW layout currentTimeSec
// is measured against. resolveNativePosition needs both (see timelineMap).
- useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips);
+ useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips, insertRanges);
return null;
}
@@ -185,7 +204,11 @@ export function NewEditorShell() {
// don't race each other's save and overwrite one another in the
// store. The hook reads the doc inside the chain (after awaiting the
// previous save) — see its source for the race this fixes.
- const { apply: applyTimelineOp, enqueue: enqueueTimelineWrite } = useSequentialTimelineOps({
+ // Only `enqueue` now: the two trim handlers were the last callers of `apply`, and both
+ // read the document inside the chain so a cut cannot be overwritten by a word edit
+ // landing between the read and the save. The `add_trim_range` / `remove_trim_range` ops
+ // stay for the agent, which addresses clips rather than moments.
+ const { enqueue: enqueueTimelineWrite } = useSequentialTimelineOps({
fallbackDocument: document,
saveDocument,
});
@@ -427,7 +450,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"), {
@@ -548,7 +580,7 @@ export function NewEditorShell() {
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
- ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
+ ? parseStoredDocument(raw) // disk-load: upgrade, validate, reconcile clip geometry
: migrateProjectDataToAxcutDocument(raw as EditorProjectData);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (saved.success && saved.document) {
@@ -571,37 +603,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,
+ doc.timeline.insertRanges ?? [],
+ );
+ if (next.length === doc.timeline.trimRanges.length) return;
+ await saveDocument(
+ { ...doc, timeline: { ...doc.timeline, trimRanges: next } },
+ { history: true },
+ );
+ });
+ },
+ [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;
+ 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) => {
+ 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),
+ });
+ }
+ });
},
- [applyTimelineOp],
+ [enqueueTimelineWrite, saveDocument, te],
);
const handleSelectProject = useCallback(
@@ -770,6 +907,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;
@@ -786,6 +973,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");
@@ -793,6 +995,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),
@@ -842,7 +1065,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
@@ -860,7 +1083,7 @@ export function NewEditorShell() {
// (properties kept, position taken from the playhead).
if (sel.kind === "trim") {
const { coalescedTrimGroups } = await import("@/lib/ai-edition/timeline/trim-mapping");
- const group = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) =>
+ const group = coalescedTrimGroups(tl.trimRanges, tl.clips, tl.insertRanges ?? []).find((g) =>
g.ids.includes(sel.id),
);
if (!group) return;
@@ -870,6 +1093,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
@@ -948,6 +1187,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);
}
};
@@ -1032,6 +1279,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());
@@ -1086,6 +1345,7 @@ export function NewEditorShell() {
isMac,
togglePlay,
handleSeek,
+ openVoiceoverFlow,
]);
const showTimeline = mode !== "rec";
@@ -1144,13 +1404,17 @@ export function NewEditorShell() {
const transcriptProps = {
clips,
+ audioTracks: document?.audioTracks ?? [],
transcripts: document?.transcripts ?? [],
assets: document?.assets ?? [],
trimRanges: document?.timeline?.trimRanges ?? [],
busyAssetIds,
onSeek: handleSeek,
- onAddTrimRange: handleAddTrimRange,
- onRemoveTrimRange: handleRemoveTrimRange,
+ onTrimTimelineSpan: handleTrimTimelineSpan,
+ onRemoveTrimRanges: handleRemoveTrimRanges,
+ onSetWordText: handleSetWordText,
+ onInsertWord: handleInsertWord,
+ onRemoveWords: handleRemoveWords,
onTranscribe: handleTranscribe,
canTranscribe: hasAsset,
isTranscribing: transcriptGate.state === "pending",
@@ -1165,7 +1429,11 @@ export function NewEditorShell() {
className={v4.app}
style={{ gridTemplateRows: `58px 1fr ${showTimeline ? timelineRow : "0px"}` }}
>
-
+
void tl.commitZoomFocus()}
@@ -1324,6 +1603,7 @@ export function NewEditorShell() {
onTogglePlay={togglePlay}
onPrevClip={handlePrevClip}
onNextClip={handleNextClip}
+ onAddVoiceover={openVoiceoverFlow}
onEditClip={setEditClipTarget}
/>
@@ -1383,6 +1663,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..2753f9635 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,
+ AxcutInsertRange,
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[];
+ insertRanges?: AxcutInsertRange[];
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,
+ insertRanges,
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[];
+ insertRanges?: AxcutInsertRange[];
selectedZoomRegionId?: string | null;
onZoomFocusChange?: (id: string, focus: ZoomFocus) => void;
onZoomFocusCommit?: () => void;
@@ -214,17 +224,18 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
// clip the playhead is currently inside, the same lookup VirtualPreview
// itself uses to map playback position back to a clip. `undefined` (no
// crop stored) normalises to the identity region.
+ const previewInserts = props.insertRanges ?? EMPTY_INSERT_RANGES;
const activeClip = useMemo(
- () => locateVirtualPosition(props.clips, props.currentTimeSec)?.clip ?? null,
- [props.clips, props.currentTimeSec],
+ () => locateVirtualPosition(props.clips, props.currentTimeSec, previewInserts)?.clip ?? null,
+ [props.clips, props.currentTimeSec, previewInserts],
);
const cropRegion: CropRegion = activeClip?.cropRegion ?? DEFAULT_CROP_REGION;
// P4 — the layout preset is global (one panel for the whole timeline) but the camera
// is per clip, so the layout has to be resolved against the clip under the playhead.
const activeCameraTrack = useMemo(
- () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec),
- [assets, props.clips, props.currentTimeSec],
+ () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec, previewInserts),
+ [assets, props.clips, props.currentTimeSec, previewInserts],
);
const activeClipHasCamera = Boolean(activeCameraTrack?.visible && activeCameraTrack.sourcePath);
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 2e15f7b12..10a478186 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,10 +43,15 @@ 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,
+ AxcutInsertRange,
AxcutTranscript,
AxcutTrimRange,
AxcutWord,
@@ -51,18 +61,26 @@ 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 { takeInserts } from "@/lib/ai-edition/timeline/insert-mapping";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import type { TranscriptGateReason } from "@/lib/ai-edition/transcription/status";
import { getAssetPath } from "@/lib/assetPath";
import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/compositeLayout";
@@ -82,17 +100,24 @@ import {
getAspectRatioLabel,
} from "@/utils/aspectRatioUtils";
import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport";
+import { CaptionsPane } from "./CaptionsPane";
import styles from "./NewEditorShell.module.css";
+/** Stable identity, so the memos below are not invalidated every render by a fresh `[]`. */
+const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
+
interface PaneProps {
title: string;
icon: ReactNode;
// 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);
@@ -100,7 +125,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,
busyAssetIds,
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[];
@@ -720,8 +830,17 @@ export function TranscriptPane({
* background pass, with nothing on screen to say why. */
busyAssetIds: readonly string[];
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;
@@ -738,42 +857,86 @@ 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 insertRanges = document?.timeline.insertRanges ?? EMPTY_INSERT_RANGES;
+ const removed = useMemo(
+ () => removedRawSpans(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
+ );
+ // The take's placements are fed the cuts AND its own insertions, so a word after a pause
+ // is struck through — and highlighted — at the moment it is actually heard (issue #560).
+ const insertsFor = useCallback(
+ (groupId: string) => (document ? takeInserts(document, groupId) : []),
+ [document],
+ );
+ const voiceover = useMemo(
+ () => voiceoverPlacements(audioTracks, removed, insertsFor),
+ [audioTracks, removed, insertsFor],
+ );
+ 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, insertRanges),
+ [placements, transcripts, assets, removed, insertRanges],
);
- // 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, insertRanges),
+ [sections, currentTimeSec, insertRanges],
+ );
- 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";
- 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") + (import.meta.env.DEV ? ` ${ts("transcript.helpInsert")}` : "");
+ const editingHint = ts(
+ import.meta.env.DEV ? "transcript.editingHintDev" : "transcript.editingHint",
+ );
+
+ if (placements.length === 0 || !hasAnyTranscript) {
return (
}
- helpText={ts("transcript.help")}
+ helpText={helpText}
+ actions={ }
>
+ {laneSwitch}
- {clips.length === 0
+ {placements.length === 0
? ts("transcript.noClips")
: isTranscribing
? ts("transcript.transcribing")
@@ -817,25 +980,44 @@ 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) => (
+
+ ))}
+
);
}
@@ -843,7 +1025,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
@@ -855,26 +1037,54 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
busy,
+ lane,
cueWordId,
+ insertRanges,
onSeek,
- onAddTrimRange,
- onRemoveTrimRange,
+ onTrimTimelineSpan,
+ onRemoveTrimRanges,
+ onSetWordText,
+ onInsertWord,
+ onRemoveWords,
}: {
index: number;
section: ClipSection;
busy: boolean;
+ /** Which lane the block belongs to. Only the insert gesture cares: a pause is a held
+ * CLIP frame, and a voiceover placement has no clip to hold. */
+ lane: TranscriptLane;
cueWordId: string | null;
+ /** The insertions this placement carries: the block maps its words' SOURCE spans onto
+ * the ruler to author a cut, and a clip carrying insertions is longer than its source
+ * window (issue #560). */
+ insertRanges: readonly AxcutInsertRange[];
onSeek: (sec: number) => void;
- 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, insertRanges);
+ const lo = extent?.startSec ?? clip.timelineStartSec;
+ const hi = extent?.endSec ?? Number.POSITIVE_INFINITY;
+ const clamp = (sec: number) =>
+ Math.min(Math.max(placementRawSec(clip, sec, insertRanges), lo), hi);
+ return [clamp(startSec), clamp(endSec)];
+ },
+ [clip, insertRanges],
);
const filename = asset?.label ?? clip.assetId;
const sourceRangeLabel =
@@ -939,25 +1149,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(
@@ -1012,38 +1235,98 @@ 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) => {
+ // ponytail: word insertion ships dev-only until a voice can be synthesized for
+ // the word — without one it only borrows free silence, and once it creates
+ // timeline time (the pause gesture) it is a silent freeze frame. Drop this gate
+ // when TTS lands.
+ if (!import.meta.env.DEV) return;
+ if (busy || !seed.trim()) return;
+ const editor = editorRef.current;
+ const selection = globalThis.getSelection();
+ 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, lane, ts, 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;
@@ -1183,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)",
@@ -1205,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}
+
+ );
+ })}
)}
@@ -1238,24 +1546,70 @@ 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;
+ setDraft(cw.word.text);
+ }, [editable, cw.word.text]);
+
+ 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;
@@ -1273,7 +1627,7 @@ const TranscriptWord = memo(function TranscriptWord({
onClick={(e) => {
e.stopPropagation();
onRestore({
- trimId: cw.trimId ?? "",
+ trimIds: cw.trimIds,
assetId: "",
startWordIndex: 0,
endWordIndex: 0,
@@ -1308,10 +1662,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)}.`,
);
}}
@@ -1333,31 +1685,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,
@@ -1394,10 +1912,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
@@ -1422,7 +2068,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));
@@ -1492,6 +2138,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;
@@ -2303,6 +3022,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 008c6161f..b3ed5f4b9 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..d582cbdbb
--- /dev/null
+++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx
@@ -0,0 +1,260 @@
+// @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("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..143051b85 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -4,12 +4,35 @@ 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,
+ AxcutInsertRange,
+ AxcutTrimRange,
+ AxcutZoomRegion,
+} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
+import { insertionEnteredBetween, rulerInserts } from "@/lib/ai-edition/timeline/inserted-time";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
+import {
+ consumedSourceSec,
+ type TakeInsert,
+ type TakePiece,
+ takePlaybackAt,
+ takeProgramme,
+} from "@/lib/ai-edition/timeline/take-programme";
import {
clampVirtualTime,
findNextKeptSegment,
@@ -65,6 +88,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,10 +206,17 @@ 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[];
trimRanges?: AxcutTrimRange[];
+ /** The media added words inserted — it lengthens playback, it does not cut it. */
+ insertRanges?: AxcutInsertRange[];
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -156,10 +257,13 @@ interface VirtualPreviewProps {
export function VirtualPreview({
videoSources,
+ audioTracks = [],
+ audioSources = [],
clips,
zoomRegions = [],
speedRegions = [],
trimRanges = [],
+ insertRanges = [],
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -206,6 +310,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 +381,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 +442,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 +506,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 +555,85 @@ 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 insertions, placed on the raw ruler once. The projection needs them or every
+ // track after one lands D seconds early — the bug this argument exists to close.
+ /** The insertion currently playing, if any.
+ *
+ * An added word inserts MEDIA inside the clip (issue #560). There is no generator for it
+ * yet, so the stand-in is a fixed frame and silence — but it is a piece of media on the
+ * timeline like any other, and playback runs THROUGH it rather than around it.
+ *
+ * The `` cannot supply those seconds: they are not in the file. So it is PARKED
+ * for the insertion's duration — paused, which holds the frame the insertion stands for
+ * and silences the recording under it — and a wall clock runs the insertion out.
+ *
+ * Parked, not re-seeked: writing `currentTime` every frame to a still-playing element
+ * is a seek storm the decoder never settles out of, and that is what "playback stops at
+ * the insertion" actually was. The cost is that `.paused` stops answering "is the
+ * film stopped?" — see `filmPlaying` in the tick. */
+ const insertionRef = useRef<{ rawSec: number; durationSec: number; startedAtMs: number } | null>(
+ null,
+ );
+ /** How far into the insertion the wall clock has run, in seconds. Read by
+ * `updateVirtualTime` so the RULER position it publishes crosses the insertion while the
+ * RAW second it publishes alongside stands still at the insertion's own moment. */
+ const insertionElapsedRef = useRef(0);
+ // The ranges themselves for anything that maps through a clip; their timeline positions
+ // for the one thing that asks "did this frame run into one".
+ const insertRangesRef = useRef(insertRanges);
+ insertRangesRef.current = insertRanges;
+ const filmInsertsRef = useRef(rulerInserts(insertRanges, clips));
+ filmInsertsRef.current = useMemo(() => rulerInserts(insertRanges, clips), [insertRanges, clips]);
+ // One walk per take, recomputed only when the cuts or the insertions move. The rAF asks
+ // it every frame per track, and walking on each would be wasteful.
+ // The take's own insertions, resolved from the ranges this component already receives.
+ // `resolveInsertPlacement` needs assets to tell the lanes apart, and the preview has
+ // none — but a range naming an AUDIO asset is exactly one whose asset is not a clip's,
+ // which is the same test, available here.
+ const clipAssetIds = useMemo(() => new Set(clips.map((c) => c.assetId)), [clips]);
+ const takeInsertsByGroup = useCallback(
+ (groupId: string): TakeInsert[] => {
+ const pill = collapseTracksToPills(audioTracks).find((t) => trackGroupId(t) === groupId);
+ if (!pill) return [];
+ return insertRanges
+ .filter((range) => range.assetId === pill.assetId && !clipAssetIds.has(range.assetId))
+ .map((range) => ({
+ id: range.id,
+ wordId: range.wordId,
+ atSourceSec: range.atSec,
+ durationSec: range.durationSec,
+ }));
+ },
+ [audioTracks, insertRanges, clipAssetIds],
+ );
+ const takePiecesRef = useRef>(new Map());
+ const takeHeadsRef = useRef>(new Map());
+ const removedRef = useRef(removedRawSpans(clips, trimRanges, insertRanges));
+ removedRef.current = useMemo(
+ () => removedRawSpans(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
+ );
+ const takeWalks = useMemo(() => {
+ const pieces = new Map();
+ const heads = new Map();
+ const removed = removedRawSpans(clips, trimRanges, insertRanges);
+ for (const pill of collapseTracksToPills(audioTracks)) {
+ if (pill.kind !== "voiceover" || pill.loop) continue;
+ const groupId = trackGroupId(pill);
+ heads.set(groupId, pill.id);
+ pieces.set(groupId, takeProgramme(pill, removed, takeInsertsByGroup(groupId)));
+ }
+ return { pieces, heads };
+ }, [audioTracks, clips, trimRanges, takeInsertsByGroup, insertRanges]);
+ 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,
@@ -411,8 +645,8 @@ export function VirtualPreview({
// source time to a RAW virtual time that jumps discontinuously by exactly the trim's
// width the moment the video itself jumps — matching the marker's own pixel span.
const playbackClips = useMemo(
- () => resolvePlaybackSegments(clips, trimRanges),
- [clips, trimRanges],
+ () => resolvePlaybackSegments(clips, trimRanges, insertRanges),
+ [clips, trimRanges, insertRanges],
);
const playbackClipsRef = useRef(playbackClips);
playbackClipsRef.current = playbackClips;
@@ -426,6 +660,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
@@ -450,6 +695,25 @@ export function VirtualPreview({
if (!v || !Number.isFinite(v.currentTime)) {
return;
}
+ // Run the insertion out FIRST: everything below is positioned against a clock that
+ // crosses it — the imported tracks especially, which sit on the programme clock
+ // exactly as they do in the render's output stream.
+ const insertion = insertionRef.current;
+ if (insertion) {
+ const elapsedSec = (performance.now() - insertion.startedAtMs) / 1000;
+ // `!v.paused` means something un-parked the element under us — the transport.
+ if (elapsedSec >= insertion.durationSec || !v.paused) {
+ insertionRef.current = null;
+ insertionElapsedRef.current = 0;
+ if (v.paused) void v.play().catch(() => undefined);
+ } else {
+ insertionElapsedRef.current = elapsedSec;
+ }
+ }
+ // A PARKED element is not a stopped film, and this is the question every gate
+ // below actually means: the picture is held on the insertion's frame on purpose
+ // while the programme keeps running over it.
+ const filmPlaying = !v.paused || insertionRef.current !== null;
for (const audio of [primaryAudioRef.current, supplementalAudioRef.current]) {
if (!audio) continue;
const target = resolveAudioTrackPlayback(v.currentTime, audio.duration);
@@ -471,12 +735,146 @@ 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.
+ // `+ insertionElapsedSec`, and only here: the RAW playhead stands still at the
+ // insertion's moment for its whole duration — none of those seconds come from the
+ // recording — and the projection of that moment is where the insertion OPENS
+ // (`expandRawSec` and this walk both give the last recorded frame its own instant).
+ // Adding the elapsed walks the programme through the inserted media, which is what
+ // the mixer downstream is doing over the same seconds.
+ const outputTimeSec =
+ projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ virtualTimeSecRef.current,
+ insertRangesRef.current,
+ speedRegionsRef.current,
+ ) + insertionElapsedRef.current;
+ 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,
+ insertRangesRef.current,
+ 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,
+ insertRangesRef.current,
+ ) -
+ projectRawTimelineSecToPlayback(
+ clipsRef.current,
+ trimRangesRef.current,
+ track.startMs / 1000,
+ insertRangesRef.current,
+ ),
+ );
+ // A voiceover follows the cuts AND its own insertions, through one walk over
+ // 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 (filmPlaying && trackTarget.shouldPlay && el.paused) {
+ // Resume a context suspended by autoplay policy, exactly as the primary
+ // loop does above — otherwise a track that starts while the primary
+ // element is silent (its span is over, or a recording with no separate
+ // 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 ((!filmPlaying || !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.
if (clockRef) {
clockRef.current.sourceTimeSec = v.currentTime;
- clockRef.current.isPlaying = !v.paused;
+ clockRef.current.isPlaying = filmPlaying;
clockRef.current.playbackRate = v.playbackRate;
clockRef.current.virtualTimeSec = virtualTimeSecRef.current;
}
@@ -521,6 +919,7 @@ export function VirtualPreview({
activeSourceId,
v.currentTime,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (nextKeptSegment) {
// `findRawClipForSegment` is the ONE definition of the segment-id
@@ -531,7 +930,11 @@ export function VirtualPreview({
if (rawClip) {
activeClipIdRef.current = rawClip.id;
}
- const rawTargetTime = getRawVirtualStartTime(nextKeptSegment, clipsRef.current);
+ const rawTargetTime = getRawVirtualStartTime(
+ nextKeptSegment,
+ clipsRef.current,
+ insertRangesRef.current,
+ );
seekToVirtualTimeRef.current?.(rawTargetTime, true);
return;
}
@@ -567,7 +970,7 @@ export function VirtualPreview({
// `clockRef` et `setSourceTimeSec` ci-dessus continuent d'être publiés : la webcam
// et le calque curseur ont besoin du temps source même à l'arrêt. Seule la
// position de la TIMELINE cesse d'être dictée par le média.
- if (v.paused) {
+ if (!filmPlaying) {
return;
}
if (clipsRef.current.length === 0) {
@@ -588,6 +991,7 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (pos) {
activeClipIdRef.current = pos.clip.id;
@@ -601,6 +1005,7 @@ export function VirtualPreview({
activeSourceId,
0.05,
activeClipIdRef.current ?? undefined,
+ insertRangesRef.current,
);
if (!position) {
// ponytail: fall back to timeline order so cross-asset / reordered
@@ -636,7 +1041,32 @@ export function VirtualPreview({
seekToVirtualTimeRef.current?.(nextClip.timelineStartSec, true);
return;
}
- updateVirtualTime(clampVirtualTime(clipsRef.current, position.virtualTimeSec));
+ const nextRawTime = clampVirtualTime(clipsRef.current, position.virtualTimeSec);
+ // The first insertion this frame ran into — the rule, and why it is half-open,
+ // lives with the other ruler arithmetic.
+ const entering = insertionRef.current
+ ? undefined
+ : insertionEnteredBetween(virtualTimeSecRef.current, nextRawTime, filmInsertsRef.current);
+ if (entering) {
+ insertionRef.current = {
+ rawSec: entering.atRawSec,
+ durationSec: entering.durationSec,
+ startedAtMs: performance.now(),
+ };
+ insertionElapsedRef.current = 0;
+ // Parks the picture on the frame the insertion stands for, and silences the
+ // recording under it. Both are what the insertion IS.
+ v.pause();
+ // The insertion's own moment, not the frame we happened to land on: the
+ // transcript cue, the caption lookup and the audio mix all read this, and
+ // through the insertion the RECORDING really is at that one instant.
+ updateVirtualTime(entering.atRawSec);
+ return;
+ }
+ // While an insertion plays the element is parked, so the position it reports stands
+ // still at where the insertion opens. Its own wall clock is what carries the
+ // playhead across it — nothing else is moving. Zero the rest of the time.
+ updateVirtualTime(nextRawTime + insertionElapsedRef.current);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
@@ -735,7 +1165,12 @@ export function VirtualPreview({
const seekToVirtualTime = useCallback(
(nextVirtualTimeSec: number, preservePlayback = false, forceResume = false) => {
- const position = locateVirtualPosition(clips, nextVirtualTimeSec);
+ // A seek ends the insertion that was playing: the playhead is somewhere else now,
+ // so the frame it parked on is not the frame any more. The rAF's own seeks (clip
+ // advance, trim skip) are gated on `!v.paused` and so never land here mid-insertion.
+ insertionRef.current = null;
+ insertionElapsedRef.current = 0;
+ const position = locateVirtualPosition(clips, nextVirtualTimeSec, insertRanges);
if (!position) {
videoRef.current?.pause();
updateVirtualTime(0);
@@ -803,7 +1238,7 @@ export function VirtualPreview({
});
}
},
- [applySourceTime, clips, videoSources, sourceIndex, updateVirtualTime],
+ [applySourceTime, clips, videoSources, sourceIndex, updateVirtualTime, insertRanges],
);
const seekToSourceTime = useCallback(
@@ -842,7 +1277,11 @@ export function VirtualPreview({
// the one that has to come back. An asset switch queued in the
// meantime is newer intent still, so it wins outright.
if (!pendingSeekRef.current) {
- const position = locateVirtualPosition(clipsRef.current, virtualTimeSecRef.current);
+ const position = locateVirtualPosition(
+ clipsRef.current,
+ virtualTimeSecRef.current,
+ insertRangesRef.current,
+ );
// `locateVirtualPosition` answers for whatever clip the playhead
// is on, which after a boundary advance can belong to a DIFFERENT
// asset — its source time would be a meaningless offset into the
@@ -1146,6 +1585,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..ffd13f30a 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -68,12 +68,14 @@ function makeDocument(): AxcutDocument {
clips: [CLIP_WITH_CAMERA, CLIP_WITHOUT_CAMERA],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
diff --git a/src/components/ai-edition/WebcamOverlay.tsx b/src/components/ai-edition/WebcamOverlay.tsx
index d9b256149..9a9dad3dd 100644
--- a/src/components/ai-edition/WebcamOverlay.tsx
+++ b/src/components/ai-edition/WebcamOverlay.tsx
@@ -17,7 +17,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import type { WebcamLayoutPreset, WebcamMaskShape } from "@/components/video-editor/types";
-import type { AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera";
@@ -31,8 +31,14 @@ import { getCssClipPath } from "@/lib/webcamMaskShapes";
import { setWebcamNativeSize } from "@/native/webcamSizeCache";
import styles from "./NewEditorShell.module.css";
+/** Stable identity, so the memos are not invalidated every render by a fresh `[]`. */
+const EMPTY_INSERT_RANGES: readonly AxcutInsertRange[] = [];
+
interface WebcamOverlayProps {
clips: AxcutClip[];
+ /** The insertions those clips carry — the camera follows the clip under the playhead,
+ * and which clip that is cannot be answered without them (issue #560). */
+ insertRanges?: readonly AxcutInsertRange[];
currentTimeSec: number;
onTimeChange: (sec: number) => void;
isPlaying: boolean;
@@ -61,14 +67,15 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
// Fallback (pre-clockRef / first paint) position from props, used only for
// the initial correction on loadedmetadata before the rAF loop below has
// had a chance to run.
+ const overlayInserts = props.insertRanges ?? EMPTY_INSERT_RANGES;
const position = useMemo(
- () => locateVirtualPosition(props.clips, props.currentTimeSec),
- [props.clips, props.currentTimeSec],
+ () => locateVirtualPosition(props.clips, props.currentTimeSec, overlayInserts),
+ [props.clips, props.currentTimeSec, overlayInserts],
);
const cameraTrack = useMemo(
- () => resolveActiveCameraTrack(assets ?? [], props.clips, props.currentTimeSec),
- [assets, props.clips, props.currentTimeSec],
+ () => resolveActiveCameraTrack(assets ?? [], props.clips, props.currentTimeSec, overlayInserts),
+ [assets, props.clips, props.currentTimeSec, overlayInserts],
);
const cameraTime = useMemo(() => {
@@ -81,6 +88,8 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
// re-creating the loop on every document mutation.
const clipsRef = useRef(props.clips);
clipsRef.current = props.clips;
+ const insertsRef = useRef(overlayInserts);
+ insertsRef.current = overlayInserts;
const assetsRef = useRef(assets);
assetsRef.current = assets;
@@ -97,11 +106,12 @@ export function WebcamOverlay(props: WebcamOverlayProps) {
raf = window.requestAnimationFrame(tick);
const clock = clockRef.current;
const clipsNow = clipsRef.current;
- const positionNow = locateVirtualPosition(clipsNow, clock.virtualTimeSec);
+ const positionNow = locateVirtualPosition(clipsNow, clock.virtualTimeSec, insertsRef.current);
const trackNow = resolveActiveCameraTrack(
assetsRef.current ?? [],
clipsNow,
clock.virtualTimeSec,
+ insertsRef.current,
);
const target = resolveCameraSyncTarget(
clock,
diff --git a/src/components/ai-edition/v4/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..15af76b3e 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
@@ -1643,6 +1770,49 @@
background: var(--danger-soft);
color: var(--danger);
}
+
+/* Where the user has ADDED a word: text with no audio behind it. A thin amber tick
+ over the waveform, at the moment the word sits on, wide enough to hit and no wider
+ — the clip underneath still has to be draggable everywhere else. Amber is the
+ colour the transcript pane gives the same word, so the two read as one thing. */
+.tlClipInsert {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 2;
+ /* A pause of zero still needs somewhere to be clicked; a real one is sized inline. */
+ min-width: 9px;
+ margin-left: -4px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+/* The mark fills its button rather than sitting at a fixed 3px inside it. The button has
+ carried the pause's real width in its inline style for a while; `width: 3px` here meant a
+ word that bought two seconds and one that bought nothing drew the same tick, and the wide
+ case was a multi-second invisible column that swallowed clip drags (issue #560).
+ `min-width` is the floor that keeps a word which borrowed existing silence — a pause of
+ zero, so a zero-width button — visible and clickable. */
+.tlClipInsert::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 4px;
+ bottom: 4px;
+ min-width: 3px;
+ border-radius: 2px;
+ background: var(--warn);
+ box-shadow: 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent);
+ transition: box-shadow var(--motion-fast) var(--ease);
+}
+.tlClipInsert:hover::before,
+.tlClipInsert:focus-visible::before {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, #080a0d 45%, transparent),
+ 0 0 0 4px var(--warn-soft);
+}
.tlDropHint {
position: absolute;
inset: 0;
@@ -1756,3 +1926,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..2700087af 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 {
@@ -983,7 +990,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
// the group's, not the clicked row's. Deleting no longer needs the same expansion here:
// `removeRegion` drops the whole pill for every kind (`dropTrimPillsByIds`), which is
// what this pane used to have to arrange for itself.
- const trimGroup = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) =>
+ const trimGroup = coalescedTrimGroups(tl.trimRanges, tl.clips, tl.insertRanges ?? []).find((g) =>
g.ids.includes(selection.id),
);
if (!trimGroup) return null;
@@ -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 a7af81acb..695a3cd35 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..1a3b97fab 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,21 @@ 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 { 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";
@@ -38,11 +50,18 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatSec } from "@/lib/ai-edition/timeline/format";
+import {
+ type InsertedWordMark,
+ insertedWordMarks,
+ rulerInserts,
+} from "@/lib/ai-edition/timeline/inserted-time";
import {
newRegionDurationSec,
setTimelineScale,
} from "@/lib/ai-edition/timeline/newRegionDuration";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation";
+import { type TakePiece, takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap";
import {
coalescedTrimGroups,
@@ -53,6 +72,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,6 +147,14 @@ 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.
@@ -198,7 +226,7 @@ interface RulerTick {
interface PlayheadOverlayProps {
/** Full timeline length in seconds — the denominator for the playhead's percentage. */
totalSec: number;
- /** Live scrub position, when a drag is in flight. Takes precedence over the store. */
+ /** Live scrub position in RAW seconds, when a drag is in flight. */
overrideTimeSec: number | null;
canvasStyle: React.CSSProperties;
onPointerDown: (e: ReactPointerEvent) => void;
@@ -229,7 +257,7 @@ const PlayheadOverlay = memo(function PlayheadOverlay({
playheadRef,
}: PlayheadOverlayProps) {
const storeTimeSec = useProjectStore((s) => s.currentTimeSec);
- const pct = ((overrideTimeSec ?? storeTimeSec) / totalSec) * 100;
+ const pct = (((overrideTimeSec ?? storeTimeSec) / totalSec) * 100) as number;
return (
@@ -335,6 +363,215 @@ 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,
+ pieces,
+}: {
+ 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;
+ /** The take's own walk, when it has one. Absent for music, for a looping take, and for
+ * a take with no insertion — all of which draw one unbroken waveform, exactly as
+ * before. */
+ pieces?: readonly TakePiece[] | null;
+}) {
+ const duration = assetDurationSec ?? track.durationSec;
+ // Only a take that actually holds somewhere is drawn in pieces. Everything else keeps
+ // the single waveform it has always had, so the common pill is untouched.
+ const notched = pieces?.some((piece) => piece.kind === "hold") ? pieces : null;
+ const pillRawStart = track.startMs / 1000;
+ const pillRawSpan = Math.max(1e-6, track.endMs / 1000 - pillRawStart);
+ const atPctOfPill = (rawSec: number) => ((rawSec - pillRawStart) / pillRawSpan) * 100;
+ return (
+ <>
+ {/* The rest of the tape, dimmed and unclickable, behind the pill — so the pill
+ 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 === " ") {
+ e.preventDefault();
+ onSelect(track.id);
+ }
+ }}
+ title={`${label} — ${slipHint}`}
+ >
+ onStartDrag(e, track, "l")}
+ />
+ {notched ? (
+ // A notch cut out of the fill, inside ONE outline. The take is still one
+ // take — one draggable, slippable object — and the eye should read "the
+ // voice stops here", not "two takes". The opposite polarity of the clip
+ // lane's band, which means "the picture freezes here" (issue #560).
+ notched.map((piece) => {
+ const left = atPctOfPill(piece.rawStartSec);
+ const width = atPctOfPill(piece.rawEndSec) - left;
+ return piece.kind === "hold" ? (
+
+ ) : (
+
+
+
+ );
+ })
+ ) : (
+
+ )}
+ {/* Where the file starts over, so a looping bed reads as one deliberate
+ repeat rather than a mystery. Only drawn when the pill actually
+ outruns its source — otherwise there is nothing to repeat. */}
+ {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 +593,7 @@ export function V4Timeline({
onPrevClip,
onNextClip,
onEditClip,
+ onAddVoiceover,
}: {
tl: TimelineApi;
setCurrentTime: (sec: number) => void;
@@ -369,8 +607,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 +656,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 +684,15 @@ export function V4Timeline({
// clicked instead of looking like it worked. Same question, same helper as the Layout
// pane: is a camera attached anywhere on this timeline?
const hasAnyCamera = useMemo(() => hasAnyClipWithCamera(tl.assets, clips), [tl.assets, clips]);
+ // The media added words inserted, placed on the ruler. Everything below measures the
+ // EXPANDED ruler — stored clip geometry plus the time those insertions add — because that
+ // is the film's real length and the one the playhead runs along. Stored geometry is
+ // never rewritten for this: only what is drawn moves.
+ // `?? []` because the key is additive: a document written before it has no insertions.
+ const inserts = useMemo(
+ () => rulerInserts(tl.insertRanges ?? [], clips),
+ [tl.insertRanges, clips],
+ );
const total = useMemo(
() =>
Math.max(
@@ -509,12 +763,31 @@ export function V4Timeline({
label: `${(p.member.customScale ?? ZOOM_DEPTH_SCALES[p.member.depth]).toFixed(2)}×`,
sourceIds: p.ids,
}));
+ // Where the user has ADDED words. Derived from the transcript on every render and
+ // stored nowhere: the word carries `source: "synth"` and its own source time, so a mark
+ // built from it cannot drift from the amber word the transcript pane shows. Grouped by
+ // clip because each mark is positioned inside its clip's own box — it then travels with
+ // the clip through a reorder for free, with no ruler arithmetic of its own.
+ const insertedWordsByClip = useMemo(() => {
+ const out = new Map
();
+ for (const mark of insertedWordMarks(tl.transcripts, clips, tl.insertRanges ?? [])) {
+ const list = out.get(mark.clipId);
+ if (list) list.push(mark);
+ else out.set(mark.clipId, [mark]);
+ }
+ return out;
+ }, [tl.transcripts, clips]);
+
// trims: content-free (no per-instance text/settings), so touching rows —
// inevitable once a trim is ventilated across a clip boundary — are
// coalesced into one pill. This is what makes growing a trim across a
// junction look like one continuously-growing pill instead of visibly
// splitting, aligning trims with how zoom/speed/annotation already behave.
- const trimPills: LanePill[] = coalescedTrimGroups(tl.trimRanges, clips).map((g) => ({
+ const trimPills: LanePill[] = coalescedTrimGroups(
+ tl.trimRanges,
+ clips,
+ tl.insertRanges ?? [],
+ ).map((g) => ({
id: g.ids[0],
kind: "trim",
start: g.start,
@@ -567,6 +840,13 @@ export function V4Timeline({
if (!el) return;
const r = el.getBoundingClientRect();
const pct = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
+ // `total` is the EXPANDED ruler, so `pct * total` is a ruler second — and
+ // `setCurrentTime` is read as a RAW one by every consumer: the preview seek, the
+ // caption lookup, the transcript cue, the audio mix. Writing the ruler value
+ // straight in put the playhead one accumulated insertion AHEAD of everything it was
+ // supposed to be pointing at, which is what showed as the wrong subtitle under a
+ // correctly-placed playhead (issue #560).
+ //
const targetTime = pct * total;
// Direct DOM playhead update (0ms latency, zero React re-render overhead)
@@ -660,11 +940,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;
@@ -726,7 +1013,7 @@ export function V4Timeline({
let ranges = ventilateTimelineSpanToTrims(s, en, clips);
if (ranges.length === 0) {
// Span sits in a gap / past the end: fall back to the nearest clip.
- const resolved = resolveTimelineSpanToTrim(s, en, clips);
+ const resolved = resolveTimelineSpanToTrim(s, en, clips, tl.insertRanges ?? []);
if (!resolved) return;
ranges = [resolved];
}
@@ -776,7 +1063,276 @@ 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.
+ // One walk per take, for the lane to draw. Same inputs the preview and the export use,
+ // so a notch cannot appear where the voice does not actually stop.
+ const takePieces = useMemo(() => {
+ const clipAssetIds = new Set(clips.map((c) => c.assetId));
+ const removed = removedRawSpans(clips, tl.trimRanges, tl.insertRanges ?? []);
+ const out = new Map();
+ for (const pill of audioPills) {
+ if (pill.kind !== "voiceover" || pill.loop) continue;
+ // A range naming an asset that is no clip's is a take's — the same test
+ // `resolveInsertPlacement` makes, available here without a document.
+ const inserts = (tl.insertRanges ?? [])
+ .filter((range) => range.assetId === pill.assetId && !clipAssetIds.has(range.assetId))
+ .map((range) => ({
+ id: range.id,
+ wordId: range.wordId,
+ atSourceSec: range.atSec,
+ durationSec: range.durationSec,
+ }));
+ if (inserts.length === 0) continue;
+ out.set(pill.id, takeProgramme(pill, removed, inserts));
+ }
+ return out;
+ }, [audioPills, clips, tl.trimRanges, tl.insertRanges]);
+
+ const audioRows = useMemo(() => {
+ const voice = audioPills.filter((p) => p.kind === "voiceover");
+ const music = audioPills.filter((p) => p.kind !== "voiceover");
+ 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(
@@ -1143,7 +1699,9 @@ export function V4Timeline({
}${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`}
style={{
left: `${pctOf(seg.segStart)}%`,
- width: `${pctOf(durSec)}%`,
+ // Measured on the expanded ruler at BOTH ends: a region straddling an insertion
+ // covers it, so its box has to grow by that insertion and not merely slide.
+ width: `${pctOf(seg.segEnd - seg.segStart)}%`,
transform: seg.shiftPx ? `translateX(${seg.shiftPx}px)` : undefined,
transition: !clipDrag
? undefined
@@ -1158,6 +1716,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 +1847,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
@@ -1482,6 +2151,98 @@ 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)}
+ pieces={takePieces.get(track.id) ?? null}
+ 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 +2264,12 @@ export function V4Timeline({
>
{clips.map((c, i) => {
const dur = c.timelineEndSec - c.timelineStartSec;
+ // On the expanded ruler the box also carries whatever insertions fall
+ // inside it — the film really does stay on this clip's frame for
+ // them, so they belong to its box rather than between boxes.
+ const boxStart = 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;
@@ -1530,12 +2297,12 @@ export function V4Timeline({
dragging ? ` ${styles.tlClipDragging}` : ""
}`}
style={{
- left: `${pctOf(c.timelineStartSec)}%`,
+ left: `${pctOf(boxStart)}%`,
// Minus the gutter that separates two cards (it used to be the
// flex row's `gap`). A clip shorter than the gutter lands on
// .tlClip's 1px min-width instead of collapsing — same rule as
// the lane pills above.
- width: `calc(${pctOf(dur)}% - ${CLIP_GUTTER_PX}px)`,
+ width: `calc(${pctOf(boxLen)}% - ${CLIP_GUTTER_PX}px)`,
transform: clipTransform,
}}
onPointerDown={(e) => startClipDrag(e, c)}
@@ -1578,6 +2345,43 @@ export function V4Timeline({
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId}
+ {(insertedWordsByClip.get(c.id) ?? []).map(({ wordId, text, atRawSec }) => {
+ // A word whose insertion the film plays gets a BAND as wide as the time
+ // it adds — that width IS the added time, drawn. One that fitted in
+ // silence already there adds nothing and stays a hairline.
+ //
+ // Both ends on ONE clock. The mark used to place a paused word on
+ // the expanded ruler and an unpaused one at a fraction of the clip's
+ // SOURCE span, in the same ternary — two clocks, one of which the
+ // box is not drawn in.
+ const inserted = inserts.find((ins) => ins.wordId === wordId);
+ const left = ((atRawSec - boxStart) / boxLen) * 100;
+ const width = inserted ? (inserted.durationSec / boxLen) * 100 : 0;
+ return (
+ 0
+ ? { left: `${left}%`, width: `${width}%`, marginLeft: 0 }
+ : { left: `${left}%` }
+ }
+ title={t("toolbar.addedWord", { word: text })}
+ aria-label={t("toolbar.addedWord", { word: text })}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => {
+ // Jump to the moment the added text sits on. The clip box
+ // underneath would otherwise take this as a selection.
+ e.stopPropagation();
+ setCurrentTime(atRawSec);
+ }}
+ />
+ );
+ })}
{selected ? (
) : 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..823b3f9b5 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -46,25 +46,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 39860c0da..678c1e377 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 5fc1ac58c..0285abfaf 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -263,26 +263,39 @@
"help": "مساعدة"
},
"transcript": {
- "title": "النص الحالي",
- "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لتمييزه كمتخطّى (بالأحمر). مرّر المؤشر فوق المقطع الأحمر لاستعادته.",
+ "blankedWord": "مُفرَّغة",
+ "clipLabel": "المقطع {{index}}",
+ "correctedWord": "مصحّحة — كان النص \"{{original}}\"",
+ "editWord": "تحرير \"{{word}}\"",
+ "editingHint": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم.",
+ "editingHintDev": "انقر نقرًا مزدوجًا على كلمة لتصحيحها، واضغط Backspace لقطعها من الفيلم، واكتب بين كلمتين لإضافة كلمة.",
+ "editorAria": "نص {{filename}}",
+ "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لقصّها من الفيديو (بالأحمر). انقر نقرًا مزدوجًا على كلمة لتصحيح نصها. مرّر المؤشر فوق كلمة معلَّمة للتراجع.",
+ "helpInsert": "اكتب بين كلمتين لإضافة كلمة جديدة باللون الكهرماني: تصل إلى الترجمات ولا تمسّ الفيديو.",
+ "insertAria": "كلمة جديدة",
+ "insertedWord": "أضفتها بنفسك — لا صوت خلفها",
+ "laneFeedsCaptions": "تُحرَق التسميات التوضيحية من هذا المسار.",
+ "laneLabel": "اقرأ النص من",
+ "laneRecording": "التسجيل",
+ "laneVoiceover": "التعليق الصوتي",
+ "noAudio": "لا يحتوي هذا الملف على مسار صوتي",
+ "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
"noClips": "لا توجد مقاطع بعد",
"noTranscript": "لا يوجد نص بعد",
- "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك.",
+ "removeInserted": "حذف \"{{word}}\"",
+ "restoreSilence": "استعادة الصمت ({{duration}} ث)",
+ "restoreWord": "استعادة \"{{word}}\"",
+ "revertWord": "استعادة \"{{original}}\"",
+ "silence": "[صمت {{duration}} ث]",
+ "title": "النص الحالي",
"transcribeNow": "فرّغ النص الآن",
"transcribing": "جارٍ التفريغ…",
- "clipLabel": "المقطع {{index}}",
- "noClipTranscript": "لا يوجد نص لهذا المقطع — افتح بطاقة الوسيط وأعد التوليد.",
- "editorAria": "نص {{filename}}",
- "silence": "[صمت {{duration}} ث]",
- "restoreSilence": "استعادة الصمت ({{duration}} ث)",
"trimSilence": "قص الصمت ({{duration}} ث)",
- "restoreWord": "استعادة \"{{word}}\"",
- "noAudio": "لا يحتوي هذا الملف على مسار صوتي"
+ "whisperHint": "يستخدم التفريغ النصي Whisper محليًا — يعمل على حاسوبك، ولا تغادر أي بيانات جهازك."
},
"captions": {
"show": "إظهار الترجمة",
"noTranscript": "تُقرأ الترجمة من نص الوسائط. فرّغ نص هذا الفيديو لتفعيلها.",
- "transcribe": "تفريغ نص الفيديو",
"transcribing": "جارٍ التفريغ…",
"derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
"hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
@@ -320,12 +333,25 @@
"alignRight": "يمين",
"lineLength": "طول السطر",
"minWords": "أقل عدد كلمات في السطر",
- "maxWords": "أكثر عدد كلمات في السطر"
+ "maxWords": "أكثر عدد كلمات في السطر",
+ "transcribe": "تفريغ نص الفيديو"
},
"audio": {
"title": "الصوت",
"outputGain": "ضبط مستوى الإخراج",
"reset": "إعادة ضبط الصوت",
"help": "اضبط مستوى إخراج الصوت. يُطبَّق بالطريقة نفسها في المعاينة وعند التصدير."
+ },
+ "audioTrack": {
+ "add": "إضافة مسار صوتي",
+ "defaultLabel": "مسار صوتي",
+ "fadeIn": "تلاشٍ للداخل",
+ "fadeOut": "تلاشٍ للخارج",
+ "help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
+ "importFailed": "تعذّر إضافة الصوت",
+ "loop": "تكرار",
+ "mute": "كتم",
+ "remove": "حذف المسار",
+ "slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل"
}
}
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 7aa5954ce..49abb9971 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 b291460d7..637f1b993 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -79,6 +79,18 @@
"outputGain": "Output level",
"reset": "Reset audio"
},
+ "audioTrack": {
+ "add": "Add audio track",
+ "defaultLabel": "Audio track",
+ "fadeIn": "Fade in",
+ "fadeOut": "Fade out",
+ "help": "Volume, fades and looping for this audio track. It mixes over the recording in the preview and the export. Drag the track on the timeline to move or resize it, or hold Alt and drag to slide the audio inside it.",
+ "importFailed": "Could not add audio",
+ "loop": "Loop",
+ "mute": "Mute",
+ "remove": "Delete track",
+ "slipHint": "Alt-drag to slide the audio inside it"
+ },
"effects": {
"title": "Composition",
"blurBg": "Blur BG",
@@ -269,26 +281,39 @@
"help": "Help"
},
"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.",
+ "blankedWord": "blanked",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corrected — the transcriber heard \"{{original}}\"",
+ "editWord": "Edit \"{{word}}\"",
+ "editingHint": "Double-click a word to correct it, Backspace cuts it from the film.",
+ "editingHintDev": "Double-click a word to correct it, Backspace cuts it from the film, type between two words to add one.",
+ "editorAria": "Transcript for {{filename}}",
+ "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.",
+ "helpInsert": "Type between two words to add one, in amber: it reaches the captions and leaves the film alone.",
+ "insertAria": "New word",
+ "insertedWord": "Added by you — no audio behind it",
+ "laneFeedsCaptions": "Captions are burnt from this lane.",
+ "laneLabel": "Read the transcript from",
+ "laneRecording": "Recording",
+ "laneVoiceover": "Voice-over",
+ "noAudio": "This media has no audio track",
+ "noClipTranscript": "No transcript for this clip — open the asset card and regenerate.",
"noClips": "No clips yet",
"noTranscript": "No transcript yet",
- "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device.",
+ "removeInserted": "Delete \"{{word}}\"",
+ "restoreSilence": "Restore silence ({{duration}}s)",
+ "restoreWord": "Restore \"{{word}}\"",
+ "revertWord": "Restore \"{{original}}\"",
+ "silence": "[silence {{duration}}s]",
+ "title": "Current transcription",
"transcribeNow": "Transcribe now",
"transcribing": "Transcribing…",
- "clipLabel": "Clip {{index}}",
- "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"
+ "whisperHint": "Transcribe uses local Whisper — runs on your computer, no data leaves the device."
},
"captions": {
"show": "Show captions",
"noTranscript": "Captions are read from the media transcript. Transcribe this video to turn them on.",
- "transcribe": "Transcribe video",
"transcribing": "Transcribing…",
"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.",
@@ -326,6 +351,7 @@
"alignRight": "Right",
"lineLength": "Line length",
"minWords": "Min words per line",
- "maxWords": "Max words per line"
+ "maxWords": "Max words per line",
+ "transcribe": "Transcribe video"
}
}
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 4d10b36a6..2e749b273 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 bb9ee27a2..768d8135a 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -263,26 +263,39 @@
"help": "Ayuda"
},
"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.",
+ "blankedWord": "vaciada",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corregida: la transcripción decía «{{original}}»",
+ "editWord": "Editar «{{word}}»",
+ "editingHint": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo.",
+ "editingHintDev": "Haz doble clic en una palabra para corregirla, Retroceso la corta del vídeo, escribe entre dos palabras para añadir una.",
+ "editorAria": "Transcripción de {{filename}}",
+ "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.",
+ "helpInsert": "Escribe entre dos palabras para añadir una, en ámbar: llega a los subtítulos y no toca el vídeo.",
+ "insertAria": "Palabra nueva",
+ "insertedWord": "Añadida por ti: no hay audio detrás",
+ "laneFeedsCaptions": "Los subtítulos se graban desde esta pista.",
+ "laneLabel": "Leer la transcripción desde",
+ "laneRecording": "Grabación",
+ "laneVoiceover": "Voz en off",
+ "noAudio": "Este medio no tiene pista de audio",
+ "noClipTranscript": "Este clip no tiene transcripción: abre la ficha del recurso y vuelve a generarla.",
"noClips": "Aún no hay clips",
"noTranscript": "Aún no hay transcripción",
- "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo.",
+ "removeInserted": "Eliminar «{{word}}»",
+ "restoreSilence": "Restaurar silencio ({{duration}} s)",
+ "restoreWord": "Restaurar «{{word}}»",
+ "revertWord": "Restaurar «{{original}}»",
+ "silence": "[silencio {{duration}} s]",
+ "title": "Transcripción actual",
"transcribeNow": "Transcribir ahora",
"transcribing": "Transcribiendo…",
- "clipLabel": "Clip {{index}}",
- "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"
+ "whisperHint": "La transcripción usa Whisper local: se ejecuta en tu equipo y ningún dato sale del dispositivo."
},
"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",
"transcribing": "Transcribiendo…",
"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.",
@@ -320,12 +333,25 @@
"alignRight": "Derecha",
"lineLength": "Longitud de línea",
"minWords": "Mín. palabras por línea",
- "maxWords": "Máx. palabras por línea"
+ "maxWords": "Máx. palabras por línea",
+ "transcribe": "Transcribir vídeo"
},
"audio": {
"title": "Audio",
"outputGain": "Ajuste de salida",
"reset": "Restablecer audio",
"help": "Ajusta el nivel de salida del audio. Se aplica igual en la vista previa y en la exportación."
+ },
+ "audioTrack": {
+ "add": "Añadir pista de audio",
+ "defaultLabel": "Pista de audio",
+ "fadeIn": "Aparición",
+ "fadeOut": "Desvanecido",
+ "help": "Volumen, fundidos y bucle de esta pista de audio. Se mezcla sobre la grabación en la vista previa y en la exportación. Arrastra la pista en la línea de tiempo para moverla o redimensionarla, o mantén Alt y arrastra para desplazar el audio dentro.",
+ "importFailed": "No se pudo añadir el audio",
+ "loop": "Bucle",
+ "mute": "Silenciar",
+ "remove": "Eliminar pista",
+ "slipHint": "Alt + arrastrar para desplazar el audio dentro"
}
}
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 7a719ade2..1c6c31bc5 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 23dd573f0..6eb4d3a7a 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -263,26 +263,39 @@
"help": "Aide"
},
"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.",
+ "blankedWord": "vidé",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corrigé — la transcription disait « {{original}} »",
+ "editWord": "Modifier « {{word}} »",
+ "editingHint": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film.",
+ "editingHintDev": "Double-cliquez sur un mot pour le corriger, Retour arrière le coupe du film, tapez entre deux mots pour en ajouter un.",
+ "editorAria": "Transcription de {{filename}}",
+ "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.",
+ "helpInsert": "Tapez entre deux mots pour en ajouter un, en ambre : il va dans les sous-titres et ne touche pas au film.",
+ "insertAria": "Nouveau mot",
+ "insertedWord": "Ajouté par vous — aucun son derrière",
+ "laneFeedsCaptions": "Les sous-titres sont gravés depuis cette piste.",
+ "laneLabel": "Lire la transcription depuis",
+ "laneRecording": "Enregistrement",
+ "laneVoiceover": "Voix off",
+ "noAudio": "Ce média n'a pas de piste audio",
+ "noClipTranscript": "Aucune transcription pour ce clip — ouvrez la fiche du média et relancez la génération.",
"noClips": "Aucun clip pour l'instant",
"noTranscript": "Aucune transcription pour l'instant",
- "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil.",
+ "removeInserted": "Supprimer « {{word}} »",
+ "restoreSilence": "Restaurer le silence ({{duration}} s)",
+ "restoreWord": "Restaurer « {{word}} »",
+ "revertWord": "Rétablir « {{original}} »",
+ "silence": "[silence {{duration}} s]",
+ "title": "Transcription actuelle",
"transcribeNow": "Transcrire maintenant",
"transcribing": "Transcription…",
- "clipLabel": "Clip {{index}}",
- "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"
+ "whisperHint": "La transcription utilise Whisper en local — tout s'exécute sur votre ordinateur, aucune donnée ne quitte l'appareil."
},
"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",
"transcribing": "Transcription…",
"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.",
@@ -320,12 +333,25 @@
"alignRight": "Droite",
"lineLength": "Longueur des lignes",
"minWords": "Mots min. par ligne",
- "maxWords": "Mots max. par ligne"
+ "maxWords": "Mots max. par ligne",
+ "transcribe": "Transcrire la vidéo"
},
"audio": {
"title": "Audio",
"outputGain": "Niveau de sortie",
"reset": "Réinitialiser l’audio",
"help": "Ajustez le niveau de sortie audio. Il s’applique à l’identique dans l’aperçu et à l’export."
+ },
+ "audioTrack": {
+ "add": "Ajouter une piste audio",
+ "defaultLabel": "Piste audio",
+ "fadeIn": "Fondu d'entrée",
+ "fadeOut": "Fondu de sortie",
+ "help": "Volume, fondus et boucle de cette piste audio. Elle se mixe par-dessus l’enregistrement dans l’aperçu et à l’export. Faites glisser la piste sur la timeline pour la déplacer ou la redimensionner, ou maintenez Alt en glissant pour faire défiler l’audio à l’intérieur.",
+ "importFailed": "Impossible d’ajouter l’audio",
+ "loop": "Boucle",
+ "mute": "Muet",
+ "remove": "Supprimer la piste",
+ "slipHint": "Alt + glisser pour faire défiler l’audio à l’intérieur"
}
}
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 70a680a7a..ed7032167 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 e10828765..08639e6a5 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -263,26 +263,39 @@
"help": "Aiuto"
},
"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.",
+ "blankedWord": "svuotata",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Corretta — la trascrizione diceva «{{original}}»",
+ "editWord": "Modifica «{{word}}»",
+ "editingHint": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video.",
+ "editingHintDev": "Fai doppio clic su una parola per correggerla, Backspace la taglia dal video, scrivi tra due parole per aggiungerne una.",
+ "editorAria": "Trascrizione di {{filename}}",
+ "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.",
+ "helpInsert": "Scrivi tra due parole per aggiungerne una, in ambra: finisce nei sottotitoli e non tocca il video.",
+ "insertAria": "Nuova parola",
+ "insertedWord": "Aggiunta da te — nessun audio dietro",
+ "laneFeedsCaptions": "I sottotitoli vengono impressi da questa traccia.",
+ "laneLabel": "Leggi la trascrizione da",
+ "laneRecording": "Registrazione",
+ "laneVoiceover": "Voce fuori campo",
+ "noAudio": "Questo contenuto non ha una traccia audio",
+ "noClipTranscript": "Nessuna trascrizione per questo clip: apri la scheda della risorsa e rigenerala.",
"noClips": "Ancora nessun clip",
"noTranscript": "Ancora nessuna trascrizione",
- "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo.",
+ "removeInserted": "Elimina «{{word}}»",
+ "restoreSilence": "Ripristina silenzio ({{duration}} s)",
+ "restoreWord": "Ripristina «{{word}}»",
+ "revertWord": "Ripristina «{{original}}»",
+ "silence": "[silenzio {{duration}} s]",
+ "title": "Trascrizione corrente",
"transcribeNow": "Trascrivi ora",
"transcribing": "Trascrizione…",
- "clipLabel": "Clip {{index}}",
- "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"
+ "whisperHint": "La trascrizione usa Whisper in locale: gira sul tuo computer, nessun dato lascia il dispositivo."
},
"captions": {
"show": "Mostra sottotitoli",
"noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Trascrivi questo video per attivarli.",
- "transcribe": "Trascrivi video",
"transcribing": "Trascrizione…",
"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.",
@@ -320,12 +333,25 @@
"alignRight": "Destra",
"lineLength": "Lunghezza riga",
"minWords": "Parole min. per riga",
- "maxWords": "Parole max. per riga"
+ "maxWords": "Parole max. per riga",
+ "transcribe": "Trascrivi video"
},
"audio": {
"title": "Audio",
"outputGain": "Livello di uscita",
"reset": "Reimposta audio",
"help": "Regola il livello di uscita audio. Si applica allo stesso modo nell’anteprima e nell’esportazione."
+ },
+ "audioTrack": {
+ "add": "Aggiungi traccia audio",
+ "defaultLabel": "Traccia audio",
+ "fadeIn": "Dissolvenza in entrata",
+ "fadeOut": "Dissolvenza in uscita",
+ "help": "Volume, dissolvenze e loop di questa traccia audio. Viene mixata sopra la registrazione nell’anteprima e nell’esportazione. Trascina la traccia sulla timeline per spostarla o ridimensionarla, oppure tieni premuto Alt e trascina per far scorrere l’audio all’interno.",
+ "importFailed": "Impossibile aggiungere l’audio",
+ "loop": "Ripeti",
+ "mute": "Muto",
+ "remove": "Elimina traccia",
+ "slipHint": "Alt + trascina per far scorrere l’audio all’interno"
}
}
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 bcbc57164..ce27b68d8 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 ead358d48..0d97f9eaa 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -263,26 +263,39 @@
"help": "ヘルプ"
},
"transcript": {
- "title": "現在の文字起こし",
- "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete でスキップ(赤色)にできます。赤い部分にカーソルを合わせると元に戻せます。",
+ "blankedWord": "空欄",
+ "clipLabel": "クリップ {{index}}",
+ "correctedWord": "修正済み — 文字起こしでは「{{original}}」でした",
+ "editWord": "「{{word}}」を編集",
+ "editingHint": "ダブルクリックで単語を修正、Backspace で映像からカット。",
+ "editingHintDev": "ダブルクリックで単語を修正、Backspace で映像からカット、2つの単語の間に入力して新しい単語を追加。",
+ "editorAria": "{{filename}} の文字起こし",
+ "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete で映像から切り取れます(赤色)。単語をダブルクリックすると文字を修正できます。字幕にだけ入り、映像は変わりません。印の付いた単語にカーソルを合わせると取り消せます。",
+ "helpInsert": "単語と単語の間で入力すると、琥珀色の新しい単語を追加できます。",
+ "insertAria": "新しい単語",
+ "insertedWord": "あなたが追加した単語 — 音声はありません",
+ "laneFeedsCaptions": "字幕はこのトラックから焼き込まれます。",
+ "laneLabel": "文字起こしの読み込み元",
+ "laneRecording": "録画",
+ "laneVoiceover": "ナレーション",
+ "noAudio": "このメディアには音声トラックがありません",
+ "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
"noClips": "クリップがまだありません",
"noTranscript": "文字起こしがまだありません",
- "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。",
+ "removeInserted": "「{{word}}」を削除",
+ "restoreSilence": "無音を元に戻す({{duration}} 秒)",
+ "restoreWord": "「{{word}}」を元に戻す",
+ "revertWord": "「{{original}}」に戻す",
+ "silence": "[無音 {{duration}} 秒]",
+ "title": "現在の文字起こし",
"transcribeNow": "今すぐ文字起こし",
"transcribing": "文字起こし中…",
- "clipLabel": "クリップ {{index}}",
- "noClipTranscript": "このクリップには文字起こしがありません。アセットカードを開いて再生成してください。",
- "editorAria": "{{filename}} の文字起こし",
- "silence": "[無音 {{duration}} 秒]",
- "restoreSilence": "無音を元に戻す({{duration}} 秒)",
"trimSilence": "無音をトリム({{duration}} 秒)",
- "restoreWord": "「{{word}}」を元に戻す",
- "noAudio": "このメディアには音声トラックがありません"
+ "whisperHint": "文字起こしはローカルの Whisper を使用します。お使いのパソコン上で実行され、データが端末外に出ることはありません。"
},
"captions": {
"show": "字幕を表示",
"noTranscript": "字幕はメディアの文字起こしから読み込まれます。有効にするにはこの動画を文字起こししてください。",
- "transcribe": "動画を文字起こし",
"transcribing": "文字起こし中…",
"derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。",
"hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。",
@@ -320,12 +333,25 @@
"alignRight": "右",
"lineLength": "行の長さ",
"minWords": "1 行の最小単語数",
- "maxWords": "1 行の最大単語数"
+ "maxWords": "1 行の最大単語数",
+ "transcribe": "動画を文字起こし"
},
"audio": {
"title": "オーディオ",
"outputGain": "出力レベル",
"reset": "オーディオをリセット",
"help": "音声の出力レベルを調整します。プレビューと書き出しで同じように適用されます。"
+ },
+ "audioTrack": {
+ "add": "オーディオトラックを追加",
+ "defaultLabel": "オーディオトラック",
+ "fadeIn": "フェードイン",
+ "fadeOut": "フェードアウト",
+ "help": "このオーディオトラックの音量・フェード・ループ。プレビューでも書き出しでも録画に重ねてミックスされます。タイムライン上でドラッグすると移動やサイズ変更、Alt を押しながらドラッグすると中の音声がずれます。",
+ "importFailed": "オーディオを追加できませんでした",
+ "loop": "ループ",
+ "mute": "ミュート",
+ "remove": "トラックを削除",
+ "slipHint": "Alt を押しながらドラッグで中の音声をずらす"
}
}
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 96e6c5339..a95000db4 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 631189192..5420fd044 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -263,26 +263,39 @@
"help": "도움말"
},
"transcript": {
- "title": "현재 전사",
- "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 건너뛴 것으로 표시됩니다(빨간색). 빨간 부분에 마우스를 올리면 복원할 수 있습니다.",
+ "blankedWord": "비움",
+ "clipLabel": "클립 {{index}}",
+ "correctedWord": "수정됨 — 전사에는 \"{{original}}\"였습니다",
+ "editWord": "\"{{word}}\" 편집",
+ "editingHint": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내세요.",
+ "editingHintDev": "단어를 더블 클릭해 수정하고, Backspace로 영상에서 잘라내고, 두 단어 사이에 입력해 새 단어를 추가하세요.",
+ "editorAria": "{{filename}}의 전사",
+ "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 영상에서 잘라냅니다(빨간색). 단어를 두 번 클릭하면 텍스트를 고칠 수 있습니다. 자막에만 들어가고 영상은 그대로입니다. 표시된 단어에 마우스를 올리면 되돌릴 수 있습니다.",
+ "helpInsert": "두 단어 사이에 입력하면 호박색 단어가 추가됩니다.",
+ "insertAria": "새 단어",
+ "insertedWord": "직접 추가한 단어 — 뒤에 오디오가 없습니다",
+ "laneFeedsCaptions": "자막은 이 트랙에서 구워집니다.",
+ "laneLabel": "전사본을 읽어올 소스",
+ "laneRecording": "녹화",
+ "laneVoiceover": "내레이션",
+ "noAudio": "이 미디어에는 오디오 트랙이 없습니다",
+ "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
"noClips": "아직 클립이 없습니다",
"noTranscript": "아직 전사가 없습니다",
- "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다.",
+ "removeInserted": "\"{{word}}\" 삭제",
+ "restoreSilence": "무음 복원 ({{duration}}초)",
+ "restoreWord": "\"{{word}}\" 복원",
+ "revertWord": "\"{{original}}\"(으)로 되돌리기",
+ "silence": "[무음 {{duration}}초]",
+ "title": "현재 전사",
"transcribeNow": "지금 전사하기",
"transcribing": "전사 중…",
- "clipLabel": "클립 {{index}}",
- "noClipTranscript": "이 클립에는 전사가 없습니다. 에셋 카드를 열고 다시 생성하세요.",
- "editorAria": "{{filename}}의 전사",
- "silence": "[무음 {{duration}}초]",
- "restoreSilence": "무음 복원 ({{duration}}초)",
"trimSilence": "무음 자르기 ({{duration}}초)",
- "restoreWord": "\"{{word}}\" 복원",
- "noAudio": "이 미디어에는 오디오 트랙이 없습니다"
+ "whisperHint": "전사는 로컬 Whisper를 사용합니다. 이 컴퓨터에서 실행되며 데이터가 기기를 벗어나지 않습니다."
},
"captions": {
"show": "자막 표시",
"noTranscript": "자막은 미디어 전사에서 읽어옵니다. 켜려면 이 동영상을 전사하세요.",
- "transcribe": "동영상 전사하기",
"transcribing": "전사 중…",
"derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.",
"hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.",
@@ -320,12 +333,25 @@
"alignRight": "오른쪽",
"lineLength": "줄 길이",
"minWords": "줄당 최소 단어 수",
- "maxWords": "줄당 최대 단어 수"
+ "maxWords": "줄당 최대 단어 수",
+ "transcribe": "동영상 전사하기"
},
"audio": {
"title": "오디오",
"outputGain": "출력 레벨",
"reset": "오디오 재설정",
"help": "오디오 출력 레벨을 조정합니다. 미리보기와 내보내기에 동일하게 적용됩니다."
+ },
+ "audioTrack": {
+ "add": "오디오 트랙 추가",
+ "defaultLabel": "오디오 트랙",
+ "fadeIn": "페이드 인",
+ "fadeOut": "페이드 아웃",
+ "help": "이 오디오 트랙의 볼륨, 페이드, 반복 설정입니다. 미리보기와 내보내기에서 녹화 위에 믹스됩니다. 타임라인에서 드래그하면 이동하거나 크기를 바꾸고, Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다.",
+ "importFailed": "오디오를 추가할 수 없습니다",
+ "loop": "반복",
+ "mute": "음소거",
+ "remove": "트랙 삭제",
+ "slipHint": "Alt 를 누른 채 드래그하면 안의 오디오가 이동합니다"
}
}
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 e5f828d3c..948183dbb 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 de22724be..ae2943c21 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -263,26 +263,39 @@
"help": "Ajuda"
},
"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.",
+ "blankedWord": "apagada",
+ "clipLabel": "Clipe {{index}}",
+ "correctedWord": "Corrigida — a transcrição dizia \"{{original}}\"",
+ "editWord": "Editar \"{{word}}\"",
+ "editingHint": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo.",
+ "editingHintDev": "Clique duas vezes em uma palavra para corrigi-la, Backspace a corta do vídeo, digite entre duas palavras para adicionar uma.",
+ "editorAria": "Transcrição de {{filename}}",
+ "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.",
+ "helpInsert": "Digite entre duas palavras para adicionar uma, em âmbar: ela vai para as legendas e não mexe no vídeo.",
+ "insertAria": "Nova palavra",
+ "insertedWord": "Adicionada por você — sem áudio por trás",
+ "laneFeedsCaptions": "As legendas são gravadas a partir desta faixa.",
+ "laneLabel": "Ler a transcrição de",
+ "laneRecording": "Gravação",
+ "laneVoiceover": "Narração",
+ "noAudio": "Esta mídia não tem faixa de áudio",
+ "noClipTranscript": "Sem transcrição para este clipe — abra o cartão do recurso e gere novamente.",
"noClips": "Nenhum clipe ainda",
"noTranscript": "Nenhuma transcrição ainda",
- "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo.",
+ "removeInserted": "Excluir \"{{word}}\"",
+ "restoreSilence": "Restaurar silêncio ({{duration}} s)",
+ "restoreWord": "Restaurar \"{{word}}\"",
+ "revertWord": "Restaurar \"{{original}}\"",
+ "silence": "[silêncio {{duration}} s]",
+ "title": "Transcrição atual",
"transcribeNow": "Transcrever agora",
"transcribing": "Transcrevendo…",
- "clipLabel": "Clipe {{index}}",
- "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"
+ "whisperHint": "A transcrição usa o Whisper local — roda no seu computador, nenhum dado sai do dispositivo."
},
"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",
"transcribing": "Transcrevendo…",
"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.",
@@ -320,12 +333,25 @@
"alignRight": "Direita",
"lineLength": "Comprimento da linha",
"minWords": "Mín. de palavras por linha",
- "maxWords": "Máx. de palavras por linha"
+ "maxWords": "Máx. de palavras por linha",
+ "transcribe": "Transcrever vídeo"
},
"audio": {
"title": "Áudio",
"outputGain": "Nível de saída",
"reset": "Redefinir áudio",
"help": "Ajuste o nível de saída do áudio. Ele se aplica da mesma forma na prévia e na exportação."
+ },
+ "audioTrack": {
+ "add": "Adicionar faixa de áudio",
+ "defaultLabel": "Faixa de áudio",
+ "fadeIn": "Fade in",
+ "fadeOut": "Fade out",
+ "help": "Volume, fades e repetição desta faixa de áudio. Ela é mixada sobre a gravação na visualização e na exportação. Arraste a faixa na linha do tempo para movê-la ou redimensioná-la, ou segure Alt e arraste para deslizar o áudio dentro dela.",
+ "importFailed": "Não foi possível adicionar o áudio",
+ "loop": "Repetir",
+ "mute": "Silenciar",
+ "remove": "Excluir faixa",
+ "slipHint": "Alt + arrastar para deslizar o áudio dentro"
}
}
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 4abdc63f3..7d81f9836 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 ca193c6d8..f60dff088 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -263,26 +263,39 @@
"help": "Справка"
},
"transcript": {
- "title": "Текущая расшифровка",
- "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению помечает его как пропущенное (красным). Наведите курсор на красный фрагмент, чтобы вернуть его.",
+ "blankedWord": "очищено",
+ "clipLabel": "Клип {{index}}",
+ "correctedWord": "Исправлено — в расшифровке было «{{original}}»",
+ "editWord": "Изменить «{{word}}»",
+ "editingHint": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма.",
+ "editingHintDev": "Дважды щёлкните слово, чтобы исправить его, Backspace вырезает его из фильма, напечатайте между двумя словами, чтобы добавить одно.",
+ "editorAria": "Расшифровка «{{filename}}»",
+ "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению вырезает его из видео (красным). Двойной щелчок по слову исправляет его текст. Наведите курсор на отмеченное слово, чтобы отменить.",
+ "helpInsert": "Наберите текст между двумя словами, чтобы добавить своё, янтарным: оно попадёт в субтитры и не тронет видео.",
+ "insertAria": "Новое слово",
+ "insertedWord": "Добавлено вами — за ним нет звука",
+ "laneFeedsCaptions": "Субтитры записываются из этой дорожки.",
+ "laneLabel": "Читать расшифровку из",
+ "laneRecording": "Запись",
+ "laneVoiceover": "Закадровый голос",
+ "noAudio": "В этом медиафайле нет аудиодорожки",
+ "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
"noClips": "Клипов пока нет",
"noTranscript": "Расшифровки пока нет",
- "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство.",
+ "removeInserted": "Удалить «{{word}}»",
+ "restoreSilence": "Вернуть тишину ({{duration}} с)",
+ "restoreWord": "Вернуть «{{word}}»",
+ "revertWord": "Вернуть «{{original}}»",
+ "silence": "[тишина {{duration}} с]",
+ "title": "Текущая расшифровка",
"transcribeNow": "Расшифровать сейчас",
"transcribing": "Расшифровка…",
- "clipLabel": "Клип {{index}}",
- "noClipTranscript": "Для этого клипа нет расшифровки — откройте карточку файла и создайте её заново.",
- "editorAria": "Расшифровка «{{filename}}»",
- "silence": "[тишина {{duration}} с]",
- "restoreSilence": "Вернуть тишину ({{duration}} с)",
"trimSilence": "Вырезать тишину ({{duration}} с)",
- "restoreWord": "Вернуть «{{word}}»",
- "noAudio": "В этом медиафайле нет аудиодорожки"
+ "whisperHint": "Расшифровка использует локальный Whisper — работает на вашем компьютере, данные не покидают устройство."
},
"captions": {
"show": "Показывать субтитры",
"noTranscript": "Субтитры берутся из расшифровки медиафайла. Расшифруйте это видео, чтобы включить их.",
- "transcribe": "Расшифровать видео",
"transcribing": "Расшифровка…",
"derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.",
"hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.",
@@ -320,12 +333,25 @@
"alignRight": "Справа",
"lineLength": "Длина строки",
"minWords": "Мин. слов в строке",
- "maxWords": "Макс. слов в строке"
+ "maxWords": "Макс. слов в строке",
+ "transcribe": "Расшифровать видео"
},
"audio": {
"title": "Аудио",
"outputGain": "Уровень выхода",
"reset": "Сбросить аудио",
"help": "Настройте уровень звука на выходе. Он одинаково применяется в предпросмотре и при экспорте."
+ },
+ "audioTrack": {
+ "add": "Добавить аудиодорожку",
+ "defaultLabel": "Аудиодорожка",
+ "fadeIn": "Нарастание",
+ "fadeOut": "Затухание",
+ "help": "Громкость, фейды и зацикливание этой аудиодорожки. Она подмешивается поверх записи в предпросмотре и при экспорте. Перетащите дорожку на таймлайне, чтобы переместить или изменить её размер, либо удерживайте Alt и тяните, чтобы сдвинуть аудио внутри неё.",
+ "importFailed": "Не удалось добавить аудио",
+ "loop": "Повтор",
+ "mute": "Без звука",
+ "remove": "Удалить дорожку",
+ "slipHint": "Alt + перетаскивание — сдвинуть аудио внутри"
}
}
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 d4bb46a10..bcc27a4d2 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 485666d42..0afc9ec22 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -263,26 +263,39 @@
"help": "Yardım"
},
"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.",
+ "blankedWord": "boşaltıldı",
+ "clipLabel": "Klip {{index}}",
+ "correctedWord": "Düzeltildi — dökümde \"{{original}}\" yazıyordu",
+ "editWord": "\"{{word}}\" kelimesini düzenle",
+ "editingHint": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser.",
+ "editingHintDev": "Bir kelimeye çift tıklayarak düzeltin, Backspace onu videodan keser, iki kelimenin arasına yazarak yeni kelime ekleyin.",
+ "editorAria": "{{filename}} dökümü",
+ "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.",
+ "helpInsert": "İki kelimenin arasına yazarak kehribar renginde yeni bir kelime ekleyebilirsiniz: altyazılara girer, videoya dokunmaz.",
+ "insertAria": "Yeni kelime",
+ "insertedWord": "Sizin eklediğiniz — arkasında ses yok",
+ "laneFeedsCaptions": "Altyazılar bu kanaldan gömülür.",
+ "laneLabel": "Deşifreyi şuradan oku",
+ "laneRecording": "Kayıt",
+ "laneVoiceover": "Dış ses",
+ "noAudio": "Bu medyada ses parçası yok",
+ "noClipTranscript": "Bu klip için döküm yok — medya kartını açıp yeniden oluşturun.",
"noClips": "Henüz klip yok",
"noTranscript": "Henüz döküm yok",
- "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz.",
+ "removeInserted": "\"{{word}}\" kelimesini sil",
+ "restoreSilence": "Sessizliği geri al ({{duration}} sn)",
+ "restoreWord": "\"{{word}}\" kelimesini geri al",
+ "revertWord": "\"{{original}}\" haline getir",
+ "silence": "[sessizlik {{duration}} sn]",
+ "title": "Geçerli döküm",
"transcribeNow": "Şimdi dökümünü çıkar",
"transcribing": "Döküm çıkarılıyor…",
- "clipLabel": "Klip {{index}}",
- "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"
+ "whisperHint": "Döküm yerel Whisper kullanır — bilgisayarınızda çalışır, hiçbir veri cihazdan çıkmaz."
},
"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",
"transcribing": "Döküm çıkarılıyor…",
"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.",
@@ -320,12 +333,25 @@
"alignRight": "Sağ",
"lineLength": "Satır uzunluğu",
"minWords": "Satır başına en az kelime",
- "maxWords": "Satır başına en çok kelime"
+ "maxWords": "Satır başına en çok kelime",
+ "transcribe": "Videonun dökümünü çıkar"
},
"audio": {
"title": "Ses",
"outputGain": "Çıkış seviyesi",
"reset": "Sesi sıfırla",
"help": "Ses çıkış seviyesini ayarlayın. Önizlemede ve dışa aktarmada aynı şekilde uygulanır."
+ },
+ "audioTrack": {
+ "add": "Ses parçası ekle",
+ "defaultLabel": "Ses parçası",
+ "fadeIn": "Açılma",
+ "fadeOut": "Kararma",
+ "help": "Bu ses kanalının seviyesi, geçişleri ve döngüsü. Önizlemede ve dışa aktarımda kaydın üzerine miksleniyor. Kanalı taşımak veya yeniden boyutlandırmak için zaman çizelgesinde sürükleyin; içindeki sesi kaydırmak için Alt tuşunu basılı tutarak sürükleyin.",
+ "importFailed": "Ses eklenemedi",
+ "loop": "Döngü",
+ "mute": "Sessiz",
+ "remove": "Parçayı sil",
+ "slipHint": "İçindeki sesi kaydırmak için Alt ile sürükleyin"
}
}
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 6f55d77a5..5b58717c0 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 a2af00397..ebf020c6e 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -263,26 +263,39 @@
"help": "Trợ giúp"
},
"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.",
+ "blankedWord": "đã xoá",
+ "clipLabel": "Clip {{index}}",
+ "correctedWord": "Đã sửa — bản chép lời ghi \"{{original}}\"",
+ "editWord": "Sửa \"{{word}}\"",
+ "editingHint": "Nhấp đúp vào một từ để sửa nó, Backspace cắt nó khỏi video.",
+ "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.",
+ "editorAria": "Bản chép lời của {{filename}}",
+ "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.",
+ "helpInsert": "Gõ giữa hai từ để thêm một từ mới, màu hổ phách: nó vào phụ đề và không đụng tới video.",
+ "insertAria": "Từ mới",
+ "insertedWord": "Bạn thêm vào — không có âm thanh phía sau",
+ "laneFeedsCaptions": "Phụ đề được ghi từ rãnh này.",
+ "laneLabel": "Đọc bản chép lời từ",
+ "laneRecording": "Bản ghi",
+ "laneVoiceover": "Lời thuyết minh",
+ "noAudio": "Media này không có bản âm thanh",
+ "noClipTranscript": "Clip này chưa có bản chép lời — mở thẻ tài nguyên và tạo lại.",
"noClips": "Chưa có clip nào",
"noTranscript": "Chưa có bản chép lời",
- "whisperHint": "Việc chép lời dùng Whisper cục bộ — chạy ngay trên máy của bạn, không dữ liệu nào rời khỏi thiết bị.",
+ "removeInserted": "Xoá \"{{word}}\"",
+ "restoreSilence": "Khôi phục khoảng lặng ({{duration}} giây)",
+ "restoreWord": "Khôi phục \"{{word}}\"",
+ "revertWord": "Khôi phục \"{{original}}\"",
+ "silence": "[khoảng lặng {{duration}} giây]",
+ "title": "Bản chép lời hiện tại",
"transcribeNow": "Chép lời ngay",
"transcribing": "Đang chép lời…",
- "clipLabel": "Clip {{index}}",
- "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"
+ "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ị."
},
"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",
"transcribing": "Đang chép lời…",
"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.",
@@ -320,12 +333,25 @@
"alignRight": "Phải",
"lineLength": "Độ dài dòng",
"minWords": "Số từ tối thiểu mỗi dòng",
- "maxWords": "Số từ tối đa mỗi dòng"
+ "maxWords": "Số từ tối đa mỗi dòng",
+ "transcribe": "Chép lời video"
},
"audio": {
"title": "Âm thanh",
"outputGain": "Mức đầu ra",
"reset": "Đặt lại âm thanh",
"help": "Điều chỉnh mức đầu ra của âm thanh. Nó được áp dụng giống hệt nhau trong bản xem trước và khi xuất."
+ },
+ "audioTrack": {
+ "add": "Thêm bản âm thanh",
+ "defaultLabel": "Bản âm thanh",
+ "fadeIn": "Mờ vào",
+ "fadeOut": "Mờ ra",
+ "help": "Âm lượng, fade và lặp của rãnh âm thanh này. Nó được trộn lên trên bản ghi trong xem trước và khi xuất. Kéo rãnh trên dòng thời gian để di chuyển hoặc đổi kích thước, hoặc giữ Alt và kéo để trượt âm thanh bên trong.",
+ "importFailed": "Không thể thêm âm thanh",
+ "loop": "Lặp",
+ "mute": "Tắt tiếng",
+ "remove": "Xóa bản nhạc",
+ "slipHint": "Giữ Alt và kéo để trượt âm thanh bên trong"
}
}
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 dcfeb282b..9407b17ad 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 bd13392a1..7a6cacefe 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -263,26 +263,39 @@
"help": "帮助"
},
"transcript": {
- "title": "当前转录",
- "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其标记为跳过(红色)。将鼠标悬停在红色片段上可恢复。",
+ "blankedWord": "已清空",
+ "clipLabel": "片段 {{index}}",
+ "correctedWord": "已更正 — 转录原文为“{{original}}”",
+ "editWord": "编辑“{{word}}”",
+ "editingHint": "双击单词即可修正,Backspace 将其从影片中剪掉。",
+ "editingHintDev": "双击单词即可修正,Backspace 将其从影片中剪掉,在两个单词之间输入即可添加新词。",
+ "editorAria": "{{filename}} 的转录",
+ "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其从画面中剪掉(红色)。双击某个词可修改文字。将鼠标悬停在带标记的词上可撤销。",
+ "helpInsert": "在两个词之间输入即可添加一个琥珀色的新词:它只进入字幕,不改动画面。",
+ "insertAria": "新词",
+ "insertedWord": "你添加的词 — 背后没有声音",
+ "laneFeedsCaptions": "字幕从这条轨道烧录。",
+ "laneLabel": "转写文本读取自",
+ "laneRecording": "录制",
+ "laneVoiceover": "配音",
+ "noAudio": "此媒体没有音频轨道",
+ "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
"noClips": "暂无片段",
"noTranscript": "暂无转录",
- "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。",
+ "removeInserted": "删除“{{word}}”",
+ "restoreSilence": "恢复静音({{duration}} 秒)",
+ "restoreWord": "恢复“{{word}}”",
+ "revertWord": "还原为“{{original}}”",
+ "silence": "[静音 {{duration}} 秒]",
+ "title": "当前转录",
"transcribeNow": "立即转录",
"transcribing": "转录中…",
- "clipLabel": "片段 {{index}}",
- "noClipTranscript": "该片段没有转录 — 请打开素材卡片并重新生成。",
- "editorAria": "{{filename}} 的转录",
- "silence": "[静音 {{duration}} 秒]",
- "restoreSilence": "恢复静音({{duration}} 秒)",
"trimSilence": "修剪静音({{duration}} 秒)",
- "restoreWord": "恢复“{{word}}”",
- "noAudio": "此媒体没有音频轨道"
+ "whisperHint": "转录使用本地 Whisper — 在您的电脑上运行,数据不会离开本设备。"
},
"captions": {
"show": "显示字幕",
"noTranscript": "字幕来自媒体的转录。请先转录此视频以启用字幕。",
- "transcribe": "转录视频",
"transcribing": "转录中…",
"derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。",
"hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。",
@@ -320,12 +333,25 @@
"alignRight": "右对齐",
"lineLength": "行长",
"minWords": "每行最少词数",
- "maxWords": "每行最多词数"
+ "maxWords": "每行最多词数",
+ "transcribe": "转录视频"
},
"audio": {
"title": "音频",
"outputGain": "输出电平",
"reset": "重置音频",
"help": "调整音频输出电平。它在预览和导出中的效果完全一致。"
+ },
+ "audioTrack": {
+ "add": "添加音频轨道",
+ "defaultLabel": "音频轨道",
+ "fadeIn": "淡入",
+ "fadeOut": "淡出",
+ "help": "此音频轨道的音量、淡入淡出和循环。在预览和导出中都会混合到录制内容之上。在时间轴上拖动可移动或调整大小,按住 Alt 拖动则可在其中滑动音频。",
+ "importFailed": "无法添加音频",
+ "loop": "循环",
+ "mute": "静音",
+ "remove": "删除轨道",
+ "slipHint": "按住 Alt 拖动可在其中滑动音频"
}
}
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 4893e4caf..3bbf45311 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 f9328155d..a38e8f5c6 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -264,26 +264,39 @@
"help": "說明"
},
"transcript": {
- "title": "目前的逐字稿",
- "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會標記為略過(紅色)。將滑鼠移到紅色片段上即可還原。",
+ "blankedWord": "已清空",
+ "clipLabel": "片段 {{index}}",
+ "correctedWord": "已更正 — 逐字稿原本是「{{original}}」",
+ "editWord": "編輯「{{word}}」",
+ "editingHint": "雙擊單字即可修正,Backspace 將其從影片中剪掉。",
+ "editingHintDev": "雙擊單字即可修正,Backspace 將其從影片中剪掉,在兩個單字之間輸入即可新增新詞。",
+ "editorAria": "{{filename}} 的逐字稿",
+ "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會將其從影片中剪掉(紅色)。連按兩下某個字即可修改文字。將滑鼠移到有標記的字上即可復原。",
+ "helpInsert": "在兩個字之間輸入即可加入一個琥珀色的新字:它只進入字幕,不會動到影片。",
+ "insertAria": "新字詞",
+ "insertedWord": "你加入的字詞 — 背後沒有聲音",
+ "laneFeedsCaptions": "字幕從這條軌道燒錄。",
+ "laneLabel": "轉錄文字讀取自",
+ "laneRecording": "錄影",
+ "laneVoiceover": "旁白",
+ "noAudio": "此媒體沒有音訊軌道",
+ "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
"noClips": "尚無片段",
"noTranscript": "尚無逐字稿",
- "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。",
+ "removeInserted": "刪除「{{word}}」",
+ "restoreSilence": "還原靜音({{duration}} 秒)",
+ "restoreWord": "還原「{{word}}」",
+ "revertWord": "還原為「{{original}}」",
+ "silence": "[靜音 {{duration}} 秒]",
+ "title": "目前的逐字稿",
"transcribeNow": "立即產生逐字稿",
"transcribing": "轉錄中…",
- "clipLabel": "片段 {{index}}",
- "noClipTranscript": "此片段沒有逐字稿 — 請開啟素材卡片並重新產生。",
- "editorAria": "{{filename}} 的逐字稿",
- "silence": "[靜音 {{duration}} 秒]",
- "restoreSilence": "還原靜音({{duration}} 秒)",
"trimSilence": "修剪靜音({{duration}} 秒)",
- "restoreWord": "還原「{{word}}」",
- "noAudio": "此媒體沒有音訊軌道"
+ "whisperHint": "逐字稿使用本機 Whisper — 在您的電腦上執行,資料不會離開本裝置。"
},
"captions": {
"show": "顯示字幕",
"noTranscript": "字幕取自媒體的逐字稿。請先為這部影片產生逐字稿以啟用字幕。",
- "transcribe": "為影片產生逐字稿",
"transcribing": "轉錄中…",
"derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。",
"hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。",
@@ -321,12 +334,25 @@
"alignRight": "靠右",
"lineLength": "行長",
"minWords": "每行最少字數",
- "maxWords": "每行最多字數"
+ "maxWords": "每行最多字數",
+ "transcribe": "為影片產生逐字稿"
},
"audio": {
"title": "音訊",
"outputGain": "輸出音量",
"reset": "重設音訊",
"help": "調整音訊輸出電平。它在預覽與匯出中的效果完全一致。"
+ },
+ "audioTrack": {
+ "add": "新增音訊軌道",
+ "defaultLabel": "音訊軌道",
+ "fadeIn": "淡入",
+ "fadeOut": "淡出",
+ "help": "此音訊軌道的音量、淡入淡出與循環。在預覽與匯出時都會混合到錄影之上。在時間軸上拖曳可移動或調整大小,按住 Alt 拖曳則可在其中滑動音訊。",
+ "importFailed": "無法新增音訊",
+ "loop": "循環",
+ "mute": "靜音",
+ "remove": "刪除軌道",
+ "slipHint": "按住 Alt 拖曳可在其中滑動音訊"
}
}
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..016f95f18
--- /dev/null
+++ b/src/lib/ai-edition/captions/captionLane.test.ts
@@ -0,0 +1,174 @@
+// 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");
+ });
+
+ it("leaves a take's cues alone when the FILM gains an insertion", () => {
+ // An insertion is media inside the clip that carries it, and it lengthens that clip.
+ // A take laid over the film keeps its own position on the timeline — the picture
+ // slides underneath it — so its cues do not move either. Measured per placement,
+ // through the asset the placement actually plays.
+ const paused = doc({
+ timeline: {
+ ...doc().timeline,
+ insertRanges: [
+ {
+ id: "i1",
+ assetId: "rec",
+ atSec: 1,
+ durationSec: 1,
+ wordId: "x",
+ reason: "",
+ origin: "user",
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ ] as any,
+ },
+ });
+ const before = deriveCaptionCues(doc(), on("voiceover"), {});
+ const after = deriveCaptionCues(paused, on("voiceover"), {});
+ // The insertion names the RECORDING's asset. The take is a different asset laid at
+ // its own timeline position, so nothing about this cue changes.
+ expect(after[0].startMs).toBe(before[0].startMs);
+ expect(after[0].endMs).toBe(before[0].endMs);
+ });
+});
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index ce3d4a961..46b04de83 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -650,3 +650,167 @@ describe("translated caption layout", () => {
);
});
});
+
+// ─── Captions across an added word's pause ───────────────────────
+// The pause lengthens the ruler, so every line after it slides — and the line the pause
+// exists FOR has to stay on screen through it rather than going dark over the one moment
+// an added word is there for.
+
+describe("captions and a pause", () => {
+ function withPause(): AxcutDocument {
+ const base = doc();
+ return {
+ ...base,
+ timeline: {
+ ...base.timeline,
+ insertRanges: [
+ {
+ id: "ins_1",
+ assetId: "asset-1",
+ // Inside "hello there friend" (0–2s), so the line covers it.
+ atSec: 1.2,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user" as const,
+ },
+ ],
+ },
+ };
+ }
+
+ it("keeps the covering line up through the pause instead of cutting it short", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withPause(), ON, {});
+ const line = (cues: typeof before) => cues.find((cue) => cue.text.includes("hello"));
+ expect(line(after)?.startMs).toBe(line(before)?.startMs);
+ // Half a second longer: exactly the pause it now spans.
+ expect((line(after)?.endMs ?? 0) - (line(before)?.endMs ?? 0)).toBe(500);
+ });
+
+ it("slides everything after the pause along by it", () => {
+ const before = deriveCaptionCues(doc(), ON, {});
+ const after = deriveCaptionCues(withPause(), ON, {});
+ const later = (cues: typeof before) => cues.find((cue) => cue.text.includes("goodbye"));
+ expect((later(after)?.startMs ?? 0) - (later(before)?.startMs ?? 0)).toBe(500);
+ });
+
+ it("is unchanged when the project has no pauses", () => {
+ expect(deriveCaptionCues(doc(), ON, {})).toEqual(deriveCaptionCues(doc(), ON, {}));
+ });
+});
+
+// ─── An added word is spoken over the media it inserted ─────────────────────
+// Lines are grouped by word count and by silences, in SOURCE time. An added word barely
+// takes up source time — the seconds it is spoken in are the INSERTION that follows it —
+// so it was swallowed into the line of the words before it and inherited their start. On
+// screen the added words appeared while the recorded picture was still playing, a whole
+// insertion early (issue #560).
+
+describe("a caption line never mixes recorded words with added ones", () => {
+ function docWithAddedWord(): AxcutDocument {
+ const t = transcript();
+ // "really" typed in after "friend", which ends at source 2. An added word takes up NO
+ // source time — the seconds it is spoken in are the insertion it buys, which is how
+ // the real documents store it — so its span is degenerate at the moment it follows.
+ // The next recorded word begins at the added word's own second — the shape every
+ // real document has, because an added word is anchored at the END of the word it
+ // follows and the transcript's next word starts there.
+ t.words = [
+ ...t.words.slice(0, 3),
+ {
+ id: "synth_1",
+ segmentId: "seg_1",
+ startSec: 2,
+ endSec: 2,
+ text: "really",
+ source: "synth",
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ } as any,
+ // Begins at the added word's own second — that adjacency is the whole defect.
+ ...t.words.slice(3).map((w) => ({ ...w, startSec: w.startSec - 2, endSec: w.endSec - 2 })),
+ ];
+ const base = doc();
+ return {
+ ...base,
+ transcripts: [t],
+ timeline: {
+ ...base.timeline,
+ clips: [{ ...base.timeline.clips[0], timelineEndSec: 12 }],
+ insertRanges: [
+ {
+ id: "i1",
+ assetId: "asset-1",
+ atSec: 2,
+ durationSec: 2,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user",
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ ] as any,
+ },
+ };
+ }
+
+ const settings: CaptionSettings = { ...DEFAULT_CAPTION_SETTINGS, enabled: true };
+
+ it("gives the added word its own cue, starting where the recorded words stop", () => {
+ const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
+ const added = cues.filter((c) => c.text.includes("really"));
+ expect(added).toHaveLength(1);
+ // It must not carry the recorded words with it — that is the whole defect.
+ expect(added[0].text.toLowerCase()).not.toContain("hello");
+ // And it opens at the recorded words' end, not at their start.
+ expect(added[0].startMs).toBeGreaterThanOrEqual(2000);
+ });
+
+ it("lays the three lines out end to end across the insertion", () => {
+ // The whole rule in one assertion set: the recorded line stops where the added one
+ // begins, the added one spans the media it bought, and what follows is pushed along
+ // by exactly that length.
+ const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
+ const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
+ const added = cues.find((c) => c.text.includes("really"));
+ const after = cues.find((c) => c.text.includes("goodbye"));
+ expect(recorded).toBeDefined();
+ expect(added).toBeDefined();
+ expect(after).toBeDefined();
+ // The insertion opens at ruler 2000 and runs 2000ms. Three consecutive facts:
+ // the recorded line STOPS there, the added line COVERS it, and what follows is
+ // pushed along by exactly its length.
+ expect(recorded?.endMs).toBe(2000);
+ expect(added?.startMs).toBe(2000);
+ expect(added?.endMs).toBe(4000);
+ expect(after?.startMs).toBe(4000);
+ });
+
+ it("leaves the recorded line ending before the added one begins", () => {
+ const cues = deriveCaptionCues(docWithAddedWord(), settings, {});
+ const recorded = cues.find((c) => c.text.toLowerCase().includes("hello"));
+ const added = cues.find((c) => c.text.includes("really"));
+ expect(recorded).toBeDefined();
+ expect(added).toBeDefined();
+ expect(recorded?.text).not.toContain("really");
+ expect(added?.startMs ?? 0).toBeGreaterThanOrEqual((recorded?.startMs ?? 0) + 1);
+ });
+
+ it("never prints two cues at once", () => {
+ // The symptom this whole rule exists for: the recorded line that follows an added
+ // word begins at the added word's own source second, so mapped before the insertion
+ // it landed inside it and the two were drawn on top of each other.
+ const cues = [...deriveCaptionCues(docWithAddedWord(), settings, {})].sort(
+ (a, b) => a.startMs - b.startMs,
+ );
+ expect(cues.length).toBeGreaterThan(1);
+ for (const [i, cue] of cues.slice(0, -1).entries()) {
+ expect(cue.endMs).toBeLessThanOrEqual(cues[i + 1].startMs);
+ }
+ });
+
+ it("changes nothing when the transcript has no added words", () => {
+ const before = deriveCaptionCues(doc(), settings, {});
+ expect(before.some((c) => c.text.toLowerCase().includes("hello"))).toBe(true);
+ expect(before.every((c) => !c.text.includes("really"))).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts
index c3287ebcb..87e9e3f1b 100644
--- a/src/lib/ai-edition/captions/cues.ts
+++ b/src/lib/ai-edition/captions/cues.ts
@@ -22,12 +22,17 @@ import {
splitMergedCaptionsByWordBounds,
} from "@/lib/captioning/annotationsFromCaptions";
import type { CaptionSegment } from "@/lib/captioning/transcribe";
-import type { AxcutClip, AxcutDocument, AxcutTranscript } from "../schema";
+import type { AxcutDocument, AxcutInsertRange, AxcutTranscript } from "../schema";
+import { lanePlacements, type TranscriptPlacement } from "../timeline/aggregated-transcript";
+import { takeInserts } from "../timeline/insert-mapping";
+import { sourceToTimelineSec } from "../timeline/inserted-time";
+import { removedRawSpans } from "../timeline/programme-time";
import {
type CaptionAnchorV,
type CaptionSettings,
captionBackgroundCss,
captionBoxRect,
+ resolveCaptionLane,
} from "./settings";
import { type CaptionTranslations, captionTranslationUnits } from "./translations";
@@ -95,7 +100,7 @@ export function captionLinesForAsset(
transcript: AxcutTranscript,
settings: CaptionSettings,
translations: CaptionTranslations,
-): CaptionSegment[] {
+): CaptionLine[] {
const minWords = settings.minWordsPerLine;
const maxWords = settings.maxWordsPerLine;
@@ -112,7 +117,76 @@ export function captionLinesForAsset(
: translatedWordStream(transcript, translations, settings.language);
if (stream.length === 0) return [];
- return polish(groupTimedCaptionWordsIntoLines(stream, minWords, maxWords));
+ // Grouped per RUN, never across one. A run is a maximal stretch of words that are all
+ // recorded or all added: the two are spoken over different media — the recording, and the
+ // insertion that added word bought — so a line holding both would have to be in two places
+ // at once, and resolved as one it lands on the recording, an insertion too early.
+ const added = addedWordSpans(transcript);
+ // Polished per RUN, so the flag survives and so the finaliser's neighbours are all the
+ // same kind. Across a run boundary the two lines share a source second and the finaliser
+ // cannot tell them apart; `deoverlapCues` settles that on the ruler instead, where the
+ // insertion has a width.
+ return captionRuns(stream, added).flatMap((run) => {
+ const isAdded = run.length > 0 && overlapsAdded(run[0], added);
+ return polish(groupTimedCaptionWordsIntoLines(run, minWords, maxWords)).map((line) => ({
+ ...line,
+ added: isAdded,
+ }));
+ });
+}
+
+function overlapsAdded(
+ word: CaptionSegment,
+ added: Array<{ startSec: number; endSec: number }>,
+): boolean {
+ return added.some(
+ (span) =>
+ (word.startSec < span.endSec && word.endSec > span.startSec) ||
+ // An added word's source span is DEGENERATE — the seconds it is spoken in are the
+ // insertion, not the recording — so a strict overlap never matches it.
+ (word.startSec >= span.startSec && word.endSec <= span.endSec),
+ );
+}
+
+/** A caption line, and whether it is spoken over inserted media rather than the recording.
+ * Only the grouping pass knows which is which, and every edge below depends on it. */
+export type CaptionLine = CaptionSegment & { added: boolean };
+
+/** Where the transcript's ADDED words sit, in source time. */
+function addedWordSpans(transcript: AxcutTranscript): Array<{ startSec: number; endSec: number }> {
+ return transcript.words
+ .filter((word) => word.source === "synth" && word.text.trim().length > 0)
+ .map((word) => ({ startSec: word.startSec, endSec: word.endSec }))
+ .sort((a, b) => a.startSec - b.startSec);
+}
+
+/**
+ * The stream cut into maximal all-recorded / all-added runs.
+ *
+ * Membership is decided by OVERLAP with an added word's source span rather than by identity,
+ * because a translated stream is rebuilt as pseudo-words and no longer carries the original
+ * ids — the spans are the one thing both streams keep.
+ */
+function captionRuns(
+ stream: CaptionSegment[],
+ added: Array<{ startSec: number; endSec: number }>,
+): CaptionSegment[][] {
+ if (added.length === 0) return [stream];
+ const isAdded = (word: CaptionSegment) => overlapsAdded(word, added);
+ const runs: CaptionSegment[][] = [];
+ let current: CaptionSegment[] = [];
+ let currentIsAdded: boolean | null = null;
+ for (const word of stream) {
+ const flag = isAdded(word);
+ if (currentIsAdded !== null && flag !== currentIsAdded) {
+ runs.push(current);
+ current = [];
+ }
+ currentIsAdded = flag;
+ current.push(word);
+ }
+ if (current.length > 0) runs.push(current);
+ return runs;
}
function originalWordStream(transcript: AxcutTranscript): CaptionSegment[] {
@@ -156,7 +230,14 @@ 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[],
+ inserts: readonly AxcutInsertRange[] = [],
+ /** True for a span spoken over INSERTED media rather than the recording. The two occupy
+ * opposite sides of the same source second, so every edge flips with it. */
+ overInsertedMedia = false,
): Array<{ startSec: number; endSec: number }> {
const out: Array<{ startSec: number; endSec: number }> = [];
for (const clip of clips) {
@@ -166,8 +247,15 @@ export function sourceSpanToTimelineSpans(
const e = Math.min(endSec, clipSourceEnd);
if (e <= s) continue;
out.push({
- startSec: clip.timelineStartSec + (s - clip.sourceStartSec),
- endSec: clip.timelineStartSec + (e - clip.sourceStartSec),
+ // An ADDED span IS the insertion: it opens before the inserted media and closes
+ // after it, which is exactly the stretch of ruler that media occupies. A RECORDED
+ // span lives BETWEEN insertions, so both its edges are the other way round — and
+ // its START is the one that matters, because an added word is anchored at the END
+ // of the word it follows and the next recorded word begins at that very second.
+ // Mapped with the opening edge, that line landed inside the insertion, printed on
+ // top of the added one.
+ startSec: sourceToTimelineSec(clip, s, inserts, overInsertedMedia ? "opens" : "closes"),
+ endSec: sourceToTimelineSec(clip, e, inserts, overInsertedMedia ? "closes" : "opens"),
});
}
return out;
@@ -185,17 +273,37 @@ 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,
+ // `?? []` for the same reason `insertRanges` has one: the key is additive, so a
+ // document written before it — or hand-built, never through the schema — has none.
+ document.audioTracks ?? [],
+ removedRawSpans(
+ document.timeline.clips,
+ document.timeline.trimRanges,
+ document.timeline.insertRanges ?? [],
+ ),
+ (groupId) => takeInserts(document, groupId),
+ );
+ if (placements.length === 0) return [];
const transcripts = new Map(document.transcripts.map((t) => [t.assetId, t]));
+ // `?? []` because the key is additive: a document written before it — or a hand-built
+ // one that never went through the schema — simply has no pauses.
+ // CLIPS-derived on both lanes, deliberately. A pause is a held CLIP frame: it
+ // lengthens the ruler under everything, including a voiceover laid over it. Feeding it
+ // placements would measure the pause against the take instead of the film, and land
+ // every voiceover cue early.
// A transcript is only projected once per asset even when several clips draw
// from it (line grouping is the expensive part, clipping is cheap).
- const linesByAsset = new Map();
+ const linesByAsset = new Map();
const cues: CaptionCue[] = [];
let n = 0;
- 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 +313,14 @@ 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,
+ document.timeline.insertRanges ?? [],
+ line.added,
+ )) {
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 +328,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..67ae59a32
--- /dev/null
+++ b/src/lib/ai-edition/document/audioTracks.test.ts
@@ -0,0 +1,347 @@
+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 });
+ expect(next.audioTracks.map((t) => t.gainDb)).toEqual([-6, -6]);
+ expect(next.audioTracks.every((t) => t.muted)).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();
+ });
+
+ 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..33f25e904
--- /dev/null
+++ b/src/lib/ai-edition/document/audioTracks.ts
@@ -0,0 +1,426 @@
+// 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.
+ return collapseTracksToPills(tracks).flatMap((track) =>
+ anchorAudioTrackFragments(track, clips, makeId),
+ );
+}
+
+/**
+ * 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/load.test.ts b/src/lib/ai-edition/document/load.test.ts
new file mode 100644
index 000000000..e6f8b3b2f
--- /dev/null
+++ b/src/lib/ai-edition/document/load.test.ts
@@ -0,0 +1,138 @@
+// A document written before insertions took up time on the timeline.
+//
+// Nothing else reconciles it: `withInsertRangesForWords` only runs when a transcript word is
+// written, so a project the user merely OPENS keeps its old geometry while all the code
+// around it assumes the new — the film's ruler stops short, the insertion pills are drawn at
+// their source position, and the subtitles slide further out of step with every insertion
+// passed (issue #560).
+
+import { describe, expect, it } from "vitest";
+import type { AxcutClip, AxcutDocument, AxcutInsertRange } from "../schema";
+import { reconcileClipsWithInserts, reconcileInsertions } from "./load";
+
+function clip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ };
+}
+
+const insert = (over: Partial & { id: string }): AxcutInsertRange => ({
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ ...over,
+});
+
+function doc(clips: AxcutClip[], insertRanges: AxcutInsertRange[]): AxcutDocument {
+ return { timeline: { clips, insertRanges } } as unknown as AxcutDocument;
+}
+
+describe("reconcileClipsWithInserts", () => {
+ it("gives a short clip back the time its insertions take", () => {
+ const before = doc([clip({ id: "c1" })], [insert({ id: "i1" })]);
+ const [after] = reconcileClipsWithInserts(before).timeline.clips;
+ expect(after.timelineEndSec - after.timelineStartSec).toBeCloseTo(11, 6);
+ // The recording is untouched: no frame was added to or taken from the file.
+ expect(after.sourceStartSec).toBe(0);
+ expect(after.sourceEndSec).toBe(10);
+ });
+
+ it("pushes every later clip along by what the one before it gained", () => {
+ const before = doc(
+ [clip({ id: "c1" }), clip({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 })],
+ [insert({ id: "i1" })],
+ );
+ const [, second] = reconcileClipsWithInserts(before).timeline.clips;
+ expect(second.timelineStartSec).toBeCloseTo(11, 6);
+ expect(second.timelineEndSec).toBeCloseTo(21, 6);
+ });
+
+ it("is idempotent, so it can run on every load", () => {
+ const before = doc([clip({ id: "c1" })], [insert({ id: "i1" })]);
+ const once = reconcileClipsWithInserts(before);
+ const twice = reconcileClipsWithInserts(once);
+ expect(twice.timeline.clips).toEqual(once.timeline.clips);
+ // And a document already in step is returned as-is, not rebuilt.
+ expect(twice).toBe(once);
+ });
+
+ it("leaves a document with no insertions completely alone", () => {
+ const before = doc([clip({ id: "c1" })], []);
+ expect(reconcileClipsWithInserts(before)).toBe(before);
+ });
+
+ it("counts several insertions in one clip, and only that clip's", () => {
+ const before = doc(
+ [
+ clip({ id: "c1" }),
+ clip({ id: "c2", assetId: "a2", timelineStartSec: 10, timelineEndSec: 20 }),
+ ],
+ [insert({ id: "i1", atSec: 3 }), insert({ id: "i2", atSec: 7, durationSec: 0.5 })],
+ );
+ const [first, second] = reconcileClipsWithInserts(before).timeline.clips;
+ expect(first.timelineEndSec).toBeCloseTo(11.5, 6);
+ expect(second.timelineEndSec - second.timelineStartSec).toBeCloseTo(10, 6);
+ });
+});
+
+// ─── An added word nobody marked ────────────────────────────────────────────
+// `source: "synth"` is how the whole pipeline recognises a word the user typed: it decides
+// whether the word gets an insertion, whether the film makes room for it, and whether the
+// caption line breaks around it. A row minted before that field existed answers no to all
+// three — its text plays over the recording and everything after it drifts. Found in the
+// live project: `synth_1`, zero-width at source 9.15, with no insertion at all (issue #560).
+
+describe("reconcileInsertions", () => {
+ function docWithUnmarkedWord(): AxcutDocument {
+ return {
+ assets: [{ id: "a1", kind: "video" }],
+ transcripts: [
+ {
+ assetId: "a1",
+ language: "en",
+ segments: [{ id: "s1", kind: "speech", startSec: 0, endSec: 6, text: "x", wordIds: [] }],
+ words: [
+ { id: "w1", segmentId: "s1", startSec: 0, endSec: 1, text: "hello" },
+ // Minted as an added word — the id says so — but never marked.
+ { id: "synth_1", segmentId: "s1", startSec: 1, endSec: 1, text: "a much longer thing" },
+ ],
+ },
+ ],
+ timeline: { clips: [clip({ id: "c1" })], insertRanges: [] },
+ } as unknown as AxcutDocument;
+ }
+
+ it("marks it, gives it an insertion, and makes room for it", () => {
+ const out = reconcileInsertions(docWithUnmarkedWord());
+ const word = out.transcripts[0].words.find((w) => w.id === "synth_1");
+ expect(word?.source).toBe("synth");
+ const range = out.timeline.insertRanges?.find((r) => r.wordId === "synth_1");
+ expect(range).toBeDefined();
+ expect(range?.durationSec ?? 0).toBeGreaterThan(0);
+ const [c] = out.timeline.clips;
+ expect(c.timelineEndSec - c.timelineStartSec).toBeCloseTo(10 + (range?.durationSec ?? 0), 6);
+ });
+
+ it("leaves a word that was never added alone", () => {
+ const out = reconcileInsertions(docWithUnmarkedWord());
+ expect(out.transcripts[0].words.find((w) => w.id === "w1")?.source).toBeUndefined();
+ });
+
+ it("is idempotent", () => {
+ const once = reconcileInsertions(docWithUnmarkedWord());
+ const twice = reconcileInsertions(once);
+ expect(twice.timeline.clips).toEqual(once.timeline.clips);
+ expect(twice.timeline.insertRanges).toEqual(once.timeline.insertRanges);
+ });
+});
diff --git a/src/lib/ai-edition/document/load.ts b/src/lib/ai-edition/document/load.ts
new file mode 100644
index 000000000..a71b81e95
--- /dev/null
+++ b/src/lib/ai-edition/document/load.ts
@@ -0,0 +1,60 @@
+// The one way to turn a document on disk into a document in memory.
+//
+// Three steps, in this order, and no caller may do two of them and skip the third:
+//
+// 1. UPGRADE — `migrateRawDocumentToCurrent` walks the vN → vN+1 chain.
+// 2. VALIDATE — `documentSchema.parse` is a pure current-version shape check.
+// 3. RECONCILE — clip geometry is brought back in line with the insert ranges.
+//
+// Step 3 is the one that is easy to forget and impossible to notice. An insertion is MEDIA
+// inside a clip (issue #560), so a clip carrying one is longer than its source window by
+// exactly that much — every reader downstream depends on it, and a document written before
+// that was true carries SHORT clips. Nothing else reconciles them: `withInsertRangesForWords`
+// only runs when a transcript word is written, so a project the user merely OPENS keeps its
+// old geometry while all the code around it assumes the new. The visible result is a film
+// whose ruler stops short, insertion pills drawn at their source position instead of their
+// timeline one, and subtitles sliding further out of step with every insertion passed.
+//
+// `reflowClipsForInserts` is absolute rather than incremental, so this is idempotent: a
+// document already in step is returned unchanged, and running it on every load costs nothing
+// while also repairing anything that writes clip geometry without allowing for insertions.
+
+import type { AxcutDocument } from "../schema";
+import { documentSchema, migrateRawDocumentToCurrent } from "../schema";
+import { reflowClipsForInserts } from "./timeline";
+import { withInsertRangesForAllWords, withMarkedAddedWords } from "./transcript";
+
+/**
+ * The whole insertion invariant, in dependency order.
+ *
+ * A word is ADDED, an added word has an INSERTION, and a clip carrying insertions is
+ * LONGER. Each step feeds the next, and reconciling only the last one left a document
+ * carrying an unmarked added word looking perfectly consistent while playing its text over
+ * the recording. Every step is idempotent, so this runs on every load and changes nothing
+ * for a document already in step.
+ */
+export function reconcileInsertions(document: AxcutDocument): AxcutDocument {
+ return reconcileClipsWithInserts(withInsertRangesForAllWords(withMarkedAddedWords(document)));
+}
+
+/** Clip geometry brought back in line with the document's insert ranges. Idempotent. */
+export function reconcileClipsWithInserts(document: AxcutDocument): AxcutDocument {
+ const insertRanges = document.timeline.insertRanges ?? [];
+ if (insertRanges.length === 0) return document;
+ const clips = reflowClipsForInserts(document.timeline.clips, insertRanges);
+ const unchanged =
+ clips.length === document.timeline.clips.length &&
+ clips.every((clip, i) => {
+ const was = document.timeline.clips[i];
+ return (
+ Math.abs(clip.timelineStartSec - was.timelineStartSec) < 1e-9 &&
+ Math.abs(clip.timelineEndSec - was.timelineEndSec) < 1e-9
+ );
+ });
+ return unchanged ? document : { ...document, timeline: { ...document.timeline, clips } };
+}
+
+/** Raw JSON (any stored version) → a validated, reconciled document. */
+export function parseStoredDocument(raw: unknown): AxcutDocument {
+ return reconcileInsertions(documentSchema.parse(migrateRawDocumentToCurrent(raw)));
+}
diff --git a/src/lib/ai-edition/document/migrate.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..8d06f6b85 100644
--- a/src/lib/ai-edition/document/migrate.ts
+++ b/src/lib/ai-edition/document/migrate.ts
@@ -28,10 +28,9 @@ import {
type AxcutLegacyEditor,
type AxcutTrimRange,
type AxcutZoomRegion,
- documentSchema,
- migrateRawDocumentToCurrent,
} from "../schema";
import { createId } from "./ids";
+import { parseStoredDocument } from "./load";
const MS_TO_SEC = 1 / 1000;
const SEC_TO_MS = 1000;
@@ -63,7 +62,7 @@ function clampSec(sec: number): number {
* v2 inputs are not handled by it — `migrateProjectDataToAxcutDocument` below
* still owns the legacy EditorProjectData → AxcutDocument translation.
*/
-export { migrateRawDocumentToCurrent };
+export { migrateRawDocumentToCurrent } from "../schema";
function toLegacyMedia(input: ProjectMedia | undefined): ProjectMedia | null {
if (!input) return null;
@@ -250,7 +249,7 @@ export function migrateProjectDataToAxcutDocument(
legacyEditor,
};
- return documentSchema.parse(migrateRawDocumentToCurrent(draft));
+ return parseStoredDocument(draft);
}
/**
diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts
index 4ceac442f..2a614edd0 100644
--- a/src/lib/ai-edition/document/outputFormat.test.ts
+++ b/src/lib/ai-edition/document/outputFormat.test.ts
@@ -61,12 +61,14 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument {
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
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..eabccabd8 100644
--- a/src/lib/ai-edition/document/timeline.test.ts
+++ b/src/lib/ai-edition/document/timeline.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
type AxcutClip,
type AxcutDocument,
+ type AxcutInsertRange,
type AxcutTrimRange,
axcutSchemaVersion,
} from "../schema";
@@ -13,6 +14,7 @@ import {
normalizeIntervals,
planTimelineReplacement,
primaryAssetDuration,
+ projectRawTimelineSecToPlayback,
rederiveRegionMs,
removeClip,
removeRegion,
@@ -51,12 +53,14 @@ function makeDoc(overrides: Partial = {}): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
...overrides,
};
@@ -210,6 +214,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -307,6 +312,7 @@ describe("timeline pure functions", () => {
clips: [],
gaps: [],
trimRanges: [makeTrim({ id: "trim_other", assetId: "asset_2", startSec: 1, endSec: 2 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -408,6 +414,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -533,6 +540,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [makeTrim({ id: "trim_1", startSec: 12, endSec: 17 })],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -609,6 +617,7 @@ describe("timeline pure functions", () => {
trimRanges: [
{ id: "s1", assetId: "asset_1", startSec: 10, endSec: 20, origin: "user", reason: "" },
],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -646,6 +655,7 @@ describe("timeline pure functions", () => {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -840,6 +850,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`,
@@ -877,6 +971,7 @@ describe("duplicateClip / moveClip", () => {
...makeDoc().timeline,
clips: [makeClip({ id: "clip_a", sourceStartSec: 0, sourceEndSec: 10 })],
trimRanges: [makeTrim({ id: "t1", clipId: "clip_a", startSec: 2, endSec: 4 })],
+ insertRanges: [],
},
});
const next = duplicateClip(doc, "clip_a");
@@ -1230,6 +1325,7 @@ describe("removeRegion — the one shared region-delete mutator", () => {
timeline: {
...makeDoc().timeline,
trimRanges: [makeTrim({ id: "trim_1" }), makeTrim({ id: "trim_2" })],
+ insertRanges: [],
},
});
const next = removeRegion(doc, "trim", "trim_1");
@@ -1597,3 +1693,142 @@ 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);
+ });
+});
+
+// ─── The pause an added word bought ──────────────────────────────
+// Created time only exists once playback honours it. These pin the one thing the record
+// is for: the stream really does stay on the held frame, and the film really is longer.
+
+describe("resolvePlaybackSegments with insert ranges", () => {
+ const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ];
+ const insert = (overrides: Partial = {}): AxcutInsertRange => ({
+ id: "ins_1",
+ assetId: "a1",
+ atSec: 10,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "held",
+ origin: "user",
+ ...overrides,
+ });
+
+ it("holds the frame where the pause sits, and lengthens the stream by it", () => {
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
+ expect(segments).toHaveLength(2);
+ expect(segments[1]).toMatchObject({
+ sourceStartSec: 10,
+ sourceEndSec: 10,
+ heldSec: 0.5,
+ timelineStartSec: 10,
+ timelineEndSec: 10.5,
+ });
+ });
+
+ it("changes nothing when there is no pause", () => {
+ expect(resolvePlaybackSegments(CLIPS, [], [])).toHaveLength(1);
+ });
+
+ // The usual case, and the one the first cut of this missed: a pause sits at the end of
+ // the word it follows, which is almost never a boundary a trim happened to leave.
+ it("cuts the clip open where a pause falls in the MIDDLE of it", () => {
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert({ atSec: 2.5 })]);
+ expect(segments.map((s) => [s.sourceStartSec, s.sourceEndSec, s.heldSec])).toEqual([
+ [0, 2.5, undefined],
+ [2.5, 2.5, 0.5],
+ [2.5, 10, undefined],
+ ]);
+ // 10s of film plus half a second of held frame.
+ expect(segments[2].timelineEndSec).toBeCloseTo(10.5, 5);
+ });
+
+ // The moment the pause holds is not in the film any more, so neither is the pause.
+ it("drops a pause whose moment a trim removed", () => {
+ const trims: AxcutTrimRange[] = [
+ { id: "t1", assetId: "a1", startSec: 4, endSec: 10, origin: "user", reason: "" },
+ ];
+ const segments = resolvePlaybackSegments(CLIPS, trims, [insert()]);
+ expect(segments.some((s) => s.heldSec !== undefined)).toBe(false);
+ });
+
+ it("places a pause inside a clip between the halves a trim left", () => {
+ const trims: AxcutTrimRange[] = [
+ { id: "t1", assetId: "a1", startSec: 4, endSec: 6, origin: "user", reason: "" },
+ ];
+ const segments = resolvePlaybackSegments(CLIPS, trims, [insert({ atSec: 4 })]);
+ expect(segments.map((s) => s.heldSec)).toEqual([undefined, 0.5, undefined]);
+ // The stream is the kept film plus the pause: 4s + 0.5s + 4s.
+ expect(segments[segments.length - 1].timelineEndSec).toBeCloseTo(8.5, 5);
+ });
+
+ it("never writes the held flag onto a stored clip", () => {
+ // The field lives on the derived segment only; that is the whole difference from
+ // the attempt that made clips for it.
+ const segments = resolvePlaybackSegments(CLIPS, [], [insert()]);
+ expect(CLIPS[0]).not.toHaveProperty("heldSec");
+ expect(segments[0]).not.toHaveProperty("heldSec");
+ });
+});
diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts
index 232bcb6ee..c52ae3b6f 100644
--- a/src/lib/ai-edition/document/timeline.ts
+++ b/src/lib/ai-edition/document/timeline.ts
@@ -3,7 +3,27 @@
// (store, exporter, agent) feeds an AxcutDocument and gets back intervals
// or a new document with updated clips.
-import type { AxcutClip, AxcutDocument, AxcutTranscript, AxcutTrimRange } from "../schema";
+import type {
+ AxcutClip,
+ AxcutDocument,
+ AxcutInsertRange,
+ AxcutTranscript,
+ AxcutTrimRange,
+} from "../schema";
+
+/**
+ * What `resolvePlaybackSegments` returns: a clip-shaped slice of playable film, plus the
+ * one thing a stored clip can never carry — `heldSec`, the media an added word inserted.
+ *
+ * A held segment's source window is the single frame it shows; its LENGTH is `heldSec`.
+ * The field lives only on this derived shape, never on `clipSchema`, so nothing can write
+ * one to disk — which is the whole difference from the attempt that made clips for it.
+ */
+export type PlaybackSegment = AxcutClip & { heldSec?: number };
+
+import { assignInsertsToClips } from "../timeline/inserted-time";
+import { type Interval, subtractInterval } from "../timeline/intervals";
+import { keptRawSpans } from "../timeline/programme-time";
import {
anchoredToRawSpanSec,
anchorRegionsWithDerivedMs,
@@ -11,13 +31,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 +49,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 +136,41 @@ function collectWordRefs(
// after any structural change (insert / move / remove / trim) so the timeline
// never has gaps or overlaps between clips. Shared by useTimeline (UI) and
// the agent tool executor (main process) so both enforce the same invariant.
+/**
+ * Clip geometry that accounts for the media inserted inside each clip (issue #560).
+ *
+ * An added word inserts media — a fixed frame and silence, until there is a generator for
+ * it — and a clip carrying it is that much longer, exactly as it would be if the media had
+ * come from a file. This is the ONE place that says so; every reader downstream then works
+ * in a single coordinate, which is what makes the playhead, the native decoder and the
+ * export agree without any of them converting between two rulers.
+ *
+ * Absolute rather than incremental, so it is idempotent: a stored clip's length is always
+ * its source length (every writer above builds it that way), and re-running this on an
+ * already-reflowed document changes nothing. That is what lets it also serve as the
+ * migration for documents written before insertions existed.
+ */
+export function reflowClipsForInserts(
+ clips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
+): AxcutClip[] {
+ // Through `assignInsertsToClips`, so the length a clip gains and the pills drawn inside it
+ // come from the same assignment. They used to be computed separately, and with two clips
+ // over one recording the film grew twice for an insertion drawn once.
+ const byClip = assignInsertsToClips(clips, insertRanges);
+ return resequenceClips(
+ clips.map((clip) => {
+ const sourceLen = (clip.sourceEndSec ?? clip.sourceStartSec) - clip.sourceStartSec;
+ if (sourceLen <= 0) return clip; // duration not probed yet; leave it to the prober
+ const owed = (byClip.get(clip.id) ?? []).reduce((sum, r) => sum + r.durationSec, 0);
+ return {
+ ...clip,
+ timelineEndSec: clip.timelineStartSec + sourceLen + owed,
+ };
+ }),
+ );
+}
+
export function resequenceClips(clips: AxcutClip[]): AxcutClip[] {
let cursor = 0;
return clips.map((c) => {
@@ -127,23 +183,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,10 +202,32 @@ export function subtractInterval(intervals: Interval[], cut: Interval): Interval
export function resolvePlaybackSegments(
clips: AxcutClip[],
trimRanges: AxcutTrimRange[],
-): AxcutClip[] {
+ insertRanges: readonly AxcutInsertRange[] = [],
+): PlaybackSegment[] {
const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
- const result: AxcutClip[] = [];
+ const result: PlaybackSegment[] = [];
let timelineCursor = 0;
+ // The media added words insert, in the order they will be met. Consumed as the walk
+ // passes each one's moment, so an insertion inside a span a trim removed is never reached —
+ // which is right: the moment it holds is not in the film any more.
+ const pending = [...insertRanges].sort((a, b) => a.atSec - b.atSec);
+ const holdAt = (clip: AxcutClip, atSec: number): PlaybackSegment | null => {
+ const insert = pending.find(
+ (range) => range.assetId === clip.assetId && Math.abs(range.atSec - atSec) < 1e-6,
+ );
+ if (!insert) return null;
+ pending.splice(pending.indexOf(insert), 1);
+ return {
+ ...clip,
+ id: `${clip.id}__hold_${insert.id}`,
+ sourceStartSec: atSec,
+ sourceEndSec: atSec,
+ timelineStartSec: 0,
+ timelineEndSec: 0,
+ heldSec: insert.durationSec,
+ reason: insert.reason,
+ };
+ };
for (const clip of ordered) {
const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
if (sourceEnd <= clip.sourceStartSec) {
@@ -185,23 +246,212 @@ 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;
+ // An insertion sits at the END of the word it follows, which is almost never a boundary a
+ // trim happened to leave. So each kept span is cut at the moments it holds, and the
+ // held frame goes between the halves: the stream plays up to that frame, stays on it
+ // for the insertion, then carries on — which is what makes the film longer.
+ const pieces: Array<{ startSec: number; endSec: number; holdAtEnd: boolean }> = [];
+ for (const iv of kept) {
+ const moments = pending
+ .filter(
+ (range) =>
+ range.assetId === clip.assetId &&
+ range.atSec > iv.startSec + 1e-6 &&
+ range.atSec <= iv.endSec + 1e-6,
+ )
+ .map((range) => range.atSec)
+ .sort((a, b) => a - b);
+ let from = iv.startSec;
+ for (const at of moments) {
+ pieces.push({ startSec: from, endSec: Math.min(at, iv.endSec), holdAtEnd: true });
+ from = Math.min(at, iv.endSec);
+ }
+ if (iv.endSec - from > 1e-6 || pieces.length === 0) {
+ pieces.push({ startSec: from, endSec: iv.endSec, holdAtEnd: false });
+ }
+ }
+ pieces.forEach((piece, i) => {
+ const dur = piece.endSec - piece.startSec;
+ if (dur > 0) {
+ result.push({
+ ...clip,
+ id: pieces.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
+ sourceStartSec: piece.startSec,
+ sourceEndSec: piece.endSec,
+ timelineStartSec: timelineCursor,
+ timelineEndSec: timelineCursor + dur,
+ });
+ timelineCursor += dur;
+ }
+ if (!piece.holdAtEnd) return;
+ const hold = holdAt(clip, piece.endSec);
+ if (!hold) return;
result.push({
- ...clip,
- id: kept.length === 1 ? clip.id : `${clip.id}_seg${i + 1}`,
- sourceStartSec: iv.startSec,
- sourceEndSec: iv.endSec,
+ ...hold,
timelineStartSec: timelineCursor,
- timelineEndSec: timelineCursor + dur,
+ timelineEndSec: timelineCursor + (hold.heldSec ?? 0),
});
- timelineCursor += dur;
+ timelineCursor += hold.heldSec ?? 0;
});
}
return result;
}
+/**
+ * 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;
+}
+
+/**
+ * The raw span that plays for `outSec` OUTPUT seconds starting at `fromRawSec` — the
+ * inverse of {@link outputDurationOfRawSpan}, and the identity when nothing is sped up.
+ *
+ * A voice-over plays at 1x in the mix, so an insertion for a spoken word is D seconds of the
+ * take's own clock. Under a 2x region that is 2 raw seconds, not 1, and getting it wrong
+ * puts the resumed narration half an insertion out of step with the picture.
+ */
+export function rawSpanForOutDuration(
+ fromRawSec: number,
+ outSec: number,
+ speedRegions: PlaybackSpeedRegion[] = [],
+): number {
+ if (!(outSec > 0)) return 0;
+ if (speedRegions.length === 0) return outSec;
+ // Walk the regions from the head, spending output budget piecewise.
+ const edges = [
+ ...new Set(
+ speedRegions
+ .flatMap((r) => [r.startMs / 1000, r.endMs / 1000])
+ .filter((edge) => edge > fromRawSec),
+ ),
+ ].sort((a, b) => a - b);
+ let raw = fromRawSec;
+ let left = outSec;
+ for (const edge of [...edges, Number.POSITIVE_INFINITY]) {
+ const mid = raw + Math.min(1e-6, (edge - raw) / 2);
+ const region = speedRegions.find(
+ (r) => mid >= r.startMs / 1000 && mid < r.endMs / 1000 && r.speed > 0,
+ );
+ const speed = region?.speed ?? 1;
+ const rawAvailable = edge - raw;
+ const outAvailable = rawAvailable / speed;
+ if (outAvailable >= left) return raw + left * speed - fromRawSec;
+ raw = edge;
+ left -= outAvailable;
+ }
+ return raw - fromRawSec;
+}
+
+export function projectRawTimelineSecToPlayback(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[],
+ rawSec: number,
+ /**
+ * The insertions, so the kept spans below can carry them.
+ *
+ * REQUIRED, not optional: an optional parameter would silently keep the early-audio bug
+ * alive at every site not yet touched — the one that shows up as "the music starts a
+ * beat early" months later.
+ */
+ insertRanges: readonly AxcutInsertRange[],
+ /**
+ * Speed regions on the raw ruler. Supplied by the AUDIO paths, which overlay
+ * a 1x track onto the finished programme and so need its real, speed-adjusted
+ * 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). It carries the insertions too: they are timeline seconds like
+ // any other, which is exactly what one clock buys — this walk used to interleave them
+ // itself, and every reader that forgot to had audio landing early.
+ //
+ // Trims only REMOVE, so a kept span's length is what survives; how long it takes to PLAY
+ // is a separate question `outputDurationOfRawSpan` answers, because a speed region scales
+ // it.
+ for (const seg of keptRawSpans(ordered, trimRanges, insertRanges)) {
+ const from = seg.startSec;
+ if (landed === null && rawSec < seg.endSec) {
+ // `rawSec` is inside this segment, or before it in a trimmed/gap region (then
+ // the span clamps to nothing → the output edge just before the gap).
+ const within = Math.min(Math.max(rawSec, from), seg.endSec);
+ landed = outCursor + outputDurationOfRawSpan(from, within, speedRegions);
+ }
+ outCursor += outputDurationOfRawSpan(from, 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 +509,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)
? {
@@ -879,14 +1151,22 @@ 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,
timeline: {
...document.timeline,
- trimRanges: dropTrimPillsByIds(document.timeline.trimRanges, document.timeline.clips, [
- id,
- ]),
+ trimRanges: dropTrimPillsByIds(
+ document.timeline.trimRanges,
+ document.timeline.clips,
+ [id],
+ document.timeline.insertRanges ?? [],
+ ),
},
};
case "speed": {
diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts
index 034abf212..688d4b5f5 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 {
@@ -43,12 +51,14 @@ function makeDoc(): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
}
@@ -120,3 +130,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 04d170718..d04ba9b67 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";
/**
@@ -43,11 +49,7 @@ export async function transcribeAsset(
const videoUrl = toFileUrl(asset.originalPath);
options.onStatus?.({ phase: "extracting-audio" });
- const audioResult = await extractMono16kFromVideoUrl(videoUrl, {
- signal: options.signal,
- });
- options.onStatus?.({ phase: "transcribing" });
// Only pass `language` to the worker when the caller forced a specific
// code. `"auto"` (or any falsy value) leaves Whisper to detect from
// the audio. The pipeline tags every chunk with the language it used
@@ -55,24 +57,52 @@ 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,
+ });
+
+ // 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,
- }),
+ 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[] = [];
@@ -122,18 +152,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..fdc3909d1
--- /dev/null
+++ b/src/lib/ai-edition/document/transcript.test.ts
@@ -0,0 +1,821 @@
+import { describe, expect, it } from "vitest";
+import { type AxcutTranscript, createEmptyDocument, documentSchema } from "../schema";
+import {
+ carryOverWordEdits,
+ insertDocumentWord,
+ insertRangesMatchWords,
+ insertWord,
+ removeDocumentWords,
+ removeWord,
+ setDocumentWordText,
+ setWordText,
+ withTranscript,
+} from "./transcript";
+
+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);
+ });
+});
+
+// ─── Inserting a word nobody said ────────────────────────────────
+// The word carries no audio, so what it may occupy is the silence around it and nothing
+// else. These pin that boundary: never over a spoken word, never a duration invented out
+// of nothing when there is no pause to take.
+
+describe("insertWord", () => {
+ // "I"(1–2) "use"(2–3) "OpenScreen"(3–4), then a gap, then segment 2 at 5.
+ it("takes the silence after the word it follows, up to what its text needs", () => {
+ const result = insertWord(fixture(), "word_3", "after", "everywhere");
+ const inserted = result.words.find((w) => w.source === "synth");
+ expect(inserted?.startSec).toBe(4);
+ // 10 characters at 15/s = 0.67s, and the next word is a full second away.
+ expect(inserted?.endSec).toBeCloseTo(4 + 10 / 15, 5);
+ });
+
+ it("never runs over the word that comes next", () => {
+ // "use" ends at 3 and "OpenScreen" starts there: a long word gets no room at all.
+ const inserted = insertWord(fixture(), "word_2", "after", "a very long addition").words.find(
+ (w) => w.source === "synth",
+ );
+ expect(inserted).toMatchObject({ startSec: 3, endSec: 3 });
+ });
+
+ it("borrows backwards when it goes before the first word", () => {
+ const inserted = insertWord(fixture(), "word_1", "before", "Well").words.find(
+ (w) => w.source === "synth",
+ );
+ // "word_1" starts at 1, and nothing precedes it — the floor is the media's own start.
+ expect(inserted?.endSec).toBe(1);
+ expect(inserted?.startSec).toBeCloseTo(1 - 0.4, 5);
+ });
+
+ it("marks it synthesized, with an id no transcription run can reuse", () => {
+ const inserted = insertWord(fixture(), "word_3", "after", "indeed").words.find(
+ (w) => w.source === "synth",
+ );
+ expect(inserted).toMatchObject({ text: "indeed", source: "synth", segmentId: "segment_1" });
+ expect(inserted?.id).toMatch(/^synth_\d+$/);
+ expect(inserted).not.toHaveProperty("originalText");
+ });
+
+ it("numbers past the inserts already there", () => {
+ const once = insertWord(fixture(), "word_3", "after", "one");
+ const twice = insertWord(once, "word_3", "after", "two");
+ const ids = twice.words.filter((w) => w.source === "synth").map((w) => w.id);
+ expect(new Set(ids).size).toBe(2);
+ expect(ids).toContain("synth_2");
+ });
+
+ it("lands in the segment's reading order, and rebuilds its text", () => {
+ const transcript = fixture();
+ const result = insertWord(transcript, "word_2", "after", "really");
+ const segment = result.segments.find((seg) => seg.id === "segment_1");
+ expect(segment?.wordIds).toEqual(["word_1", "word_2", "synth_1", "word_3"]);
+ expect(segment?.text).toBe("I use really OpenScreen");
+ // The segment the insert did not land in is carried over untouched, not rebuilt.
+ expect(result.segments[1]).toBe(transcript.segments[1]);
+ });
+
+ it("sits beside its anchor in the words array, which is what orders a zero-length insert", () => {
+ const result = insertWord(fixture(), "word_2", "after", "really");
+ const ids = result.words.map((w) => w.id);
+ expect(ids.indexOf("synth_1")).toBe(ids.indexOf("word_2") + 1);
+ });
+
+ it("refuses empty text and unknown anchors", () => {
+ expect(() => insertWord(fixture(), "word_2", "after", " ")).toThrow(/empty/);
+ expect(() => insertWord(fixture(), "nope", "after", "x")).toThrow(/missing/);
+ });
+
+ it("keeps the input transcript untouched", () => {
+ const transcript = fixture();
+ const before = JSON.stringify(transcript);
+ insertWord(transcript, "word_2", "after", "really");
+ expect(JSON.stringify(transcript)).toBe(before);
+ });
+});
+
+describe("removeWord", () => {
+ const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
+
+ it("takes the word out of the array, the segment, and its text", () => {
+ const result = removeWord(withInsert(), "synth_1");
+ expect(result.words.some((w) => w.id === "synth_1")).toBe(false);
+ const segment = result.segments.find((seg) => seg.id === "segment_1");
+ expect(segment?.wordIds).toEqual(["word_1", "word_2", "word_3"]);
+ expect(segment?.text).toBe("I use OpenScreen");
+ });
+
+ // Deleting a transcribed word would leave the film saying something the transcript
+ // denies. The operation for making a spoken word go away is a trim.
+ it("refuses a word that was actually spoken", () => {
+ expect(() => removeWord(fixture(), "word_2")).toThrow(/Refusing to remove transcribed word/);
+ });
+
+ it("refuses a word that is not there", () => {
+ expect(() => removeWord(fixture(), "nope")).toThrow(/missing/);
+ });
+});
+
+describe("insertDocumentWord / removeDocumentWords", () => {
+ it("writes both the per-asset transcript and the legacy mirror", () => {
+ const result = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "really");
+ expect(result.transcript?.words.some((w) => w.id === "synth_1")).toBe(true);
+ expect(result.transcript).toBe(result.transcripts.find((t) => t.assetId === "asset_1"));
+ });
+
+ // One save for the whole set: a Backspace over three inserted words must be one Ctrl+Z.
+ it("removes several inserted words in a single document", () => {
+ let doc = insertDocumentWord(makeDoc(), "asset_1", "word_2", "after", "one");
+ doc = insertDocumentWord(doc, "asset_1", "word_3", "after", "two");
+ const result = removeDocumentWords(doc, "asset_1", ["synth_1", "synth_2"]);
+ expect(result.transcripts[0].words.some((w) => w.source === "synth")).toBe(false);
+ });
+
+ it("rejects an asset with no transcript", () => {
+ expect(() => insertDocumentWord(makeDoc(), "nope", "word_2", "after", "x")).toThrow(
+ /no transcript/,
+ );
+ });
+});
+
+describe("carryOverWordEdits with inserted words", () => {
+ const withInsert = () => insertWord(fixture(), "word_2", "after", "really");
+
+ it("puts an insert back after whatever the new run now ends last before it", () => {
+ // The insert sits at 3s. The new transcript says "I"(1–2) "used"(2–3) "it"(3.5–4).
+ const next = retranscribed([
+ ["n1", "I", 1, 2],
+ ["n2", "used", 2, 3],
+ ["n3", "it", 3.5, 4],
+ ]);
+ const result = carryOverWordEdits(withInsert(), next);
+ expect(result).toMatchObject({ carried: 1, dropped: 0 });
+ const ids = result.transcript.words.map((w) => w.id);
+ expect(ids.indexOf("synth_1")).toBe(ids.indexOf("n2") + 1);
+ expect(result.transcript.words.find((w) => w.id === "synth_1")).toMatchObject({
+ text: "really",
+ source: "synth",
+ });
+ });
+
+ it("puts it at the head when the new run has nothing before it", () => {
+ const carried = carryOverWordEdits(
+ insertWord(fixture(), "word_1", "before", "Well"),
+ retranscribed([["n1", "I", 1, 2]]),
+ );
+ expect(carried.carried).toBe(1);
+ expect(carried.transcript.words[0].text).toBe("Well");
+ });
+
+ it("counts an insert it could not place, rather than losing it quietly", () => {
+ const empty: AxcutTranscript = { assetId: "asset_1", language: "en", segments: [], words: [] };
+ expect(carryOverWordEdits(withInsert(), empty)).toMatchObject({ carried: 0, dropped: 1 });
+ });
+
+ it("carries corrections and inserts together", () => {
+ const both = insertWord(
+ setWordText(fixture(), "word_3", "OpenScreenApp"),
+ "word_2",
+ "after",
+ "really",
+ );
+ const next = retranscribed([
+ ["n1", "I", 1, 2],
+ ["n2", "use", 2, 3],
+ ["n3", "OpenScreen", 3, 4],
+ ]);
+ const result = carryOverWordEdits(both, next);
+ expect(result).toMatchObject({ carried: 2, dropped: 0 });
+ expect(result.transcript.words.find((w) => w.id === "n3")?.text).toBe("OpenScreenApp");
+ expect(result.transcript.words.some((w) => w.text === "really")).toBe(true);
+ });
+});
+
+// ─── The pause an added word needs ───────────────────────────────
+// Created time is STORED, as a region beside the trims. Something has to keep those
+// records true against the words they belong to, and `withInsertRangesForWords` is the one
+// writer — these hold it to the invariant it maintains. The first attempt at this made
+// CLIPS instead, and every other writer of `timeline.clips` disagreed with them.
+
+describe("insert ranges", () => {
+ function docWithClip() {
+ const doc = makeDoc();
+ return {
+ ...doc,
+ timeline: {
+ ...doc.timeline,
+ clips: [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user" as const,
+ reason: "",
+ },
+ ],
+ },
+ };
+ }
+
+ it("stores a pause when the free silence does not cover the word", () => {
+ // "really" after word_2: word_3 starts exactly where word_2 ends, so the word
+ // borrows nothing and needs its whole reading time — max(0.4, 6/15) = 0.4s.
+ const result = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ expect(result.timeline.insertRanges).toHaveLength(1);
+ expect(result.timeline.insertRanges[0]).toMatchObject({
+ assetId: "asset_1",
+ wordId: "synth_1",
+ atSec: 3,
+ durationSec: 0.4,
+ origin: "user",
+ });
+ expect(insertRangesMatchWords(result)).toBe(true);
+ });
+
+ // The reason is user-visible on the region, and the two lanes do not hold the same
+ // thing: the film holds a FRAME, a take holds silence and no picture is involved
+ // (issue #560). The keying above is lane-agnostic and stays that way — one row per
+ // word per asset, whichever lane the asset is on.
+ it("names what is actually held, per lane", () => {
+ const film = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ expect(film.timeline.insertRanges[0].reason).toContain("Held frame");
+
+ // `createEmptyDocument` carries no assets, so the lane has to be given one to read.
+ const base = docWithClip();
+ const take = insertDocumentWord(
+ {
+ ...base,
+ assets: [
+ {
+ id: "asset_1",
+ kind: "audio" as const,
+ label: "take.mp3",
+ originalPath: "/take.mp3",
+ durationSec: 30,
+ cameraTrack: null,
+ },
+ ],
+ },
+ "asset_1",
+ "word_2",
+ "after",
+ "really",
+ );
+ expect(take.timeline.insertRanges[0].reason).toContain("Silence");
+ expect(take.timeline.insertRanges[0].reason).not.toContain("frame");
+ // Same row otherwise, and the invariant still holds on an audio asset.
+ expect(take.timeline.insertRanges[0]).toMatchObject({ atSec: 3, durationSec: 0.4 });
+ expect(insertRangesMatchWords(take)).toBe(true);
+ });
+
+ // An insertion is MEDIA inside the clip, so the clip carrying it is exactly that much
+ // longer — the one fact every reader downstream depends on, and the reason none of them
+ // needs a second ruler to convert to. Its source window is untouched: no frame of the
+ // recording was added or removed.
+ it("lengthens the clip that carries the insertion, by the insertion", () => {
+ const before = docWithClip();
+ const result = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
+ const [range] = result.timeline.insertRanges;
+ const was = before.timeline.clips[0];
+ const now = result.timeline.clips[0];
+ expect(now.timelineEndSec - now.timelineStartSec).toBeCloseTo(
+ was.timelineEndSec - was.timelineStartSec + range.durationSec,
+ 5,
+ );
+ expect(now.sourceStartSec).toBe(was.sourceStartSec);
+ expect(now.sourceEndSec).toBe(was.sourceEndSec);
+ });
+
+ it("gives the length back when the word goes", () => {
+ const before = docWithClip();
+ const added = insertDocumentWord(before, "asset_1", "word_2", "after", "really");
+ const removed = removeDocumentWords(added, "asset_1", ["synth_1"]);
+ expect(removed.timeline.clips).toEqual(before.timeline.clips);
+ });
+
+ it("stores nothing when the word fits in silence that is already there", () => {
+ // word_3 ends at 4 and word_4 starts at 5: a full second, more than "really" needs.
+ const result = insertDocumentWord(docWithClip(), "asset_1", "word_3", "after", "really");
+ expect(result.timeline.insertRanges).toEqual([]);
+ expect(insertRangesMatchWords(result)).toBe(true);
+ });
+
+ it("resizes the pause when the word is rewritten longer", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const longer = setDocumentWordText(added, "asset_1", "synth_1", "really quite genuinely so");
+ const [range] = longer.timeline.insertRanges;
+ expect(range.durationSec).toBeCloseTo(25 / 15, 5);
+ expect(range.id).toBe(added.timeline.insertRanges[0].id);
+ expect(insertRangesMatchWords(longer)).toBe(true);
+ });
+
+ it("drops the pause with the word", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const gone = removeDocumentWords(added, "asset_1", ["synth_1"]);
+ expect(gone.timeline.insertRanges).toEqual([]);
+ expect(insertRangesMatchWords(gone)).toBe(true);
+ });
+
+ it("keeps one pause per added word, and no more", () => {
+ let doc = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ doc = insertDocumentWord(doc, "asset_1", "word_1", "after", "personally");
+ expect(doc.timeline.insertRanges).toHaveLength(2);
+ expect(new Set(doc.timeline.insertRanges.map((r) => r.wordId)).size).toBe(2);
+ expect(insertRangesMatchWords(doc)).toBe(true);
+ });
+
+ // Correcting a SPOKEN word must not invent a pause: it has audio behind it already.
+ it("stores nothing for an ordinary correction", () => {
+ const result = setDocumentWordText(docWithClip(), "asset_1", "word_3", "OpenScreenApp");
+ expect(result.timeline.insertRanges).toEqual([]);
+ });
+
+ it("survives the document schema", () => {
+ const added = insertDocumentWord(docWithClip(), "asset_1", "word_2", "after", "really");
+ const parsed = documentSchema.parse(JSON.parse(JSON.stringify(added)));
+ expect(parsed.timeline.insertRanges).toHaveLength(1);
+ expect(insertRangesMatchWords(parsed)).toBe(true);
+ });
+});
diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts
new file mode 100644
index 000000000..82df1928d
--- /dev/null
+++ b/src/lib/ai-edition/document/transcript.ts
@@ -0,0 +1,590 @@
+import type { AxcutDocument, AxcutInsertRange, AxcutTranscript, AxcutWord } from "../schema";
+import { createId } from "./ids";
+import { reflowClipsForInserts } from "./timeline";
+
+const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
+const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u;
+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 {
+ 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`);
+ }
+ // Rewriting an added word changes how long it takes to read, so its pause is resized
+ // here too — the one writer, whatever the edit was.
+ return withInsertRangesForWords(
+ withTranscript(document, setWordText(transcript, wordId, text)),
+ assetId,
+ );
+}
+
+/** Where a new word goes relative to the word the caret was resting on. */
+export type InsertSide = "before" | "after";
+
+/**
+ * How long an inserted word needs to be readable on screen. Subtitle practice is roughly
+ * fifteen characters a second, with a floor so a one-letter word is not a single frame.
+ * It is only ever a REQUEST — `insertWord` gives the word whatever silence is actually
+ * free, and no more.
+ */
+function readingSeconds(text: string): number {
+ return Math.max(0.4, text.trim().length / 15);
+}
+
+/** `synth_N`, numbered past every id already in the transcript.
+ *
+ * The prefix buys uniqueness, not meaning: a transcription run regenerates `word_N` from
+ * 1, so a synthesized word holding one of those ids would be overwritten by the next run.
+ * What the word IS lives in `source`, which is what every reader checks. */
+function nextSynthWordId(transcript: AxcutTranscript): string {
+ let highest = 0;
+ for (const word of transcript.words) {
+ const match = /^synth_(\d+)$/.exec(word.id);
+ if (match) highest = Math.max(highest, Number(match[1]));
+ }
+ return `synth_${highest + 1}`;
+}
+
+/**
+ * Insert a word that no one said.
+ *
+ * It carries no audio, so it takes the SILENCE it is dropped into and nothing else: from
+ * the word it follows up to what its text needs to be read, and never past the word that
+ * comes next. Dropped between two words that run straight into each other it has no
+ * duration at all and simply rides their caption line — which is where it reads correctly
+ * anyway, since there is no pause on screen to fill.
+ *
+ * That is the whole of what an inserted word can do today: it reaches the captions and
+ * stops there. When a voice can be synthesized for it, `source: "synth"` is what marks the
+ * words that need speaking, and the span computed here is the slot that audio has to fit.
+ */
+export function insertWord(
+ transcript: AxcutTranscript,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutTranscript {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) {
+ throw new Error("Cannot insert an empty word");
+ }
+ const anchorIndex = transcript.words.findIndex((word) => word.id === anchorWordId);
+ if (anchorIndex < 0) {
+ throw new Error(`Cannot insert next to missing transcript word "${anchorWordId}"`);
+ }
+ const anchor = transcript.words[anchorIndex];
+ const segment = transcript.segments.find((seg) => seg.id === anchor.segmentId);
+ if (!segment) {
+ throw new Error(
+ `Transcript word "${anchorWordId}" references missing segment "${anchor.segmentId}"`,
+ );
+ }
+ const anchorSlot = segment.wordIds.indexOf(anchorWordId);
+ if (anchorSlot < 0) {
+ throw new Error(`Segment "${segment.id}" does not reference anchor word "${anchorWordId}"`);
+ }
+
+ const wanted = readingSeconds(trimmed);
+ let startSec: number;
+ let endSec: number;
+ if (side === "after") {
+ startSec = anchor.endSec;
+ // The next word IN TIME, which is not necessarily the next one in the array — the
+ // array is insertion order, and only time decides what the new word may overlap.
+ const nextStart = transcript.words
+ .filter((word) => word.startSec >= startSec && word.id !== anchorWordId)
+ .reduce(
+ (soonest, word) => (soonest === null ? word.startSec : Math.min(soonest, word.startSec)),
+ null,
+ );
+ endSec = nextStart === null ? startSec + wanted : Math.min(startSec + wanted, nextStart);
+ } else {
+ endSec = anchor.startSec;
+ const previousEnd = transcript.words
+ .filter((word) => word.endSec <= endSec && word.id !== anchorWordId)
+ .reduce(
+ (latest, word) => (latest === null ? word.endSec : Math.max(latest, word.endSec)),
+ null,
+ );
+ const floor = previousEnd === null ? 0 : previousEnd;
+ startSec = Math.max(floor, endSec - wanted);
+ }
+
+ const inserted: AxcutWord = {
+ id: nextSynthWordId(transcript),
+ segmentId: segment.id,
+ startSec,
+ endSec: Math.max(startSec, endSec),
+ text: trimmed,
+ source: "synth",
+ };
+
+ // Position in `words` matters as well as the timings: a zero-length insert shares its
+ // start with the word it sits against, and the reading order of that tie is the array
+ // order (see `withSilenceGaps`).
+ const at = side === "after" ? anchorIndex + 1 : anchorIndex;
+ const words = [...transcript.words.slice(0, at), inserted, ...transcript.words.slice(at)];
+ const slot = side === "after" ? anchorSlot + 1 : anchorSlot;
+ const wordIds = [...segment.wordIds.slice(0, slot), inserted.id, ...segment.wordIds.slice(slot)];
+ const byId = new Map(words.map((word) => [word.id, word]));
+ const segmentText = joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? ""));
+
+ return {
+ ...transcript,
+ words,
+ segments: transcript.segments.map((seg) =>
+ seg.id === segment.id ? { ...seg, wordIds, text: segmentText } : seg,
+ ),
+ };
+}
+
+/**
+ * Delete an inserted word.
+ *
+ * Only a synthesized one: a transcribed word is the label on a piece of audio, and the
+ * operation for making that go away is a trim, which removes the sound with it. Deleting
+ * the label alone would leave the film saying a word the transcript denies.
+ */
+export function removeWord(transcript: AxcutTranscript, wordId: string): AxcutTranscript {
+ const target = transcript.words.find((word) => word.id === wordId);
+ if (!target) {
+ throw new Error(`Cannot remove missing transcript word "${wordId}"`);
+ }
+ if (target.source !== "synth") {
+ throw new Error(
+ `Refusing to remove transcribed word "${wordId}": cut it with a trim, or blank its text`,
+ );
+ }
+ const words = transcript.words.filter((word) => word.id !== wordId);
+ const byId = new Map(words.map((word) => [word.id, word]));
+ return {
+ ...transcript,
+ words,
+ segments: transcript.segments.map((segment) => {
+ if (!segment.wordIds.includes(wordId)) return segment;
+ const wordIds = segment.wordIds.filter((id) => id !== wordId);
+ return {
+ ...segment,
+ wordIds,
+ text: joinSegmentText(wordIds.map((id) => byId.get(id)?.text ?? "")),
+ };
+ }),
+ };
+}
+
+/**
+ * How much created time an added word still needs, on top of the silence it borrowed.
+ *
+ * Zero when the pause it landed in was already long enough — an added word between two
+ * sentences costs the film nothing.
+ */
+function pauseDeficitSec(word: AxcutWord): number {
+ const borrowed = word.endSec - word.startSec;
+ return Math.max(0, readingSeconds(word.text) - borrowed);
+}
+
+/** Below this, a pause is not worth a record — a few milliseconds of held frame is a
+ * stutter, not a slot to speak in. */
+const MIN_PAUSE_SEC = 0.05;
+
+/**
+ * Bring the document's insert ranges back in line with its words.
+ *
+ * The ranges are STORED, so something has to keep them true; this is that something, and
+ * it is the only writer. Called after every word write, it adds the pause an added word
+ * needs, resizes one whose text changed length, and drops the ones whose word is gone —
+ * so no caller has to remember any of the three. `insertRangesMatchWords` is the same rule
+ * read back, for a test to hold this to.
+ */
+/** `synth_N` — the id every added word has been minted with, and the one thing a row
+ * written before the `source` field carries to say what it is. */
+const SYNTH_WORD_ID = /^synth_\d+$/;
+
+/**
+ * Added words that never got marked as such, marked.
+ *
+ * `source: "synth"` is how the whole pipeline recognises a word the user typed: it decides
+ * whether the word gets an insertion, whether the film makes room for it, and whether the
+ * caption line breaks around it. A row minted before that field existed answers no to all
+ * three, so its text plays over the recording and everything after it drifts. The id is the
+ * evidence — `nextSynthWordId` has always minted exactly this shape, and the numbering scan
+ * already reads it back with the same pattern.
+ */
+export function withMarkedAddedWords(document: AxcutDocument): AxcutDocument {
+ let touched = false;
+ const transcripts = document.transcripts.map((transcript) => {
+ let changed = false;
+ const words = transcript.words.map((word) => {
+ if (word.source !== undefined || !SYNTH_WORD_ID.test(word.id)) return word;
+ changed = true;
+ return { ...word, source: "synth" as const };
+ });
+ if (!changed) return transcript;
+ touched = true;
+ return { ...transcript, words };
+ });
+ return touched ? { ...document, transcripts } : document;
+}
+
+/**
+ * Every asset's insert ranges brought back in line with its words.
+ *
+ * The per-asset reconciler applied to the whole document, so a load can enforce the
+ * invariant it maintains rather than waiting for the next word write to notice.
+ */
+export function withInsertRangesForAllWords(document: AxcutDocument): AxcutDocument {
+ return document.transcripts.reduce(
+ (doc, transcript) => withInsertRangesForWords(doc, transcript.assetId),
+ document,
+ );
+}
+
+function withInsertRangesForWords(document: AxcutDocument, assetId: string): AxcutDocument {
+ // The reason is user-visible on the region, and it is not the same fact on both lanes:
+ // the film holds a FRAME, a take holds nothing but silence — no picture is involved
+ // (issue #560). Keying, below, stays lane-agnostic: one row per word per asset.
+ const isTake = document.assets.find((a) => a.id === assetId)?.kind === "audio";
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ const words = transcript?.words ?? [];
+ const wanted = new Map();
+ for (const word of words) {
+ if (word.source !== "synth") continue;
+ const deficit = pauseDeficitSec(word);
+ if (deficit >= MIN_PAUSE_SEC) wanted.set(word.id, deficit);
+ }
+
+ const existing = document.timeline.insertRanges;
+ const kept: AxcutInsertRange[] = [];
+ const seen = new Set();
+ for (const range of existing) {
+ // Ranges for OTHER assets are none of this call's business.
+ if (range.assetId !== assetId) {
+ kept.push(range);
+ continue;
+ }
+ const durationSec = wanted.get(range.wordId);
+ if (durationSec === undefined) continue; // its word is gone, or needs no pause now
+ seen.add(range.wordId);
+ const word = words.find((w) => w.id === range.wordId);
+ const atSec = word?.endSec ?? range.atSec;
+ kept.push(
+ durationSec === range.durationSec && atSec === range.atSec
+ ? range
+ : { ...range, atSec, durationSec },
+ );
+ }
+ for (const [wordId, durationSec] of wanted) {
+ if (seen.has(wordId)) continue;
+ const word = words.find((w) => w.id === wordId);
+ if (!word) continue;
+ kept.push({
+ id: createId("insert"),
+ assetId,
+ atSec: word.endSec,
+ durationSec,
+ wordId,
+ reason: isTake
+ ? `Silence for the added word "${word.text}".`
+ : `Held frame for the added word "${word.text}".`,
+ origin: "user",
+ });
+ }
+
+ if (kept.length === existing.length && kept.every((range, i) => range === existing[i])) {
+ return document;
+ }
+ // The clips grow with them. An insertion is media inside the clip, so the clip is that
+ // much longer — the single fact every downstream reader needs, written once, here, where
+ // the ranges themselves are written.
+ return {
+ ...document,
+ timeline: {
+ ...document.timeline,
+ insertRanges: kept,
+ clips: reflowClipsForInserts(document.timeline.clips, kept),
+ },
+ };
+}
+
+/**
+ * The invariant {@link withInsertRangesForWords} maintains, read back: every stored pause
+ * belongs to an added word that still needs one, sits where that word ends, and lasts what
+ * its text needs. Exported for the test that holds the writer to it.
+ */
+export function insertRangesMatchWords(document: AxcutDocument): boolean {
+ const byAsset = new Map(document.transcripts.map((t) => [t.assetId, t]));
+ const expected = new Set();
+ for (const transcript of document.transcripts) {
+ for (const word of transcript.words) {
+ if (word.source === "synth" && pauseDeficitSec(word) >= MIN_PAUSE_SEC) {
+ expected.add(`${transcript.assetId}::${word.id}`);
+ }
+ }
+ }
+ const seen = new Set();
+ for (const range of document.timeline.insertRanges) {
+ const key = `${range.assetId}::${range.wordId}`;
+ if (!expected.has(key) || seen.has(key)) return false;
+ seen.add(key);
+ const word = byAsset.get(range.assetId)?.words.find((w) => w.id === range.wordId);
+ if (!word) return false;
+ if (range.atSec !== word.endSec) return false;
+ if (Math.abs(range.durationSec - pauseDeficitSec(word)) > 1e-9) return false;
+ }
+ return seen.size === expected.size;
+}
+
+/** {@link insertWord}, addressed by asset. Goes through `withTranscript` for the same
+ * reason {@link setDocumentWordText} does, and leaves behind the pause the new word
+ * needs — see {@link withInsertRangesForWords}. */
+export function insertDocumentWord(
+ document: AxcutDocument,
+ assetId: string,
+ anchorWordId: string,
+ side: InsertSide,
+ text: string,
+): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ if (!transcript) {
+ throw new Error(`Cannot insert a word into asset "${assetId}": it has no transcript`);
+ }
+ return withInsertRangesForWords(
+ withTranscript(document, insertWord(transcript, anchorWordId, side, text)),
+ assetId,
+ );
+}
+
+/** {@link removeWord}, addressed by asset and taking the whole set at once: a Backspace
+ * over several inserted words has to be ONE write, or undoing it takes as many presses as
+ * there were words. */
+export function removeDocumentWords(
+ document: AxcutDocument,
+ assetId: string,
+ wordIds: readonly string[],
+): AxcutDocument {
+ const transcript = document.transcripts.find((t) => t.assetId === assetId);
+ if (!transcript) {
+ throw new Error(`Cannot remove a word from asset "${assetId}": it has no transcript`);
+ }
+ return withInsertRangesForWords(
+ withTranscript(
+ document,
+ wordIds.reduce((acc, wordId) => removeWord(acc, wordId), transcript),
+ ),
+ assetId,
+ );
+}
+
+/** 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,
+ );
+ const inserts = (previous?.words ?? [])
+ .filter((word) => word.source === "synth")
+ .sort((a, b) => a.startSec - b.startSec);
+ if (edits.length === 0 && inserts.length === 0) {
+ return { transcript: next, carried: 0, dropped: 0 };
+ }
+
+ // Candidates are read from `next` throughout, never from the transcript being
+ // built up: a word already rewritten by an earlier correction no longer carries
+ // 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;
+ }
+
+ // An inserted word has no original text to recognise, so time is what places it: the
+ // audio did not change between runs, only how it was heard. Each one goes back after
+ // whatever the new transcript now ends last before it — including a word re-inserted a
+ // moment ago, which is what keeps two inserts at the same spot in their old order.
+ for (const insert of inserts) {
+ const before = transcript.words
+ .filter((word) => word.endSec <= insert.startSec)
+ .reduce(
+ (latest, word) => (latest === null || word.endSec >= latest.endSec ? word : latest),
+ null,
+ );
+ const head = transcript.words[0];
+ const target = before ?? head ?? null;
+ if (!target) continue;
+ transcript = insertWord(transcript, target.id, before ? "after" : "before", insert.text);
+ carried += 1;
+ }
+
+ return { transcript, carried, dropped: edits.length + inserts.length - carried };
+}
diff --git a/src/lib/ai-edition/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..8a715ab0e 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(),
@@ -252,6 +274,34 @@ export const trimRangeSchema = endGteStart(
"startSec",
);
+/**
+ * Time the film does NOT have: the pause an added word needs so a synthesized voice will
+ * have somewhere to speak. The film holds the frame at `atSec` for `durationSec`, screen
+ * and webcam together, and everything after it shifts.
+ *
+ * The exact inverse of a trim, and stored the same way and for the same reason. The first
+ * attempt created CLIPS for this; every other writer of `timeline.clips` — the duration
+ * probe, the recording import, resequencing — is entitled to disagree with a clip it did
+ * not make, and they did: a project came back split twice, both pauses gone, and the words
+ * they belonged to with them. A region is the shape this timeline already carries safely.
+ *
+ * `wordId` is what makes it derived-in-spirit while stored in fact: `document/transcript.ts`
+ * is the only writer, it creates the range with the word and drops it with the word, and
+ * `insertRangesMatchWords` is the invariant a test holds it to. Nothing else may write one.
+ */
+export const insertRangeSchema = z.object({
+ id: z.string().min(1),
+ assetId: z.string().min(1),
+ /** Source moment the film holds on. */
+ atSec: z.number().nonnegative(),
+ /** Timeline time created. Always positive — a pause of zero is simply not stored. */
+ durationSec: z.number().positive(),
+ /** The transcript word this pause exists for. */
+ wordId: z.string().min(1),
+ reason: z.string().default(""),
+ origin: z.enum(["system", "agent", "user"]),
+});
+
export const timelineSchema = z.preprocess(
// Back-compat: the field was renamed skipRanges → trimRanges. Old persisted
// documents (disk + browser-shim localStorage) still carry `skipRanges`;
@@ -269,6 +319,10 @@ export const timelineSchema = z.preprocess(
clips: z.array(clipSchema).default([]),
gaps: z.array(gapSchema).default([]),
trimRanges: z.array(trimRangeSchema).default([]),
+ // Additive, like every optional field before it: absent on every document written
+ // before this, so no schema bump — an older build simply drops the key on save, and
+ // the words it belonged to keep their text and lose only their pause.
+ insertRanges: z.array(insertRangeSchema).default([]),
muteRanges: z.array(rangeSchema).default([]),
speedRanges: z.array(rangeSchema).default([]),
captionRanges: z.array(rangeSchema).default([]),
@@ -471,6 +525,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).
@@ -497,12 +618,16 @@ const documentSchemaShape = z.object({
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
}),
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 +906,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 +978,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),
});
@@ -937,11 +1116,13 @@ export type AxcutClip = z.infer;
export type AxcutClipCropRegion = z.infer;
export type AxcutGap = z.infer;
export type AxcutTrimRange = z.infer;
+export type AxcutInsertRange = z.infer;
export type AxcutTimeline = z.infer;
export type AxcutTimelineOperation = z.infer;
export type AxcutAnnotationRegion = z.infer;
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 +1158,7 @@ export function createEmptyDocument(
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
});
}
@@ -984,3 +1166,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..1e8cc43de 100644
--- a/src/lib/ai-edition/store/editorSettings.test.ts
+++ b/src/lib/ai-edition/store/editorSettings.test.ts
@@ -23,12 +23,14 @@ const baseDoc: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
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..18df17063 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: {
@@ -56,12 +67,14 @@ const sampleDoc = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
@@ -72,6 +85,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 +312,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..4af906cd4 100644
--- a/src/lib/ai-edition/store/projectStore.ts
+++ b/src/lib/ai-edition/store/projectStore.ts
@@ -3,9 +3,12 @@ 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 { reconcileInsertions } from "../document/load";
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 +61,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 +80,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
@@ -120,7 +159,10 @@ export interface ProjectState {
}
function parseDocument(value: unknown): AxcutDocument {
- return documentSchema.parse(value);
+ // Reconciled here too, not only in the main process: this is the renderer's own gate on
+ // every document it accepts, and it is idempotent, so a document that arrived correct
+ // passes through untouched.
+ return reconcileInsertions(documentSchema.parse(value));
}
/**
@@ -159,6 +201,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
sourceDurationSec: 0,
currentTimeSec: 0,
+ selectedAudioTrackId: null,
playing: false,
dirty: false,
lastSavedAt: null,
@@ -179,6 +222,7 @@ export const useProjectStore = create((set, get) => ({
error: null,
dirty: false,
lastSavedAt: new Date(),
+ selectedAudioTrackId: null,
});
clearHistory();
} catch (error) {
@@ -325,6 +369,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 +585,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 64d910b13..05578b0cb 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,
@@ -128,6 +130,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 };
}
}
@@ -399,6 +405,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
@@ -412,7 +432,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..834103e60 100644
--- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx
+++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx
@@ -29,12 +29,14 @@ function doc(title: string): AxcutDocument {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
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..84694923c 100644
--- a/src/lib/ai-edition/store/useCaptions.test.ts
+++ b/src/lib/ai-edition/store/useCaptions.test.ts
@@ -65,12 +65,14 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
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..fe433f070 100644
--- a/src/lib/ai-edition/store/useEditorSettings.test.ts
+++ b/src/lib/ai-edition/store/useEditorSettings.test.ts
@@ -68,12 +68,14 @@ const docA: AxcutDocument = {
clips: [],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
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..ecb5dced9 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,
};
});
@@ -104,12 +106,14 @@ const sampleDoc: AxcutDocument = {
],
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
@@ -1285,3 +1289,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..79e4340f6 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -4,9 +4,17 @@
// (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,
@@ -19,9 +27,9 @@ import {
resequenceClips,
setClipSourceRange,
} 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
@@ -289,7 +371,12 @@ export function useTimeline() {
// the trim at the wrong source position.
const playhead = playheadSec();
const end = playhead + durationSec;
- const resolved = resolveTimelineSpanToTrim(playhead, end, document.timeline.clips);
+ const resolved = resolveTimelineSpanToTrim(
+ playhead,
+ end,
+ document.timeline.clips,
+ document.timeline.insertRanges ?? [],
+ );
const asset =
document.assets.find((a) => a.id === document.project.primaryAssetId) ?? document.assets[0];
if (!resolved && !asset) return;
@@ -870,6 +957,7 @@ export function useTimeline() {
document.timeline.trimRanges,
document.timeline.clips,
trimIds,
+ document.timeline.insertRanges ?? [],
),
},
legacyEditor:
@@ -894,6 +982,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 +995,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.
//
@@ -1149,11 +1239,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 +1278,198 @@ 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`).
+ insertRanges: document?.timeline.insertRanges ?? [],
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 +1482,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..d4aa314e5
--- /dev/null
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.lanes.test.ts
@@ -0,0 +1,367 @@
+// 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],
+ ]);
+ });
+
+ it("maps the words after an insertion to the moment they actually occupy", () => {
+ // The reason this stopped being per-fragment. Source 4 is heard at ruler 5, because
+ // the pause before it took a second of the take's span.
+ const placements = voiceoverPlacements(
+ [track({ id: "f1", trackId: "T", startMs: 0, endMs: 6000, offsetMs: 0 })],
+ [],
+ () => [{ id: "i1", wordId: "w1", atSourceSec: 4, durationSec: 1 }],
+ );
+ expect(placements.map((p) => [p.timelineStartSec, p.sourceStartSec, p.sourceEndSec])).toEqual([
+ [0, 0, 4],
+ [5, 4, 5],
+ ]);
+ });
+});
+
+describe("lanePlacements", () => {
+ 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
+// what walking the take by play pieces buys. Asserted rather than assumed.
+
+describe("the karaoke highlight after a pause", () => {
+ const TAKE = track({ id: "vo", startMs: 0, endMs: 6000, offsetMs: 0, durationSec: 6 });
+ const WORDS = {
+ assetId: "asset_vo",
+ language: "en",
+ segments: [],
+ words: [0, 1, 2, 3, 4, 5].map((i) => ({
+ id: `w${i}`,
+ segmentId: "s",
+ text: `w${i}`,
+ startSec: i + 0.1,
+ endSec: i + 0.9,
+ })),
+ };
+
+ const sectionsWith = (
+ inserts: Array<{ id: string; wordId: string; atSourceSec: number; durationSec: number }>,
+ ) =>
+ buildAggregatedSections(
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ voiceoverPlacements([TAKE as any], [], () => inserts),
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ [WORDS as any],
+ [],
+ [],
+ [],
+ );
+
+ it("tracks the voice with no insertion", () => {
+ expect(findCueWordId(sectionsWith([]), 4.5, [])).toBe("vo:w4");
+ });
+
+ it("follows the word D later once a pause has pushed it there", () => {
+ // A one-second pause at source 3: source 4 is now heard at ruler 5.
+ const inserts = [{ id: "i1", wordId: "w3", atSourceSec: 3, durationSec: 1 }];
+ const sections = sectionsWith(inserts);
+ expect(findCueWordId(sections, 5.5, [])).toBe("vo#1:w4");
+ // And it is NOT still answering with the pre-pause mapping.
+ expect(findCueWordId(sections, 4.5, [])).not.toBe("vo:w4");
+ });
+
+ it("highlights nothing while the voice is parked", () => {
+ // No word is being said during the pause, so the karaoke goes quiet rather than
+ // leaving a word lit that has already been spoken.
+ const inserts = [{ id: "i1", wordId: "w3", atSourceSec: 3, durationSec: 1 }];
+ expect(findCueWordId(sectionsWith(inserts), 3.5, [])).toBeNull();
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.test.ts b/src/lib/ai-edition/timeline/aggregated-transcript.test.ts
index adc70b6cb..085435091 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 {
@@ -62,7 +63,7 @@ describe("buildClipSection", () => {
{ id: "w3", segmentId: "s1", startSec: 2, endSec: 3, text: "friend" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
expect(section.trimRuns).toEqual([]);
});
@@ -78,18 +79,24 @@ 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 +118,21 @@ 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 +159,33 @@ 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 +200,13 @@ 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]);
@@ -197,16 +220,16 @@ describe("buildClipSection", () => {
{ id: "w3", segmentId: "s1", startSec: 2, endSec: 3, text: "Um," },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
// ponytail: the LLM (not the renderer) decides what is a filler. Every
// word renders as plain text in the right pane.
expect(section.words.map((cw) => cw.kept)).toEqual([true, true, true]);
- 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", () => {
const clip = makeClip({ sourceStartSec: 0, sourceEndSec: 5 });
- const section = buildClipSection(clip, null, makeAsset(), []);
+ const section = buildClipSection(clip, null, makeAsset(), [], []);
expect(section.words).toEqual([]);
expect(section.trimRuns).toEqual([]);
@@ -221,7 +244,7 @@ describe("buildClipSection", () => {
{ id: "w_after", segmentId: "s1", startSec: 5, endSec: 6, text: "trim" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
// Leading (2s→2.5s) and trailing (3.5s→4s) gaps are both silences.
expect(section.words.map((cw) => cw.word.id)).toEqual(["silence_1", "w_mid", "silence_2"]);
});
@@ -235,7 +258,7 @@ describe("silence gaps", () => {
{ id: "w2", segmentId: "s1", startSec: 1.3, endSec: 2, text: "there" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
const ids = section.words.map((cw) => cw.word.id);
expect(ids).toEqual(["w1", "silence_1", "w2", "silence_2"]);
expect(section.words.filter((cw) => isSilenceWord(cw.word))).toHaveLength(2);
@@ -249,7 +272,7 @@ describe("silence gaps", () => {
{ id: "w2", segmentId: "s1", startSec: 1.1, endSec: 2, text: "there" },
]);
- const section = buildClipSection(clip, transcript, makeAsset(), []);
+ const section = buildClipSection(clip, transcript, makeAsset(), [], []);
expect(section.words.map((cw) => cw.word.id)).toEqual(["w1", "w2"]);
});
@@ -261,10 +284,16 @@ 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"]);
});
});
@@ -301,7 +330,7 @@ describe("buildAggregatedSections", () => {
makeAsset({ id: "asset_2", label: "second.mp4" }),
];
- const sections = buildAggregatedSections(clips, transcripts, assets, []);
+ const sections = buildAggregatedSections(clips, transcripts, assets, [], []);
expect(sections).toHaveLength(2);
expect(sections[0]?.clip.id).toBe("c1");
expect(sections[1]?.clip.id).toBe("c2");
@@ -314,7 +343,7 @@ describe("buildAggregatedSections", () => {
const transcripts = [makeTranscript([])];
const assets = [makeAsset(), makeAsset({ id: "asset_2" })];
- const sections = buildAggregatedSections(clips, transcripts, assets, []);
+ const sections = buildAggregatedSections(clips, transcripts, assets, [], []);
expect(sections).toHaveLength(2);
expect(sections[0]?.transcript).toBeTruthy();
expect(sections[1]?.transcript).toBeNull();
@@ -323,114 +352,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();
+ 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");
});
});
});
@@ -466,6 +545,7 @@ describe("clipWordId", () => {
[transcript],
[makeAsset()],
[],
+ [],
);
const rawIds = sections.flatMap((s) => s.words.map((cw) => cw.word.id));
const scopedIds = sections.flatMap((s) => s.words.map((cw) => cw.id));
@@ -474,3 +554,60 @@ describe("clipWordId", () => {
expect(new Set(scopedIds).size).toBe(scopedIds.length);
});
});
+
+// ─── The word an insertion is spoken over ───────────────────────────────────
+// The pane mapped source → ruler and back with two hand-written shifts that ignored the
+// media inserted inside the clip. Past an insertion the highlight ran ahead of the voice by
+// exactly the inserted time — it sat on the word AFTER the one being spoken — and the added
+// word itself was never highlighted at all, because the only seconds it is spoken in are the
+// insertion, and no source second exists inside one (issue #560).
+
+describe("the cue word across an insertion", () => {
+ // "b" is typed in after "a". It fits in 0.1s of existing silence and buys 2s of media.
+ const words = () => [
+ { id: "w_a", segmentId: "s1", startSec: 0, endSec: 1, text: "a" },
+ { id: "w_b", segmentId: "s1", startSec: 1, endSec: 1.1, text: "b", source: "synth" as const },
+ { id: "w_c", segmentId: "s1", startSec: 2, endSec: 3, text: "c" },
+ ];
+ // The clip carries the insertion, so it runs 0..12 for 10s of recording.
+ const clips = () => [makeClip({ sourceStartSec: 0, sourceEndSec: 10, timelineEndSec: 12 })];
+ const inserted = [
+ {
+ id: "i1",
+ assetId: "asset_1",
+ atSec: 1.1,
+ durationSec: 2,
+ wordId: "w_b",
+ reason: "",
+ origin: "user" as const,
+ },
+ ];
+ const sections = () =>
+ buildAggregatedSections(
+ clips(),
+ [makeTranscript(words())],
+ [makeAsset()],
+ removedRawSpans(clips(), [], inserted),
+ inserted,
+ );
+
+ it("highlights the added word for the whole stretch its insertion occupies", () => {
+ // The insertion opens at ruler 1.1 and closes at 3.1.
+ for (const at of [1.2, 2, 3.0]) {
+ expect(findCueWordId(sections(), at, inserted)).toBe(clipWordId("clip_1", "w_b"));
+ }
+ });
+
+ it("does not run ahead of the voice after the insertion", () => {
+ // Source 2..3 is "c", which the insertion has pushed to ruler 4..5.
+ expect(findCueWordId(sections(), 4.5, inserted)).toBe(clipWordId("clip_1", "w_c"));
+ // Without the shift this same moment resolved to source 4.5 — past "c" entirely.
+ expect(findCueWordId(sections(), 0.5, inserted)).toBe(clipWordId("clip_1", "w_a"));
+ });
+
+ it("still finds a word at the very end of the clip", () => {
+ // The extent used to stop at the source length, so the last 2s belonged to no
+ // section and the highlight simply went out.
+ expect(findCueWordId(sections(), 11.5, inserted)).not.toBeNull();
+ });
+});
diff --git a/src/lib/ai-edition/timeline/aggregated-transcript.ts b/src/lib/ai-edition/timeline/aggregated-transcript.ts
index 1a35e9e61..27dcbb5cd 100644
--- a/src/lib/ai-edition/timeline/aggregated-transcript.ts
+++ b/src/lib/ai-edition/timeline/aggregated-transcript.ts
@@ -14,8 +14,79 @@
// 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, trackGroupId } from "../document/audioTracks";
+import type {
+ AxcutAsset,
+ AxcutAudioTrack,
+ AxcutClip,
+ AxcutInsertRange,
+ AxcutTranscript,
+ AxcutWord,
+} from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
+import { type RawSpan, type RemovedRawSpan, removalAt } from "./programme-time";
+import { type TakeInsert, takeProgramme } from "./take-programme";
+
+/**
+ * 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,
+ insertRanges: readonly AxcutInsertRange[],
+ edge: "opens" | "closes" = "opens",
+): number {
+ return sourceToTimelineSec(placement, sourceSec, insertRanges, edge);
+}
+
+/** The placement's own stretch of raw ruler, or null when it runs open-ended. */
+export function placementRawExtent(
+ placement: TranscriptPlacement,
+ insertRanges: readonly AxcutInsertRange[],
+): RawSpan | null {
+ if (placement.sourceEndSec === undefined) return null;
+ return {
+ startSec: placement.timelineStartSec,
+ // `"closes"` on the end: the media inserted inside this placement is part of its
+ // stretch of ruler, so the extent has to reach past the last one. Ending short left
+ // the final seconds of every such clip belonging to no section at all, and the
+ // highlight simply went out there.
+ endSec: placementRawSec(placement, placement.sourceEndSec, insertRanges, "closes"),
+ };
+}
/** Gaps between words at least this long are surfaced as a `[silence]` token. */
export const SILENCE_THRESHOLD_SEC = 0.2;
@@ -25,6 +96,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 +116,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 +151,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 +188,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 +205,37 @@ 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[],
+ insertRanges: readonly AxcutInsertRange[],
): 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 +244,30 @@ 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, insertRanges),
+ );
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 +276,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 +311,11 @@ 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[],
+ insertRanges: readonly AxcutInsertRange[],
): ClipSection[] {
const transcriptById = new Map(transcripts.map((t) => [t.assetId, t]));
const assetById = new Map(assets.map((a) => [a.id, a]));
@@ -227,19 +324,63 @@ export function buildAggregatedSections(
clip,
transcriptById.get(clip.assetId) ?? null,
assetById.get(clip.assetId) ?? null,
- trimRanges,
+ removed,
+ insertRanges,
),
);
}
-/** 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, which is what makes the words after
+ * an insertion map to the raw moment they actually occupy. It used to be one per stored
+ * FRAGMENT — equivalent while a take could only lose time, wrong the moment it can gain
+ * some, because a fragment's source window knows nothing about the pause before it.
+ *
+ * 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 and no
+ * insertions the walk yields one piece per take, which is what this always produced. */
+ removed: readonly RemovedRawSpan[] = [],
+ /** This take's own insertions, by group id. */
+ insertsFor: (groupId: string) => readonly TakeInsert[] = () => [],
+): TranscriptPlacement[] {
+ return collapseTracksToPills(audioTracks)
+ .filter((pill) => pill.kind === "voiceover" && !pill.loop)
+ .sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id))
+ .flatMap((pill) =>
+ takeProgramme(pill, removed, insertsFor(trackGroupId(pill)))
+ .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[] = [],
+ insertsFor: (groupId: string) => readonly TakeInsert[] = () => [],
+): TranscriptPlacement[] {
+ return lane === "voiceover" ? voiceoverPlacements(audioTracks, removed, insertsFor) : clips;
}
/**
@@ -254,22 +395,50 @@ 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,
+ insertRanges: readonly AxcutInsertRange[],
+): string | null {
+ if (rawSec === null || !Number.isFinite(rawSec)) return null;
+ // No fallback to a neighbouring section: a placement with no transcript simply has no
+ // cue word, and borrowing another's would point at the wrong text.
+ 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, insertRanges);
+ 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 { sourceSec: t, insideInsert } = timelineToSourceSec(match.clip, rawSec, insertRanges);
+ // Inside an insertion, the word being spoken is the one that bought it — those seconds
+ // exist for no other reason. There is no source second in there to find it by, which is
+ // why the added word could never be highlighted before.
+ if (insideInsert) {
+ const own = match.words.find((cw) => cw.word.id === insideInsert.wordId);
+ if (own) return own.id;
+ }
let previous: string | null = null;
for (const cw of match.words) {
if (isSilenceWord(cw.word)) continue;
diff --git a/src/lib/ai-edition/timeline/camera.test.ts b/src/lib/ai-edition/timeline/camera.test.ts
index 1f7fea91b..e1caa03a5 100644
--- a/src/lib/ai-edition/timeline/camera.test.ts
+++ b/src/lib/ai-edition/timeline/camera.test.ts
@@ -56,6 +56,7 @@ describe("resolveActiveCameraTrack", () => {
[assetWithCamera, assetWithoutCamera],
[clipWithCamera, clipWithoutCamera],
2,
+ [],
);
expect(track?.sourcePath).toBe("/cam-1.mp4");
});
@@ -65,17 +66,18 @@ describe("resolveActiveCameraTrack", () => {
[assetWithCamera, assetWithoutCamera],
[clipWithCamera, clipWithoutCamera],
7,
+ [],
);
expect(track).toBeNull();
});
it("returns null when there are no clips", () => {
- expect(resolveActiveCameraTrack([assetWithCamera], [], 0)).toBeNull();
+ expect(resolveActiveCameraTrack([assetWithCamera], [], 0, [])).toBeNull();
});
it("returns null when the active clip references an unknown asset", () => {
const orphanClip: AxcutClip = { ...clipWithCamera, assetId: "missing" };
- expect(resolveActiveCameraTrack([assetWithCamera], [orphanClip], 2)).toBeNull();
+ expect(resolveActiveCameraTrack([assetWithCamera], [orphanClip], 2, [])).toBeNull();
});
});
diff --git a/src/lib/ai-edition/timeline/camera.ts b/src/lib/ai-edition/timeline/camera.ts
index 4fc576952..0f11ed0ce 100644
--- a/src/lib/ai-edition/timeline/camera.ts
+++ b/src/lib/ai-edition/timeline/camera.ts
@@ -4,15 +4,18 @@
// timeline, and whether the timeline has ANY camera at all — used to gate
// camera-only preview chrome and settings controls.
-import type { AxcutAsset, AxcutCameraTrack, AxcutClip } from "../schema";
+import type { AxcutAsset, AxcutCameraTrack, AxcutClip, AxcutInsertRange } from "../schema";
import { locateVirtualPosition } from "./virtual-preview";
export function resolveActiveCameraTrack(
assets: AxcutAsset[],
clips: AxcutClip[],
currentTimeSec: number,
+ /** REQUIRED: a clip carrying insertions is longer than its source window, so which clip
+ * a timeline second falls on cannot be answered without them (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): AxcutCameraTrack | null {
- const position = locateVirtualPosition(clips, currentTimeSec);
+ const position = locateVirtualPosition(clips, currentTimeSec, insertRanges);
if (!position) return null;
const activeAsset = assets.find((a) => a.id === position.clip.assetId);
return activeAsset?.cameraTrack ?? null;
diff --git a/src/lib/ai-edition/timeline/cursor-track.ts b/src/lib/ai-edition/timeline/cursor-track.ts
index aec4324a4..f190828d6 100644
--- a/src/lib/ai-edition/timeline/cursor-track.ts
+++ b/src/lib/ai-edition/timeline/cursor-track.ts
@@ -16,7 +16,7 @@
// sample, nothing is summarised, and every pointer-shape change survives the
// reduction because a shape change is an observed event, not a verdict about it.
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import { locateSourcePosition } from "./virtual-preview";
/**
@@ -158,6 +158,9 @@ export interface CursorTrackOptions {
durationSec: number;
clips: AxcutClip[];
trimRanges?: AxcutTrimRange[];
+ /** The insertions those clips carry: a clip carrying one is longer than its source
+ * window, so a capture timestamp's place on the timeline moves with them (issue #560). */
+ insertRanges?: readonly AxcutInsertRange[];
hz?: number;
maxPoints?: number;
/** Movement threshold in frame fractions; see DEFAULT_TRACK_EPSILON. */
@@ -169,6 +172,7 @@ export interface CursorTrackOptions {
export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
const { assetId, samples, durationSec, clips } = options;
const trimRanges = options.trimRanges ?? [];
+ const insertRanges = options.insertRanges ?? [];
const maxPoints = options.maxPoints ?? DEFAULT_MAX_TRACK_POINTS;
const ceilingMs = Math.max(0, durationSec) * 1000 || Number.POSITIVE_INFINITY;
@@ -294,7 +298,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
// not told twice.
const shifted = keep.some((s) => {
const atSec = s.timeMs / 1000;
- const position = locateSourcePosition(clips, atSec, assetId);
+ const position = locateSourcePosition(clips, atSec, assetId, 0.05, undefined, insertRanges);
return !position || Math.abs(position.virtualTimeSec - atSec) > 0.005;
});
@@ -303,7 +307,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
// `locateSourcePosition` is the existing source→virtual mapping, exact here
// because trims do NOT compact the document's virtual axis — a trim is a hole
// in playback, not a shortening of the ruler (see timeline/trim-mapping.ts).
- const position = locateSourcePosition(clips, atSec, assetId);
+ const position = locateSourcePosition(clips, atSec, assetId, 0.05, undefined, insertRanges);
const point: CursorTrackPoint = {
atSec: round2(atSec),
cx: round3(s.cx),
diff --git a/src/lib/ai-edition/timeline/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/insert-mapping.test.ts b/src/lib/ai-edition/timeline/insert-mapping.test.ts
new file mode 100644
index 000000000..8e75b2d11
--- /dev/null
+++ b/src/lib/ai-edition/timeline/insert-mapping.test.ts
@@ -0,0 +1,192 @@
+// Issue #560. An insertion made from the recording transcript buys the FILM time; one made
+// from a voiceover transcript buys the TAKE silence and leaves the picture alone. The row
+// looks identical either way — only the asset it names says which lane it is on.
+//
+// These also lock the inertness that is true today by ACCIDENT: a voiceover row reaches
+// `rulerInserts` and `resolvePlaybackSegments` and is ignored by both, purely because they
+// match on `clip.assetId`. That accident is the only reason writing one is harmless right
+// now, so it becomes a rule with a test before anything starts writing them.
+
+import { describe, expect, it } from "vitest";
+import { resolvePlaybackSegments } from "../document/timeline";
+import type { AxcutAudioTrack, AxcutClip, AxcutDocument, AxcutInsertRange } from "../schema";
+import { resolveInsertPlacement, takeInserts } from "./insert-mapping";
+import { rulerInserts } from "./inserted-time";
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "c1",
+ assetId: "rec",
+ sourceStartSec: 0,
+ sourceEndSec: 6,
+ timelineStartSec: 0,
+ timelineEndSec: 6,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ {
+ id: "c2",
+ assetId: "rec",
+ sourceStartSec: 20,
+ sourceEndSec: 26,
+ timelineStartSec: 6,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+const TAKE = {
+ id: "vo_frag",
+ trackId: "vo",
+ assetId: "aud",
+ kind: "voiceover",
+ startMs: 2000,
+ endMs: 10_000,
+ durationSec: 30,
+ offsetMs: 1000,
+ gainDb: 0,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+} as unknown as AxcutAudioTrack;
+
+function insert(over: Partial & { id: string }): AxcutInsertRange {
+ return {
+ assetId: "rec",
+ atSec: 3,
+ durationSec: 0.5,
+ wordId: `w_${over.id}`,
+ reason: "",
+ origin: "user",
+ ...over,
+ } as AxcutInsertRange;
+}
+
+function doc(inserts: AxcutInsertRange[], over: Partial = {}): AxcutDocument {
+ return {
+ schemaVersion: 7,
+ project: { id: "p", title: "T", createdAt: "", updatedAt: "" },
+ assets: [
+ {
+ id: "rec",
+ kind: "video",
+ label: "r",
+ originalPath: "/r.mp4",
+ durationSec: 30,
+ cameraTrack: null,
+ },
+ {
+ id: "aud",
+ kind: "audio",
+ label: "a",
+ originalPath: "/a.mp3",
+ durationSec: 30,
+ cameraTrack: null,
+ },
+ ],
+ transcript: null,
+ transcripts: [],
+ timeline: {
+ clips: CLIPS,
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ insertRanges: inserts,
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks: [TAKE],
+ legacyEditor: null,
+ ...over,
+ } as unknown as AxcutDocument;
+}
+
+describe("resolveInsertPlacement", () => {
+ it("leaves a recording insert to `rulerInserts`, the one place that places one", () => {
+ // It used to answer with a raw second of its own, from a plain shift the clip's own
+ // insertions made wrong — a second, contradictory answer that nothing read.
+ const row = insert({ id: "i1", atSec: 3 });
+ expect(resolveInsertPlacement(row, doc([row]))).toBeNull();
+ });
+
+ it("leaves a voiceover insert UNPROJECTED, naming the take and a source second", () => {
+ // Deliberately not a raw moment: where it lands depends on the insertions before it
+ // inside the same take and on the cuts under it, and only the take's walk knows.
+ const row = insert({ id: "i1", assetId: "aud", atSec: 4 });
+ expect(resolveInsertPlacement(row, doc([row]))).toEqual({
+ lane: "voiceover",
+ trackGroupId: "vo",
+ atSourceSec: 4,
+ });
+ });
+
+ it("returns null when nothing carries the moment any more", () => {
+ // Past every clip's source window...
+ expect(resolveInsertPlacement(insert({ id: "i1", atSec: 40 }), doc([]))).toBeNull();
+ // ...outside the take's own window (offset 1s, span 8s → source 1..9)...
+ expect(
+ resolveInsertPlacement(insert({ id: "i2", assetId: "aud", atSec: 12 }), doc([])),
+ ).toBeNull();
+ // ...and when the take has been deleted outright.
+ expect(
+ resolveInsertPlacement(
+ insert({ id: "i3", assetId: "aud", atSec: 4 }),
+ doc([], { audioTracks: [] }),
+ ),
+ ).toBeNull();
+ });
+
+ it("names the take by its GROUP, so a split take resolves to one thing", () => {
+ const split = doc([], {
+ audioTracks: [
+ { ...TAKE, id: "f1", trackId: "vo", startMs: 2000, endMs: 6000, offsetMs: 1000 },
+ { ...TAKE, id: "f2", trackId: "vo", startMs: 6000, endMs: 10_000, offsetMs: 5000 },
+ ],
+ });
+ const row = insert({ id: "i1", assetId: "aud", atSec: 2 });
+ expect(resolveInsertPlacement(row, split)).toMatchObject({ trackGroupId: "vo" });
+ });
+});
+
+describe("takeInserts", () => {
+ it("collects one take's insertions in its own source order", () => {
+ const rows = [
+ insert({ id: "b", assetId: "aud", atSec: 6 }),
+ insert({ id: "a", assetId: "aud", atSec: 2 }),
+ insert({ id: "film", assetId: "rec", atSec: 3 }),
+ ];
+ expect(takeInserts(doc(rows), "vo").map((i) => [i.id, i.atSourceSec])).toEqual([
+ ["a", 2],
+ ["b", 6],
+ ]);
+ });
+
+ it("returns nothing for a take that has none", () => {
+ expect(takeInserts(doc([insert({ id: "film" })]), "vo")).toEqual([]);
+ });
+});
+
+describe("a voiceover insert is inert on the film, deliberately", () => {
+ const row = insert({ id: "i1", assetId: "aud", atSec: 4 });
+
+ it("produces no ruler insert, so the film's length does not move", () => {
+ expect(rulerInserts([row], CLIPS)).toEqual([]);
+ // And the recording's own row still does.
+ expect(rulerInserts([insert({ id: "i2", atSec: 3 })], CLIPS)).toHaveLength(1);
+ });
+
+ it("produces no held segment, so no clip freezes for it", () => {
+ const held = (rows: AxcutInsertRange[]) =>
+ resolvePlaybackSegments(CLIPS, [], rows).filter((s) => s.heldSec !== undefined);
+ expect(held([row])).toEqual([]);
+ expect(held([insert({ id: "i2", atSec: 3 })])).toHaveLength(1);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/insert-mapping.ts b/src/lib/ai-edition/timeline/insert-mapping.ts
new file mode 100644
index 000000000..66f2fdf0c
--- /dev/null
+++ b/src/lib/ai-edition/timeline/insert-mapping.ts
@@ -0,0 +1,82 @@
+// Where an insertion lives, resolved rather than stored (issue #560).
+//
+// An added word buys itself time. On the RECORDING lane that time is film: the clip holds
+// a frame and the ruler grows. On the VOICEOVER lane it is a silence inside the take: the
+// picture is not touched at all, and the narration that follows lands later against the
+// same image.
+//
+// The record says which by naming an asset, and the asset's `kind` says which lane it is
+// on. Nothing stores a container id, and that is deliberate: every candidate is ephemeral.
+// A voiceover fragment id is re-minted by `reanchorAudioTracks` on the first clip drag; a
+// clip id does not survive a split, which in this repo is `duplicateClip` plus two
+// `setClipSourceRange` calls that move `atSec` out of the half that was named. An
+// un-anchored region reaching every placement of its asset is the working state here — see
+// `duplicateClip`'s own comment about trims.
+//
+// The two lanes come back in DIFFERENT shapes on purpose. A recording insert can be given
+// its raw moment immediately, through the clip that plays that source second. A voiceover
+// insert cannot: its ruler position depends on the insertions before it inside the same
+// take AND on the cuts under it, and only the take's own walk can resolve that. Handing
+// back an unprojected result is what stops a caller from inventing a projection that would
+// disagree with the walk.
+
+import { trackGroupId } from "../document/audioTracks";
+import type { AxcutDocument, AxcutInsertRange } from "../schema";
+
+/** A silence inside a take: no picture of its own. */
+export interface VoiceoverInsertPlacement {
+ lane: "voiceover";
+ /** The user-visible take, not one of its stored fragments. */
+ trackGroupId: string;
+ /** Deliberately in the take's SOURCE seconds — see the note above. */
+ atSourceSec: number;
+}
+
+export type InsertPlacement = VoiceoverInsertPlacement;
+
+/**
+ * Which TAKE carries this insertion, or null — a recording's insertion, or a take that has
+ * been deleted. Only takes need answering here: an insertion in the film is placed by
+ * `rulerInserts`, which is the one definition of where an insertion sits on the timeline.
+ *
+ * The lane is read from the ASSET, never from the row: `kind: "audio"` is the only thing
+ * that distinguishes a take's transcript from the film's, and it is already the
+ * discriminator `lanePlacements` uses for the transcript tab.
+ */
+export function resolveInsertPlacement(
+ insert: AxcutInsertRange,
+ document: AxcutDocument,
+): InsertPlacement | null {
+ const asset = document.assets.find((a) => a.id === insert.assetId);
+ if (asset?.kind !== "audio") return null;
+ // The first take drawing on this asset whose source window contains the moment.
+ // Inclusive at both edges, matching `rulerInserts`: an insertion sits at the END of the
+ // word it follows, which is routinely a window's own boundary.
+ for (const track of document.audioTracks ?? []) {
+ if (track.kind !== "voiceover" || track.assetId !== insert.assetId) continue;
+ const startSec = track.offsetMs / 1000;
+ const endSec = startSec + Math.max(0, track.endMs - track.startMs) / 1000;
+ if (insert.atSec < startSec || insert.atSec > endSec) continue;
+ return { lane: "voiceover", trackGroupId: trackGroupId(track), atSourceSec: insert.atSec };
+ }
+ return null;
+}
+
+/** The insertions belonging to one take, in the take's own source order. */
+export function takeInserts(
+ document: AxcutDocument,
+ groupId: string,
+): Array<{ id: string; wordId: string; atSourceSec: number; durationSec: number }> {
+ const out: Array<{ id: string; wordId: string; atSourceSec: number; durationSec: number }> = [];
+ for (const insert of document.timeline.insertRanges ?? []) {
+ const placement = resolveInsertPlacement(insert, document);
+ if (placement?.lane !== "voiceover" || placement.trackGroupId !== groupId) continue;
+ out.push({
+ id: insert.id,
+ wordId: insert.wordId,
+ atSourceSec: placement.atSourceSec,
+ durationSec: insert.durationSec,
+ });
+ }
+ return out.sort((a, b) => a.atSourceSec - b.atSourceSec || a.id.localeCompare(b.id));
+}
diff --git a/src/lib/ai-edition/timeline/inserted-time.test.ts b/src/lib/ai-edition/timeline/inserted-time.test.ts
new file mode 100644
index 000000000..b1037f94c
--- /dev/null
+++ b/src/lib/ai-edition/timeline/inserted-time.test.ts
@@ -0,0 +1,281 @@
+// The ruler arithmetic behind an added word's pause.
+//
+// The one thing these have to pin: stored raw seconds and the seconds the user scrubs stop
+// being the same number the moment a pause exists, and every reader that confuses the two
+// puts a region, a playhead or a caption in the wrong place. The pair is an inverse
+// everywhere except inside a pause — which is not a gap in the model, it is the pause.
+
+import { describe, expect, it } from "vitest";
+import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
+import {
+ insertedWordMarks,
+ insertionEnteredBetween,
+ type RulerInsert,
+ rulerInserts,
+ sourceToTimelineSec,
+ timelineToSourceSec,
+} from "./inserted-time";
+
+function clipFixture(overrides: Partial & Pick): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...overrides,
+ };
+}
+
+function insert(overrides: Partial = {}): AxcutInsertRange {
+ return {
+ id: "ins_1",
+ assetId: "a1",
+ atSec: 4,
+ durationSec: 0.5,
+ wordId: "synth_1",
+ reason: "",
+ origin: "user",
+ ...overrides,
+ };
+}
+
+describe("rulerInserts", () => {
+ it("projects a pause through the clip that plays its moment", () => {
+ // The clip plays source 4–10 starting at ruler 20, so source 6 is ruler 22.
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 4, timelineStartSec: 20, timelineEndSec: 26 }),
+ ];
+ expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([
+ { id: "ins_1", wordId: "synth_1", atRawSec: 22, durationSec: 0.5 },
+ ]);
+ });
+
+ // The word is not on the timeline, so its pause has no place on the ruler and adds
+ // nothing — the same rule a caption line follows when no clip covers it.
+ it("drops a pause no clip plays", () => {
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 3, timelineEndSec: 3 }),
+ ];
+ expect(rulerInserts([insert({ atSec: 6 })], clips)).toEqual([]);
+ });
+
+ it("counts a pause sitting exactly on a clip's edge", () => {
+ // A pause sits at the END of the word it follows, which is routinely the boundary.
+ const clips = [
+ clipFixture({ id: "c1", sourceStartSec: 0, sourceEndSec: 4, timelineEndSec: 4 }),
+ ];
+ expect(rulerInserts([insert({ atSec: 4 })], clips)).toHaveLength(1);
+ });
+
+ it("returns them in ruler order, whatever order they were stored in", () => {
+ const clips = [clipFixture({ id: "c1" })];
+ const placed = rulerInserts(
+ [insert({ id: "b", atSec: 8 }), insert({ id: "a", atSec: 2 })],
+ clips,
+ );
+ expect(placed.map((p) => p.id)).toEqual(["a", "b"]);
+ });
+
+ it("places a pause only once when two clips could play its moment", () => {
+ const clips = [
+ clipFixture({ id: "c1" }),
+ clipFixture({ id: "c2", timelineStartSec: 10, timelineEndSec: 20 }),
+ ];
+ expect(rulerInserts([insert()], clips)).toHaveLength(1);
+ });
+});
+
+// ─── Where an added word's mark goes ─────────────────────────────────────────
+// Issue #560. Two defects lived in one ternary in V4Timeline: a word WITH a pause was
+// placed on the expanded ruler and one WITHOUT at a fraction of the clip's SOURCE span —
+// two clocks, and the clip box is drawn in neither of them consistently. And both edges
+// were inclusive, so a word whose pause sits on a split boundary painted twice.
+
+function markClip(over: Partial & { id: string }): AxcutClip {
+ return {
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ ...over,
+ } as AxcutClip;
+}
+
+const synth = (id: string, startSec: number): AxcutWord =>
+ ({ id, segmentId: "s", text: id, startSec, endSec: startSec, source: "synth" }) as AxcutWord;
+
+describe("insertedWordMarks", () => {
+ const split = [
+ markClip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ }),
+ markClip({
+ id: "c2",
+ sourceStartSec: 5,
+ sourceEndSec: 10,
+ timelineStartSec: 5,
+ timelineEndSec: 10,
+ }),
+ ];
+
+ it("paints a word on a split boundary exactly once", () => {
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_edge", 5)] }], split, []);
+ expect(marks).toHaveLength(1);
+ expect(marks[0]).toMatchObject({ clipId: "c2", atRawSec: 5 });
+ });
+
+ it("places every mark in RAW seconds through its own clip", () => {
+ const marks = insertedWordMarks(
+ [{ assetId: "a1", words: [synth("early", 2), synth("late", 7)] }],
+ split,
+ [],
+ );
+ expect(marks.map((m) => [m.clipId, m.atRawSec])).toEqual([
+ ["c1", 2],
+ ["c2", 7],
+ ]);
+ });
+
+ it("keeps a word at the very end of the last clip", () => {
+ // Half-open everywhere but the tail, or the final word of a project vanishes.
+ const marks = insertedWordMarks([{ assetId: "a1", words: [synth("w_end", 10)] }], split, []);
+ expect(marks.map((m) => m.wordId)).toEqual(["w_end"]);
+ });
+
+ it("ignores words nobody added", () => {
+ const spoken = { id: "w1", segmentId: "s", text: "w1", startSec: 2, endSec: 3 } as AxcutWord;
+ expect(insertedWordMarks([{ assetId: "a1", words: [spoken] }], split, [])).toEqual([]);
+ });
+
+ it("ignores a transcript no clip draws on", () => {
+ expect(insertedWordMarks([{ assetId: "other", words: [synth("w1", 2)] }], split, [])).toEqual(
+ [],
+ );
+ });
+});
+
+// ─── Source ↔ timeline, inside one clip ─────────────────────────────────────
+// The whole consequence of an insertion being MEDIA: the clip is longer than its source
+// window, so a moment past an insertion sits that much further along the timeline. Every
+// place that used to convert between a "raw" and an "expanded" ruler is asking this, of
+// one clip — and getting it wrong put a caption, a playhead or a decoder in the wrong
+// place (issue #560).
+
+describe("source ↔ timeline through a clip that carries insertions", () => {
+ // Ten seconds of recording laid at timeline 0, with 0.5s inserted at source 2 and 1s
+ // at source 6 — so the clip is 11.5s long and its source window is untouched.
+ const clip = clipFixture({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11.5,
+ });
+ const ranges: AxcutInsertRange[] = [
+ {
+ id: "a",
+ assetId: "a1",
+ atSec: 2,
+ durationSec: 0.5,
+ wordId: "w_a",
+ reason: "",
+ origin: "user",
+ },
+ {
+ id: "b",
+ assetId: "a1",
+ atSec: 6,
+ durationSec: 1,
+ wordId: "w_b",
+ reason: "",
+ origin: "user",
+ },
+ ];
+
+ it("leaves everything before the first insertion where it was", () => {
+ expect(sourceToTimelineSec(clip, 0, ranges)).toBeCloseTo(0, 6);
+ expect(sourceToTimelineSec(clip, 1.9, ranges)).toBeCloseTo(1.9, 6);
+ });
+
+ it("counts every insertion before the moment, and only those", () => {
+ expect(sourceToTimelineSec(clip, 4, ranges)).toBeCloseTo(4.5, 6);
+ expect(sourceToTimelineSec(clip, 10, ranges)).toBeCloseTo(11.5, 6);
+ });
+
+ it("puts the insertion's own moment where it opens, or where it closes", () => {
+ // The choice is real: a position and a span's START go before the inserted media,
+ // a span's END goes after it, so a caption running up to an added word covers it.
+ expect(sourceToTimelineSec(clip, 2, ranges, "opens")).toBeCloseTo(2, 6);
+ expect(sourceToTimelineSec(clip, 2, ranges, "closes")).toBeCloseTo(2.5, 6);
+ });
+
+ it("comes back to the source moment it started from", () => {
+ for (const source of [0, 1.9, 2, 3, 5.5, 6, 9.99]) {
+ const back = timelineToSourceSec(clip, sourceToTimelineSec(clip, source, ranges), ranges);
+ expect(back.sourceSec).toBeCloseTo(source, 6);
+ }
+ });
+
+ it("has no source moment inside an insertion, and says which one", () => {
+ // There is nothing else it could answer: none of those seconds come from the file.
+ const inside = timelineToSourceSec(clip, 2.25, ranges);
+ expect(inside.sourceSec).toBeCloseTo(2, 6);
+ expect(inside.insideInsert?.id).toBe("a");
+ expect(timelineToSourceSec(clip, 2.5, ranges).insideInsert).toBeNull();
+ });
+
+ it("is the plain shift when the clip carries nothing", () => {
+ expect(sourceToTimelineSec(clip, 4, [])).toBeCloseTo(4, 6);
+ expect(timelineToSourceSec(clip, 4, []).sourceSec).toBeCloseTo(4, 6);
+ });
+
+ it("ignores insertions belonging to another recording", () => {
+ const other = [{ ...ranges[0], assetId: "a2" }];
+ expect(sourceToTimelineSec(clip, 4, other)).toBeCloseTo(4, 6);
+ });
+});
+
+// ─── Running into an insertion ──────────────────────────────────────────────
+// An added word inserts MEDIA inside the clip — a fixed frame and silence, until there is
+// a generator for it. Playback runs THROUGH that media, and the half-open rule below is
+// what keeps it from running through the same insertion forever.
+
+describe("the insertion a frame runs into", () => {
+ const marks: RulerInsert[] = [
+ { id: "i1", wordId: "w1", atRawSec: 4, durationSec: 2 },
+ { id: "i2", wordId: "w2", atRawSec: 9, durationSec: 1 },
+ ];
+
+ it("is found when the frame crosses it", () => {
+ expect(insertionEnteredBetween(3.98, 4.02, marks)?.id).toBe("i1");
+ expect(insertionEnteredBetween(8.9, 9.1, marks)?.id).toBe("i2");
+ });
+
+ it("is not found again from the moment it occupies", () => {
+ // While the insertion plays, the raw playhead stands still at exactly 4. Coming out,
+ // the next frames must not re-enter — otherwise the film never gets past it.
+ expect(insertionEnteredBetween(4, 4.02, marks)).toBeUndefined();
+ expect(insertionEnteredBetween(4, 4.5, marks)).toBeUndefined();
+ });
+
+ it("takes the earliest of several in one frame, and none outside", () => {
+ expect(insertionEnteredBetween(0, 20, marks)?.id).toBe("i1");
+ expect(insertionEnteredBetween(5, 8, marks)).toBeUndefined();
+ });
+
+ it("plays an insertion landing exactly on the frame boundary", () => {
+ expect(insertionEnteredBetween(3.9, 4, marks)?.id).toBe("i1");
+ });
+});
diff --git a/src/lib/ai-edition/timeline/inserted-time.ts b/src/lib/ai-edition/timeline/inserted-time.ts
new file mode 100644
index 000000000..8b3e7950e
--- /dev/null
+++ b/src/lib/ai-edition/timeline/inserted-time.ts
@@ -0,0 +1,237 @@
+// Time the film does not have.
+//
+// An added word needs somewhere to be spoken. Where the transcript has free silence it
+// borrows it; where it does not, the film holds its frame and everything after it moves
+// along the ruler. That created time is stored as an `AxcutInsertRange` — the inverse of a
+// trim, and deliberately the same shape, because a region is what this timeline already
+// carries safely from end to end. (An earlier attempt made CLIPS for it; see the schema's
+// note on `insertRangeSchema` for how that ended.)
+//
+// This module is the arithmetic, and nothing else: pure, no document, no React. It answers
+// two questions.
+//
+// • Where does an insertion land on the RULER? A range is anchored in SOURCE time, so it has
+// to be projected through whichever clip plays that moment — `rulerInserts`.
+// • What does the ruler look like once the insertions are counted? Stored raw seconds and
+// the seconds the user actually scrubs are no longer the same number, and
+// `expandRawSec` / `collapseRawSec` are the one place that difference is resolved.
+//
+// The two are inverses everywhere except INSIDE an insertion, where they cannot be: a stretch
+// of ruler maps to the single source moment being held. `collapseRawSec` returns that
+// moment, which is exactly what a decoder parked on a held frame should be told.
+
+import type { AxcutClip, AxcutInsertRange, AxcutWord } from "../schema";
+
+/** An insertion placed on the raw ruler, ready to be counted. */
+export interface RulerInsert {
+ id: string;
+ wordId: string;
+ /** Where the insertion begins, in STORED raw seconds — before any insertion is counted. */
+ atRawSec: number;
+ durationSec: number;
+}
+
+/**
+ * Project each insert onto the ruler through the clip that owns it.
+ *
+ * A range whose moment no clip plays yields nothing: the insertion exists for a word that is
+ * not on the timeline, so there is no ruler position for it and nothing to add. Same rule
+ * the captions follow for a line no clip covers.
+ *
+ * Ordered by ruler position, which is what lets the accumulation below be a single pass.
+ */
+/**
+ * Which clip owns each insertion.
+ *
+ * ONE definition, because the geometry and the drawing must not answer this differently.
+ * An insertion is anchored to an ASSET and a source moment, not to a clip, so two clips over
+ * the same recording could both claim it — and when they did, the film grew twice while the
+ * pill was drawn once. It is claimed by the FIRST clip that plays its moment, in timeline
+ * order: the insertion is stored once and the word exists once, so it happens once.
+ */
+export function assignInsertsToClips(
+ clips: readonly AxcutClip[],
+ inserts: readonly AxcutInsertRange[],
+): Map {
+ const byClip = new Map();
+ const claimed = new Set();
+ for (const clip of [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec)) {
+ const sourceEnd = clip.sourceEndSec ?? Number.POSITIVE_INFINITY;
+ const mine = inserts
+ // Inclusive at both edges: an insertion sits at the END of the word it follows,
+ // which is routinely a clip's own boundary.
+ .filter(
+ (insert) =>
+ insert.assetId === clip.assetId &&
+ !claimed.has(insert.id) &&
+ insert.atSec >= clip.sourceStartSec &&
+ insert.atSec <= sourceEnd,
+ )
+ .sort((a, b) => a.atSec - b.atSec);
+ for (const insert of mine) claimed.add(insert.id);
+ if (mine.length > 0) byClip.set(clip.id, mine);
+ }
+ return byClip;
+}
+
+export function rulerInserts(
+ inserts: readonly AxcutInsertRange[],
+ clips: readonly AxcutClip[],
+): RulerInsert[] {
+ const byClip = assignInsertsToClips(clips, inserts);
+ const placed: RulerInsert[] = [];
+ for (const clip of clips) {
+ // Each insertion opens after the ones before it in the same clip: the clip's length
+ // already carries all of them, so a plain source-shift would stack them all at the
+ // first one's position.
+ let carriedSec = 0;
+ for (const insert of byClip.get(clip.id) ?? []) {
+ placed.push({
+ id: insert.id,
+ wordId: insert.wordId,
+ atRawSec: clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + carriedSec,
+ durationSec: insert.durationSec,
+ });
+ carriedSec += insert.durationSec;
+ }
+ }
+ return placed.sort((a, b) => a.atRawSec - b.atRawSec);
+}
+
+/**
+ * A clip's own source moment → where it lands on the timeline.
+ *
+ * Not a plain shift, and this is the whole consequence of an insertion being MEDIA: the
+ * clip is longer than its source window by everything inserted inside it, so a moment past
+ * an insertion sits that much further along. Every place that used to convert between a
+ * "raw" and an "expanded" ruler is really asking this, of one clip.
+ *
+ * `edge` decides what happens AT an insertion's own moment, which is a real choice and not
+ * a rounding detail. `"opens"` puts the moment before the inserted media — right for a
+ * position, and for the START of a span, so the span does not swallow the insertion that
+ * precedes it. `"closes"` puts it after — right for the END of a span, so a stretch running
+ * up to an insertion covers it rather than stopping short and leaving it orphaned.
+ */
+export function sourceToTimelineSec(
+ /** Only the three fields that locate a clip — so a voiceover placement, which carries
+ * the same three, maps through this too (issue #560). */
+ clip: Pick,
+ sourceSec: number,
+ inserts: readonly AxcutInsertRange[],
+ edge: "opens" | "closes" = "opens",
+): number {
+ let added = 0;
+ for (const insert of inserts) {
+ if (insert.assetId !== clip.assetId) continue;
+ if (insert.atSec <= clip.sourceStartSec) continue;
+ if (edge === "opens" ? insert.atSec < sourceSec : insert.atSec <= sourceSec + 1e-6) {
+ added += insert.durationSec;
+ }
+ }
+ return clip.timelineStartSec + (sourceSec - clip.sourceStartSec) + added;
+}
+
+/**
+ * The inverse: a timeline second → the source moment the clip is showing there.
+ *
+ * Inside an insertion there is no source moment — that is what makes it an insertion — so
+ * it answers with the moment the inserted media follows, and names the insertion. A caller
+ * driving a decoder needs both: where to park, and the fact that it should stay parked.
+ */
+export function timelineToSourceSec(
+ /** The same three fields `sourceToTimelineSec` needs, so a voiceover placement maps
+ * through this too. */
+ clip: Pick,
+ timelineSec: number,
+ inserts: readonly AxcutInsertRange[],
+): { sourceSec: number; insideInsert: AxcutInsertRange | null } {
+ const mine = inserts
+ .filter((insert) => insert.assetId === clip.assetId && insert.atSec > clip.sourceStartSec)
+ .sort((a, b) => a.atSec - b.atSec);
+ let added = 0;
+ for (const insert of mine) {
+ const opensAt = clip.timelineStartSec + (insert.atSec - clip.sourceStartSec) + added;
+ if (timelineSec < opensAt) break;
+ if (timelineSec < opensAt + insert.durationSec) {
+ return { sourceSec: insert.atSec, insideInsert: insert };
+ }
+ added += insert.durationSec;
+ }
+ return {
+ sourceSec: clip.sourceStartSec + (timelineSec - clip.timelineStartSec) - added,
+ insideInsert: null,
+ };
+}
+
+/**
+ * The insertion a frame of playback ran into, if it ran into one.
+ *
+ * Half-open on the LEFT, and that is the whole point: a player parks on the insertion's
+ * frame and pins its clock to exactly `atRawSec` for the first frame of it, so `>` is what
+ * refuses that same moment on the way in a second time. Closed on the right (with the frame
+ * epsilon) so an insertion landing precisely on a frame boundary is played, not skipped.
+ */
+export function insertionEnteredBetween(
+ prevSec: number,
+ nextSec: number,
+ inserts: readonly RulerInsert[],
+ epsilonSec = 1e-6,
+): RulerInsert | undefined {
+ return inserts.find(
+ (insert) => insert.atRawSec > prevSec && insert.atRawSec <= nextSec + epsilonSec,
+ );
+}
+
+/** An added word, placed on the raw ruler through the clip that carries it. */
+export interface InsertedWordMark {
+ clipId: string;
+ wordId: string;
+ text: string;
+ atRawSec: number;
+}
+
+/**
+ * Where each added word's mark belongs, one per word.
+ *
+ * Claimed once, and half-open at a clip's far edge except for the last: an insertion sits at the
+ * END of the word it follows, which is routinely a split boundary, and testing both edges
+ * inclusively painted the same word in BOTH halves (issue #560).
+ *
+ * Returns RAW seconds. The caller expands them; it used to mix a raw-then-expanded position
+ * for a word with an insertion and a fraction of the clip's SOURCE span for one without, in the
+ * same ternary — two clocks, and the clip box is not drawn in the second.
+ */
+export function insertedWordMarks(
+ transcripts: ReadonlyArray<{ assetId: string; words: ReadonlyArray }>,
+ clips: readonly AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
+): InsertedWordMark[] {
+ const byAsset = new Map>();
+ for (const transcript of transcripts) {
+ const added = transcript.words.filter((word) => word.source === "synth");
+ if (added.length > 0) byAsset.set(transcript.assetId, added);
+ }
+ if (byAsset.size === 0) return [];
+
+ const marks: InsertedWordMark[] = [];
+ const claimed = new Set();
+ clips.forEach((clip, index) => {
+ const words = byAsset.get(clip.assetId);
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (!words || sourceEnd <= clip.sourceStartSec) return;
+ const isLast = index === clips.length - 1;
+ for (const word of words) {
+ if (claimed.has(word.id)) continue;
+ if (word.startSec < clip.sourceStartSec) continue;
+ if (word.startSec > sourceEnd || (!isLast && word.startSec === sourceEnd)) continue;
+ claimed.add(word.id);
+ marks.push({
+ clipId: clip.id,
+ wordId: word.id,
+ text: word.text,
+ atRawSec: sourceToTimelineSec(clip, word.startSec, insertRanges),
+ });
+ }
+ });
+ return marks;
+}
diff --git a/src/lib/ai-edition/timeline/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..e302703dd
--- /dev/null
+++ b/src/lib/ai-edition/timeline/programme-time.test.ts
@@ -0,0 +1,407 @@
+// 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, AxcutInsertRange, 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 }]);
+ });
+});
+
+// ─── The insertion the projection has to walk over ──────────────────────────
+// An insertion is media INSIDE a clip, so the clip carrying it is that much longer and the
+// insertion's seconds are timeline seconds like any other. The projection's job is unchanged
+// by that — timeline in, output out — but it has to be TOLD, because it walks each clip's
+// kept SOURCE stretches and those are shorter than the clip.
+
+describe("projectRawTimelineSecToPlayback across an insertion", () => {
+ // One second inserted at source 5 of the first clip, so that clip runs 0..11 and the
+ // second one starts at 11.
+ const inserted: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ const clips = [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 11,
+ timelineEndSec: 21,
+ }),
+ ];
+
+ it("is the identity when nothing is cut — the insertion is already in the film", () => {
+ // This is what one clock buys. Under two, the walk had to re-add the insertion here
+ // and every reader that forgot to had its audio landing a second early.
+ expect(projectRawTimelineSecToPlayback(clips, [], 3, inserted)).toBeCloseTo(3, 6);
+ expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted)).toBeCloseTo(8, 6);
+ expect(projectRawTimelineSecToPlayback(clips, [], 15, inserted)).toBeCloseTo(15, 6);
+ });
+
+ it("keeps the insertion's own seconds when a trim takes the film around it", () => {
+ // Cutting source 0..2 of the first clip removes two seconds of RECORDING. The second
+ // the added word bought is not recording, so it survives.
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 0, endSec: 2 })];
+ expect(projectRawTimelineSecToPlayback(clips, trims, 8, inserted)).toBeCloseTo(6, 6);
+ });
+
+ it("loses an insertion whose own moment a trim removed", () => {
+ // The moment it follows is not in the film any more, so neither is it — the same
+ // rule `resolvePlaybackSegments` follows.
+ const trims = [trim({ id: "t1", clipId: "c1", startSec: 4, endSec: 6 })];
+ const out = projectRawTimelineSecToPlayback(clips, trims, 11, inserted);
+ // 10s of recording, less the 2s cut, and the insertion gone with it.
+ expect(out).toBeCloseTo(8, 6);
+ });
+
+ it("compresses the film around an insertion, and the insertion with it", () => {
+ // A 2x region halves whatever timeline it covers. The insertion is timeline, so it
+ // is halved too — the film is one thing, and speed is a property of the film.
+ const speed = [{ startMs: 0, endMs: 21_000, speed: 2 }];
+ expect(projectRawTimelineSecToPlayback(clips, [], 8, inserted, speed)).toBeCloseTo(4, 6);
+ });
+});
+
+// ─── The hole an insertion is not ────────────────────────────────────────────
+// `clipRawExtent` measured a clip by its SOURCE window, so a clip carrying insertions
+// ended short by exactly the inserted time — and `removedRawSpans`, walking clip to clip,
+// reported the difference as a gap nothing had removed. Everything that cuts on removed
+// spans then cut there: a voiceover crossing it went silent for the insertion's length, in
+// the preview and in the exported mix, and its words were struck through in the transcript
+// pane. The user heard the voice drop out exactly where they added a word (issue #560).
+
+describe("an insertion is not a hole in the programme", () => {
+ const inserted: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ const clips = [
+ clip({
+ id: "c1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11,
+ }),
+ clip({
+ id: "c2",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 11,
+ timelineEndSec: 21,
+ }),
+ ];
+
+ it("reports nothing removed when nothing was trimmed", () => {
+ expect(removedRawSpans(clips, [], inserted)).toEqual([]);
+ });
+
+ it("covers the insertion's own seconds as kept programme", () => {
+ const spans = keptRawSpans(clips, [], inserted);
+ const covers = (sec: number) => spans.some((s) => sec >= s.startSec && sec < s.endSec);
+ // 5.5 is inside the inserted media, 10.5 is the first clip's last second.
+ expect(covers(5.5)).toBe(true);
+ expect(covers(10.5)).toBe(true);
+ });
+
+ it("still reports a real gap between two clips", () => {
+ const apart = [clips[0], clip({ ...clips[1], timelineStartSec: 13, timelineEndSec: 23 })];
+ const removed = removedRawSpans(apart, [], inserted);
+ expect(removed).toHaveLength(1);
+ expect(removed[0].startSec).toBeCloseTo(11, 6);
+ expect(removed[0].endSec).toBeCloseTo(13, 6);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/programme-time.ts b/src/lib/ai-edition/timeline/programme-time.ts
new file mode 100644
index 000000000..d9bfd77a7
--- /dev/null
+++ b/src/lib/ai-edition/timeline/programme-time.ts
@@ -0,0 +1,256 @@
+// 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, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import { assignInsertsToClips, sourceToTimelineSec } from "./inserted-time";
+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.
+ */
+/** A clip's whole stretch of timeline — its source window PLUS the media inserted inside it.
+ *
+ * Computed, not read off `timelineEndSec`: derived the same way `reflowClipsForInserts`
+ * writes it, so a clip whose stored geometry is stale or half-written cannot make this lie.
+ * Leaving the insertions out was its own bug — the extent then ended short by exactly the
+ * inserted time, and `removedRawSpans` reported a phantom hole at the tail of every clip
+ * carrying one, which the audio paths cut as if a trim had taken it. */
+function clipRawExtent(clip: AxcutClip, ownInserts: readonly AxcutInsertRange[]): RawSpan {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) {
+ return { startSec: clip.timelineStartSec, endSec: clip.timelineEndSec };
+ }
+ const owed = ownInserts.reduce((sum, insert) => sum + insert.durationSec, 0);
+ return {
+ startSec: clip.timelineStartSec,
+ endSec: clip.timelineStartSec + (sourceEnd - clip.sourceStartSec) + owed,
+ };
+}
+
+/** Source interval → timeline, through the clip that carries it.
+ *
+ * `"closes"` on the end is what makes an insertion INSIDE a kept stretch part of it: the
+ * film plays those seconds, so they belong to the span. An insertion at the stretch's own
+ * start belongs to whatever came before — and if a trim took that, it is gone with it,
+ * which is right: the moment it follows is not in the film any more. */
+function sourceToRaw(
+ clip: AxcutClip,
+ interval: Interval,
+ insertRanges: readonly AxcutInsertRange[],
+): RawSpan {
+ return {
+ startSec: sourceToTimelineSec(clip, interval.startSec, insertRanges, "opens"),
+ endSec: sourceToTimelineSec(clip, interval.endSec, insertRanges, "closes"),
+ };
+}
+
+/** 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[],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
+): RawSpan[] {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ const owners = assignInsertsToClips(ordered, insertRanges);
+ const spans: RawSpan[] = [];
+ for (const clip of ordered) {
+ const sourceEnd = clip.sourceEndSec ?? clip.sourceStartSec;
+ if (sourceEnd <= clip.sourceStartSec) {
+ // Duration not probed yet — the whole raw clip passes through unnarrowed.
+ const extent = clipRawExtent(clip, owners.get(clip.id) ?? []);
+ if (extent.endSec > extent.startSec) spans.push(extent);
+ continue;
+ }
+ for (const iv of keptSourceIntervals(clip, trimRanges)) {
+ const span = sourceToRaw(clip, iv, insertRanges);
+ 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[],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
+): RemovedRawSpan[] {
+ const ordered = [...clips].sort((a, b) => a.timelineStartSec - b.timelineStartSec);
+ if (ordered.length === 0) return [];
+ const owners = assignInsertsToClips(ordered, insertRanges);
+
+ const removed: RemovedRawSpan[] = [];
+ let cursor = 0; // raw end of the programme walked so far
+
+ for (const clip of ordered) {
+ const extent = clipRawExtent(clip, owners.get(clip.id) ?? []);
+ // 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 }, insertRanges),
+ }));
+
+ let holeStart = extent.startSec;
+ for (const iv of kept) {
+ const span = sourceToRaw(clip, iv, insertRanges);
+ 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..4edd43902 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,8 @@ 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);
@@ -83,7 +85,7 @@ describe("repro: trim on clip 2 of two clips sharing one media", () => {
]);
// 2. Ruler — one pill, over clip 2 (timeline 11.8 + 8.4 = 20.2 … 22.2).
- const pills = coalescedTrimGroups(next.timeline.trimRanges, next.timeline.clips);
+ const pills = coalescedTrimGroups(next.timeline.trimRanges, next.timeline.clips, []);
expect(pills).toHaveLength(1);
expect(pills[0].start).toBeCloseTo(20.2, 6);
expect(pills[0].end).toBeCloseTo(22.2, 6);
diff --git a/src/lib/ai-edition/timeline/take-programme.test.ts b/src/lib/ai-edition/timeline/take-programme.test.ts
new file mode 100644
index 000000000..7cf3cce0c
--- /dev/null
+++ b/src/lib/ai-edition/timeline/take-programme.test.ts
@@ -0,0 +1,274 @@
+// 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 { rawSpanForOutDuration } from "../document/timeline";
+import type { AxcutAudioTrack, AxcutClip, AxcutTrimRange } from "../schema";
+import { removedRawSpans, subtractRemoved } from "./programme-time";
+import { consumedSourceSec, 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"
+>;
+
+const ins = (atSourceSec: number, durationSec: number, id = "i1") => ({
+ id,
+ wordId: `w_${id}`,
+ atSourceSec,
+ durationSec,
+});
+
+const shape = (pieces: ReturnType) =>
+ pieces.map((p) => [p.kind, p.rawStartSec, p.rawEndSec, p.sourceStartSec, p.sourceEndSec]);
+
+describe("takeProgramme", () => {
+ it("is exactly subtractRemoved when nothing is inserted", () => {
+ // The demotion. Every fixture the export and the preview already agree on has to
+ // 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);
+ }
+ });
+
+ it("parks the voice for an insertion and resumes on the same word", () => {
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1)]);
+ expect(shape(pieces)).toEqual([
+ ["play", 0, 4, 0, 4],
+ ["hold", 4, 5, 4, 4],
+ ["play", 5, 10, 4, 9],
+ ]);
+ // The take consumed 9 seconds of its file, not 10: the second the pause took is
+ // pushed off the end and lost, which is the accepted cost of the clips deciding
+ // the length.
+ expect(consumedSourceSec(pieces)).toBeCloseTo(9, 6);
+ });
+
+ it("resolves the second insertion AFTER the first one's hold, not before it", () => {
+ // The case a two-pass design gets wrong: mapped up front, source 6 would be raw 6,
+ // which is inside the first hold.
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1, "a"), ins(6, 1, "b")]);
+ expect(shape(pieces)).toEqual([
+ ["play", 0, 4, 0, 4],
+ ["hold", 4, 5, 4, 4],
+ ["play", 5, 7, 4, 6],
+ ["hold", 7, 8, 6, 6],
+ ["play", 8, 10, 6, 8],
+ ]);
+ });
+
+ it("makes two insertions at one moment two adjacent holds, with no empty play between", () => {
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1, "a"), ins(4, 0.5, "b")]);
+ expect(pieces.map((p) => p.kind)).toEqual(["play", "hold", "hold", "play"]);
+ expect(pieces.filter((p) => p.kind === "hold").map((p) => p.holdId)).toEqual(["a", "b"]);
+ expect(pieces.some((p) => p.rawEndSec === p.rawStartSec)).toBe(false);
+ });
+
+ it("gives nothing to an insertion a cut swallowed", () => {
+ const removed = removedRawSpans(CLIPS, [trim(3, 6)], []);
+ const withIt = takeProgramme(TAKE, removed, [ins(4, 1)]);
+ const without = takeProgramme(TAKE, removed, []);
+ // The moment it holds is not in the film any more, so it buys no time.
+ expect(withIt.some((p) => p.kind === "hold")).toBe(false);
+ expect(shape(withIt)).toEqual(shape(without));
+ });
+
+ it("keeps an insertion on a cut's far edge, which is what follows the cut", () => {
+ const removed = removedRawSpans(CLIPS, [trim(3, 6)], []);
+ const pieces = takeProgramme(TAKE, removed, [ins(6, 1)]);
+ expect(pieces.map((p) => p.kind)).toEqual(["play", "removed", "hold", "play"]);
+ });
+
+ it("drops an insertion at or past the take's last moment", () => {
+ expect(takeProgramme(TAKE, [], [ins(10, 1)]).some((p) => p.kind === "hold")).toBe(false);
+ expect(takeProgramme(TAKE, [], [ins(30, 1)]).some((p) => p.kind === "hold")).toBe(false);
+ });
+
+ it("ignores an insertion of no duration", () => {
+ expect(takeProgramme(TAKE, [], [ins(4, 0)]).map((p) => p.kind)).toEqual(["play"]);
+ });
+
+ it("spends a pause on the take's own clock under a speed region", () => {
+ // A voice plays at 1x in the mix. Under a 2x region a one-second pause has to eat
+ // TWO raw seconds to last one second of programme.
+ const speed = [{ startMs: 0, endMs: 20_000, speed: 2 }];
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1)], speed);
+ const hold = pieces.find((p) => p.kind === "hold");
+ expect(hold && hold.rawEndSec - hold.rawStartSec).toBeCloseTo(2, 6);
+ });
+});
+
+describe("rawSpanForOutDuration", () => {
+ it("is the identity with no regions", () => {
+ expect(rawSpanForOutDuration(3, 2)).toBe(2);
+ });
+
+ it("inverts outputDurationOfRawSpan across a boundary", () => {
+ const speed = [{ startMs: 4000, endMs: 8000, speed: 2 }];
+ // From raw 3: one output second buys 1 raw second before the region, then the rest
+ // at 2x. Two output seconds = 1 + 2 = 3 raw seconds.
+ expect(rawSpanForOutDuration(3, 2, speed)).toBeCloseTo(3, 6);
+ });
+
+ it("returns zero for a non-positive duration", () => {
+ expect(rawSpanForOutDuration(0, 0)).toBe(0);
+ expect(rawSpanForOutDuration(0, -1)).toBe(0);
+ });
+});
+
+describe("takePlaybackAt", () => {
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)], []), [ins(4, 1)]);
+
+ it("plays the file where the file plays", () => {
+ expect(takePlaybackAt(pieces, 2)).toMatchObject({ targetTimeSec: 2, shouldPlay: true });
+ });
+
+ it("parks on one moment inside the pause rather than tracking a moving target", () => {
+ expect(takePlaybackAt(pieces, 4.5)).toMatchObject({
+ targetTimeSec: 4,
+ shouldPlay: false,
+ heldBy: "i1",
+ });
+ });
+
+ it("keeps the clock running through a cut, silently", () => {
+ // Raw 7.5 is one second past the pause, so the file is at 6.5 — and muted.
+ expect(takePlaybackAt(pieces, 7.5)).toMatchObject({ targetTimeSec: 6.5, shouldPlay: false });
+ });
+
+ it("has nothing to say outside the take", () => {
+ expect(takePlaybackAt(pieces, 12)).toBeNull();
+ });
+});
+
+// ─── 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.
+
+describe("preview and export agree over a take with a cut and a pause", () => {
+ const removed = removedRawSpans(CLIPS, [trim(7, 8)], []);
+ const pieces = takeProgramme(TAKE, removed, [ins(4, 1)]);
+
+ it("plays exactly the play pieces, and nothing between them", () => {
+ const runs: Array<{ from: number; to: number }> = [];
+ // A run breaks on SILENCE, not on a jump in source time. Across a pause the source
+ // is deliberately continuous — the voice resumes on the word it stopped on — so a
+ // detector watching only the source would merge the two halves and see one run.
+ let wasPlaying = false;
+ for (let raw = 0; raw < 10; raw += 0.05) {
+ const at = takePlaybackAt(pieces, raw);
+ if (!at?.shouldPlay) {
+ wasPlaying = false;
+ continue;
+ }
+ const last = runs.at(-1);
+ if (wasPlaying && last && Math.abs(at.targetTimeSec - last.to) < 0.06) {
+ last.to = at.targetTimeSec;
+ } else {
+ runs.push({ from: at.targetTimeSec, to: at.targetTimeSec });
+ }
+ wasPlaying = true;
+ }
+ const entries = pieces
+ .filter((p) => p.kind === "play")
+ .map((p) => [p.sourceStartSec, p.sourceEndSec]);
+ expect(runs).toHaveLength(entries.length);
+ runs.forEach((run, i) => {
+ expect(run.from).toBeCloseTo(entries[i][0], 1);
+ expect(run.to).toBeCloseTo(entries[i][1], 1);
+ });
+ });
+
+ it("never re-seeks while the voice is parked", () => {
+ // One value for the whole pause: a target that drifted would re-seek a paused
+ // element every frame, and resuming would restart on the wrong word.
+ const inside = [4.1, 4.3, 4.5, 4.7, 4.9].map((raw) => takePlaybackAt(pieces, raw));
+ expect(inside.every((at) => at?.shouldPlay === false)).toBe(true);
+ expect(new Set(inside.map((at) => at?.targetTimeSec)).size).toBe(1);
+ });
+
+ it("resumes on the second it stopped on", () => {
+ const parked = takePlaybackAt(pieces, 4.5)?.targetTimeSec;
+ const resumed = takePlaybackAt(pieces, 5.01)?.targetTimeSec;
+ expect(resumed).toBeCloseTo(parked ?? -1, 1);
+ });
+});
+
+// ─── What the lane has to draw ──────────────────────────────────────────────
+// The notch is positioned from the walk, as a fraction of the PILL's own raw span. These
+// pin the arithmetic the drawing does, so a notch cannot appear where the voice does not
+// actually stop.
+
+describe("the pieces a pill draws", () => {
+ const pctOfPill = (pieces: ReturnType, rawSec: number) =>
+ ((rawSec - TAKE.startMs / 1000) / (TAKE.endMs / 1000 - TAKE.startMs / 1000)) * 100;
+
+ it("cuts one notch, in the middle, at the width of the time it took", () => {
+ const pieces = takeProgramme(TAKE, [], [ins(4, 1)]);
+ const hold = pieces.find((p) => p.kind === "hold");
+ expect(hold).toBeDefined();
+ if (!hold) return;
+ expect(pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(40, 6);
+ expect(pctOfPill(pieces, hold.rawEndSec) - pctOfPill(pieces, hold.rawStartSec)).toBeCloseTo(
+ 10,
+ 6,
+ );
+ });
+
+ it("leaves a take with no insertion in one piece, so the pill draws as it always did", () => {
+ expect(takeProgramme(TAKE, [], []).some((p) => p.kind === "hold")).toBe(false);
+ });
+
+ it("covers the pill end to end, with no overlap and no hole", () => {
+ const pieces = takeProgramme(TAKE, removedRawSpans(CLIPS, [trim(7, 8)], []), [ins(4, 1)]);
+ let cursor = TAKE.startMs / 1000;
+ for (const piece of pieces) {
+ expect(piece.rawStartSec).toBeCloseTo(cursor, 6);
+ cursor = piece.rawEndSec;
+ }
+ expect(cursor).toBeCloseTo(TAKE.endMs / 1000, 6);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/take-programme.ts b/src/lib/ai-edition/timeline/take-programme.ts
new file mode 100644
index 000000000..828055f03
--- /dev/null
+++ b/src/lib/ai-edition/timeline/take-programme.ts
@@ -0,0 +1,217 @@
+// One walk over a voice-over take (issue #560).
+//
+// A take is subject to two opposite forces at once and they must not be two passes. A CUT
+// under it takes time away — step 3 already slices the mix by `removedRawSpans`. An
+// INSERTION inside it adds time: a word added to the take's transcript needs somewhere to
+// be spoken, so the voice stops and resumes on the same word.
+//
+// What this walk deliberately does NOT do:
+//
+// - It does not react to an insertion in the RECORDING lane. A word added to the film
+// freezes the picture; the take has its own audio and keeps talking, finishing that
+// much earlier against a picture that has slid. The maintainer settled this: "the
+// voice-over is not impacted by the insertion in the recording track". A take is as
+// long as the audio it holds, and nothing under it changes that.
+// - It does not lengthen the programme. The clips decide the length, full stop. A take
+// insertion pushes the take's later content later inside the SAME timeline, and
+// whatever that pushes past the last frame is lost at export — `mix_external_tracks`
+// clamps every track to the programme. Placing content inside the useful span of the
+// timeline is the user's job.
+//
+// It runs on the PILL, never on a stored fragment. The document stores one fragment per
+// clip a take covers; growing one fragment leaves its successor's head where it was, and
+// the mixer sums with `+=` at an absolute offset — so a fragment-wise walk ships a take
+// playing on top of itself. `anchorAudioTrackFragments` is untouched and stays correct for
+// the music and loop paths that still read it.
+
+import type { PlaybackSpeedRegion } from "../document/timeline";
+import { rawSpanForOutDuration } from "../document/timeline";
+import type { AxcutAudioTrack } from "../schema";
+import type { RemovedRawSpan } from "./programme-time";
+
+/** One stretch of a take, in playback order. */
+export interface TakePiece {
+ /**
+ * `play` — the file is heard. `hold` — the voice is parked on one source moment while
+ * the timeline runs on (an insertion). `removed` — the film lost this stretch, so the
+ * take is silent through it, its own clock still running underneath.
+ */
+ kind: "play" | "hold" | "removed";
+ /** Stored RAW ruler seconds. Insertions occupy raw time here because they consume it
+ * from the take's own span — they do not create programme time. */
+ rawStartSec: number;
+ rawEndSec: number;
+ /** The take's own file. Equal on a `hold`: the moment the voice is parked on. */
+ sourceStartSec: number;
+ sourceEndSec: number;
+ /** The insertion that produced a `hold`. */
+ holdId?: string;
+ /** The word that insertion exists for. */
+ wordId?: string;
+}
+
+/** An insertion inside one take, in the take's own source seconds. */
+export interface TakeInsert {
+ id: string;
+ wordId: string;
+ atSourceSec: number;
+ /** OUTPUT seconds. A voice plays at 1x in the mix, so a pause for a spoken word is
+ * measured on the take's own clock, not on a raw ruler a speed region compresses. */
+ durationSec: number;
+}
+
+const EPSILON_SEC = 1e-9;
+
+/**
+ * The take, stretch by stretch, in playback order.
+ *
+ * Both cursors advance together except inside a `hold`, where the source parks. A `removed`
+ * stretch advances BOTH — the cut mutes the take without rewinding it, which is what keeps
+ * the words after a cut landing on the picture they belong to (the behaviour step 3 shipped
+ * and its tests pin).
+ *
+ * With no insertions this reduces exactly to `subtractRemoved` over the take's span, which
+ * is the property that lets the export and the preview keep their current answers.
+ */
+export function takeProgramme(
+ pill: Pick,
+ removed: readonly RemovedRawSpan[],
+ inserts: readonly TakeInsert[],
+ speedRegions: PlaybackSpeedRegion[] = [],
+): TakePiece[] {
+ const rawStart = pill.startMs / 1000;
+ const rawEnd = Math.max(rawStart, pill.endMs / 1000);
+ const sourceStart = Math.max(0, pill.offsetMs / 1000);
+
+ // Every boundary the walk has to stop at, on the RAW ruler, resolved sequentially:
+ // an insertion's raw moment depends on the holds before it, so it cannot be mapped in
+ // one pass up front.
+ const pending = [...inserts]
+ .filter((ins) => ins.durationSec > 0)
+ .sort((a, b) => a.atSourceSec - b.atSourceSec || a.id.localeCompare(b.id));
+
+ const cuts = [...removed]
+ .filter((span) => span.endSec > rawStart && span.startSec < rawEnd)
+ .sort((a, b) => a.startSec - b.startSec);
+
+ const pieces: TakePiece[] = [];
+ let raw = rawStart;
+ let source = sourceStart;
+ let nextInsert = 0;
+ let nextCut = 0;
+
+ const push = (kind: TakePiece["kind"], rawTo: number, sourceTo: number, ins?: TakeInsert) => {
+ if (rawTo - raw <= EPSILON_SEC) return;
+ pieces.push({
+ kind,
+ rawStartSec: raw,
+ rawEndSec: rawTo,
+ sourceStartSec: source,
+ sourceEndSec: sourceTo,
+ ...(ins ? { holdId: ins.id, wordId: ins.wordId } : {}),
+ });
+ raw = rawTo;
+ source = sourceTo;
+ };
+
+ // A hang is the worst failure a renderer can have. This loop terminates because every
+ // pass either advances `raw` or consumes a boundary, so the passes are bounded by the
+ // boundary count — but that is an argument, and an argument is not a guarantee. A
+ // mutation of the boundary arithmetic span it forever rather than failing an assertion,
+ // so the bound is enforced. Counting passes rather than watching `raw` on purpose: a
+ // pass that consumes a zero-length insertion makes real progress without moving `raw`,
+ // and a no-progress test would cut the walk short on a legitimate document.
+ const maxPasses = 4 * (removed.length + inserts.length + 2);
+ let passes = 0;
+ while (raw < rawEnd - EPSILON_SEC) {
+ if (passes++ > maxPasses) break;
+ // Skip cuts and insertions the walk has already passed.
+ while (nextCut < cuts.length && cuts[nextCut].endSec <= raw + EPSILON_SEC) nextCut++;
+ while (nextInsert < pending.length && pending[nextInsert].atSourceSec <= source - EPSILON_SEC) {
+ // Its moment is behind the source cursor: a cut swallowed it, or two inserts
+ // share a moment and the first already consumed it. Either way it buys nothing.
+ nextInsert++;
+ }
+
+ const cut = cuts[nextCut];
+ const insert = pending[nextInsert];
+
+ // Sitting exactly on an insertion's moment: hold before anything else, so two
+ // inserts at one moment become two adjacent holds rather than one merged stretch.
+ if (insert && insert.atSourceSec <= source + EPSILON_SEC) {
+ const held = Math.min(
+ rawSpanForOutDuration(raw, insert.durationSec, speedRegions),
+ rawEnd - raw,
+ );
+ nextInsert++;
+ if (held > EPSILON_SEC) {
+ pieces.push({
+ kind: "hold",
+ rawStartSec: raw,
+ rawEndSec: raw + held,
+ sourceStartSec: source,
+ sourceEndSec: source,
+ holdId: insert.id,
+ wordId: insert.wordId,
+ });
+ raw += held;
+ }
+ continue;
+ }
+
+ // Inside a cut: silent to the cut's end, both cursors running.
+ if (cut && cut.startSec <= raw + EPSILON_SEC) {
+ const to = Math.min(cut.endSec, rawEnd);
+ push("removed", to, source + (to - raw));
+ continue;
+ }
+
+ // Otherwise play up to whichever boundary comes first.
+ let to = rawEnd;
+ if (cut) to = Math.min(to, cut.startSec);
+ if (insert) to = Math.min(to, raw + (insert.atSourceSec - source));
+ push("play", Math.max(raw, to), source + (Math.max(raw, to) - raw));
+ }
+
+ 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 — feed
+ * them a span grown by a hold and the fade-out starts early in the preview only.
+ */
+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; heldBy?: string } | null {
+ for (const piece of pieces) {
+ if (rawSec < piece.rawStartSec) break;
+ if (rawSec >= piece.rawEndSec) continue;
+ if (piece.kind === "hold") {
+ // Parked on ONE source moment, not clamped to a moving target: resuming from the
+ // post-insert source would restart the narration on the wrong word, and a target
+ // that drifts every frame would re-seek a paused element every frame.
+ return { targetTimeSec: piece.sourceStartSec, shouldPlay: false, heldBy: piece.holdId };
+ }
+ const target = piece.sourceStartSec + (rawSec - piece.rawStartSec);
+ return { targetTimeSec: target, shouldPlay: piece.kind === "play" };
+ }
+ return null;
+}
diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts
index ea3f6c61b..608350a49 100644
--- a/src/lib/ai-edition/timeline/timelineMap.test.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.test.ts
@@ -5,7 +5,7 @@
import { describe, expect, it } from "vitest";
import { resolvePlaybackSegments } from "../document/timeline";
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
import {
anchorRawRegionsToClips,
anchorRegionsWithDerivedMs,
@@ -48,7 +48,7 @@ const region = (id: string, startSec: number, endSec: number, payload?: string):
describe("projectRegionsToSource", () => {
it("passes a region through unchanged (no clipIndex) when there are no segments", () => {
- const out = projectRegionsToSource([region("r", 1.5, 4.25)], [], [], () => "x");
+ const out = projectRegionsToSource([region("r", 1.5, 4.25)], [], [], () => "x", []);
expect(out).toEqual([{ id: "r", startMs: 1500, endMs: 4250 }]);
});
@@ -60,7 +60,7 @@ describe("projectRegionsToSource", () => {
sourceEndSec: 10,
timelineEndSec: 10,
});
- const out = projectRegionsToSource([region("r", 3, 5)], [c], [c], () => "x");
+ const out = projectRegionsToSource([region("r", 3, 5)], [c], [c], () => "x", []);
expect(out).toEqual([{ id: "r", startMs: 3000, endMs: 5000, clipIndex: 0 }]);
});
@@ -75,7 +75,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
- const out = projectRegionsToSource([region("r", 6, 8)], segments, [c], () => "x");
+ const out = projectRegionsToSource([region("r", 6, 8)], segments, [c], () => "x", []);
expect(out).toEqual([{ id: "r", startMs: 6000, endMs: 8000, clipIndex: 1 }]);
});
@@ -96,7 +96,13 @@ describe("projectRegionsToSource", () => {
timelineStartSec: 5,
timelineEndSec: 10,
});
- const out = projectRegionsToSource([region("r", 3, 7, "keep")], [c1, c2], [c1, c2], () => "r2");
+ const out = projectRegionsToSource(
+ [region("r", 3, 7, "keep")],
+ [c1, c2],
+ [c1, c2],
+ () => "r2",
+ [],
+ );
expect(out).toEqual([
{ id: "r", startMs: 103000, endMs: 105000, clipIndex: 0, payload: "keep" },
{ id: "r2", startMs: 200000, endMs: 202000, clipIndex: 1, payload: "keep" },
@@ -114,7 +120,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]);
- const out = projectRegionsToSource([region("r", 3, 8)], segments, [c], () => "r2");
+ const out = projectRegionsToSource([region("r", 3, 8)], segments, [c], () => "r2", []);
expect(out).toEqual([
{ id: "r", startMs: 3000, endMs: 4000, clipIndex: 0 },
{ id: "r2", startMs: 6000, endMs: 8000, clipIndex: 1 },
@@ -149,7 +155,7 @@ describe("projectRegionsToSource", () => {
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 2, 8)]);
// segments: c1[0,2] (0), c1[8,10] (1), c2[0,10] (2).
const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 };
- expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([
+ expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x", [])).toEqual([
{ ...anchored, startMs: 3000, endMs: 5000, clipIndex: 0, underTrim: true },
]);
});
@@ -175,7 +181,7 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 0, 10)]);
const anchored = { ...region("r", 3, 5), clipId: "c1", sourceStartSec: 3, sourceEndSec: 5 };
- expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x")).toEqual([]);
+ expect(projectRegionsToSource([anchored], segments, [c1, c2], () => "x", [])).toEqual([]);
});
it("keeps an unanchored region a trim removes entirely, mapped through its raw clip", () => {
@@ -191,7 +197,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 4, 6)]);
- expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x")).toEqual([
+ expect(projectRegionsToSource([region("r", 4.5, 5.5)], segments, [c], () => "x", [])).toEqual([
{ id: "r", startMs: 4500, endMs: 5500, clipIndex: 0, underTrim: true },
]);
});
@@ -208,7 +214,7 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]);
const anchored = { ...region("r", 1, 2), clipId: "c1", sourceStartSec: 1, sourceEndSec: 2 };
- expect(projectRegionsToSource([anchored], segments, [c], () => "x")).toEqual([
+ expect(projectRegionsToSource([anchored], segments, [c], () => "x", [])).toEqual([
{ ...anchored, startMs: 1000, endMs: 2000, clipIndex: 0, underTrim: true },
]);
});
@@ -234,9 +240,9 @@ describe("projectRegionsToSource", () => {
});
const segments = resolvePlaybackSegments([c1, c2], [trim("a", 6, 10)]);
const anchored = { ...region("r", 7, 9), clipId: "c1", sourceStartSec: 7, sourceEndSec: 9 };
- const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x");
+ const [projected] = projectRegionsToSource([anchored], segments, [c1, c2], () => "x", []);
// raw 8 is inside c1's removed tail; the region covering source [7,9] is that same cut.
- expect(resolveNativePosition(8, segments, [c1, c2])?.clipIndex).toBe(projected.clipIndex);
+ expect(resolveNativePosition(8, segments, [c1, c2], [])?.clipIndex).toBe(projected.clipIndex);
});
// --- anchored path: the anchor is the SSOT, `startMs`/`endMs` are not consulted ---
@@ -258,7 +264,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 6,
sourceEndSec: 8,
};
- const out = projectRegionsToSource([stale], [c], [c], () => "x");
+ const out = projectRegionsToSource([stale], [c], [c], () => "x", []);
expect(out).toEqual([{ ...stale, startMs: 6000, endMs: 8000, clipIndex: 0 }]);
});
@@ -278,7 +284,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 3,
sourceEndSec: 8,
};
- const out = projectRegionsToSource([anchored], segments, [c], () => "r2");
+ const out = projectRegionsToSource([anchored], segments, [c], () => "r2", []);
expect(out).toEqual([
{ ...anchored, id: "r", startMs: 3000, endMs: 4000, clipIndex: 0 },
{ ...anchored, id: "r2", startMs: 6000, endMs: 8000, clipIndex: 1 },
@@ -310,7 +316,7 @@ describe("projectRegionsToSource", () => {
sourceStartSec: 1,
sourceEndSec: 2,
};
- const out = projectRegionsToSource([anchored], [c1, c2], [c1, c2], () => "x");
+ const out = projectRegionsToSource([anchored], [c1, c2], [c1, c2], () => "x", []);
expect(out).toEqual([{ ...anchored, startMs: 1000, endMs: 2000, clipIndex: 1 }]);
});
@@ -325,7 +331,7 @@ describe("projectRegionsToSource", () => {
timelineEndSec: 10,
});
const partial = { ...region("r", 3, 5), clipId: "c1" }; // no source span
- const out = projectRegionsToSource([partial], [c], [c], () => "x");
+ const out = projectRegionsToSource([partial], [c], [c], () => "x", []);
expect(out).toEqual([{ ...partial, startMs: 3000, endMs: 5000, clipIndex: 0 }]);
});
});
@@ -351,12 +357,12 @@ describe("resolveNativePosition", () => {
timelineEndSec: 12,
}),
];
- expect(resolveNativePosition(6.5, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(6.5, clips, clips, [])).toMatchObject({
clip: { id: "c2" },
clipIndex: 1,
sourceTimeSec: 22.5,
});
- expect(resolveNativePosition(10, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(10, clips, clips, [])).toMatchObject({
clip: { id: "c3" },
clipIndex: 2,
sourceTimeSec: 42,
@@ -383,12 +389,12 @@ describe("resolveNativePosition", () => {
timelineEndSec: 12,
}),
];
- expect(resolveNativePosition(7.25, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(7.25, clips, clips, [])).toMatchObject({
clip: { assetId: "asset-b" },
clipIndex: 1,
sourceTimeSec: 103.25,
});
- expect(resolveNativePosition(11, clips, clips)).toMatchObject({
+ expect(resolveNativePosition(11, clips, clips, [])).toMatchObject({
clip: { assetId: "asset-c" },
clipIndex: 2,
sourceTimeSec: 14,
@@ -405,11 +411,11 @@ describe("resolveNativePosition", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
// raw 1 → source 1 on seg1; raw 6 → source 6 on seg2 (NOT 8).
- expect(resolveNativePosition(1, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(1, segments, [c], [])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 1,
});
- expect(resolveNativePosition(6, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(6, segments, [c], [])).toMatchObject({
clipIndex: 1,
sourceTimeSec: 6,
});
@@ -430,7 +436,7 @@ describe("resolveNativePosition", () => {
// points at, and would incrust any modifier under the cut on someone else's image (#216).
// The segment it borrows is the one the cut interrupts (seg1), so a modifier under that
// cut — addressed the same way — survives `belongs()`.
- expect(resolveNativePosition(3, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(3, segments, [c], [])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 3,
});
@@ -445,7 +451,7 @@ describe("resolveNativePosition", () => {
timelineEndSec: 10,
});
const segments = resolvePlaybackSegments([c], [trim("a", 0, 3)]);
- expect(resolveNativePosition(1, segments, [c])).toMatchObject({
+ expect(resolveNativePosition(1, segments, [c], [])).toMatchObject({
clipIndex: 0,
sourceTimeSec: 1,
});
@@ -461,11 +467,11 @@ describe("resolveNativePosition", () => {
});
const segments = resolvePlaybackSegments([c], [trim("a", 2, 4)]);
// No raw clip owns raw 99 — nothing to present, so the historical clamp stands.
- expect(resolveNativePosition(99, segments, [c])).toMatchObject({ clipIndex: 1 });
+ expect(resolveNativePosition(99, segments, [c], [])).toMatchObject({ clipIndex: 1 });
});
it("returns null when there are no segments", () => {
- expect(resolveNativePosition(1, [], [])).toBeNull();
+ expect(resolveNativePosition(1, [], [], [])).toBeNull();
});
});
@@ -805,3 +811,65 @@ describe("legacy groupId must never affect identity (regression: test 1)", () =>
expect(out[0]).not.toHaveProperty("groupId");
});
});
+
+// ─── Placing an unanchored region past an insertion ─────────────────────────
+// A caption cue is built fresh on every derive and carries no clip anchor, so it is placed
+// by intersecting its TIMELINE span with each segment's extent. A clip carrying insertions
+// is longer than its source window, so the segment that resumes after one starts that much
+// further along — and a projection blind to that put every caption after an insertion on
+// the wrong stretch of source. That is what "the subtitles are out of sync" was (#560).
+
+describe("projectRegionsToSource past an insertion", () => {
+ const raw = clip({
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 11, // 10s of recording + 1s inserted at source 5
+ });
+ const inserts: AxcutInsertRange[] = [
+ {
+ id: "i1",
+ assetId: "a1",
+ atSec: 5,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ ];
+ // What `resolvePlaybackSegments` produces: the clip split at the insertion, with the
+ // inserted media between the halves.
+ const segments = [
+ clip({ id: "c1_seg1", assetId: "a1", sourceStartSec: 0, sourceEndSec: 5 }),
+ clip({ id: "c1_seg2", assetId: "a1", sourceStartSec: 5, sourceEndSec: 10 }),
+ ];
+
+ it("lands a region on the source it actually names", () => {
+ // Timeline 7..9 is one second past the insertion, so it is source 6..8.
+ const [out] = projectRegionsToSource(
+ [region("cue", 7, 9)],
+ segments,
+ [raw],
+ () => "x",
+ inserts,
+ );
+ expect(out.startMs).toBe(6000);
+ expect(out.endMs).toBe(8000);
+ expect(out.clipIndex).toBe(1);
+ });
+
+ it("leaves a region before the insertion where it was", () => {
+ const [out] = projectRegionsToSource(
+ [region("cue", 1, 3)],
+ segments,
+ [raw],
+ () => "x",
+ inserts,
+ );
+ expect(out.startMs).toBe(1000);
+ expect(out.endMs).toBe(3000);
+ expect(out.clipIndex).toBe(0);
+ });
+});
diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts
index b98601a03..e344c2398 100644
--- a/src/lib/ai-edition/timeline/timelineMap.ts
+++ b/src/lib/ai-edition/timeline/timelineMap.ts
@@ -17,7 +17,8 @@
// against the COMPRESSED segment layout, which slips every region after a trim
// forward by the trimmed duration.
-import type { AxcutClip } from "../schema";
+import type { PlaybackSegment } from "../document/timeline";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
@@ -95,6 +96,11 @@ export function anchoredToRawSpanSec(
): { startSec: number; endSec: number } | null {
const clip = clips.find((c) => c.id === fragment.clipId);
if (!clip) return null;
+ // ponytail: plain shift, wrong by the clip's own insertions for a region anchored past
+ // one — the pill is drawn early while `projectRegionsToSource` exports it on the right
+ // frames. Route through `sourceToTimelineSec` when zoom/annotation pills need to agree
+ // with the effect; it means threading the ranges through `anchorRegionsWithDerivedMs`
+ // and its 15 callers, which is its own change.
return {
startSec: clip.timelineStartSec + (fragment.sourceStartSec - clip.sourceStartSec),
endSec: clip.timelineStartSec + (fragment.sourceEndSec - clip.sourceStartSec),
@@ -399,11 +405,19 @@ export function anchorRegionsWithDerivedMs<
* the gap.
*/
export function segmentRawSpanSec(
- segment: AxcutClip,
+ segment: PlaybackSegment,
rawClips: AxcutClip[],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): { startSec: number; endSec: number } {
- const startSec = getRawVirtualStartTime(segment, rawClips);
- const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
+ const startSec = getRawVirtualStartTime(segment, rawClips, insertRanges);
+ // A held segment's source window is the single frame it shows, so its source length
+ // is zero — its RAW span is the pause it carries. Without this the playhead could
+ // never be inside it and would step straight over the pause.
+ const lenSec =
+ segment.heldSec ?? (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
@@ -559,14 +573,21 @@ export function projectRegionsToSource<
T extends { id: string; startMs: number; endMs: number } & RegionClipAnchor,
>(
regions: T[],
- visibleSegments: AxcutClip[],
+ visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
makeId: () => string,
+ /** The insertions the clips carry. A segment after one starts that much further along
+ * the timeline, and an UNANCHORED region — a caption cue, which is built fresh each
+ * time and has no clip anchor — is placed by intersecting with exactly that extent.
+ * Without them the caption landed on the wrong stretch of source (issue #560).
+ *
+ * REQUIRED for the same reason as its neighbours: omitting it is silently wrong. */
+ insertRanges: readonly AxcutInsertRange[],
): (T & { clipIndex?: number; underTrim?: boolean })[] {
// RAW extents + owning raw clip per visible segment. Both are only consulted by the
// path that needs them (raw fallback / anchor match), but resolving them once keeps
// the per-region loop free of repeated lookups.
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
const segmentRawClipIds = visibleSegments.map((seg) => findRawClipForSegment(seg, rawClips)?.id);
const out: (T & { clipIndex?: number; underTrim?: boolean })[] = [];
for (const region of regions) {
@@ -639,7 +660,7 @@ export function projectRegionsToSource<
export interface NativePosition {
/** The trim-narrowed playback segment (from `visibleSegments`) that is active. */
- clip: AxcutClip;
+ clip: PlaybackSegment;
/** Its index in `visibleSegments`, matching `SceneDescription.clips` / native `clip_index`. */
clipIndex: number;
/** Screen-source seconds the native decoder should present for this segment. */
@@ -675,20 +696,31 @@ const NATIVE_EOF_MARGIN_SEC = 0.033;
*/
export function resolveNativePosition(
rawSec: number,
- visibleSegments: AxcutClip[],
+ visibleSegments: PlaybackSegment[],
rawClips: AxcutClip[],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
// Segment whose RAW extent contains the playhead (last segment's end inclusive).
const index = spans.findIndex((s, i) => {
const isLast = i === spans.length - 1;
return rawSec >= s.startSec && (rawSec < s.endSec || (isLast && rawSec <= s.endSec));
});
- if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips);
+ if (index < 0) return positionUnderCut(rawSec, visibleSegments, rawClips, insertRanges);
const seg = visibleSegments[index];
+ // Inside a pause the source clock does not advance: the whole point of the segment
+ // is created time over one held frame. Clamping here is what stops the raw-playhead
+ // delta — which DOES advance through the pause — from pushing the decoder past the
+ // held frame into the content that belongs after it.
+ if (seg.heldSec !== undefined) {
+ return { clip: seg, clipIndex: index, sourceTimeSec: seg.sourceStartSec };
+ }
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
const unclamped = seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
@@ -714,6 +746,7 @@ function positionUnderCut(
rawSec: number,
visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
): NativePosition {
const rawClip = rawClipAt(rawSec, rawClips);
if (rawClip) {
@@ -738,7 +771,7 @@ function positionUnderCut(
}
}
- const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
+ const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips, insertRanges));
const next = spans.findIndex((s) => s.startSec >= rawSec);
const index = next >= 0 ? next : visibleSegments.length - 1;
const seg = visibleSegments[index];
diff --git a/src/lib/ai-edition/timeline/trim-mapping.test.ts b/src/lib/ai-edition/timeline/trim-mapping.test.ts
index 3ac5cba00..0e003351e 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.test.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.test.ts
@@ -38,7 +38,7 @@ describe("trimToTimelineSpan", () => {
timelineEndSec: 42,
}),
];
- expect(trimToTimelineSpan({ assetId: "a", startSec: 5, endSec: 7 }, clips)).toEqual({
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 5, endSec: 7 }, clips, [])).toEqual({
start: 5,
end: 7,
});
@@ -65,7 +65,7 @@ describe("trimToTimelineSpan", () => {
}),
];
// A trim at source 20..22 lives in c2 → timeline 14 + (20-16) = 18..20.
- expect(trimToTimelineSpan({ assetId: "a", startSec: 20, endSec: 22 }, clips)).toEqual({
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 20, endSec: 22 }, clips, [])).toEqual({
start: 18,
end: 20,
});
@@ -73,8 +73,8 @@ describe("trimToTimelineSpan", () => {
it("returns null when no clip carries the trim's source region", () => {
const clips = [clip({ id: "c1", assetId: "a", sourceStartSec: 0, sourceEndSec: 10 })];
- expect(trimToTimelineSpan({ assetId: "a", startSec: 40, endSec: 42 }, clips)).toBeNull();
- expect(trimToTimelineSpan({ assetId: "b", startSec: 2, endSec: 4 }, clips)).toBeNull();
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 40, endSec: 42 }, clips, [])).toBeNull();
+ expect(trimToTimelineSpan({ assetId: "b", startSec: 2, endSec: 4 }, clips, [])).toBeNull();
});
});
@@ -99,7 +99,7 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Timeline 18..20 falls in c2 (asset b) → source 16 + (18-14)=20 .. 22.
- expect(resolveTimelineSpanToTrim(18, 20, clips)).toEqual({
+ expect(resolveTimelineSpanToTrim(18, 20, clips, [])).toEqual({
assetId: "b",
clipId: "c2",
sourceStartSec: 20,
@@ -127,8 +127,8 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Start in c1 → asset a, start in c2 → asset b.
- expect(resolveTimelineSpanToTrim(2, 4, clips)?.assetId).toBe("a");
- expect(resolveTimelineSpanToTrim(20, 22, clips)?.assetId).toBe("b");
+ expect(resolveTimelineSpanToTrim(2, 4, clips, [])?.assetId).toBe("a");
+ expect(resolveTimelineSpanToTrim(20, 22, clips, [])?.assetId).toBe("b");
});
it("clamps the span to the carrier clip's extent (no straddling)", () => {
@@ -151,7 +151,7 @@ describe("resolveTimelineSpanToTrim", () => {
}),
];
// Span 10..20 starts in c1; end clamps to c1's end (timeline 14 → source 14).
- expect(resolveTimelineSpanToTrim(10, 20, clips)).toEqual({
+ expect(resolveTimelineSpanToTrim(10, 20, clips, [])).toEqual({
assetId: "a",
clipId: "c1",
sourceStartSec: 10,
@@ -178,7 +178,7 @@ describe("resolveTimelineSpanToTrim", () => {
timelineEndSec: 28,
}),
];
- const resolved = resolveTimelineSpanToTrim(18, 21, clips);
+ const resolved = resolveTimelineSpanToTrim(18, 21, clips, []);
expect(resolved).not.toBeNull();
if (!resolved) return;
const back = trimToTimelineSpan(
@@ -188,12 +188,13 @@ describe("resolveTimelineSpanToTrim", () => {
endSec: resolved.sourceEndSec,
},
clips,
+ [],
);
expect(back).toEqual({ start: 18, end: 21 });
});
it("returns null with no clips", () => {
- expect(resolveTimelineSpanToTrim(1, 2, [])).toBeNull();
+ expect(resolveTimelineSpanToTrim(1, 2, [], [])).toBeNull();
});
});
@@ -270,7 +271,9 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 8, endSec: 14 }), // -> timeline 8..14
trim({ id: "t2", assetId: "a", startSec: 16, endSec: 22 }), // -> timeline 14..20
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1", "t2"], start: 8, end: 20 }]);
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([
+ { ids: ["t1", "t2"], start: 8, end: 20 },
+ ]);
});
it("groups two independently-created trims snapped to touching clip boundaries", () => {
@@ -299,7 +302,9 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 7, endSec: 10 }), // -> timeline 7..10
trim({ id: "t2", assetId: "b", startSec: 0, endSec: 2 }), // -> timeline 10..12
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1", "t2"], start: 7, end: 12 }]);
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([
+ { ids: ["t1", "t2"], start: 7, end: 12 },
+ ]);
});
it("keeps a trim separated by a real gap in its own group", () => {
@@ -317,7 +322,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "t1", assetId: "a", startSec: 2, endSec: 4 }),
trim({ id: "t2", assetId: "a", startSec: 10, endSec: 12 }),
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([
{ ids: ["t1"], start: 2, end: 4 },
{ ids: ["t2"], start: 10, end: 12 },
]);
@@ -338,7 +343,7 @@ describe("coalescedTrimGroups", () => {
trim({ id: "gone", assetId: "b", startSec: 0, endSec: 2 }), // no clip carries asset b
trim({ id: "t1", assetId: "a", startSec: 3, endSec: 5 }),
];
- expect(coalescedTrimGroups(trims, clips)).toEqual([{ ids: ["t1"], start: 3, end: 5 }]);
+ expect(coalescedTrimGroups(trims, clips, [])).toEqual([{ ids: ["t1"], start: 3, end: 5 }]);
});
});
@@ -370,15 +375,17 @@ describe("two clips sharing one asset over the same source window", () => {
// Without the anchor this returned {3,5} — the first clip — because the loop
// stopped at the first clip whose asset and source window matched.
expect(
- trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, sharedClips()),
+ trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, sharedClips(), []),
).toEqual({ start: 15, end: 17 });
});
it("keeps mapping an un-anchored trim to the first matching clip (pre-v7 behaviour)", () => {
- expect(trimToTimelineSpan({ assetId: "a", startSec: 3, endSec: 5 }, sharedClips())).toEqual({
- start: 3,
- end: 5,
- });
+ expect(trimToTimelineSpan({ assetId: "a", startSec: 3, endSec: 5 }, sharedClips(), [])).toEqual(
+ {
+ start: 3,
+ end: 5,
+ },
+ );
});
it("draws one pill per clip when each clip carries its own trim", () => {
@@ -387,7 +394,7 @@ describe("two clips sharing one asset over the same source window", () => {
trim({ id: "t2", assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }),
];
// Two pills, 12s apart — not one merged pill, and not two stacked on c1.
- expect(coalescedTrimGroups(trims, sharedClips())).toEqual([
+ expect(coalescedTrimGroups(trims, sharedClips(), [])).toEqual([
{ ids: ["t1"], start: 3, end: 5 },
{ ids: ["t2"], start: 15, end: 17 },
]);
@@ -397,7 +404,7 @@ describe("two clips sharing one asset over the same source window", () => {
// The twin still uses the same asset over the same source range, so an asset-only
// match would resurrect the cut on it.
const trims = [trim({ id: "orphan", assetId: "a", clipId: "c2", startSec: 3, endSec: 5 })];
- expect(coalescedTrimGroups(trims, [sharedClips()[0]])).toEqual([]);
+ expect(coalescedTrimGroups(trims, [sharedClips()[0]], [])).toEqual([]);
});
it("still shows a pill when the anchor clip was re-cut past the trim's start", () => {
@@ -414,7 +421,7 @@ describe("two clips sharing one asset over the same source window", () => {
}),
];
expect(
- trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, clips),
+ trimToTimelineSpan({ assetId: "a", clipId: "c2", startSec: 3, endSec: 5 }, clips, []),
).toEqual({ start: 0, end: 1 });
});
});
diff --git a/src/lib/ai-edition/timeline/trim-mapping.ts b/src/lib/ai-edition/timeline/trim-mapping.ts
index 98ba9f68a..701b575d0 100644
--- a/src/lib/ai-edition/timeline/trim-mapping.ts
+++ b/src/lib/ai-edition/timeline/trim-mapping.ts
@@ -14,7 +14,8 @@
// clip) share a coordinate space: without the anchor, "which clip is this cut on?"
// had no answer and each caller invented its own. See `trimAppliesToClip`.
-import type { AxcutClip, AxcutTrimRange } from "../schema";
+import type { AxcutClip, AxcutInsertRange, AxcutTrimRange } from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
import { type CoalescedSpan, ventilateSpanAcrossClips } from "./region-ventilation";
import { coalesceByIdentity, regionIdentityKey } from "./timelineMap";
@@ -42,7 +43,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;
}
@@ -65,6 +69,9 @@ export function trimAppliesToClip(trim: TrimAnchor, clip: AxcutClip): boolean {
export function trimToTimelineSpan(
trim: TrimAnchor,
clips: AxcutClip[],
+ /** REQUIRED: a clip carrying insertions is longer than its source window, so this is not
+ * a plain shift (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): { start: number; end: number } | null {
for (const c of clips) {
if (!trimAppliesToClip(trim, c)) continue;
@@ -75,7 +82,7 @@ export function trimToTimelineSpan(
: trim.startSec >= c.sourceStartSec && trim.startSec <= srcEnd;
if (carries) {
const map = (s: number) =>
- c.timelineStartSec + (Math.min(Math.max(s, c.sourceStartSec), srcEnd) - c.sourceStartSec);
+ sourceToTimelineSec(c, Math.min(Math.max(s, c.sourceStartSec), srcEnd), insertRanges);
return { start: map(trim.startSec), end: map(trim.endSec) };
}
}
@@ -137,11 +144,12 @@ export function ventilateTimelineSpanToTrims(
export function coalescedTrimGroups(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
+ insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): CoalescedSpan[] {
const spans = trimRanges
.map((t) => {
- const mapped = trimToTimelineSpan(t, clips);
+ const mapped = trimToTimelineSpan(t, clips, insertRanges);
return mapped
? {
id: t.id,
@@ -180,10 +188,12 @@ export function resolveTrimPillIds(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
id: string,
+ insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): string[] {
return (
- coalescedTrimGroups(trimRanges, clips, epsilonSec).find((g) => g.ids.includes(id))?.ids ?? [id]
+ coalescedTrimGroups(trimRanges, clips, insertRanges, epsilonSec).find((g) => g.ids.includes(id))
+ ?.ids ?? [id]
);
}
@@ -198,11 +208,14 @@ export function dropTrimPillsByIds(
trimRanges: AxcutTrimRange[],
clips: AxcutClip[],
ids: Iterable,
+ insertRanges: readonly AxcutInsertRange[],
epsilonSec?: number,
): AxcutTrimRange[] {
const under = new Set();
for (const id of ids) {
- for (const member of resolveTrimPillIds(trimRanges, clips, id, epsilonSec)) under.add(member);
+ for (const member of resolveTrimPillIds(trimRanges, clips, id, insertRanges, epsilonSec)) {
+ under.add(member);
+ }
}
if (under.size === 0) return trimRanges;
return trimRanges.filter((t) => !under.has(t.id));
@@ -222,6 +235,9 @@ export function resolveTimelineSpanToTrim(
startSec: number,
endSec: number,
clips: AxcutClip[],
+ /** REQUIRED: a clip carrying insertions is longer than its source window, so this is not
+ * a plain shift (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): TrimSourceRange | null {
if (clips.length === 0) return null;
const lo = Math.min(startSec, endSec);
@@ -243,7 +259,7 @@ export function resolveTimelineSpanToTrim(
Math.min(lo, carrier.timelineStartSec + srcLen),
);
const tEnd = Math.max(tStart, Math.min(hi, carrier.timelineStartSec + srcLen));
- const toSrc = (t: number) => carrier.sourceStartSec + (t - carrier.timelineStartSec);
+ const toSrc = (t: number) => timelineToSourceSec(carrier, t, insertRanges).sourceSec;
return {
assetId: carrier.assetId,
clipId: carrier.id,
diff --git a/src/lib/ai-edition/timeline/virtual-preview.test.ts b/src/lib/ai-edition/timeline/virtual-preview.test.ts
index f3dd64888..37bac870b 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.test.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.test.ts
@@ -51,24 +51,24 @@ describe("virtual-preview pure functions", () => {
});
it("locateVirtualPosition maps virtual time to source time", () => {
- const pos = locateVirtualPosition(clips, 12);
+ const pos = locateVirtualPosition(clips, 12, []);
expect(pos).not.toBeNull();
expect(pos?.clipIndex).toBe(1);
expect(pos?.sourceTimeSec).toBe(22);
});
it("locateVirtualPosition returns null for empty clips", () => {
- expect(locateVirtualPosition([], 0)).toBeNull();
+ expect(locateVirtualPosition([], 0, [])).toBeNull();
});
it("locateSourcePosition maps source time back to virtual time", () => {
- const pos = locateSourcePosition(clips, 25);
+ const pos = locateSourcePosition(clips, 25, undefined, 0.05, undefined, []);
expect(pos).not.toBeNull();
expect(pos?.virtualTimeSec).toBe(15);
});
it("locateSourcePosition returns null for source time in a cut", () => {
- expect(locateSourcePosition(clips, 15)).toBeNull();
+ expect(locateSourcePosition(clips, 15, undefined, 0.05, undefined, [])).toBeNull();
});
it("keptWordIdSet flattens wordRefs from all clips", () => {
@@ -106,13 +106,13 @@ describe("virtual-preview pure functions", () => {
reason: "",
},
];
- const pos1 = locateSourcePosition(multiClips, 5, "a1");
+ const pos1 = locateSourcePosition(multiClips, 5, "a1", 0.05, undefined, []);
expect(pos1?.clip.id).toBe("clip_1");
- const pos2 = locateSourcePosition(multiClips, 5, "a2");
+ const pos2 = locateSourcePosition(multiClips, 5, "a2", 0.05, undefined, []);
expect(pos2?.clip.id).toBe("clip_2");
- const posNone = locateSourcePosition(multiClips, 5, "a3");
+ const posNone = locateSourcePosition(multiClips, 5, "a3", 0.05, undefined, []);
expect(posNone).toBeNull();
});
@@ -148,19 +148,19 @@ describe("virtual-preview pure functions", () => {
// the earliest matching clip — this is the bug: playing back the
// second clip's segment would still report position/identity for the
// first.
- const ambiguous = locateSourcePosition(duplicateClips, 5, "a1");
+ const ambiguous = locateSourcePosition(duplicateClips, 5, "a1", 0.05, undefined, []);
expect(ambiguous?.clip.id).toBe("clip_1");
// With the currently-active clip id passed through, it's preferred
// even though clip_1 also matches (assetId, sourceTime).
- const disambiguated = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_2");
+ const disambiguated = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_2", []);
expect(disambiguated?.clip.id).toBe("clip_2");
expect(disambiguated?.virtualTimeSec).toBe(15);
// A preferred clip id that no longer applies (source time moved
// outside its range) falls back to the ambiguous scan rather than
// forcing a stale match.
- const outOfRange = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_3");
+ const outOfRange = locateSourcePosition(duplicateClips, 5, "a1", 0.05, "clip_3", []);
expect(outOfRange?.clip.id).toBe("clip_1");
});
@@ -213,6 +213,7 @@ describe("virtual-preview pure functions", () => {
playingClip.assetId,
0.05,
playingClip.id,
+ [],
);
expect(pos?.clip.id).toBe(playing);
expect(pos?.virtualTimeSec).toBeCloseTo(playingClip.timelineStartSec + sourceTimeSec, 6);
@@ -222,8 +223,8 @@ describe("virtual-preview pure functions", () => {
// The scan cannot know which twin is playing — but its answer must at least not
// depend on which twin happens to sit last in the array, which is what
// `index === clips.length - 1` made it do.
- const forward = locateSourcePosition(twins([a1, a2]), 9.96, "a1");
- const reversed = locateSourcePosition(twins([a2, a1]), 9.96, "a1");
+ const forward = locateSourcePosition(twins([a1, a2]), 9.96, "a1", 0.05, undefined, []);
+ const reversed = locateSourcePosition(twins([a2, a1]), 9.96, "a1", 0.05, undefined, []);
expect(forward?.clip.id).toBe("clip_a1");
expect(reversed?.clip.id).toBe("clip_a2");
// i.e. both resolve to the FIRST clip of the asset — the documented behaviour of
@@ -246,17 +247,17 @@ describe("virtual-preview pure functions", () => {
timelineEndSec: 20,
},
];
- expect(locateSourcePosition(split, 10, "a1")?.clip.id).toBe("clip_a2");
- expect(locateSourcePosition(split, 9.9, "a1")?.clip.id).toBe("clip_a1");
+ expect(locateSourcePosition(split, 10, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a2");
+ expect(locateSourcePosition(split, 9.9, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a1");
// …and the very end of the timeline still resolves rather than falling off it.
- expect(locateSourcePosition(split, 20, "a1")?.clip.id).toBe("clip_a2");
+ expect(locateSourcePosition(split, 20, "a1", 0.05, undefined, [])?.clip.id).toBe("clip_a2");
});
it("ignores a named clip whose asset is not the one playing", () => {
// A stale id during an asset swap must fall through to the scan rather than
// mapping the time through media that is not on screen.
const clips = twins([a1, c3]);
- const pos = locateSourcePosition(clips, 5, "a1", 0.05, "clip_c3");
+ const pos = locateSourcePosition(clips, 5, "a1", 0.05, "clip_c3", []);
expect(pos?.clip.id).toBe("clip_a1");
});
});
@@ -367,8 +368,8 @@ describe("virtual-preview pure functions", () => {
timelineEndSec: 13.8,
};
- expect(getRawVirtualStartTime(segClip1Part2, rawClips)).toBe(6);
- expect(getRawVirtualStartTime(segClip2Part1, rawClips)).toBe(13.2);
+ expect(getRawVirtualStartTime(segClip1Part2, rawClips, [])).toBe(6);
+ expect(getRawVirtualStartTime(segClip2Part1, rawClips, [])).toBe(13.2);
});
it("findNextKeptSegment finds next kept segment across multi-clip trim boundary", () => {
@@ -421,11 +422,11 @@ describe("virtual-preview pure functions", () => {
];
// At current raw virtual time 2.5s (end of seg 0), next kept segment is seg 1 (clip_2)
- const nextSeg = findNextKeptSegment(playbackClips, rawClips, 2.5, "a1", 2.5);
+ const nextSeg = findNextKeptSegment(playbackClips, rawClips, 2.5, "a1", 2.5, undefined, []);
expect(nextSeg).toBeDefined();
expect(nextSeg?.id).toBe("clip_2");
expect(nextSeg?.assetId).toBe("a2");
- expect(getRawVirtualStartTime(nextSeg!, rawClips)).toBe(10.7);
+ expect(getRawVirtualStartTime(nextSeg!, rawClips, [])).toBe(10.7);
});
describe("findNextKeptSegment never goes backwards", () => {
@@ -473,9 +474,9 @@ describe("virtual-preview pure functions", () => {
// clip_1 starts at source 30, which IS "later in source time", and its raw start
// is 0: answering it sent playback back to the beginning, straight into the same
// cut again, forever.
- const next = findNextKeptSegment(playbackClips, rawClips, 17, "a1", 7, "clip_2");
+ const next = findNextKeptSegment(playbackClips, rawClips, 17, "a1", 7, "clip_2", []);
expect(next).toBeDefined();
- expect(getRawVirtualStartTime(next!, rawClips)).toBe(20);
+ expect(getRawVirtualStartTime(next!, rawClips, [])).toBe(20);
expect(next?.sourceStartSec).toBe(10);
});
@@ -483,7 +484,7 @@ describe("virtual-preview pure functions", () => {
// Same moment, but the raw position has not caught up (still reads 10, the start
// of clip_2). The ruler test alone would answer clip_2's FIRST kept segment —
// the stretch already played. The clip-scoped source test carries it past the cut.
- const next = findNextKeptSegment(playbackClips, rawClips, 10, "a1", 7, "clip_2");
+ const next = findNextKeptSegment(playbackClips, rawClips, 10, "a1", 7, "clip_2", []);
expect(next?.sourceStartSec).toBe(10);
});
});
diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts
index 1cdc5d2f6..ba27e8cfb 100644
--- a/src/lib/ai-edition/timeline/virtual-preview.ts
+++ b/src/lib/ai-edition/timeline/virtual-preview.ts
@@ -1,7 +1,8 @@
// Ported from axcut/apps/web/src/lib/virtual-preview.ts — pure time-mapping
// functions shared by the VirtualPreview component and the timeline math.
-import type { AxcutClip } from "../schema";
+import type { AxcutClip, AxcutInsertRange } from "../schema";
+import { sourceToTimelineSec, timelineToSourceSec } from "./inserted-time";
export type VirtualPosition = {
clip: AxcutClip;
@@ -22,6 +23,10 @@ export function clampVirtualTime(clips: AxcutClip[], value: number): number {
export function locateVirtualPosition(
clips: AxcutClip[],
virtualTimeSec: number,
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): VirtualPosition | null {
if (clips.length === 0) return null;
const clamped = clampVirtualTime(clips, virtualTimeSec);
@@ -32,7 +37,11 @@ export function locateVirtualPosition(
const resolvedIndex = clipIndex >= 0 ? clipIndex : clips.length - 1;
const clip = clips[resolvedIndex];
const clipDuration = (clip.sourceEndSec ?? 0) - clip.sourceStartSec;
- const clipOffset = Math.max(0, Math.min(clipDuration, clamped - clip.timelineStartSec));
+ // Inside an insertion there is no source moment — none of those seconds came from the
+ // file — so this answers with the one the inserted media follows, which is the frame a
+ // decoder should be parked on.
+ const { sourceSec } = timelineToSourceSec(clip, clamped, insertRanges);
+ const clipOffset = Math.max(0, Math.min(clipDuration, sourceSec - clip.sourceStartSec));
return {
clip,
clipIndex: resolvedIndex,
@@ -72,10 +81,22 @@ export function findRawClipForSegment(
* Maps a kept segment (`AxcutClip` from `resolvePlaybackSegments`) back to its
* exact start position on the raw (untrimmed) document timeline.
*/
-export function getRawVirtualStartTime(segment: AxcutClip, rawClips: AxcutClip[]): number {
+export function getRawVirtualStartTime(
+ segment: AxcutClip,
+ rawClips: AxcutClip[],
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
+): number {
const rawClip = findRawClipForSegment(segment, rawClips);
if (!rawClip) return segment.timelineStartSec;
- return rawClip.timelineStartSec + (segment.sourceStartSec - rawClip.sourceStartSec);
+ // A HELD segment is the inserted media itself, so it starts where the insertion opens.
+ // Every other segment starting at that same source moment is the film RESUMING, so it
+ // starts where the insertion closes. Same source second, two different places on the
+ // timeline — which is the whole reason an insertion is media and not a marker.
+ const edge = (segment as { heldSec?: number }).heldSec !== undefined ? "opens" : "closes";
+ return sourceToTimelineSec(rawClip, segment.sourceStartSec, insertRanges, edge);
}
/**
@@ -99,12 +120,13 @@ export function findNextKeptSegment(
playbackClips: AxcutClip[],
rawClips: AxcutClip[],
currentRawTime: number,
- activeSourceId?: string,
- currentSourceTime?: number,
- activeClipId?: string,
+ activeSourceId: string | undefined,
+ currentSourceTime: number | undefined,
+ activeClipId: string | undefined,
+ insertRanges: readonly AxcutInsertRange[],
): AxcutClip | undefined {
for (const seg of playbackClips) {
- const segRawStart = getRawVirtualStartTime(seg, rawClips);
+ const segRawStart = getRawVirtualStartTime(seg, rawClips, insertRanges);
if (segRawStart > currentRawTime + 0.001) {
return seg;
}
@@ -126,6 +148,7 @@ function toPositionAt(
clips: AxcutClip[],
clipIndex: number,
sourceTimeSec: number,
+ insertRanges: readonly AxcutInsertRange[] = [],
): VirtualPosition {
const clip = clips[clipIndex];
const sourceOffset = Math.max(
@@ -135,7 +158,9 @@ function toPositionAt(
return {
clip,
clipIndex,
- virtualTimeSec: clip.timelineStartSec + sourceOffset,
+ // Not `timelineStartSec + offset`: a clip carrying insertions is longer than its
+ // source window, so a moment past one sits that much further along (issue #560).
+ virtualTimeSec: sourceToTimelineSec(clip, clip.sourceStartSec + sourceOffset, insertRanges),
sourceTimeSec,
};
}
@@ -170,8 +195,8 @@ function isWithinClipBounds(
export function locateSourcePosition(
clips: AxcutClip[],
sourceTimeSec: number,
- assetId?: string,
- epsilon = 0.05,
+ assetId: string | undefined,
+ epsilon: number,
// When two clips share the same source asset (and possibly overlapping
// source ranges — a duplicated clip, or simply not trimmed yet), scanning
// by (assetId, sourceTime) alone is ambiguous and always resolves to the
@@ -180,7 +205,11 @@ export function locateSourcePosition(
// which clip they're tracking (VirtualPreview, mid-playback) should pass
// its id here so it's preferred whenever the source time still falls
// inside it, before falling back to the ambiguous asset-wide scan.
- preferredClipId?: string,
+ preferredClipId: string | undefined,
+ /** REQUIRED, not defaulted. A clip carrying insertions is longer than its source window,
+ * so a caller that omits these gets an answer that is plausible and wrong by exactly the
+ * inserted time, with nothing to catch it (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): VirtualPosition | null {
if (preferredClipId) {
const preferredIndex = clips.findIndex((clip) => clip.id === preferredClipId);
@@ -198,7 +227,7 @@ export function locateSourcePosition(
(!assetId || clips[preferredIndex].assetId === assetId) &&
isWithinClipBounds(clips[preferredIndex], sourceTimeSec, epsilon, "inclusive")
) {
- return toPositionAt(clips, preferredIndex, sourceTimeSec);
+ return toPositionAt(clips, preferredIndex, sourceTimeSec, insertRanges);
}
}
const scan = (closingEdge: ClosingEdge) =>
@@ -219,7 +248,7 @@ export function locateSourcePosition(
const strict = scan("exclusive");
const clipIndex = strict >= 0 ? strict : scan("inclusive");
if (clipIndex < 0) return null;
- return toPositionAt(clips, clipIndex, sourceTimeSec);
+ return toPositionAt(clips, clipIndex, sourceTimeSec, insertRanges);
}
/**
@@ -247,8 +276,12 @@ export function locateKeptSegment(
const ownSegments = activeClipId
? playbackClips.filter((seg) => findRawClipForSegment(seg, rawClips)?.id === activeClipId)
: [];
- if (ownSegments.length > 0) return locateSourcePosition(ownSegments, sourceTimeSec, assetId);
- return locateSourcePosition(playbackClips, sourceTimeSec, assetId);
+ // The SEGMENTS are already split at every insertion, so within one of them source → its
+ // own start is a plain shift again. `[]` here is the honest answer, not a forgotten
+ // argument: there is no insertion inside a segment to account for.
+ if (ownSegments.length > 0)
+ return locateSourcePosition(ownSegments, sourceTimeSec, assetId, 0.05, undefined, []);
+ return locateSourcePosition(playbackClips, sourceTimeSec, assetId, 0.05, undefined, []);
}
export function keptWordIdSet(clips: AxcutClip[]): Set {
diff --git a/src/lib/ai-edition/timeline/voiceoverCut.test.ts b/src/lib/ai-edition/timeline/voiceoverCut.test.ts
new file mode 100644
index 000000000..701b403ef
--- /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 00261712e..e29581518 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,
isCpuBackend,
@@ -243,6 +244,7 @@ describe("transcriptRelevantAssetIds", () => {
transcripts: [],
annotations: [],
zoomRanges: [],
+ audioTracks: [],
legacyEditor: null,
};
@@ -270,6 +272,7 @@ describe("transcriptRelevantAssetIds", () => {
})),
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -350,3 +353,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 64fdb411c..e699b2d91 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";
@@ -272,12 +273,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 3ca985649..8703f0832 100644
--- a/src/lib/captioning/transcribe.ts
+++ b/src/lib/captioning/transcribe.ts
@@ -70,12 +70,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"));
@@ -104,7 +132,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..c2d290387 100644
--- a/src/native/contracts.ts
+++ b/src/native/contracts.ts
@@ -170,6 +170,16 @@ export interface CompositorClipInput {
* convention). Populated by `buildSceneDescription` and `buildNativeClipList`;
* see the comment on the producer side for the exact rule. */
hasAudio: boolean;
+ /** Extra OUTPUT seconds this clip holds its last frame for, silently.
+ *
+ * A word added to the recording transcript needs somewhere to be spoken, so the film
+ * holds a frame and everything after it moves along the ruler (issue #560). The ruler
+ * and the preview have honoured that for a while; the EXPORT did not, because a held
+ * segment has an empty source window and `walk_composited_timeline` skips those. It is
+ * a separate clip carrying this field rather than an adjustment to its predecessor:
+ * scene regions are keyed by clip INDEX, so removing an entry from the list would point
+ * every region after it at the wrong clip. */
+ holdSec: number;
}
/** Bilan d'un export natif (mesure enveloppante §10 : frames, durée, fps). */
@@ -533,7 +543,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..cda53e559 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -84,6 +84,7 @@ function makeDoc(
clips,
gaps: [],
trimRanges: [],
+ insertRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
@@ -91,6 +92,7 @@ function makeDoc(
},
annotations: overrides.annotations ?? [],
zoomRanges: overrides.zoomRanges ?? [],
+ audioTracks: overrides.audioTracks ?? [],
legacyEditor: overrides.legacyEditor ?? null,
};
}
@@ -1990,3 +1992,409 @@ describe("buildSceneDescription.captions", () => {
expect(text?.color).toBe("#ffffff");
});
});
+
+// --- imported audio tracks (issue #350) ------------------------------------
+// ─── The pause that exported as nothing ─────────────────────────────────────
+// A held segment has an empty source window, and `walk_composited_timeline` skipped every
+// clip shaped like that — so a word added to the recording lengthened the ruler and the
+// preview and produced no frames at all in the exported file (issue #560). The segment now
+// reaches the compositor as its OWN clip carrying `holdSec`, keeping its index so the scene
+// regions after it still point at the right clip.
+
+describe("buildSceneDescription.holdSec", () => {
+ it("sends a pause to the compositor as a clip that holds", () => {
+ const doc = makeDoc({
+ assets: [makeAsset({ id: "scr", kind: "video", originalPath: "/s.mp4", durationSec: 10 })],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ }),
+ ],
+ timeline: {
+ insertRanges: [
+ {
+ id: "i1",
+ assetId: "scr",
+ atSec: 4,
+ durationSec: 1,
+ wordId: "w1",
+ reason: "",
+ origin: "user",
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: fixture, not a schema exercise
+ ] as any,
+ },
+ });
+ const clips = buildSceneDescription(doc).clips;
+ const held = clips.filter((c) => c.holdSec > 0);
+ expect(held).toHaveLength(1);
+ expect(held[0].holdSec).toBeCloseTo(1, 6);
+ // Its own entry, in place — not folded onto its predecessor, which would shift every
+ // clip index after it and point the scene's per-clip regions at the wrong clip.
+ expect(clips).toHaveLength(3);
+ expect(clips[1]).toBe(held[0]);
+ // An empty source window: it decodes nothing and exists only for its held frames.
+ expect(held[0].sourceEndSec).toBeCloseTo(held[0].sourceStartSec, 6);
+ });
+
+ it("holds nothing on an ordinary clip", () => {
+ const doc = makeDoc({
+ assets: [makeAsset({ id: "scr", kind: "video", originalPath: "/s.mp4", durationSec: 10 })],
+ clips: [
+ makeClip({
+ id: "c1",
+ assetId: "scr",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ }),
+ ],
+ });
+ expect(buildSceneDescription(doc).clips.every((c) => c.holdSec === 0)).toBe(true);
+ });
+});
+
+describe("buildSceneDescription.audioTracks", () => {
+ const audioAsset = makeAsset({
+ id: "aud",
+ 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..6ede99478 100644
--- a/src/native/sceneDescription.ts
+++ b/src/native/sceneDescription.ts
@@ -28,13 +28,22 @@ 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 { takeInserts } from "@/lib/ai-edition/timeline/insert-mapping";
+import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
+import { takeProgramme } from "@/lib/ai-edition/timeline/take-programme";
import { projectRegionsToSource } from "@/lib/ai-edition/timeline/timelineMap";
import {
computeCompositeLayout,
@@ -411,6 +420,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 +506,31 @@ 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
+ * caller maps it to `holdSec` on the clip input (issue #560).
+ */
+export function resolveVisibleClips(document: AxcutDocument): PlaybackSegment[] {
const assetById = new Map(document.assets.map((a) => [a.id, a]));
- return resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)
+ return resolvePlaybackSegments(
+ document.timeline.clips,
+ document.timeline.trimRanges,
+ document.timeline.insertRanges,
+ )
.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 +541,174 @@ 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
+ // the ruler says rather than D seconds early.
+ const filmInserts = document.timeline.insertRanges ?? [];
+ const removed = removedRawSpans(projectedClips, document.timeline.trimRanges, filmInserts);
+ // The take's pills, keyed by group. A voiceover is walked ONCE per pill and never per
+ // stored fragment: the document keeps one fragment per clip a take covers, so walking
+ // them separately would emit overlapping entries and `overlay_track_pcm` sums with `+=`
+ // 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,
+ filmInserts,
+ 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,
+ filmInserts,
+ ) -
+ projectRawTimelineSecToPlayback(
+ projectedClips,
+ document.timeline.trimRanges,
+ track.startMs / 1000,
+ filmInserts,
+ );
+ 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;
+ // One walk: the cuts take time away, the take's own insertions add it, and the
+ // walk resolves them together so a second insertion lands after the first one's
+ // hold rather than inside it.
+ const kept = takeProgramme(pill, removed, takeInserts(document, groupId), rawSpeedRegions)
+ .filter((piece) => piece.kind === "play")
+ .map((piece) => ({
+ ...base,
+ startSec: projectRawTimelineSecToPlayback(
+ projectedClips,
+ document.timeline.trimRanges,
+ piece.rawStartSec,
+ filmInserts,
+ 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 +729,9 @@ 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.
+ holdSec: clip.heldSec ?? 0,
},
];
});
@@ -552,6 +777,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("zoom"),
+ document.timeline.insertRanges ?? [],
);
// Same raw→source projection as the zoom regions above, for the same reason: annotations are
// authored in RAW document time and the compositor matches each frame's SOURCE time.
@@ -588,6 +814,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("ann"),
+ document.timeline.insertRanges ?? [],
);
const projectedCameraFullscreenRegions = projectRegionsToSource(
((document.legacyEditor as Record | null)?.cameraFullscreenRegions as
@@ -596,6 +823,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("camfull"),
+ document.timeline.insertRanges ?? [],
);
// Speed regions carry an extra `speed` field the standard `rangeSchema` does not, so we
// can't read from `document.timeline.speedRanges` today (see SceneDescription.speedRegions
@@ -610,6 +838,7 @@ export function buildSceneDescription(
visibleClips,
document.timeline.clips,
() => createId("speed"),
+ document.timeline.insertRanges ?? [],
);
// Webcam rect, single source of truth between preview & native :
@@ -821,6 +1050,7 @@ export function buildSceneDescription(
audio: {
gainDb: settings.audioGainDb,
},
+ audioTracks,
background: parseWallpaper(settings.wallpaper),
zoomRegions: projectedZoomRegions.map((region) => ({
id: region.id,
diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts
index 64e877b63..7263d14a0 100644
--- a/src/native/useNativePlaybackSync.ts
+++ b/src/native/useNativePlaybackSync.ts
@@ -18,7 +18,7 @@
* re-aligns them.
*/
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
-import type { AxcutClip } from "@/lib/ai-edition/schema";
+import type { AxcutClip, AxcutInsertRange } from "@/lib/ai-edition/schema";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
getCurrentNativeViewId,
@@ -34,13 +34,22 @@ export function useNativePlaybackSync(
visibleSegments: readonly AxcutClip[],
/** RAW clip layout (`document.timeline.clips`) `currentTimeSec` is expressed against. */
rawClips: readonly AxcutClip[],
+ /** The insertions those clips carry — a clip is longer than its source window by them,
+ * so a segment's place on the timeline cannot be found without them (issue #560). */
+ insertRanges: readonly AxcutInsertRange[],
): void {
const activePosition = useMemo(
- () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips]),
- [visibleSegments, rawClips, currentTimeSec],
+ () => resolveNativePosition(currentTimeSec, [...visibleSegments], [...rawClips], insertRanges),
+ [visibleSegments, rawClips, currentTimeSec, insertRanges],
);
const activeClipId = activePosition?.clip.id ?? null;
const sourceTimeSec = activePosition?.sourceTimeSec ?? null;
+ // A pause holds ONE frame for its whole length. Free-running the decoder through it
+ // would play what comes after instead, and the app clock — which does traverse the
+ // pause — would then re-seek on the drift and stutter. Pausing the decoder is what
+ // makes the pause a pause; the webcam holds with the screen because both derive
+ // from the one asset source clock the pause stops advancing.
+ const held = activePosition?.clip.heldSec !== undefined;
// Reactive "is a native view active?" so activation mid-session re-pushes the
// current transport/playhead (time & playing aren't memoised in the store).
@@ -54,8 +63,8 @@ export function useNativePlaybackSync(
if (!active) {
return;
}
- setNativePlaying(playing);
- }, [active, playing]);
+ setNativePlaying(playing && !held);
+ }, [active, playing, held]);
// Scrub/step while paused OR periodic resync during playback when drift > 100ms
const lastSyncedSourceTimeRef = useRef(null);
@@ -68,6 +77,16 @@ export function useNativePlaybackSync(
}
const now = performance.now();
+ // Inside a pause while playing: the decoder is parked on the held frame (see the
+ // transport effect). Refresh the drift refs every run so the check never reads a
+ // correctly-frozen source clock as divergence and fights itself with seeks.
+ if (playing && held) {
+ setNativeTime(sourceTimeSec);
+ lastSyncedSourceTimeRef.current = sourceTimeSec;
+ lastSyncedWallTimeRef.current = now;
+ return;
+ }
+
// When clip changes, let setActiveClip handle the atomic clip-switch-and-seek.
if (lastActiveClipIdRef.current !== activeClipId) {
lastActiveClipIdRef.current = activeClipId;
@@ -95,5 +114,5 @@ export function useNativePlaybackSync(
lastSyncedSourceTimeRef.current = sourceTimeSec;
lastSyncedWallTimeRef.current = now;
}
- }, [active, playing, activeClipId, sourceTimeSec]);
+ }, [active, playing, held, activeClipId, sourceTimeSec]);
}
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 f5550a1eb..9ddb34a2f 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