Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions crates/compositor/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use crate::ffi::*;
use crate::regions::SpeedSegment;
use crate::scene::SceneAudio;
use anyhow::{bail, Result};
use std::f32::consts::PI;
use std::ffi::CString;
Expand All @@ -26,6 +27,44 @@ const PASSTHROUGH_EPSILON: f64 = 1e-3;

pub type PlanarPcm = Vec<Vec<f32>>;

/// Apply the editor's output trim to the assembled timeline.
///
/// One stage, and keeping it that way takes some resisting. This runs on the assembled
/// timeline — trimmed, speed-adjusted, concatenated — while the editor preview plays the
/// untouched SOURCE file, seeked. A linear gain is the only operation that means the same
/// thing on both, which is what lets the editor claim that what you hear is what you export.
///
/// Three things that look like they belong here and do not:
/// - a filter or a compressor, which carries state across cuts here and not in the preview;
/// - a loudness normaliser, whose makeup is a single scalar measured over the whole assembled
/// programme — the preview never holds that programme, and the value moves with every trim;
/// - a sync offset, which shipped here once. It is expressed in TIMELINE seconds at this
/// point in the pipeline, but the preview would apply it in SOURCE seconds, so a 2x speed
/// region halved it; and because the shift is uniform over the assembled programme, near a
/// cut the export pulls audio across the junction while the preview only has the active
/// asset loaded.
///
/// Any of them means either rendering the export's audio assembly preview-side, or accepting
/// and documenting a divergence — not a quiet extra stage in this function.
///
/// The bound mirrors `AUDIO_GAIN_DB_LIMIT` in editorSettings.ts. The result stays the same
/// length so video and following clips cannot drift.
pub fn finish_audio(mut pcm: PlanarPcm, settings: SceneAudio) -> PlanarPcm {
let samples = pcm.first().map(Vec::len).unwrap_or(0);
if samples == 0 {
return pcm;
}
for channel in pcm.iter_mut() {
channel.resize(samples, 0.0);
}

let trim = 10.0f32.powf(settings.gain_db.clamp(-12.0, 12.0) / 20.0);
for sample in pcm.iter_mut().flatten() {
*sample = (*sample * trim).clamp(-1.0, 1.0);
}
pcm
}

extern "C" {
fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream;
// bindgen rend `AVFormatContext` opaque (atteinte seulement par pointeur), d'où l'accesseur
Expand Down Expand Up @@ -1063,4 +1102,48 @@ mod tests {
let mixed = mix_aligned_tracks(&[(-0.001, &early)], 0.0, 8);
assert_eq!(mixed[0], vec![0.5; 8]);
}

/// The gain must be the SAME scalar the editor preview feeds its GainNode
/// (`10 ** (dB / 20)`), because that identity is the whole parity guarantee: nothing
/// else stands between what the editor plays and what this writes.
#[test]
fn output_trim_is_the_same_scalar_the_preview_applies() {
for gain_db in [-12.0f32, -6.0206, 0.0, 6.0206, 12.0] {
let result = finish_audio(
planar(&[0.25, -0.25]),
SceneAudio { gain_db },
);
let expected = (0.25 * 10.0f32.powf(gain_db / 20.0)).clamp(-1.0, 1.0);
assert!(
(result[0][0] - expected).abs() < 1e-6,
"gain {gain_db} dB: got {}, want {expected}",
result[0][0]
);
assert!((result[0][1] + expected).abs() < 1e-6);
}
}

#[test]
fn out_of_range_gain_is_clamped_to_the_editor_bound() {
// A hand-edited project, the AI edition agent, or a future UI change must not be
// able to ask for a gain the slider cannot display.
let quiet = finish_audio(planar(&[0.5]), SceneAudio { gain_db: -99.0 });
let floor = 0.5 * 10.0f32.powf(-12.0 / 20.0);
assert!((quiet[0][0] - floor).abs() < 1e-6);

let loud = finish_audio(planar(&[0.1]), SceneAudio { gain_db: 99.0 });
let ceiling = 0.1 * 10.0f32.powf(12.0 / 20.0);
assert!((loud[0][0] - ceiling).abs() < 1e-6);
}

#[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
// same length either way, or video and the following clips drift against it.
let result = finish_audio(planar(&[0.9, -0.9, 0.1]), SceneAudio { gain_db: 12.0 });
assert_eq!(result[0].len(), 3);
assert_eq!(result[0][0], 1.0);
assert_eq!(result[0][1], -1.0);
assert!((result[0][2] - 0.1 * 10.0f32.powf(12.0 / 20.0)).abs() < 1e-6);
}
}
5 changes: 4 additions & 1 deletion crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1230,9 +1230,12 @@ impl Compositor {
// `cover_crop_uv` est la primitive partagee que macOS et Windows
// utilisent ; elle rend le rect inchange quand il a deja le bon
// ratio, donc aucun placement correct ne bouge.
let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv(
let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect(
[wcw, wch],
[wtw as f32, wth as f32],
scene_ref
.as_ref()
.and_then(|scene| scene.layout.webcam_crop),
g.w_px[0] / g.w_px[1].max(0.0001),
);
// MIROIR : on inverse l'intervalle u. Le VS interpole `src`
Expand Down
3 changes: 2 additions & 1 deletion crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1601,9 +1601,10 @@ impl Compositor {
// --- caméra : ombre PiP puis vidéo ---
let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?;
if let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) {
let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv(
let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect(
[wcw, wch],
[wtw as f32, wth as f32],
scene_ref.as_ref().and_then(|scene| scene.layout.webcam_crop),
g.w_px[0] / g.w_px[1].max(0.0001),
);
let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) };
Expand Down
5 changes: 4 additions & 1 deletion crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,9 +1517,12 @@ impl Compositor {
//
// Le center-crop carré de square/circle en est un cas particulier (boîte 1:1) — il n'a
// plus besoin d'être traité à part.
let (su0, sv0, su1, sv1) = cover_crop_uv(
let [su0, sv0, su1, sv1] = crate::frame_geometry::webcam_source_rect(
[wcw, wch],
[wtw as f32, wth as f32],
scene_ref
.as_ref()
.and_then(|scene| scene.layout.webcam_crop),
w_px[0] / w_px[1].max(0.0001),
);
// miroir = échanger les bornes u du rect source (flip horizontal).
Expand Down
35 changes: 35 additions & 0 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,23 @@ pub(crate) fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f
let [u0, v0, u1, v1] = cover_uv_rect(full, tex, box_ar);
(u0, v0, u1, v1)
}

/// Camera equivalent of the screen crop pipeline: apply the user crop first, then a centred
/// cover-crop inside that authored window so arbitrary layout slots never stretch the image.
pub(crate) fn webcam_source_rect(
visible: [f32; 2],
tex: [f32; 2],
crop: Option<SceneCrop>,
box_ar: f32,
) -> [f32; 4] {
let u_max = visible[0].max(1.0) / tex[0].max(1.0);
let v_max = visible[1].max(1.0) / tex[1].max(1.0);
cover_uv_rect(
screen_source_rect(u_max, v_max, crop, 1.0, [0.5, 0.5]),
tex,
box_ar,
)
}
/// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son
/// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la
/// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui
Expand Down Expand Up @@ -1750,4 +1767,22 @@ mod tests {
assert!((su0 - (960.0 - 720.0) * 0.5 / tex[0]).abs() < 1e-6);
assert!((su1 - (960.0 + 720.0) * 0.5 / tex[0]).abs() < 1e-6);
}

#[test]
fn webcam_crop_identity_keeps_the_full_visible_frame() {
let uv = webcam_source_rect([1280.0, 720.0], [2048.0, 1024.0], None, 16.0 / 9.0);
assert_rect(uv, [0.0, 0.0, 1280.0 / 2048.0, 720.0 / 1024.0]);
}

#[test]
fn webcam_crop_applies_authored_zoom_and_pan_before_layout_cover() {
let crop = SceneCrop {
x: 0.25,
y: 0.20,
width: 0.50,
height: 0.60,
};
let uv = webcam_source_rect([100.0, 100.0], [100.0, 100.0], Some(crop), 0.50 / 0.60);
assert_rect(uv, [0.25, 0.20, 0.75, 0.80]);
}
}
8 changes: 6 additions & 2 deletions crates/compositor/src/pipeline_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use std::ffi::CString;
use std::ptr;

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio,
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::config::Cfg;
Expand Down Expand Up @@ -457,6 +457,7 @@ pub fn run_composited_multi(
let mut clip_frame_counts: Vec<u64> = vec![0; clips.len()];

let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).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.
Expand Down Expand Up @@ -533,7 +534,10 @@ pub fn run_composited_multi(
// raccourci voit son audio raccourci d'autant), puis un seul encode AAC.
let declared_audio: Vec<bool> = 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(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?;
audio_encoder.encode(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
octx,
)?;
crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?;
crate::ffi::avio_closep(&mut pb);
crate::ffi::avformat_free_context(octx);
Expand Down
10 changes: 7 additions & 3 deletions crates/compositor/src/pipeline_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
//! décodeurs, symétrique.

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio,
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::compositor::Compositor;
Expand Down Expand Up @@ -1076,6 +1076,7 @@ pub fn run_composited_multi(
// exactement le bug de troncature en slow-motion que la doc de `walk_composited_timeline`
// 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();
frames = unsafe {
crate::timeline_walk::walk_composited_timeline(
clips,
Expand Down Expand Up @@ -1130,7 +1131,10 @@ pub fn run_composited_multi(
// d'autant, sinon la piste dérive pour tous les suivants.
let declared_audio: Vec<bool> = 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(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?;
audio_encoder.encode(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
octx,
)?;

crate::ffi::averr(
crate::ffi::av_write_trailer(octx),
Expand Down Expand Up @@ -1188,4 +1192,4 @@ pub fn probe_frame_count(_path: &str) -> Result<u64> {
// cfg-re-export `crate::compositor::Compositor`, et cette fonction helper garantit
// que le type reste référencé.
#[allow(dead_code)]
fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {}
fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {}
8 changes: 6 additions & 2 deletions crates/compositor/src/pipeline_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, decode_clip_audio,
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::compositor::{Compositor, OUT_H, OUT_W};
Expand Down Expand Up @@ -1342,6 +1342,7 @@ unsafe fn run_multi_inner(
// La scène (déjà posée par l'appelant via comp.set_scene) pilote le curseur et le
// 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();

// ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ----
// Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur
Expand Down Expand Up @@ -1465,7 +1466,10 @@ unsafe fn run_multi_inner(
&declared_audio,
out_fps as f64,
);
let assembled_audio = assemble_concatenated_pcm(&clip_pcm, &audio_plan);
let assembled_audio = finish_audio(
assemble_concatenated_pcm(&clip_pcm, &audio_plan),
audio_settings,
);
audio_encoder.encode(&assembled_audio, octx)?;

averr(av_write_trailer(octx), "write_trailer")?;
Expand Down
19 changes: 19 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ pub struct SceneLayout {
pub webcam_position: Option<WebcamPosition>,
/// la webcam rétrécit pendant un zoom actif.
pub webcam_reactive_zoom: bool,
/// User-authored source crop for the camera. Absent keeps the full frame.
#[serde(default)]
pub webcam_crop: Option<SceneCrop>,
/// Rect webcam résolu côté app (0..1 fractions du cadre de sortie), en PARITÉ EXACTE avec
/// `computeCompositeLayout` (TS). Permet à TS et Rust de partager la même source de vérité :
/// le natif ne dérive PLUS ses propres placements pour PiP/dual-frame/vertical-stack — il
Expand Down Expand Up @@ -361,6 +364,19 @@ pub struct SceneCrop {
pub height: f32,
}

/// Audio finishing. Deliberately limited to a linear gain — the one operation the editor
/// preview can apply identically to the source file it plays. See `audio::finish_audio`
/// before adding a field here; a sync offset was tried and removed.
///
/// The field carries `#[serde(default)]`: a payload from a build that predates it must
/// degrade to "that stage is neutral", not fail the whole scene.
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneAudio {
#[serde(default)]
pub gain_db: f32,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneOutput {
Expand Down Expand Up @@ -389,6 +405,9 @@ pub struct Scene {
#[serde(default)]
pub camera_fullscreen_regions: Vec<SceneCameraFullscreenRegion>,
pub cursor: SceneCursor,
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
pub audio: SceneAudio,
/// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS).
#[serde(default)]
pub crop_by_clip: Vec<Option<SceneCrop>>,
Expand Down
14 changes: 14 additions & 0 deletions src/components/ai-edition/NewEditorShell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2026,6 +2026,20 @@
color: var(--danger);
}

.secondaryBtn {
margin: 4px var(--sp-4) 12px;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-2);
color: var(--fg-2);
font: 500 12px/1 var(--font-body);
cursor: pointer;
}
.secondaryBtn:hover { background: var(--surface-3); color: var(--fg); }
.secondaryBtn:disabled { opacity: 0.45; cursor: not-allowed; }

.authPanel {
padding: 14px 16px;
border: 1px solid var(--brand);
Expand Down
Loading