Skip to content

Commit 77cbe2f

Browse files
committed
feat(compositor): macOS export encodes — first MP4 out of the Metal engine
Export had never produced a frame on macOS. Three things stood between it and one. **`send_composited` sent an empty frame.** It called `render_nv12()` and then handed the encoder `self.sw` — a buffer nothing had ever written — with no pts. It now reads the composed NV12 back into the frame's planes via `read_nv12_scaled` and stamps the timestamp. The old body even carried `let _ = w; let _ = h; let _ = pts;`, which is the shape of code written to satisfy a signature rather than to do the work. **The clip walk was hand-rolled.** It decoded 1:1 while advancing `t` by `1/fps`, so it ignored speed regions, per-clip scene windowing and the cursor. That is precisely the slow-motion truncation `walk_composited_timeline`'s own doc records having cost once — "a GIF driven by its own loop is how the bug happened". It now calls the shared walk, so MP4, GIF and both backends agree on which source frame belongs at output frame N. **The encoder was configured for a pool that never existed.** The candidate list asked `h264_videotoolbox` for `AV_PIX_FMT_VIDEOTOOLBOX` and built a fresh `hw_frames_ctx` per export. But `ffmpeg -h encoder=h264_videotoolbox` reports `videotoolbox_vld nv12 yuv420p`: it takes SOFTWARE NV12 and uploads internally — and NV12 is exactly what the compositor produces. The candidate now asks for NV12 and the whole `hw_frames_ctx` construction is deleted. An entire layer of hardware-pool plumbing that had never run, replaced by the format both sides already speak. Measured on an M-series Mac against a real recording: 120 frames at **73 fps**, 1280x720, `h264_videotoolbox`. `ffmpeg -f null -` decodes it clean, and the frames carry the whole composition — background, padding, rounded corners, drop shadow and the circular PiP camera. Still absent from the export: audio (`audio.rs` is portable and compiles, but the muxing path has not been exercised on macOS).
1 parent 1fb71cb commit 77cbe2f

1 file changed

Lines changed: 52 additions & 111 deletions

File tree

crates/compositor/src/pipeline_macos.rs

