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
29 changes: 27 additions & 2 deletions crates/compositor-view-napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use napi_derive::napi;
use openscreen_compositor::compositor::{live_params_from_scene, Compositor};
use openscreen_compositor::d3d::{Backend, Gpu};
use openscreen_compositor::gif_export::{GifExportParams, GifStats};
use openscreen_compositor::gif_export_control::{GifExportCancelled, GifExportControl};
use openscreen_compositor::live::{LiveView, PausedPreviews};
use openscreen_compositor::scene::Scene;
use openscreen_compositor::{config, pipeline};
Expand Down Expand Up @@ -544,6 +545,24 @@ pub struct GifParamsInput {
/// `Option<&str>`, et `None` désactive le rendu du curseur côté
/// `Compositor` (équivalent de `cfg.cursor = false` dans
/// `run_composited_multi`).
#[napi]
pub fn create_gif_export_control() -> External<GifExportControl> {
External::new(GifExportControl::default())
}

#[napi]
pub fn cancel_gif_export(control: External<GifExportControl>) -> bool {
control.cancel()
}

fn gif_task_error(error: anyhow::Error) -> Error {
if error.is::<GifExportCancelled>() {
Error::from_reason("GIF_EXPORT_CANCELLED")
} else {
Error::from_reason(format!("{error:#}"))
}
}

pub struct ExportGifTask {
/// Same clip list the MP4 export takes — GIF is now a multiclip export
/// driven by the same walk, not a single-file special case.
Expand All @@ -554,6 +573,7 @@ pub struct ExportGifTask {
scene_json: Option<String>,
out_path: PathBuf,
params: GifExportParams,
control: GifExportControl,
on_progress: Option<ThreadsafeFunction<u32, ErrorStrategy::Fatal>>,
}

Expand All @@ -562,6 +582,7 @@ impl Task for ExportGifTask {
type JsValue = GifExportStats;

fn compute(&mut self) -> Result<Self::Output> {
self.control.check().map_err(gif_task_error)?;
// Mêmes garanties que `ExportMultiTask` : previews paused for the
// whole render et restored exactement comme trouvées, y compris
// sur les chemins d'erreur. L'export GPU+CPU ne partage pas le
Expand All @@ -576,6 +597,7 @@ impl Task for ExportGifTask {
// host without a usable GPU could export an MP4 but not a GIF — the one path
// where the CPU backend exists specifically so the export still completes.
let gpu = Gpu::create_auto(false).map_err(|e| Error::from_reason(format!("{e:#}")))?;
self.control.check().map_err(gif_task_error)?;
let mut cfg = config::all().pop().expect("au moins une config"); // C8
cfg.zoom = false;
cfg.layout_anim = false;
Expand Down Expand Up @@ -605,16 +627,17 @@ impl Task for ExportGifTask {
comp.set_scene(scene);

let mut progress = throttled_progress(self.on_progress.take());
openscreen_compositor::gif_export::export_gif(
openscreen_compositor::gif_export::export_gif_cancellable(
&self.clips,
&self.out_path,
&gpu,
&comp,
&cfg,
&self.params,
&mut progress,
&self.control,
)
.map_err(|e| Error::from_reason(format!("{e:#}")))
.map_err(gif_task_error)
}

fn resolve(&mut self, _env: Env, out: Self::Output) -> Result<Self::JsValue> {
Expand Down Expand Up @@ -644,6 +667,7 @@ pub fn export_gif(
scene_json: Option<String>,
params: Option<GifParamsInput>,
on_progress: Option<JsFunction>,
control: Option<External<GifExportControl>>,
) -> Result<AsyncTask<ExportGifTask>> {
// Deliberately the same argument shape as `export_multi`: the caller builds
// one clip list and one scene, and picks the container. Cursor comes from
Expand Down Expand Up @@ -673,6 +697,7 @@ pub fn export_gif(
scene_json,
out_path: PathBuf::from(out_path),
params: gif_params,
control: control.map(|c| (*c).clone()).unwrap_or_default(),
on_progress: make_progress_tsfn(on_progress)?,
}))
}
Expand Down
42 changes: 26 additions & 16 deletions crates/compositor/src/gif_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
use crate::compositor::Compositor;
use crate::config::Cfg;
use crate::d3d::Gpu;
use crate::gif_export_control::{with_gif_output, GifExportControl};
use crate::pipeline::{ClipSource, Decoder};
use crate::timeline_walk::walk_composited_timeline;
use anyhow::{anyhow, bail, Context, Result};
Expand Down Expand Up @@ -160,9 +161,7 @@ impl Default for GifExportParams {
/// 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.
/// Publish only a finished GIF; errors leave an existing destination intact.
pub fn export_gif(
clips: &[ClipSource],
out_path: &Path,
Expand All @@ -172,22 +171,36 @@ pub fn export_gif(
params: &GifExportParams,
progress: &mut dyn FnMut(u64),
) -> Result<GifStats> {
let result = export_gif_inner(clips, out_path, gpu, comp, cfg, params, progress);
if result.is_err() {
let _ = std::fs::remove_file(out_path);
}
result
export_gif_cancellable(clips, out_path, gpu, comp, cfg, params, progress, &GifExportControl::default())
}

pub fn export_gif_cancellable(
clips: &[ClipSource],
out_path: &Path,
gpu: &Gpu,
comp: &Compositor,
cfg: &Cfg,
params: &GifExportParams,
progress: &mut dyn FnMut(u64),
control: &GifExportControl,
) -> Result<GifStats> {
with_gif_output(out_path, control, |file, staging_path| {
export_gif_inner(clips, staging_path, file, gpu, comp, cfg, params, progress, control)
})
}

fn export_gif_inner(
clips: &[ClipSource],
out_path: &Path,
file: File,
gpu: &Gpu,
comp: &Compositor,
cfg: &Cfg,
params: &GifExportParams,
progress: &mut dyn FnMut(u64),
control: &GifExportControl,
) -> Result<GifStats> {
control.check()?;
if clips.is_empty() {
bail!("export_gif: aucun clip à exporter");
}
Expand All @@ -203,13 +216,6 @@ fn export_gif_inner(
// per-frame local palette (the standard "high-quality" form: a
// palette tuned to each frame's colours), so the global palette in
// the header is empty.
if let Some(parent) = out_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).ok();
}
}
let file = File::create(out_path)
.with_context(|| format!("export_gif: create {}", out_path.display()))?;
let mut writer = BufWriter::new(file);

// GIF frame delay in centiseconds (= 1/100 s). `fps` →
Expand Down Expand Up @@ -268,6 +274,7 @@ fn export_gif_inner(
&mut screen_decs,
&mut webcam_decs,
&mut |frame_index| {
control.check()?;
// 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.
Expand Down Expand Up @@ -302,20 +309,23 @@ fn export_gif_inner(
}

// Per-frame palette (GIF local palette, written by `write_frame`).
control.check()?;
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(()),
&mut |_, _, _, _| control.check(),
)?
};

control.check()?;
gw.finish()?;
frames
};
// Drop the writer before stat-ing the file so the trailer is
// flushed.
writer.flush().context("flushing completed GIF")?;
drop(writer);

let wall_s = t0.elapsed().as_secs_f64();
Expand Down
Loading
Loading