diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index 69476ffc2..2f5d770d7 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -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}; @@ -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 { + External::new(GifExportControl::default()) +} + +#[napi] +pub fn cancel_gif_export(control: External) -> bool { + control.cancel() +} + +fn gif_task_error(error: anyhow::Error) -> Error { + if error.is::() { + 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. @@ -554,6 +573,7 @@ pub struct ExportGifTask { scene_json: Option, out_path: PathBuf, params: GifExportParams, + control: GifExportControl, on_progress: Option>, } @@ -562,6 +582,7 @@ impl Task for ExportGifTask { type JsValue = GifExportStats; fn compute(&mut self) -> Result { + 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 @@ -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; @@ -605,7 +627,7 @@ 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, @@ -613,8 +635,9 @@ impl Task for ExportGifTask { &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 { @@ -644,6 +667,7 @@ pub fn export_gif( scene_json: Option, params: Option, on_progress: Option, + control: 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 @@ -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)?, })) } diff --git a/crates/compositor/src/gif_export.rs b/crates/compositor/src/gif_export.rs index c74adb090..7ec366a57 100644 --- a/crates/compositor/src/gif_export.rs +++ b/crates/compositor/src/gif_export.rs @@ -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}; @@ -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, @@ -172,22 +171,36 @@ pub fn export_gif( params: &GifExportParams, progress: &mut dyn FnMut(u64), ) -> Result { - 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 { + 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 { + control.check()?; if clips.is_empty() { bail!("export_gif: aucun clip à exporter"); } @@ -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` → @@ -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. @@ -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(); diff --git a/crates/compositor/src/gif_export_control.rs b/crates/compositor/src/gif_export_control.rs new file mode 100644 index 000000000..bf4ad1243 --- /dev/null +++ b/crates/compositor/src/gif_export_control.rs @@ -0,0 +1,216 @@ +//! Cooperative GIF cancellation and publication of a completed output only. + +use anyhow::{bail, Context, Result}; +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; +use std::sync::Arc; + +const RUNNING: u8 = 0; +const CANCELLED: u8 = 1; +const COMMITTING: u8 = 2; + +#[derive(Clone, Default)] +pub struct GifExportControl(Arc); + +#[derive(Debug)] +pub struct GifExportCancelled; + +impl std::fmt::Display for GifExportCancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GIF export cancelled") + } +} + +impl std::error::Error for GifExportCancelled {} + +impl GifExportControl { + /// False means publication has already won the race. Repeated cancellation + /// of the same pending job is harmless. + pub fn cancel(&self) -> bool { + match self.0.compare_exchange(RUNNING, CANCELLED, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) | Err(CANCELLED) => true, + Err(_) => false, + } + } + + pub fn check(&self) -> Result<()> { + if self.0.load(Ordering::Acquire) == CANCELLED { + return Err(GifExportCancelled.into()); + } + Ok(()) + } + + fn begin_commit(&self) -> Result<()> { + match self.0.compare_exchange(RUNNING, COMMITTING, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) => Ok(()), + Err(CANCELLED) => Err(GifExportCancelled.into()), + Err(_) => bail!("GIF export control has already been used"), + } + } +} + +static NEXT_OUTPUT: AtomicU64 = AtomicU64::new(0); + +struct StagedGif { + path: PathBuf, + published: bool, +} + +impl Drop for StagedGif { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_file(&self.path); + } + } +} + +/// Keep the destination intact until rendering and flushing have succeeded. +/// `render` owns the file so it is closed before rename/cleanup on Windows. +pub(crate) fn with_gif_output( + target: &Path, + control: &GifExportControl, + render: impl FnOnce(File, &Path) -> Result, +) -> Result { + control.check()?; + let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + fs::create_dir_all(parent)?; + let (mut staged, file) = loop { + let nonce = NEXT_OUTPUT.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!(".openscreen-gif-{}-{nonce}.partial", std::process::id())); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => break (StagedGif { path, published: false }, file), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e).context("creating temporary GIF output"), + } + }; + let result = render(file, &staged.path).and_then(|stats| { + control.begin_commit()?; + fs::rename(&staged.path, target).context("publishing completed GIF output")?; + staged.published = true; + Ok(stats) + }); + if result.is_err() && !staged.published { + if let Err(cleanup) = fs::remove_file(&staged.path) { + if cleanup.kind() != std::io::ErrorKind::NotFound { + // A failed cleanup is an error, even if cancellation caused it. + // Do not report a clean cancellation while leaving an output. + bail!("could not remove partial GIF {}: {cleanup}", staged.path.display()); + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::Barrier; + + struct TestDir(PathBuf); + impl TestDir { + fn new() -> Self { + let nonce = NEXT_OUTPUT.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("openscreen-gif-test-{}-{nonce}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn assert_only(&self, name: &str) { + let names: Vec<_> = fs::read_dir(&self.0).unwrap().map(|p| p.unwrap().file_name()).collect(); + assert_eq!(names, vec![std::ffi::OsString::from(name)]); + } + } + impl Drop for TestDir { + fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } + } + + #[test] + fn cancellation_before_rendering_never_opens_output() { + let dir = TestDir::new(); + let control = GifExportControl::default(); + assert!(control.cancel()); + let result = with_gif_output(&dir.0.join("out.gif"), &control, |_, _| -> Result<()> { + panic!("cancelled job must not render"); + }); + assert!(result.unwrap_err().is::()); + assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 0); + } + + #[test] + fn cancellation_discards_partial_output_and_preserves_destination() { + let dir = TestDir::new(); + let target = dir.0.join("out.gif"); + fs::write(&target, b"original GIF").unwrap(); + let control = GifExportControl::default(); + let result = with_gif_output(&target, &control, |mut file, _| { + file.write_all(b"partial replacement")?; + assert!(control.cancel()); + // Even a renderer that has just completed cannot publish now. + Ok(42) + }); + assert!(result.unwrap_err().is::()); + assert_eq!(fs::read(&target).unwrap(), b"original GIF"); + dir.assert_only("out.gif"); + } + + #[test] + fn successful_publication_replaces_destination_and_rejects_late_cancel() { + let dir = TestDir::new(); + let target = dir.0.join("out.gif"); + fs::write(&target, b"original").unwrap(); + let control = GifExportControl::default(); + assert_eq!(with_gif_output(&target, &control, |mut file, _| { + file.write_all(b"finished GIF")?; + Ok(7) + }).unwrap(), 7); + assert!(!control.cancel()); + assert_eq!(fs::read(&target).unwrap(), b"finished GIF"); + dir.assert_only("out.gif"); + } + + #[test] + fn render_failure_is_not_misreported_as_cancellation() { + let dir = TestDir::new(); + let target = dir.0.join("out.gif"); + let control = GifExportControl::default(); + let result = with_gif_output(&target, &control, |mut file, _| -> Result<()> { + file.write_all(b"partial")?; + control.cancel(); + bail!("encoder failed") + }); + assert_eq!(result.unwrap_err().to_string(), "encoder failed"); + assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 0); + } + + #[test] + fn publication_failure_cleans_up_staging() { + let dir = TestDir::new(); + let target = dir.0.join("destination-directory"); + fs::create_dir(&target).unwrap(); + let control = GifExportControl::default(); + assert!(with_gif_output(&target, &control, |mut file, _| { + file.write_all(b"finished GIF")?; + Ok(()) + }).is_err()); + assert!(target.is_dir()); + dir.assert_only("destination-directory"); + } + + #[test] + fn cancellation_and_commit_have_exactly_one_winner() { + for _ in 0..64 { + let control = GifExportControl::default(); + let cancel_control = control.clone(); + let barrier = Arc::new(Barrier::new(2)); + let cancel_barrier = barrier.clone(); + let cancel = std::thread::spawn(move || { + cancel_barrier.wait(); + cancel_control.cancel() + }); + barrier.wait(); + let committed = control.begin_commit().is_ok(); + assert_ne!(committed, cancel.join().unwrap()); + } + } +} diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index ac9c93bab..bc7374091 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -35,6 +35,7 @@ pub mod export_probe; pub mod ffi; pub mod frame_geometry; pub mod gif_export; +pub mod gif_export_control; pub mod regions; // Multiplateforme à dessein : n'utilise que libavformat (liée sur les trois // cibles) et le shim C. Seul Linux l'appelle aujourd'hui, parce que c'est la @@ -140,4 +141,4 @@ pub use pipeline_linux as pipeline; // Compositor (cfg-ré-exporté) et au ffmpeg `Decoder` (portable). Seules les // helpers `run_standalone`/`host_proc`/`wide`/`client_size` (harnais Win32 du POC) // sont cfg-gatées à l'intérieur du fichier. -pub mod live; \ No newline at end of file +pub mod live; diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6de7a3cc4..f1372d0bd 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -32,7 +32,7 @@ interface Window { /** Native (D3D) export progress — frames encoded so far, pushed at ~10 Hz max while * `compositor.export`/`compositor.exportMulti` runs. Distinct from `exportOnFrameAck`, * the OLD web/CPU pipeline's per-frame ack, not a progress signal. */ - onNativeExportProgress?: (callback: (frames: number) => void) => () => void; + onNativeExportProgress?: (callback: (frames: number, exportId?: string) => void) => () => void; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; switchToHud: () => Promise; diff --git a/electron/ipc/gifExportJobs.test.ts b/electron/ipc/gifExportJobs.test.ts new file mode 100644 index 000000000..b54550da5 --- /dev/null +++ b/electron/ipc/gifExportJobs.test.ts @@ -0,0 +1,116 @@ +import { EventEmitter } from "node:events"; +import type { WebContents } from "electron"; +import { describe, expect, it, vi } from "vitest"; +import { GifExportJobs, isGifExportId } from "./gifExportJobs"; + +function owner(id = 1) { + return Object.assign(new EventEmitter(), { + id, + isDestroyed: () => false, + }) as unknown as WebContents; +} + +function pending() { + let resolve!: (result: number) => void; + let reject!: (error: Error) => void; + const result = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { result, resolve, reject, cancel: vi.fn(() => true) }; +} + +describe("GIF export jobs", () => { + it("binds cancel to the sender and ID, including before native compute starts", async () => { + const jobs = new GifExportJobs(); + const sender = owner(); + const job = pending(); + const run = jobs.run(sender, "job_1", () => job, vi.fn()); + expect(jobs.cancel(owner(2), "job_1")).toBe(false); + expect(jobs.cancel(sender, "unknown")).toBe(false); + expect(jobs.cancel(sender, "job_1")).toBe(true); + expect(jobs.cancel(sender, "job_1")).toBe(true); + expect(job.cancel).toHaveBeenCalledTimes(2); + job.resolve(1); + await run; + expect(jobs.cancel(sender, "job_1")).toBe(false); + expect(sender.listenerCount("destroyed")).toBe(0); + }); + + it("rejects a duplicate job without starting it and allows a retry after failure", async () => { + const jobs = new GifExportJobs(); + const sender = owner(); + const job = pending(); + const run = jobs.run(sender, "first", () => job, vi.fn()); + const duplicate = vi.fn(() => pending()); + await expect(jobs.run(sender, "second", duplicate, vi.fn())).rejects.toThrow("already running"); + expect(duplicate).not.toHaveBeenCalled(); + const failed = expect(run).rejects.toThrow("encoder failed"); + job.reject(new Error("encoder failed")); + await failed; + await expect( + jobs.run(sender, "retry", () => ({ result: Promise.resolve(2), cancel: vi.fn() }), vi.fn()), + ).resolves.toBe(2); + expect(sender.listenerCount("destroyed")).toBe(0); + }); + + it("cancels on sender destruction and suppresses late progress after a retry", async () => { + const jobs = new GifExportJobs(); + const sender = owner(); + const job = pending(); + const progress = vi.fn(); + let oldProgress!: (frames: number) => void; + const run = jobs.run( + sender, + "old", + (cb) => { + oldProgress = cb; + return job; + }, + progress, + ); + oldProgress(1); + expect(progress).toHaveBeenCalledWith(1); + sender.emit("destroyed"); + expect(job.cancel).toHaveBeenCalledOnce(); + job.resolve(1); + await run; + const next = pending(); + const retry = jobs.run(sender, "new", () => next, progress); + oldProgress(99); + expect(progress).toHaveBeenCalledTimes(1); + next.resolve(2); + await retry; + }); + + it("does not reinterpret a completion that wins the cancellation race", async () => { + const jobs = new GifExportJobs(); + const sender = owner(); + const job = pending(); + job.cancel.mockReturnValue(false); + const run = jobs.run(sender, "done", () => job, vi.fn()); + expect(jobs.cancel(sender, "done")).toBe(false); + job.resolve(5); + await expect(run).resolves.toBe(5); + }); + + it.each([ + undefined, + null, + 1, + {}, + "", + "a/b", + "a".repeat(129), + ])("rejects malformed IDs: %j", (id) => { + expect(isGifExportId(id)).toBe(false); + }); + + it("rejects an invalid start before invoking native code", async () => { + const start = vi.fn(() => pending()); + await expect(new GifExportJobs().run(owner(), "../bad", start, vi.fn())).rejects.toThrow( + "Invalid GIF export ID", + ); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/ipc/gifExportJobs.ts b/electron/ipc/gifExportJobs.ts new file mode 100644 index 000000000..fe318b35e --- /dev/null +++ b/electron/ipc/gifExportJobs.ts @@ -0,0 +1,50 @@ +import type { WebContents } from "electron"; + +type ExportOwner = Pick; + +export interface GifExportJob { + result: Promise; + cancel: () => boolean; +} + +export function isGifExportId(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); +} + +/** Controls never leave main. Unknown and foreign IDs have the same result. */ +export class GifExportJobs { + private readonly jobs = new Map boolean }>(); + + cancel(owner: ExportOwner, exportId: string): boolean { + const job = this.jobs.get(owner.id); + return job?.exportId === exportId && job.cancel(); + } + + async run( + owner: ExportOwner, + exportId: string, + start: (progress: (frames: number) => void) => GifExportJob, + onProgress: (frames: number) => void, + ): Promise { + if (!isGifExportId(exportId)) throw new Error("Invalid GIF export ID."); + if (owner.isDestroyed()) throw new Error("GIF export window is closed."); + if (this.jobs.has(owner.id)) throw new Error("A GIF export is already running in this window."); + let active = true; + const job = start((frames) => { + if (active && !owner.isDestroyed()) onProgress(frames); + }); + const entry = { exportId, cancel: job.cancel }; + this.jobs.set(owner.id, entry); + const onDestroyed = () => { + job.cancel(); + }; + owner.once("destroyed", onDestroyed); + try { + return await job.result; + } finally { + active = false; + owner.removeListener("destroyed", onDestroyed); + if (this.jobs.get(owner.id) === entry) this.jobs.delete(owner.id); + } + } +} diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 7b01e0b2f..1b38ce0e7 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -22,6 +22,7 @@ import { CursorService } from "../native-bridge/services/cursorService"; import { ProjectService } from "../native-bridge/services/projectService"; import { SystemService } from "../native-bridge/services/systemService"; import { createNativeBridgeState } from "../native-bridge/store"; +import { GifExportJobs, isGifExportId } from "./gifExportJobs"; export interface NativeBridgeContext { getPlatform: () => NodeJS.Platform; @@ -238,6 +239,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { deleteSession: context.deleteAiEditionChatSession, }); + const gifExportJobs = new GifExportJobs(); ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (event, request: unknown) => { if (!isBridgeRequest(request)) { return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request."); @@ -428,17 +430,34 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { } 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); - } - }, - ); + const exportId = request.payload?.exportId; + if (exportId !== undefined && !isGifExportId(exportId)) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid GIF export ID."); + } + const onProgress = (frames: number) => { + if (!sender.isDestroyed()) sender.send("export:native-progress", frames, exportId); + }; + const stats = exportId + ? await gifExportJobs.run( + sender, + exportId, + (progress) => + compositorViewService.startGifExport( + request.payload.clips, + request.payload.outPath, + request.payload.sceneJson, + request.payload.params, + progress, + ), + onProgress, + ) + : await compositorViewService.exportGif( + request.payload.clips, + request.payload.outPath, + request.payload.sceneJson, + request.payload.params, + onProgress, + ); if (!stats) { return createErrorResponse( requestId, @@ -448,6 +467,15 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { } return createSuccessResponse(requestId, stats); } + case "cancelGifExport": { + const exportId = request.payload?.exportId; + if (!isGifExportId(exportId)) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid GIF export ID."); + } + return createSuccessResponse(requestId, { + accepted: gifExportJobs.cancel(event.sender, exportId), + }); + } default: return createErrorResponse( requestId, @@ -654,6 +682,14 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ); } } catch (error) { + if ( + request.domain === "compositor" && + request.action === "exportGif" && + error instanceof Error && + error.message === "GIF_EXPORT_CANCELLED" + ) { + return createErrorResponse(requestId, "CANCELLED", "GIF export cancelled."); + } // Not retryable by default: most failures here are permanent (a missing // file, a bad payload, an unavailable addon), and a blanket `true` tells // the client to spin on them. The message keeps the reason but drops any diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 29f492b10..967fe6920 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CURSOR_THEMES, DEFAULT_CURSOR_SPRITES } from "../../../src/lib/cursor/cursorThemes"; +import type { CompositorViewAddon, GifExportStats } from "../../native/compositor-view/addon"; import { buildCandidatePaths, CompositorViewService, @@ -10,6 +11,53 @@ import { resolveSceneAssetPaths, } from "./compositorViewService"; +describe("native GIF cancellation capability", () => { + it("passes one opaque control to native start and cancel before settlement", async () => { + const control = {}; + let finish!: (stats: GifExportStats) => void; + const exportGif = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const cancelGifExport = vi.fn(() => true); + const service = new CompositorViewService({ + addon: { + createGifExportControl: () => control, + cancelGifExport, + exportGif, + } as unknown as CompositorViewAddon, + }); + const progress = vi.fn(); + const job = service.startGifExport([], "/tmp/test.gif", undefined, { fps: 15 }, progress); + expect(exportGif).toHaveBeenCalledWith( + [], + "/tmp/test.gif", + undefined, + { fps: 15 }, + progress, + control, + ); + expect(job.cancel()).toBe(true); + expect(cancelGifExport).toHaveBeenCalledWith(control); + const stats = { frames: 1, wallS: 1, fps: 1, videoDurationS: 1, fileBytes: 100 }; + finish(stats); + await expect(job.result).resolves.toEqual(stats); + }); + + it("rejects a stale addon before starting an export that cannot be cancelled", () => { + const exportGif = vi.fn(); + const service = new CompositorViewService({ + addon: { exportGif } as unknown as CompositorViewAddon, + }); + expect(() => service.startGifExport([], "/tmp/test.gif")).toThrow( + "cancellation is unavailable", + ); + expect(exportGif).not.toHaveBeenCalled(); + }); +}); + /** A source checkout's `crates/.cargo/config.toml`, with `FFMPEG_DIR` written as `body`. */ function writeCargoConfig(root: string, body: string): void { const cargoDir = path.join(root, "crates", ".cargo"); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 59d0ee547..2adceb62a 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { app } from "electron"; import { resolveCursorSprites } from "../../../src/lib/cursor/cursorThemes"; +import type { GifExportJob } from "../../ipc/gifExportJobs"; import type { ClipInput, CompositorBackend, @@ -179,6 +180,8 @@ export function resolveSceneAssetPaths(sceneJson: string): string { } export interface CompositorViewServiceOptions { + /** Explicit in-process addon, used by tests of native job lifecycle. */ + addon?: CompositorViewAddon; /** * Optional explicit override for the addon path. Has precedence over the * `OPENSCREEN_COMPOSITOR_VIEW_NODE` env var and the candidate path list. @@ -437,6 +440,7 @@ export class CompositorViewService { } private ensureAddon(): CompositorViewAddon | null { + if (this.options.addon) return this.options.addon; if (this.loadAttempted) { return this.addon; } @@ -659,6 +663,7 @@ export class CompositorViewService { sceneJson?: string, params?: GifParamsInput, onProgress?: (frames: number) => void, + control?: object, ): Promise { const addon = this.ensureAddon(); if (!addon) { @@ -671,9 +676,31 @@ export class CompositorViewService { sceneJson ? resolveSceneAssetPaths(sceneJson) : undefined, params, onProgress, + control, ); } + startGifExport( + clips: ClipInput[], + outPath?: string, + sceneJson?: string, + params?: GifParamsInput, + onProgress?: (frames: number) => void, + ): GifExportJob { + const addon = this.ensureAddon(); + if (!addon?.createGifExportControl || !addon.cancelGifExport) { + throw new Error( + "Native GIF cancellation is unavailable. Rebuild or update the compositor addon.", + ); + } + const control = addon.createGifExportControl(); + const cancel = addon.cancelGifExport.bind(addon); + return { + result: this.exportGif(clips, outPath, sceneJson, params, onProgress, control), + cancel: () => cancel(control), + }; + } + /** Stream-copy `inputPath` to `outputPath` through libavformat's matroska muxer. * No re-encode: the packets are copied verbatim and only the container is rebuilt, * which is what gives the output a real `Duration`, `Cues` and `SeekHead`. diff --git a/electron/native/compositor-view/addon.d.ts b/electron/native/compositor-view/addon.d.ts index 4c63065d8..1a32e554d 100644 --- a/electron/native/compositor-view/addon.d.ts +++ b/electron/native/compositor-view/addon.d.ts @@ -191,7 +191,11 @@ export interface CompositorViewAddon { sceneJson?: string, params?: GifParamsInput, onProgress?: (frames: number) => void, + control?: object, ): Promise; + /** Opaque napi External: kept in main, never sent to a renderer. */ + createGifExportControl?(): object; + cancelGifExport?(control: object): boolean; /** Stream-copy `inputPath` to `outputPath` through the matroska muxer, rebuilding the * container (real `Duration` computed from the packet timestamps, plus `Cues` and diff --git a/electron/preload.ts b/electron/preload.ts index 7873bef90..49f914902 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -63,8 +63,8 @@ contextBridge.exposeInMainWorld("electronAPI", { /** Native (D3D) export progress — frames encoded so far, pushed at ~10 Hz max while * `compositor.export`/`compositor.exportMulti` runs. Distinct from `exportOnFrameAck` * above, which is the OLD web/CPU pipeline's per-frame ack, not a progress signal. */ - onNativeExportProgress: (cb: (frames: number) => void) => { - const handler = (_e: unknown, frames: number) => cb(frames); + onNativeExportProgress: (cb: (frames: number, exportId?: string) => void) => { + const handler = (_e: unknown, frames: number, exportId?: string) => cb(frames, exportId); ipcRenderer.on("export:native-progress", handler); return () => ipcRenderer.off("export:native-progress", handler); }, diff --git a/src/components/ai-edition/ExportDialog.cancel.test.tsx b/src/components/ai-edition/ExportDialog.cancel.test.tsx new file mode 100644 index 000000000..c51495c91 --- /dev/null +++ b/src/components/ai-edition/ExportDialog.cancel.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/native", () => ({ + exportMultiNative: vi.fn(), + exportGifNative: vi.fn(), + cancelGifExportNative: vi.fn(async () => ({ accepted: true })), + useIsCpuCompositor: () => false, +})); +vi.mock("@/native/sceneDescription", () => ({ + buildSceneDescription: () => ({ speedRegions: [] }), + resolveVisibleClips: (doc: AxcutDocument) => doc.timeline.clips, +})); + +import { toast } from "sonner"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { type AxcutDocument, axcutSchemaVersion } from "@/lib/ai-edition/schema"; +import { cancelGifExportNative, exportGifNative } from "@/native"; +import { NativeBridgeRequestError } from "@/native/client"; +import type { CompositorExportGifResult } from "@/native/contracts"; +import { ExportDialog } from "./ExportDialog"; + +const DOC: AxcutDocument = { + schemaVersion: axcutSchemaVersion, + project: { + id: "proj_1", + title: "Cancellation test", + createdAt: "2026-06-26T10:00:00Z", + updatedAt: "2026-06-26T10:00:00Z", + primaryAssetId: "a1", + }, + assets: [ + { + id: "a1", + kind: "video", + label: "asset", + originalPath: "/tmp/a.mp4", + cameraTrack: null, + video: { codec: "h264", width: 1920, height: 1080, fps: 30 }, + }, + ], + transcript: null, + transcripts: [], + timeline: { + clips: [ + { + id: "c1", + assetId: "a1", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + wordRefs: [], + origin: "user", + reason: "", + }, + ], + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + annotations: [], + zoomRanges: [], + audioTracks: [], + legacyEditor: null, +}; +const STATS: CompositorExportGifResult = { + frames: 150, + wallS: 1, + fps: 150, + videoDurationS: 10, + fileBytes: 4000, +}; + +function pendingExport() { + let resolve!: (stats: CompositorExportGifResult) => void; + let reject!: (error: Error) => void; + const result = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + vi.mocked(exportGifNative).mockReturnValueOnce(result); + return { resolve, reject }; +} + +let progress: (frames: number, exportId?: string) => void; +let unsubscribe: ReturnType; +let onClose: ReturnType void>>; + +async function start() { + const view = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "GIF" })); + fireEvent.click(screen.getByRole("button", { name: "Export GIF" })); + await waitFor(() => expect(exportGifNative).toHaveBeenCalledOnce()); + const id = vi.mocked(exportGifNative).mock.calls[0][4]; + expect(id).toMatch(/^[A-Za-z0-9_-]+$/); + return { ...view, id }; +} + +describe("GIF export cancellation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(cancelGifExportNative).mockResolvedValue({ accepted: true }); + onClose = vi.fn(); + unsubscribe = vi.fn(); + window.electronAPI = { + pickExportSavePath: vi.fn(async () => ({ path: "/tmp/result.gif" })), + onNativeExportProgress: vi.fn((cb) => { + progress = cb; + return unsubscribe; + }), + } as unknown as Window["electronAPI"]; + }); + afterEach(cleanup); + + it("enables Cancel immediately, waits for cleanup and returns to the same options", async () => { + const job = pendingExport(); + const { id } = await start(); + const cancel = screen.getByRole("button", { name: "Cancel" }); + expect(cancel).toBeEnabled(); + fireEvent.click(cancel); + await waitFor(() => expect(cancelGifExportNative).toHaveBeenCalledWith(id)); + expect(cancel).toBeDisabled(); + fireEvent.click(cancel); + expect(cancelGifExportNative).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Export GIF" })).not.toBeInTheDocument(); + await act(async () => job.reject(new NativeBridgeRequestError("cancelled", "CANCELLED"))); + expect(screen.getByRole("button", { name: "Export GIF" })).toBeEnabled(); + expect(screen.getByRole("dialog")).toBeVisible(); + expect(onClose).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("retries with a new ID, ignores old progress and then exports successfully", async () => { + const first = pendingExport(); + const { id } = await start(); + const staleProgress = progress; + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => first.reject(new NativeBridgeRequestError("cancelled", "CANCELLED"))); + const second = pendingExport(); + fireEvent.click(screen.getByRole("button", { name: "Export GIF" })); + await waitFor(() => expect(exportGifNative).toHaveBeenCalledTimes(2)); + const nextId = vi.mocked(exportGifNative).mock.calls[1][4]; + expect(nextId).not.toBe(id); + expect(vi.mocked(exportGifNative).mock.calls[1][3]).toEqual( + vi.mocked(exportGifNative).mock.calls[0][3], + ); + const before = screen.getByRole("dialog").textContent; + act(() => { + staleProgress(149, id); + progress(149, id); + }); + expect(screen.getByRole("dialog").textContent).toBe(before); + act(() => progress(75, nextId)); + expect(screen.getByRole("dialog").textContent).not.toBe(before); + await act(async () => second.resolve(STATS)); + expect(screen.getByText("/tmp/result.gif")).toBeVisible(); + expect(toast.success).toHaveBeenCalledOnce(); + }); + + it("surfaces a rejected cancel request instead of keeping the progress view", async () => { + vi.mocked(cancelGifExportNative).mockRejectedValueOnce(new Error("cancel ipc failed")); + pendingExport(); + await start(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.getByText("cancel ipc failed")).toBeVisible()); + expect(toast.error).toHaveBeenCalledWith("cancel ipc failed"); + expect(toast.success).not.toHaveBeenCalled(); + expect(screen.queryByText(/Rendering frames/i)).not.toBeInTheDocument(); + }); + + it("reports success when native publication wins the race", async () => { + vi.mocked(cancelGifExportNative).mockResolvedValue({ accepted: false }); + const job = pendingExport(); + await start(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => job.resolve(STATS)); + expect(screen.getByText("/tmp/result.gif")).toBeVisible(); + expect(toast.success).toHaveBeenCalledOnce(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("does not hide a real export error after a cancellation request", async () => { + const job = pendingExport(); + await start(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => job.reject(new Error("disk full"))); + expect(screen.getByText("disk full")).toBeVisible(); + expect(toast.error).toHaveBeenCalledOnce(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("requests cancellation on unmount and ignores later settlement", async () => { + const job = pendingExport(); + const { id, unmount } = await start(); + unmount(); + expect(cancelGifExportNative).toHaveBeenCalledWith(id); + await act(async () => job.resolve(STATS)); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("does not start an export when the save picker returns after unmount", async () => { + let choose!: (value: { success: boolean; path: string }) => void; + window.electronAPI.pickExportSavePath = vi.fn( + () => + new Promise<{ success: boolean; path: string }>((resolve) => { + choose = resolve; + }), + ); + const view = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "GIF" })); + fireEvent.click(screen.getByRole("button", { name: "Export GIF" })); + view.unmount(); + await act(async () => choose({ success: true, path: "/tmp/late.gif" })); + expect(exportGifNative).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 5dff50acf..016d1d541 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -1,12 +1,10 @@ // Export dialog for the new editor. Wires together: // 1. pickExportSavePath (native save dialog) // 2. the native D3D exporter (exportMultiNative / exportGifNative) -// 3. writeExportToPath (writes the resulting buffer to disk) +// 3. per-job GIF cancellation, with native cleanup before returning to options // // Format/quality/GIF options live in the dialog's local state. The -// legacy `ExportDialog` (in components/video-editor) is the rich version -// used by the legacy VideoEditor; this one is a compact surface tuned for -// the new shell's modal style. +// dialog uses the new shell's modal style. import { Download, FileVideo, FolderOpen, Loader2 } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -34,7 +32,13 @@ import { } from "@/lib/exporter"; import { calculateMp4ExportSettings, wouldUpscale } from "@/lib/exporter/mp4ExportSettings"; import { outputFrameCount } from "@/lib/exporter/outputFrameCount"; -import { exportGifNative, exportMultiNative, useIsCpuCompositor } from "@/native"; +import { + cancelGifExportNative, + exportGifNative, + exportMultiNative, + useIsCpuCompositor, +} from "@/native"; +import { NativeBridgeRequestError } from "@/native/client"; import type { CompositorClipInput } from "@/native/contracts"; import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription"; import { ModalShell } from "./Modals"; @@ -42,6 +46,21 @@ import styles from "./NewEditorShell.module.css"; type Phase = "idle" | "configuring" | "rendering" | "writing" | "done" | "error"; +interface ActiveExport { + id?: string; + cancelRequested: boolean; + unsubscribe?: () => void; +} + +function disposeExport(exportJob: ActiveExport | null) { + exportJob?.unsubscribe?.(); + if (exportJob?.id) { + void cancelGifExportNative(exportJob.id).catch((error) => { + console.warn("[export] failed to cancel detached GIF export", error); + }); + } +} + /** hh:mm:ss (always shows hours, unlike the shared mm:ss `formatTimePadded`) — exports can run * past an hour on either axis (video duration or render wall-time). */ function formatHms(totalSeconds: number): string { @@ -140,7 +159,18 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { const [progress, setProgress] = useState(null); const [error, setError] = useState(null); const [savedPath, setSavedPath] = useState(null); - const cancelRef = useRef<{ cancel: () => void } | null>(null); + const activeExport = useRef(null); + const [cancelPending, setCancelPending] = useState(false); + const pickerGeneration = useRef(0); + + useEffect( + () => () => { + pickerGeneration.current += 1; + disposeExport(activeExport.current); + activeExport.current = null; + }, + [], + ); // (Old behavior: the native compositor overlay used to be a top-level OS window outside the // Chromium surface, so we'd hide it here to put this modal in front. The compositor now @@ -228,21 +258,48 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { useEffect(() => { if (!open) { + pickerGeneration.current += 1; + disposeExport(activeExport.current); + activeExport.current = null; setPhase("idle"); setProgress(null); setError(null); setSavedPath(null); - cancelRef.current = null; + setCancelPending(false); } }, [open]); const handleClose = () => { - if (phase === "rendering" || phase === "writing") return; + if (phase === "rendering" || phase === "writing" || phase === "configuring") return; onClose(); }; + const handleCancel = async () => { + const job = activeExport.current; + if (phase !== "rendering" || !job?.id) { + handleClose(); + return; + } + if (job.cancelRequested) return; + job.cancelRequested = true; + setCancelPending(true); + try { + await cancelGifExportNative(job.id); + // Native settlement decides the winner and confirms file cleanup. + } catch (err) { + if (activeExport.current !== job) return; + job.cancelRequested = false; + setCancelPending(false); + const message = err instanceof Error ? err.message : String(err); + setError(message); + setPhase("error"); + toast.error(message); + } + }; + const handleStart = async () => { - if (!document) return; + if (!document || activeExport.current || phase === "configuring") return; + const generation = ++pickerGeneration.current; const asset = primaryAsset; if (!asset) { setError(t("exportDialog.addVideoBeforeExporting")); @@ -260,16 +317,19 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { setError(null); setProgress(null); setSavedPath(null); + setCancelPending(false); let pickedPath: string | undefined; try { const picker = await window.electronAPI?.pickExportSavePath?.(suggested); pickedPath = picker && "path" in picker ? picker.path : undefined; } catch (err) { + if (generation !== pickerGeneration.current) return; setError(err instanceof Error ? err.message : String(err)); setPhase("error"); return; } + if (generation !== pickerGeneration.current) return; if (!pickedPath) { setPhase("idle"); return; @@ -283,6 +343,11 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { // as the live preview, so an export can no longer disagree with what the // user previewed. { + const job: ActiveExport = { + id: format === "gif" ? crypto.randomUUID() : undefined, + cancelRequested: false, + }; + activeExport.current = job; setPhase("rendering"); // Render the real timeline when there are clips; else fall back to the fixture. const clips = buildNativeClipList(document); @@ -300,17 +365,21 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { const sceneDesc = buildSceneDescription(document); const totalFrames = outputFrameCount(clips, sceneDesc.speedRegions, outFps); const startedAt = Date.now(); - const unsubscribeProgress = window.electronAPI?.onNativeExportProgress?.((frames) => { - const elapsedS = (Date.now() - startedAt) / 1000; - const fractionDone = Math.min(1, frames / totalFrames); - const estimatedTimeRemaining = fractionDone > 0 ? elapsedS / fractionDone - elapsedS : 0; - setProgress({ - currentFrame: frames, - totalFrames, - percentage: fractionDone * 100, - estimatedTimeRemaining, - }); - }); + const unsubscribeProgress = window.electronAPI?.onNativeExportProgress?.( + (frames, exportId) => { + if (activeExport.current !== job || job.cancelRequested || exportId !== job.id) return; + const elapsedS = (Date.now() - startedAt) / 1000; + const fractionDone = Math.min(1, frames / totalFrames); + const estimatedTimeRemaining = fractionDone > 0 ? elapsedS / fractionDone - elapsedS : 0; + setProgress({ + currentFrame: frames, + totalFrames, + percentage: fractionDone * 100, + estimatedTimeRemaining, + }); + }, + ); + job.unsubscribe = unsubscribeProgress; try { // The webcam background effect is applied by the compositor from the scene, // so the clip list needs no pre-rendering pass. @@ -323,20 +392,27 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { } const stats = format === "gif" - ? await exportGifNative(exportClips, pickedPath, sceneJson, { - // GIF is 256-colour and grows fast; cap the long edge at the - // chosen preset rather than exporting at source size. - ...gifOutputDims(gifSize, outDims), - fps: gifFrameRate, - // 0 = infinite, the historical GIF default; 1 = play once. - loopCount: gifLoop ? 0 : 1, - }) + ? await exportGifNative( + exportClips, + pickedPath, + sceneJson, + { + // GIF is 256-colour and grows fast; cap the long edge at the + // chosen preset rather than exporting at source size. + ...gifOutputDims(gifSize, outDims), + fps: gifFrameRate, + // 0 = infinite, the historical GIF default; 1 = play once. + loopCount: gifLoop ? 0 : 1, + }, + job.id, + ) : await exportMultiNative(exportClips, pickedPath, sceneJson, { width: outDims?.width, height: outDims?.height, fps, codec, }); + if (activeExport.current !== job) return; setSavedPath(pickedPath); setPhase("done"); toast.success(t("exportDialog.exportedVideo"), { @@ -349,6 +425,13 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { }, }); } catch (err) { + if (activeExport.current !== job) return; + if (err instanceof NativeBridgeRequestError && err.code === "CANCELLED") { + setPhase("idle"); + setProgress(null); + setError(null); + return; + } setError(err instanceof Error ? err.message : String(err)); setPhase("error"); toast.error(t("exportDialog.exportFailed"), { @@ -356,6 +439,10 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { }); } finally { unsubscribeProgress?.(); + if (activeExport.current === job) { + activeExport.current = null; + setCancelPending(false); + } } return; } @@ -655,9 +742,11 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {