diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adbb039db6..9a719360f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - name: Typecheck tests against a baseline shell: bash env: - BASELINE: 80 + BASELINE: 71 run: | set -uo pipefail COUNT=$(npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep -c 'error TS' || true) @@ -88,8 +88,6 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: npm run test - - run: npm run test:browser:install - - run: npm run test:browser build: name: Build diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index b3a4b951d4..9b2e428810 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -499,11 +499,13 @@ pub struct GifParamsInput { /// `Compositor` (équivalent de `cfg.cursor = false` dans /// `run_composited_multi`). pub struct ExportGifTask { - screen_path: String, - webcam_path: String, - /// Option (pas Option) parce que napi-rs n'expose - /// pas PathBuf en type d'entrée pratique. - cursor_path: Option, + /// Same clip list the MP4 export takes — GIF is now a multiclip export + /// driven by the same walk, not a single-file special case. + clips: Vec, + /// Scene JSON from the app, exactly as `exportMulti` receives it: it + /// carries background, layout, webcam and cursor. Absent/invalid → no + /// scene, same as a preview that was never configured. + scene_json: Option, out_path: PathBuf, params: GifExportParams, on_progress: Option>, @@ -520,12 +522,45 @@ impl Task for ExportGifTask { // RT avec la preview (sa propre `Compositor::new_sized`) mais le // 3D engine de la preview pollue quand même, d'où la pause. let _previews = PreviewPause::begin(); + + // Same construction as ExportMultiTask — GIF and MP4 differ only in the + // encoder, so everything up to it is built identically. + let gpu = Gpu::create(false).map_err(|e| Error::from_reason(format!("{e:#}")))?; + let mut cfg = config::all().pop().expect("au moins une config"); // C8 + cfg.zoom = false; + cfg.layout_anim = false; + cfg.mblur_n = 1; + + let scene = self.scene_json.as_deref().and_then(|j| Scene::from_json(j).ok()); + if let Some(scene) = &scene { + cfg.bg_blur = scene.effects.blur; + cfg.cursor = scene.cursor.show; + } else { + cfg.cursor = false; + } + + let width = self + .params + .width + .unwrap_or(openscreen_compositor::gif_export::DEFAULT_GIF_WIDTH); + let height = self + .params + .height + .unwrap_or(openscreen_compositor::gif_export::DEFAULT_GIF_HEIGHT); + let comp = Compositor::new_sized(&gpu, width, height) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + if let Some(scene) = &scene { + comp.set_live_params(live_params_from_scene(scene)); + } + comp.set_scene(scene); + let mut progress = throttled_progress(self.on_progress.take()); openscreen_compositor::gif_export::export_gif( - &self.screen_path, - &self.webcam_path, - self.cursor_path.as_deref(), + &self.clips, &self.out_path, + &gpu, + &comp, + &cfg, &self.params, &mut progress, ) @@ -554,13 +589,26 @@ impl Task for ExportGifTask { /// throttled à ~10/s comme l'export MP4 (voir `throttled_progress`). #[napi] pub fn export_gif( - screen_path: String, - webcam_path: String, - cursor_path: Option, + clips: Vec, out_path: String, + scene_json: Option, params: Option, on_progress: Option, ) -> Result> { + // Deliberately the same argument shape as `export_multi`: the caller builds + // one clip list and one scene, and picks the container. Cursor comes from + // the scene like every other effect — there is no GIF-specific input left. + let clips = clips + .into_iter() + .map(|c| pipeline::ClipSource { + screen: c.screen_path, + webcam: c.webcam_path, + source_start_sec: c.source_start_sec, + source_end_sec: c.source_end_sec, + webcam_offset_sec: c.webcam_offset_sec, + has_audio: c.has_audio, + }) + .collect(); let gif_params = params .map(|p| GifExportParams { width: p.width, @@ -571,9 +619,8 @@ pub fn export_gif( }) .unwrap_or_default(); Ok(AsyncTask::new(ExportGifTask { - screen_path, - webcam_path, - cursor_path, + clips, + scene_json, out_path: PathBuf::from(out_path), params: gif_params, on_progress: make_progress_tsfn(on_progress)?, diff --git a/crates/compositor/src/gif_export.rs b/crates/compositor/src/gif_export.rs index 2fe600ede3..56bb594313 100644 --- a/crates/compositor/src/gif_export.rs +++ b/crates/compositor/src/gif_export.rs @@ -25,13 +25,18 @@ //! //! ## What's in this file //! -//! - `export_gif` — the orchestrator. Opens the same `Player` the live -//! preview drives, constructs a `Compositor` sized to the requested -//! output dims, and per output frame: `Player::step` → -//! `Compositor::compose_frame` → `Compositor::readback_direct` → -//! optional Floyd-Steinberg dither → nearest-color index → -//! `GifWriter::write_frame`. Reports the same `GifStats` shape the -//! MP4 `pipeline::Stats` returns. +//! - `export_gif` — the orchestrator. Drives `pipeline::walk_composited_timeline`, +//! the SAME clip walk the MP4 exporter uses, so clip iteration, speed +//! segments and output-time decoder advancement have exactly one +//! definition. Per output frame the walk composes, then this module does +//! `Compositor::readback_direct` → palette → optional fused +//! Floyd-Steinberg → `GifWriter::write_frame`. Reports the same `GifStats` +//! shape the MP4 `pipeline::Stats` returns. +//! +//! It previously ran its own loop over the live-preview `Player`, stepping +//! one SOURCE frame per OUTPUT frame — which made a 30 s/60 fps recording +//! export as 6 s of content stretched over 30 s, and could not decode files +//! the MP4 path handled. `tests/export_timing.rs` pins the behaviour. //! - `GifWriter` — GIF89a format writer: header, optional Netscape 2.0 //! loop extension, per-frame Graphics Control Extension + Image //! Descriptor + LZW image data, trailer. Pure std `Write`. @@ -65,9 +70,8 @@ use crate::compositor::Compositor; use crate::config::Cfg; -use crate::cursor::CursorTrack; use crate::d3d::Gpu; -use crate::live::Player; +use crate::pipeline::{walk_composited_timeline, ClipSource, Decoder}; use anyhow::{anyhow, bail, Context, Result}; use std::collections::HashMap; use std::fs::File; @@ -143,21 +147,28 @@ impl Default for GifExportParams { } } -/// Drive a single-clip GIF export end-to-end. Mirrors the shape of -/// `pipeline::run_composited` so the bench can compare apples to -/// apples once the readback cost has been measured. +/// Drive a multiclip GIF export end-to-end. +/// +/// Deliberately the same shape as `pipeline::run_composited_multi`, and +/// deliberately driven by the same `walk_composited_timeline`: the clip walk, +/// the speed segments and the output-time decoder advancement are the video +/// exporter's, not a second implementation. Only the per-frame sink differs — +/// MP4 hands the composed texture to a hardware NV12 encoder, GIF reads it back +/// to the CPU and quantizes it to 256 colours. +/// /// A failed run leaves a truncated GIF under exactly the name the user thinks /// they exported. Remove it rather than leave it lying around — same contract /// as `discard_partial_output` on the MP4 path. pub fn export_gif( - screen: &str, - webcam: &str, - cursor_json: Option<&str>, + clips: &[ClipSource], out_path: &Path, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, params: &GifExportParams, progress: &mut dyn FnMut(u64), ) -> Result { - let result = export_gif_inner(screen, webcam, cursor_json, out_path, params, progress); + let result = export_gif_inner(clips, out_path, gpu, comp, cfg, params, progress); if result.is_err() { let _ = std::fs::remove_file(out_path); } @@ -165,53 +176,25 @@ pub fn export_gif( } fn export_gif_inner( - screen: &str, - webcam: &str, - cursor_json: Option<&str>, + clips: &[ClipSource], out_path: &Path, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, params: &GifExportParams, progress: &mut dyn FnMut(u64), ) -> Result { + if clips.is_empty() { + bail!("export_gif: aucun clip à exporter"); + } + // The caller builds the compositor at the output size (same contract as + // `run_composited_multi` / `ExportParams`), so these must agree with what + // `readback_direct` hands back — asserted per frame below. let width = params.width.unwrap_or(DEFAULT_GIF_WIDTH); let height = params.height.unwrap_or(DEFAULT_GIF_HEIGHT); let fps = params.fps.unwrap_or(DEFAULT_GIF_FPS).max(1); let dither = params.dither; - // Native compositor: D3D11 device + offscreen RT sized to the - // output. Same idiom as `run_composited` (one Cfg, full C8 - // effects), but zoom / layout anim / motion blur are off — GIF is - // a still-timeline artefact, the moving-camera cost that makes C8 - // relevant for MP4 doesn't move the needle on a 256-colour - // palette. - let gpu = Gpu::create(false).map_err(|e| anyhow!("export_gif: gpu init: {e:#}"))?; - let comp = Compositor::new_sized(&gpu, width, height) - .map_err(|e| anyhow!("export_gif: compositor: {e:#}"))?; - if let Some(cursor_path) = cursor_json { - match CursorTrack::load(cursor_path, 0.0, 24.0 * 3600.0) { - Ok(track) => comp.set_cursor(track), - Err(e) => bail!("export_gif: cursor.json load: {e:#}"), - } - } - let cfg = Cfg { - name: "gif", - composite: true, - cursor: cursor_json.is_some(), - ..Cfg::c8() - }; - - // Open the screen + webcam. Use the existing `Player` so cursor - // framing and webcam lockstep are identical to the live preview. - let mut player = unsafe { Player::open(screen, webcam, &gpu) } - .map_err(|e| anyhow!("export_gif: player open: {e:#}"))?; - - // Frame plan: the source decodes at 60 fps (fixture) and the - // output is `fps` — so we drive the player one step per output - // frame and stop after `target_frames` (i.e. the source time is - // `target_frames / fps`). We don't know the source duration up - // front; we let the player EOF when it has no more frames. - let target_frames = estimate_target_frames(&player, fps) - .ok_or_else(|| anyhow!("export_gif: source has zero decodable frames"))?; - // Set up the GIF writer up front: file + global header. We use a // per-frame local palette (the standard "high-quality" form: a // palette tuned to each frame's colours), so the global palette in @@ -229,6 +212,12 @@ fn export_gif_inner( // `100 / fps` cs per frame, rounded to the nearest unit the // GIF spec supports. u16 caps at 65535 — 10.9 minutes per // frame, plenty. + // + // ponytail: integer centiseconds can't express every fps exactly + // (12 → 8 cs → 12.5 fps). `video_duration_s` below is computed + // from the delays actually written, so the reported duration never + // disagrees with the file. Fractional accumulation if a viewer ever + // cares about the ~4% drift. let delay_cs: u16 = (100_u32 / fps).max(1) as u16; // Pre-allocate the per-frame index buffer. Reused across @@ -245,8 +234,8 @@ fn export_gif_inner( let mut palette_rgb: Vec = vec![0u8; PALETTE_COLORS * 3]; let t0 = Instant::now(); - let mut frames: u64 = 0; - { + let scene = comp.scene_snapshot(); + let frames = { let mut gw = GifWriter::new(&mut writer, width as u16, height as u16)?; gw.write_header()?; // Netscape 2.0 application extension drives the loop count. @@ -258,81 +247,83 @@ fn export_gif_inner( }; gw.write_netscape_loop(loops)?; - for n in 0..target_frames { - // Drive the player one output frame. `Player::step` is - // the same path the live preview's render thread uses. - let more = unsafe { player.step(&comp, &cfg) } - .map_err(|e| anyhow!("export_gif: player.step @ frame {n}: {e:#}"))?; - if !more { - break; - } - // CPU readback of the staged RT (RGBA8 - // tightly-packed, `width * height * 4` bytes). The - // dominant per-frame cost. - let (rw, rh, rgba) = unsafe { comp.readback_direct() } - .map_err(|e| anyhow!("export_gif: readback @ frame {n}: {e:#}"))?; - debug_assert_eq!(rw, width); - debug_assert_eq!(rh, height); - debug_assert_eq!(rgba.len(), (width as usize) * (height as usize) * 4); - - // Refresh the palette on a schedule. Building the - // histogram and running median-cut is O(unique colors) - // — fast enough at 480p and our 30-frame cadence. - if n % PALETTE_REQUANTIZE_EVERY == 0 { - build_palette_median_cut(&rgba, PALETTE_COLORS, &mut palette_rgb); - } - - // Quantize (with optional dithering). The dither pass - // mutates a working copy of the readback pixels - // (in-place error propagation); the quantize pass - // then walks the (possibly dithered) pixels once and - // writes indices. - let mut rgba_buf = rgba; - if dither { - floyd_steinberg(&mut rgba_buf, width, height, &mut err_cur, &mut err_next); - } - map_to_indices(&palette_rgb, &rgba_buf, &mut indices); + let mut screen_decs: HashMap = HashMap::new(); + let mut webcam_decs: HashMap = HashMap::new(); + screen_decs.insert(clips[0].screen.clone(), unsafe { + Decoder::open(&clips[0].screen, gpu)? + }); + + let frames = unsafe { + walk_composited_timeline( + clips, + gpu, + comp, + cfg, + fps as i32, + &scene, + &mut screen_decs, + &mut webcam_decs, + &mut |frame_index| { + // CPU readback of the staged RT (RGBA8 tightly-packed, + // `width * height * 4` bytes). The dominant per-frame cost, + // and the reason GIF can't use the MP4 zero-copy sink. + let (rw, rh, rgba) = comp + .readback_direct() + .map_err(|e| anyhow!("export_gif: readback @ frame {frame_index}: {e:#}"))?; + debug_assert_eq!(rw, width); + debug_assert_eq!(rh, height); + + // Refresh the palette on a schedule. Building the histogram + // and running median-cut is O(unique colors) — fast enough at + // 480p on our 30-frame cadence. + if frame_index % PALETTE_REQUANTIZE_EVERY == 0 { + build_palette_median_cut(&rgba, PALETTE_COLORS, &mut palette_rgb); + } - // Per-frame palette (GIF local palette, written by - // `write_frame`). - gw.write_frame(&indices, &palette_rgb, delay_cs, fps)?; + // Quantize (with optional dithering). The dither pass diffuses + // the error against the CHOSEN PALETTE ENTRY, so it has to run + // fused with the index mapping — see `map_to_indices_dithered`. + if dither { + map_to_indices_dithered( + &palette_rgb, + &rgba, + width, + height, + &mut err_cur, + &mut err_next, + &mut indices, + ); + } else { + map_to_indices(&palette_rgb, &rgba, &mut indices); + } - frames += 1; - progress(frames); - } + // Per-frame palette (GIF local palette, written by `write_frame`). + gw.write_frame(&indices, &palette_rgb, delay_cs, fps)?; + progress(frame_index + 1); + Ok(()) + }, + // GIF has no audio track, so clip boundaries need no work. + &mut |_, _, _, _| Ok(()), + )? + }; - // Trailer is written on drop (the writer's `Drop` flushes - // any pending bytes and appends `0x3B`). gw.finish()?; - } + frames + }; // Drop the writer before stat-ing the file so the trailer is // flushed. drop(writer); let wall_s = t0.elapsed().as_secs_f64(); let fps_actual = if wall_s > 0.0 { frames as f64 / wall_s } else { 0.0 }; - let video_duration_s = frames as f64 / fps as f64; + // From the delays actually written, not from the requested fps — see the + // `delay_cs` note above. + let video_duration_s = frames as f64 * (delay_cs as f64 / 100.0); let file_bytes = std::fs::metadata(out_path).map(|m| m.len()).unwrap_or(0); Ok(GifStats { frames, wall_s, fps: fps_actual, video_duration_s, file_bytes }) } -/// Estimate how many output frames a single-clip export will produce. -/// -/// The Player doesn't expose its source duration up front without -/// seeking to the EOF. We open the screen decoder, ask for the -/// duration, and floor to the nearest output frame. Returns `None` -/// when the source is undecodable or the duration is zero — the caller -/// should fail the export with a clear message in that case. -fn estimate_target_frames(player: &Player, fps: u32) -> Option { - // `screen_duration_sec` is `unsafe` because it touches the raw - // decoder. We just opened the player above and never moved it, so - // the contract is satisfied — but the `unsafe` keyword on the - // Player method has to be wrapped. - let dur = unsafe { player.screen_duration_sec() }?; - let out = (dur * fps as f64).floor() as u64; - if out == 0 { None } else { Some(out) } -} // ===================================================================== // GIF89a format writer (pure std::io::Write). @@ -876,6 +867,82 @@ fn map_to_indices(palette_rgb: &[u8], rgba: &[u8], indices: &mut [u8]) { } } +/// Nearest-palette mapping with Floyd-Steinberg error diffusion, fused. +/// +/// Fused on purpose. A separate dither pass has nothing to diffuse against: +/// quantizing to `round()` of an already-integer channel gives an error of +/// exactly zero for every pixel, so the whole pass is a no-op that still costs +/// a full float traversal. The error that matters is `pixel - palette[chosen]`, +/// which only exists once the palette entry has been picked — hence one loop. +/// +/// Kernel is the standard 7/16 right, 3/16 below-left, 5/16 below, +/// 1/16 below-right. Two row buffers keep the working set at O(width). +#[allow(clippy::too_many_arguments)] +fn map_to_indices_dithered( + palette_rgb: &[u8], + rgba: &[u8], + width: u32, + height: u32, + err_cur: &mut [f32], + err_next: &mut [f32], + indices: &mut [u8], +) { + let w = width as usize; + let h = height as usize; + debug_assert_eq!(indices.len(), w * h); + debug_assert_eq!(palette_rgb.len(), PALETTE_COLORS * 3); + + err_cur.fill(0.0); + err_next.fill(0.0); + + for y in 0..h { + for x in 0..w { + let base = (y * w + x) * 4; + let e = x * 3; + // Channel value carrying the error diffused into this pixel. + let cr = (rgba[base] as f32 + err_cur[e]).clamp(0.0, 255.0); + let cg = (rgba[base + 1] as f32 + err_cur[e + 1]).clamp(0.0, 255.0); + let cb = (rgba[base + 2] as f32 + err_cur[e + 2]).clamp(0.0, 255.0); + + let mut best_idx = 0usize; + let mut best_dist = f32::MAX; + for k in 0..PALETTE_COLORS { + let dr = cr - palette_rgb[k * 3] as f32; + let dg = cg - palette_rgb[k * 3 + 1] as f32; + let db = cb - palette_rgb[k * 3 + 2] as f32; + let dist = dr * dr + dg * dg + db * db; + if dist < best_dist { + best_dist = dist; + best_idx = k; + } + } + indices[y * w + x] = best_idx as u8; + + // THE error: distance to the colour actually written. + let er = cr - palette_rgb[best_idx * 3] as f32; + let eg = cg - palette_rgb[best_idx * 3 + 1] as f32; + let eb = cb - palette_rgb[best_idx * 3 + 2] as f32; + + let mut spread = |slot: &mut [f32], idx: usize, f: f32| { + slot[idx] += er * f; + slot[idx + 1] += eg * f; + slot[idx + 2] += eb * f; + }; + if x + 1 < w { + spread(err_cur, e + 3, 7.0 / 16.0); + spread(err_next, e + 3, 1.0 / 16.0); + } + if x > 0 { + spread(err_next, e - 3, 3.0 / 16.0); + } + spread(err_next, e, 5.0 / 16.0); + } + // Next row becomes current; clear the far row for reuse. + err_cur.copy_from_slice(err_next); + err_next.fill(0.0); + } +} + // ===================================================================== // Floyd-Steinberg dither. // ===================================================================== diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs index 2bcf278f13..f2e03c3025 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline.rs @@ -11,7 +11,8 @@ use crate::config::Cfg; use crate::cursor::CursorTrack; use crate::d3d::Gpu; use crate::ffi::*; -use crate::regions::speed_segments_for_window; +use crate::regions::{speed_segments_for_window, SpeedSegment}; +use crate::scene::Scene; use anyhow::{anyhow, bail, Result}; use std::collections::HashMap; use std::ffi::{c_void, CString}; @@ -1038,82 +1039,39 @@ impl Default for ExportParams { } } -unsafe fn run_multi_inner( + +/// The format-agnostic half of a multiclip export: clip iteration, decoder +/// reuse, availability clamping, per-clip scene windowing, keyframe seeks, +/// cursor binding, speed segments, and — the part that matters — advancing the +/// decoders by OUTPUT time rather than by source frames. +/// +/// MP4 and GIF differ only in what they do with a composed frame (hardware NV12 +/// encode vs CPU readback + palette quantize), so that is all they supply here. +/// Sharing this walk is what keeps "which source frame belongs at output frame +/// N" defined exactly once: a GIF driven by its own loop is how the slow-motion +/// truncation bug happened. +/// +/// `on_frame` runs after `compose_frame` with the running output index; +/// `on_clip_end` runs once per clip with its clamped source window, the frames +/// it produced, and the speed segments used (MP4 needs those for audio). +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn walk_composited_timeline( clips: &[ClipSource], - out: &str, gpu: &Gpu, comp: &Compositor, cfg: &Cfg, - params: &ExportParams, - progress: &mut dyn FnMut(u64), -) -> Result { - if clips.is_empty() { - bail!("aucun clip à exporter"); - } - let (out_w, out_h) = (params.width, params.height); - // décodeurs ouverts une fois par chemin, réutilisés entre clips (screen ≠ webcam → 2 maps - // pour deux &mut indépendants). - let mut screen_decs: HashMap = HashMap::new(); - let mut webcam_decs: HashMap = HashMap::new(); - - // fps de sortie : choix explicite de l'app si fourni, sinon dérivé du 1er clip (recordings - // uniformes) — comportement historique. - screen_decs.insert(clips[0].screen.clone(), Decoder::open(&clips[0].screen, gpu)?); - let out_fps = params - .fps - .unwrap_or_else(|| screen_decs[&clips[0].screen].fps().round().max(1.0) as u32) - as i32; - - // Curseur : la scène (déjà posée par l'appelant via comp.set_scene) pilote tout — même - // parité que le live. Piste par chemin ÉCRAN distinct (convention sidecar `.cursor.json`, - // temps ABSOLU non re-basé : chaque décodeur avance dans le même référentiel que la piste). - let scene = comp.scene_snapshot(); + out_fps: i32, + scene: &Option, + screen_decs: &mut HashMap, + webcam_decs: &mut HashMap, + on_frame: &mut dyn FnMut(u64) -> Result<()>, + on_clip_end: &mut dyn FnMut(usize, f64, u64, &[SpeedSegment]) -> Result<()>, +) -> Result { let cursor_enabled = scene.as_ref().map(|s| s.cursor.show).unwrap_or(false); let cursor_smoothing = scene.as_ref().map(|s| s.cursor.smoothing).unwrap_or(0.0); let mut cursor_tracks: HashMap = HashMap::new(); let mut cursor_active_path: Option = None; - - // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- - let (mut enc_hwdev, mut enc_frames) = make_enc_frames(gpu, out_w as i32, out_h as i32)?; - // débit proportionnel à la surface de sortie (référence : 8Mbps @ 1920x1080), plancher - // 2Mbps pour rester regardable sur les petites tailles. - let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); - let mut enc = VideoEncoder::open( - ¶ms.codec, - out_w as i32, - out_h as i32, - out_fps, - bit_rate, - enc_frames, - )?; - let ectx = enc.ctx; - - let mut octx: *mut AVFormatContext = ptr::null_mut(); - let outc = CString::new(out)?; - averr( - avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), - "alloc_output_context2", - )?; - let ostream = avformat_new_stream(octx, ptr::null()); - if ostream.is_null() { - bail!("video avformat_new_stream"); - } - averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; - (*ostream).time_base = (*ectx).time_base; - // Les deux streams doivent exister avant le header MP4 ; l'AAC reste ouvert pendant le - // rendu puis reçoit le PCM assemblé à partir des comptes de frames réellement produits. - let mut audio_encoder = AacEncoder::open(octx)?; - let mut pb: *mut AVIOContext = ptr::null_mut(); - averr(avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), "avio_open")?; - sn_fmt_set_pb(octx, pb); - averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; - - let opkt = av_packet_alloc(); let mut frames: u64 = 0; - let mut clip_frame_counts = vec![0u64; clips.len()]; - let mut clip_pcm: Vec> = - std::iter::repeat_with(|| None).take(clips.len()).collect(); - let t0 = Instant::now(); for (clip_index, clip) in clips.iter().enumerate() { if !screen_decs.contains_key(&clip.screen) { @@ -1242,40 +1200,138 @@ unsafe fn run_multi_inner( } comp.compose_frame(sf, wf, frames as f32, cfg)?; - let outf = av_frame_alloc(); - averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; - let out_tex = (*outf).data[0] as *mut c_void; - let out_slice = (*outf).data[1] as u32; - comp.rgb_to_nv12_scaled(out_w, out_h, out_tex, out_slice)?; - (*outf).pts = frames as i64; - enc.send(outf)?; - drain_encoder(ectx, octx, ostream, opkt)?; - av_frame_free(&mut (outf as *mut _)); + on_frame(frames)?; frames += 1; - progress(frames); } } - clip_frame_counts[clip_index] = frames - frames_before_clip; - if clip.has_audio && clip_frame_counts[clip_index] > 0 { - match decode_clip_audio(&clip.screen, clip.source_start_sec, source_end_sec) { - Ok(Some(pcm)) => { - clip_pcm[clip_index] = Some(stretch_clip_pcm_by_speed( - &pcm, - &speed_segments, - out_fps as f64, - )); + on_clip_end(clip_index, source_end_sec, frames - frames_before_clip, &speed_segments)?; + } + + comp.set_cursor_time(None); + comp.set_timeline_time(None); + Ok(frames) +} + +unsafe fn run_multi_inner( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + if clips.is_empty() { + bail!("aucun clip à exporter"); + } + let (out_w, out_h) = (params.width, params.height); + // décodeurs ouverts une fois par chemin, réutilisés entre clips (screen ≠ webcam → 2 maps + // pour deux &mut indépendants). + let mut screen_decs: HashMap = HashMap::new(); + let mut webcam_decs: HashMap = HashMap::new(); + + // fps de sortie : choix explicite de l'app si fourni, sinon dérivé du 1er clip (recordings + // uniformes) — comportement historique. + screen_decs.insert(clips[0].screen.clone(), Decoder::open(&clips[0].screen, gpu)?); + let out_fps = params + .fps + .unwrap_or_else(|| screen_decs[&clips[0].screen].fps().round().max(1.0) as u32) + as i32; + + // 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(); + + // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- + let (mut enc_hwdev, mut enc_frames) = make_enc_frames(gpu, out_w as i32, out_h as i32)?; + // débit proportionnel à la surface de sortie (référence : 8Mbps @ 1920x1080), plancher + // 2Mbps pour rester regardable sur les petites tailles. + let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); + let mut enc = VideoEncoder::open( + ¶ms.codec, + out_w as i32, + out_h as i32, + out_fps, + bit_rate, + enc_frames, + )?; + let ectx = enc.ctx; + + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let ostream = avformat_new_stream(octx, ptr::null()); + if ostream.is_null() { + bail!("video avformat_new_stream"); + } + averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; + (*ostream).time_base = (*ectx).time_base; + // Les deux streams doivent exister avant le header MP4 ; l'AAC reste ouvert pendant le + // rendu puis reçoit le PCM assemblé à partir des comptes de frames réellement produits. + let mut audio_encoder = AacEncoder::open(octx)?; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr(avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), "avio_open")?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + + let opkt = av_packet_alloc(); + let mut clip_frame_counts = vec![0u64; clips.len()]; + let mut clip_pcm: Vec> = + std::iter::repeat_with(|| None).take(clips.len()).collect(); + let t0 = Instant::now(); + + let frames = walk_composited_timeline( + clips, + gpu, + comp, + cfg, + out_fps, + &scene, + &mut screen_decs, + &mut webcam_decs, + &mut |frame_index| { + // Hardware path: the composed texture goes straight into an NV12 + // encoder frame, so nothing ever descends to system memory. + let outf = av_frame_alloc(); + averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; + let out_tex = (*outf).data[0] as *mut c_void; + let out_slice = (*outf).data[1] as u32; + comp.rgb_to_nv12_scaled(out_w, out_h, out_tex, out_slice)?; + (*outf).pts = frame_index as i64; + enc.send(outf)?; + drain_encoder(ectx, octx, ostream, opkt)?; + av_frame_free(&mut (outf as *mut _)); + progress(frame_index + 1); + Ok(()) + }, + &mut |clip_index, source_end_sec, frames_in_clip, speed_segments| { + clip_frame_counts[clip_index] = frames_in_clip; + let clip = &clips[clip_index]; + if clip.has_audio && frames_in_clip > 0 { + match decode_clip_audio(&clip.screen, clip.source_start_sec, source_end_sec) { + Ok(Some(pcm)) => { + clip_pcm[clip_index] = Some(stretch_clip_pcm_by_speed( + &pcm, + speed_segments, + out_fps as f64, + )); + } + Ok(None) => eprintln!( + "[pipeline] warning: clip #{} déclaré audio mais sans flux décodable; silence conservé", + clip_index, + ), + Err(error) => eprintln!( + "[pipeline] warning: décodage audio du clip #{} échoué ({error:#}); silence conservé", + clip_index, + ), } - Ok(None) => eprintln!( - "[pipeline] warning: clip #{} déclaré audio mais sans flux décodable; silence conservé", - clip_index, - ), - Err(error) => eprintln!( - "[pipeline] warning: décodage audio du clip #{} échoué ({error:#}); silence conservé", - clip_index, - ), } - } - } + Ok(()) + }, + )?; comp.set_cursor_time(None); comp.set_timeline_time(None); diff --git a/crates/compositor/tests/export_timing.rs b/crates/compositor/tests/export_timing.rs new file mode 100644 index 0000000000..ac67d07bbf --- /dev/null +++ b/crates/compositor/tests/export_timing.rs @@ -0,0 +1,210 @@ +//! Frame-timing regression net for the export paths. +//! +//! The bug class this exists to catch is silent: an exporter that advances its +//! decoder by one SOURCE frame per OUTPUT frame emits the right number of +//! frames while covering only `out_fps / source_fps` of the recording. Frame +//! count alone therefore proves nothing — the content has to be checked. +//! +//! So the fixture is a 4 s / 60 fps clip whose colour changes every second +//! (red → green → blue → white). A correct export spans all four colours; a +//! mis-advanced one stays red for its whole length. +//! +//! Needs a D3D11 GPU and the generated media, so it is opt-in: set +//! OPENSCREEN_TEST_MEDIA to a directory holding `screen_colors.mp4` and +//! `webcam_gray.mp4`. Without it every test here skips (no CI builds this +//! crate today — see the Rust-CI gap noted in the PR). +//! +//! Regenerate the media with the vendored ffmpeg: +//! for c in red green blue white; do ffmpeg -f lavfi \ +//! -i "color=c=$c:size=640x360:duration=1:rate=60" -c:v libopenh264 \ +//! -g 60 -pix_fmt yuv420p seg_$c.mp4; done +//! ffmpeg -f concat -safe 0 -i concat.txt -c copy screen_colors.mp4 +//! ffmpeg -f lavfi -i "color=c=gray:size=320x240:duration=4:rate=60" \ +//! -c:v libopenh264 -g 60 -pix_fmt yuv420p webcam_gray.mp4 + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::gif_export::{self, GifExportParams}; +use openscreen_compositor::pipeline::{self, ClipSource, ExportCodec, ExportParams}; +use std::path::PathBuf; + +const SOURCE_SEC: f64 = 4.0; + +/// Per-frame local colour tables, in order, from a GIF89a file. +/// +/// Enough of the format to walk block-to-block: extensions are skipped by +/// their sub-block chain, image descriptors yield their local table and then +/// their LZW data is skipped the same way. No LZW decode — the palette alone +/// says which colours a frame is made of, which is all the timing assertions +/// need. +fn gif_frame_palettes(bytes: &[u8]) -> Vec> { + fn table_len(packed: u8) -> usize { + if packed & 0x80 == 0 { + 0 + } else { + 3 * (1usize << ((packed & 0x07) + 1)) + } + } + /// Skips a `len,data…,0` sub-block chain, returning the position after it. + fn skip_sub_blocks(bytes: &[u8], mut p: usize) -> usize { + while p < bytes.len() && bytes[p] != 0 { + p += 1 + bytes[p] as usize; + } + p + 1 + } + + let mut palettes = Vec::new(); + let mut p = 6; // "GIF89a" + let packed = bytes[p + 4]; + p += 7 + table_len(packed); // logical screen descriptor + global table + + while p < bytes.len() { + match bytes[p] { + 0x21 => p = skip_sub_blocks(bytes, p + 2), // extension: 0x21, label, chain + 0x2C => { + let packed = bytes[p + 9]; + let start = p + 10; + let len = table_len(packed); + palettes.push( + bytes[start..start + len] + .chunks_exact(3) + .map(|c| [c[0], c[1], c[2]]) + .collect(), + ); + p = skip_sub_blocks(bytes, start + len + 1); // +1 = LZW min code size + } + _ => break, // 0x3B trailer, or done + } + } + palettes +} + +/// Mean `R - B` across a palette. The fixture's first second is pure red +/// (large positive) and its last is white (≈ 0), so this single number +/// separates "covered the timeline" from "stuck on frame 0". +fn redness(palette: &[[u8; 3]]) -> f64 { + if palette.is_empty() { + return 0.0; + } + palette + .iter() + .map(|c| c[0] as f64 - c[2] as f64) + .sum::() + / palette.len() as f64 +} + +fn media_dir() -> Option { + let dir = PathBuf::from(std::env::var("OPENSCREEN_TEST_MEDIA").ok()?); + dir.join("screen_colors.mp4").exists().then_some(dir) +} + +/// One clip covering the whole fixture. +fn whole_clip(dir: &PathBuf) -> ClipSource { + ClipSource { + screen: dir.join("screen_colors.mp4").to_string_lossy().into_owned(), + webcam: dir.join("webcam_gray.mp4").to_string_lossy().into_owned(), + source_start_sec: 0.0, + source_end_sec: SOURCE_SEC, + webcam_offset_sec: 0.0, + has_audio: false, + } +} + +/// MP4 at 30 fps over a 4 s source must emit 120 frames — i.e. the walk is +/// driven by OUTPUT time, not by "one source frame per output frame" (which +/// would still emit 120 frames but cover only 2 s of the recording; the GIF +/// test below is the one that catches the coverage half). +#[test] +fn mp4_export_frame_count_follows_output_fps() { + let Some(dir) = media_dir() else { + eprintln!("skipped: set OPENSCREEN_TEST_MEDIA"); + return; + }; + let gpu = Gpu::create(false).expect("gpu"); + let params = ExportParams { + width: 640, + height: 360, + fps: Some(30), + codec: ExportCodec::H264, + }; + let comp = Compositor::new_sized(&gpu, params.width, params.height).expect("compositor"); + let out = dir.join("out_timing.mp4"); + let stats = pipeline::run_composited_multi( + &[whole_clip(&dir)], + &out.to_string_lossy(), + &gpu, + &comp, + &Cfg::c8(), + ¶ms, + &mut |_| {}, + ) + .expect("mp4 export"); + + assert_eq!( + stats.frames, 120, + "4 s of source at 30 fps out must be 120 frames, got {}", + stats.frames + ); + let probed = pipeline::probe_frame_count(&out.to_string_lossy()).expect("probe"); + assert_eq!(probed, 120, "muxed file disagrees with the reported count"); +} + +/// The GIF must cover the WHOLE timeline, not just its first +/// `out_fps / source_fps` slice. +/// +/// This is the assertion frame count cannot make: an exporter that advances one +/// source frame per output frame still writes 48 frames for a 4 s / 12 fps +/// request — it just takes them all from the first 0.8 s, so every frame is red +/// and the GIF plays 5x slow. Comparing the first and last frame palettes +/// catches exactly that. +#[test] +fn gif_export_spans_the_whole_timeline() { + let Some(dir) = media_dir() else { + eprintln!("skipped: set OPENSCREEN_TEST_MEDIA"); + return; + }; + let out = dir.join("out_timing.gif"); + let params = GifExportParams { + width: Some(320), + height: Some(180), + fps: Some(12), + loop_count: None, + dither: false, + }; + let gpu = Gpu::create(false).expect("gpu"); + // Same contract as the MP4 path: the caller sizes the compositor to the output. + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositor"); + let stats = gif_export::export_gif( + &[whole_clip(&dir)], + &out, + &gpu, + &comp, + &Cfg::c8(), + ¶ms, + &mut |_| {}, + ) + .expect("gif export"); + + assert_eq!( + stats.frames, 48, + "4 s at 12 fps must be 48 frames, got {}", + stats.frames + ); + + let bytes = std::fs::read(&out).expect("read gif"); + let palettes = gif_frame_palettes(&bytes); + assert_eq!(palettes.len(), 48, "GIF carries {} frames", palettes.len()); + + let first = redness(&palettes[0]); + let last = redness(&palettes[palettes.len() - 1]); + assert!( + first > 40.0, + "first frame should be red-dominated (redness {first:.1})" + ); + assert!( + last < first - 40.0, + "last frame still looks like the first — the export never advanced past \ + the opening red second (first {first:.1}, last {last:.1})" + ); +} diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 56009ba1fb..b411ae2e98 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -413,6 +413,28 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { } return createSuccessResponse(requestId, stats); } + case "exportGif": { + const sender = event.sender; + const stats = await compositorViewService.exportGif( + request.payload.clips, + request.payload.outPath, + request.payload.sceneJson, + request.payload.params, + (frames) => { + if (!sender.isDestroyed()) { + sender.send("export:native-progress", frames); + } + }, + ); + if (!stats) { + return createErrorResponse( + requestId, + "UNAVAILABLE", + "Native compositor addon not present.", + ); + } + return createSuccessResponse(requestId, stats); + } default: return createErrorResponse( requestId, diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index c2932a89af..2e835d4b1d 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -494,17 +494,17 @@ export class CompositorViewService { ); } - /** Native single-clip GIF export (slice 1, behind `NATIVE_GIF_EXPORT_ENABLED`). - * Mirrors `exportMulti`'s shape, but the slice-1 surface is deliberately small: - * one screen + one webcam file, optional cursor sidecar (`.cursor.json`), - * no multiclip, no app `SceneDescription` (the Player drives the compositing, - * same as the live preview). Returns null when the addon is absent — the renderer - * treats that as "fall back to the legacy `gif.js` path" without raising. */ + /** Native GIF export. Same inputs as `exportMulti` — one clip list, one scene — + * because it is the same render: both drive `walk_composited_timeline` in the + * compositor crate and differ only in the encoder. The scene carries background, + * layout, webcam and cursor, so there is no GIF-specific input. + * + * Returns null when the addon is absent, which the renderer surfaces as a failed + * export — there is no longer a renderer-side GIF path to fall back to. */ async exportGif( - screenPath: string, - webcamPath: string, - cursorPath?: string | null, + clips: ClipInput[], outPath?: string, + sceneJson?: string, params?: GifParamsInput, onProgress?: (frames: number) => void, ): Promise { @@ -513,6 +513,12 @@ export class CompositorViewService { return null; } const target = outPath ?? path.join(app.getPath("temp"), "openscreen-native-export.gif"); - return addon.exportGif(screenPath, webcamPath, cursorPath ?? null, target, params, onProgress); + return addon.exportGif( + clips, + target, + sceneJson ? resolveSceneAssetPaths(sceneJson) : undefined, + params, + onProgress, + ); } } diff --git a/electron/native/compositor-view/addon.d.ts b/electron/native/compositor-view/addon.d.ts index 401a4b6a2a..67e617515f 100644 --- a/electron/native/compositor-view/addon.d.ts +++ b/electron/native/compositor-view/addon.d.ts @@ -148,20 +148,17 @@ export interface CompositorViewAddon { params?: ExportParamsInput, onProgress?: (frames: number) => void, ): Promise; - /** Native single-clip GIF export (slice 1, behind `NATIVE_GIF_EXPORT_ENABLED`). - * Mirrors `exportMulti`'s shape, but the slice-1 PR keeps the surface small: - * one screen + one webcam file (no multiclip), no app `SceneDescription` (the - * Player drives the compositing, same as the live preview), and no - * encoder-config codec pick — GIF is one codec. `cursorPath` follows the - * sidecar convention (`.cursor.json`); `null`/missing → render - * without cursor. `params` defaults to 854×480, 12 fps, infinite loop, no - * dithering (`GifExportParams::default`). `onProgress(frames)` is throttled - * to ~10/s like the MP4 path. */ + /** Native GIF export. Identical inputs to `exportMulti` — same clips, same + * scene — because it is the same render: both drive `walk_composited_timeline` + * in the compositor crate and differ only in the encoder. Cursor, background, + * layout and webcam all come from the scene, so there is no GIF-specific + * input. No codec pick: GIF is one codec. `params` defaults to 854×480, + * 12 fps, infinite loop, no dithering (`GifExportParams::default`). + * `onProgress(frames)` is throttled to ~10/s like the MP4 path. */ exportGif( - screenPath: string, - webcamPath: string, - cursorPath: string | null, + clips: ClipInput[], outPath: string, + sceneJson?: string, params?: GifParamsInput, onProgress?: (frames: number) => void, ): Promise; diff --git a/package-lock.json b/package-lock.json index c73d08aa52..e7c3c517b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.8.0-rc.3", + "version": "1.8.0-rc.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.8.0-rc.3", + "version": "1.8.0-rc.4", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", @@ -28,22 +28,18 @@ "@tiptap/extension-text-style": "^3.27.1", "@tiptap/react": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", - "@types/gif.js": "^0.2.5", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", "@uiw/react-color-colorful": "^2.9.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "deepagents": "^1.10.5", - "gif.js": "^0.2.0", "i18next": "^23.16.0", "langchain": "^1.2.39", "lucide-react": "^0.545.0", "mediabunny": "^1.40.1", "motion": "^12.38.0", "mp4box": "^2.3.0", - "pixi-filters": "^6.1.5", - "pixi.js": "^8.18.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.6.0", @@ -69,8 +65,6 @@ "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^5.2.0", - "@vitest/browser": "^4.1.4", - "@vitest/browser-playwright": "^4.1.4", "autoprefixer": "^10.5.0", "electron": "^41.2.1", "electron-builder": "^26.8.1", @@ -729,7 +723,9 @@ "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@bramus/specificity": { "version": "2.4.2", @@ -2356,12 +2352,6 @@ "@octokit/openapi-types": "^27.0.0" } }, - "node_modules/@pixi/colord": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", - "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", - "license": "MIT" - }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -2383,7 +2373,9 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@radix-ui/number": { "version": "1.1.1", @@ -4488,12 +4480,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/events": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", - "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", - "license": "MIT" - }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -4504,21 +4490,6 @@ "@types/node": "*" } }, - "node_modules/@types/gif.js": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@types/gif.js/-/gif.js-0.2.5.tgz", - "integrity": "sha512-OdDQYh9v7td9ztjaooBSqjUBAyAuui2xwDDmQcyRLd6c9T0iWgkebAoCBEdEEBoZG3ekJE/6UnH63Dzq0S3bvw==", - "license": "MIT", - "dependencies": { - "@types/events": "*" - } - }, - "node_modules/@types/gradient-parser": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@types/gradient-parser/-/gradient-parser-0.1.5.tgz", - "integrity": "sha512-r7K3NkJz3A95WkVVmjs0NcchhHstC2C/VIYNX4JC6tieviUNo774FFeOHjThr3Vw/WCeMP9kAT77MKbIRlO/4w==", - "license": "MIT" - }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4856,6 +4827,8 @@ "integrity": "sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.5", @@ -4879,6 +4852,8 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -5010,16 +4985,11 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webgpu/types": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", - "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", - "license": "BSD-3-Clause" - }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7420,21 +7390,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gif.js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", - "integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==", - "license": "MIT" - }, - "node_modules/gifuct-js": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", - "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", - "license": "MIT", - "dependencies": { - "js-binary-schema-parser": "^2.0.3" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7987,12 +7942,6 @@ "node": ">=18" } }, - "node_modules/ismobilejs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", - "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", - "license": "MIT" - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -8021,12 +7970,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-binary-schema-parser": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", - "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", - "license": "MIT" - }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -8820,6 +8763,8 @@ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10" } @@ -9225,12 +9170,6 @@ "node": ">=8" } }, - "node_modules/parse-svg-path": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", - "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", - "license": "MIT" - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -9335,62 +9274,6 @@ "node": ">= 6" } }, - "node_modules/pixi-filters": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz", - "integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==", - "license": "MIT", - "dependencies": { - "@types/gradient-parser": "^0.1.2" - }, - "peerDependencies": { - "pixi.js": ">=8.0.0-0" - } - }, - "node_modules/pixi.js": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", - "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", - "license": "MIT", - "workspaces": [ - "examples", - "playground" - ], - "dependencies": { - "@pixi/colord": "^2.9.6", - "@types/earcut": "^3.0.0", - "@webgpu/types": "^0.1.69", - "@xmldom/xmldom": "^0.8.12", - "earcut": "^3.0.2", - "eventemitter3": "^5.0.1", - "gifuct-js": "^2.1.2", - "ismobilejs": "^1.1.1", - "parse-svg-path": "^0.1.2", - "tiny-lru": "^11.4.7" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/pixijs" - } - }, - "node_modules/pixi.js/node_modules/@types/earcut": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", - "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", - "license": "MIT" - }, - "node_modules/pixi.js/node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, - "node_modules/pixi.js/node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -9444,6 +9327,8 @@ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=14.19.0" } @@ -10570,6 +10455,8 @@ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -11068,15 +10955,6 @@ "semver": "bin/semver" } }, - "node_modules/tiny-lru": { - "version": "11.4.7", - "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", - "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11178,6 +11056,8 @@ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index 46210df768..0c8c0194a2 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,6 @@ "diagnostic:run": "node scripts/diagnostic-tool/diagnostic.mjs", "diagnostic:smoke:win": "node scripts/diagnostic-tool/diagnostic.mjs --duration 3", "build-vite": "tsc && vite build", - "test:browser": "vitest --config vitest.browser.config.ts --run", - "test:browser:install": "playwright install --with-deps chromium-headless-shell", "test:e2e": "playwright test", "test:e2e:windows-native-checklist": "playwright test tests/e2e/windows-native-checklist.spec.ts", "prepare": "husky", @@ -78,22 +76,18 @@ "@tiptap/extension-text-style": "^3.27.1", "@tiptap/react": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", - "@types/gif.js": "^0.2.5", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", "@uiw/react-color-colorful": "^2.9.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "deepagents": "^1.10.5", - "gif.js": "^0.2.0", "i18next": "^23.16.0", "langchain": "^1.2.39", "lucide-react": "^0.545.0", "mediabunny": "^1.40.1", "motion": "^12.38.0", "mp4box": "^2.3.0", - "pixi-filters": "^6.1.5", - "pixi.js": "^8.18.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.6.0", @@ -119,8 +113,6 @@ "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^5.2.0", - "@vitest/browser": "^4.1.4", - "@vitest/browser-playwright": "^4.1.4", "autoprefixer": "^10.5.0", "electron": "^41.2.1", "electron-builder": "^26.8.1", diff --git a/src/components/ai-edition/CursorPreviewLayer.module.css b/src/components/ai-edition/CursorPreviewLayer.module.css deleted file mode 100644 index 8237d3aef2..0000000000 --- a/src/components/ai-edition/CursorPreviewLayer.module.css +++ /dev/null @@ -1,34 +0,0 @@ -/* Cursor preview layer. Transparent overlay that hosts a Pixi canvas + - a native-cursor . The Pixi canvas is appended by the component on - mount; the is positioned via translate3d in the same coordinate - space as the