Lines changed: 52 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,15 @@ impl ExportCodec {
378378
pub fn candidates(&self) -> &'static [EncoderCandidate] {
379379
match self {
380380
ExportCodec::H264 => &[
381+
// `h264_videotoolbox` annonce `videotoolbox_vld nv12 yuv420p` : il accepte
382+
// donc des frames LOGICIELLES NV12 et fait l'upload lui-même. C'est
383+
// exactement le format que le compositeur produit, donc pas de
384+
// `hw_frames_ctx` à construire ni de pool à partager entre décodeur et
385+
// encodeur — un étage de complexité que le port avait écrit et qui n'a
386+
// jamais tourné.
381387
EncoderCandidate {
382388
name: "h264_videotoolbox",
383-
pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX,
389+
pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_NV12,
384390
},
385391
EncoderCandidate {
386392
name: "libopenh264",
@@ -390,7 +396,7 @@ impl ExportCodec {
390396
ExportCodec::H265 => &[
391397
EncoderCandidate {
392398
name: "hevc_videotoolbox",
393-
pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX,
399+
pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_NV12,
394400
},
395401
EncoderCandidate {
396402
name: "libkvazaar",
@@ -539,43 +545,6 @@ impl VideoEncoder {
539545
// que le décodeur. Pour l'instant, on crée un hw_frames_ctx frais à partir du
540546
// device VT par défaut (un seul device VideoToolbox par process — OK pour un
541547
// export mono-clip).
542-
if candidate.pix_fmt == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX {
543-
// `av_hwframe_ctx_alloc(device_ref)` prend UN argument et REND l'AVBufferRef ;
544-
// et il lui faut un device VideoToolbox, qu'il faut donc créer d'abord.
545-
let mut hw_device: *mut crate::ffi::AVBufferRef = ptr::null_mut();
546-
let r = crate::ffi::av_hwdevice_ctx_create(
547-
&mut hw_device,
548-
crate::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
549-
ptr::null(),
550-
ptr::null_mut(),
551-
0,
552-
);
553-
if r < 0 || hw_device.is_null() {
554-
crate::ffi::avcodec_free_context(&mut ctx);
555-
bail!("av_hwdevice_ctx_create (VT, encodeur) : {r}");
556-
}
557-
let hw_frames = crate::ffi::av_hwframe_ctx_alloc(hw_device);
558-
if hw_frames.is_null() {
559-
crate::ffi::av_buffer_unref(&mut hw_device);
560-
crate::ffi::avcodec_free_context(&mut ctx);
561-
bail!("av_hwframe_ctx_alloc (VT)");
562-
}
563-
let fc = (*hw_frames).data as *mut crate::ffi::AVHWFramesContext;
564-
(*fc).format = crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX;
565-
(*fc).sw_format = crate::ffi::AVPixelFormat::AV_PIX_FMT_NV12;
566-
(*fc).width = w;
567-
(*fc).height = h;
568-
let mut hw_frames = hw_frames;
569-
if crate::ffi::av_hwframe_ctx_init(hw_frames) < 0 {
570-
crate::ffi::av_buffer_unref(&mut hw_frames);
571-
crate::ffi::av_buffer_unref(&mut hw_device);
572-
crate::ffi::avcodec_free_context(&mut ctx);
573-
bail!("av_hwframe_ctx_init (VT)");
574-
}
575-
(*ctx).hw_frames_ctx = crate::ffi::av_buffer_ref(hw_frames);
576-
crate::ffi::av_buffer_unref(&mut hw_frames);
577-
crate::ffi::av_buffer_unref(&mut hw_device);
578-
}
579548

580549
if let Err(e) = crate::ffi::averr(
581550
crate::ffi::avcodec_open2(ctx, enc, ptr::null_mut()),
@@ -650,25 +619,31 @@ impl VideoEncoder {
650619
pts: i64,
651620
) -> Result<()> {
652621
unsafe {
653-
// 1. RT → NV12 interne (render_nv12 écrit self.nv12_y / self.nv12_uv).
654-
compositor.render_nv12();
655-
// 2. NV12 → AVFrame (planes du caller).
622+
if self.sw.is_null() {
623+
bail!("send_composited: pas de frame logicielle (encodeur zero-copy non câblé)");
624+
}
625+
// Rendre le RGBA composé en NV12 côté GPU, PUIS le relire dans les plans de la
626+
// frame. Le port appelait bien `render_nv12()` mais envoyait ensuite une frame
627+
// que rien n'avait remplie, sans pts : l'encodeur recevait du contenu
628+
// indéterminé et des timestamps absents.
629+
compositor.render_nv12()?;
656630
crate::ffi::averr(
657631
crate::ffi::av_frame_make_writable(self.sw),
658632
"make_writable_sw",
659633
)?;
660-
let landing = if self.nv12.is_null() { self.sw } else { self.nv12 };
634+
compositor.read_nv12_scaled(
635+
w,
636+
h,
637+
(*self.sw).data[0],
638+
(*self.sw).linesize[0] as usize,
639+
(*self.sw).data[1],
640+
(*self.sw).linesize[1] as usize,
641+
)?;
642+
(*self.sw).pts = pts;
661643
crate::ffi::averr(
662-
crate::ffi::avcodec_send_frame(self.ctx, landing),
644+
crate::ffi::avcodec_send_frame(self.ctx, self.sw),
663645
"send_frame_composited",
664-
)?;
665-
// Note : le code complet qui peuple `landing->data[0]/data[1]` depuis
666-
// `compositor.read_nv12_scaled` viendra avec le câblage final de
667-
// `run_composited_multi` ; le squelette ci-dessus pose juste l'API.
668-
let _ = w;
669-
let _ = h;
670-
let _ = pts;
671-
Ok(())
646+
)
672647
}
673648
}
674649
}
@@ -826,66 +801,32 @@ pub fn run_composited_multi(
826801

827802
let mut opkt = unsafe { crate::ffi::av_packet_alloc() };
828803

829-
for clip in clips {
830-
if !screen_decs.contains_key(&clip.screen) {
831-
screen_decs.insert(
832-
clip.screen.clone(),
833-
Decoder::open(&clip.screen, gpu)?,
834-
);
835-
}
836-
if !webcam_decs.contains_key(&clip.webcam) {
837-
webcam_decs.insert(
838-
clip.webcam.clone(),
839-
Decoder::open(&clip.webcam, gpu)?,
840-
);
841-
}
842-
let sdec = screen_decs.get_mut(&clip.screen).unwrap();
843-
let wdec = webcam_decs.get_mut(&clip.webcam).unwrap();
844-
845-
// Seek initial (keyframes-only) aux bornes du clip.
846-
let start = clip.source_start_sec;
847-
let end = clip.source_end_sec.min(
848-
unsafe { sdec.available_duration_sec() }.unwrap_or(clip.source_end_sec),
849-
);
850-
if end <= start {
851-
continue;
852-
}
853-
unsafe {
854-
if sdec.seek_to(start)?.is_null() {
855-
continue;
856-
}
857-
if wdec
858-
.seek_to((start - clip.webcam_offset_sec).max(0.0))?
859-
.is_null()
860-
{
861-
continue;
862-
}
863-
}
864-
865-
// Boucle frame-par-frame. First-pass : pas de speed-regions ni de timeline
866-
// interpolation ; on rend à out_fps fixe du début à la fin du clip. La scène
867-
// globale a déjà été posée par le caller via `comp.set_scene(...)` (le napi
868-
// le fait avant `run_composited_multi`), donc on n'a pas à la repositionner.
869-
let mut t = start;
870-
while t < end {
871-
unsafe {
872-
let sf = sdec.next()?;
873-
let wf = wdec.next()?;
874-
if sf.is_null() || wf.is_null() {
875-
break;
876-
}
877-
comp.compose_frame(sf, wf, frames as f32, cfg)?;
878-
// send_composited : la première passe ne peuple pas encore les plans du
879-
// buffer d'encodeur depuis le NV12 interne — c'est un no-op côté bits,
880-
// mais il pose l'API et draine l'encodeur.
881-
enc.send_composited(comp, out_w, out_h, frames as i64)?;
804+
// La marche de timeline est PARTAGÉE (`timeline_walk`) : c'est elle qui décide quelle
805+
// frame source appartient à quelle frame de sortie, en tenant compte des régions de
806+
// vitesse, du fenêtrage de scène par clip et du curseur. La version maison qui vivait
807+
// ici décodait 1:1 en avançant `t` de `1/fps`, donc elle ignorait tout cela — et c'est
808+
// exactement le bug de troncature en slow-motion que la doc de `walk_composited_timeline`
809+
// raconte avoir déjà coûté une fois.
810+
let scene = comp.scene_snapshot();
811+
frames = unsafe {
812+
crate::timeline_walk::walk_composited_timeline(
813+
clips,
814+
gpu,
815+
comp,
816+
cfg,
817+
out_fps,
818+
&scene,
819+
&mut screen_decs,
820+
&mut webcam_decs,
821+
&mut |n| {
822+
enc.send_composited(comp, out_w, out_h, n as i64)?;
882823
drain_encoder(ectx, octx, ostream, opkt)?;
883-
}
884-
frames += 1;
885-
progress(frames);
886-
t += 1.0 / out_fps as f64;
887-
}
888-
}
824+
progress(n + 1);
825+
Ok(())
826+
},
827+
&mut |_, _, _, _| Ok(()),
828+
)?
829+
};
889830

890831
// Flush : un null frame à l'encodeur finalise son bitstream.
891832
unsafe {

0 commit comments

Comments
 (0)