@@ -303,7 +303,17 @@ impl NodeGraphExecutor {
303303 . map_err ( |e| e. to_string ( ) ) ?;
304304
305305 if let Some ( animation) = export_config. animation {
306- // Allocate an export ID and accumulator, then queue one execution per frame
306+ // Defense-in-depth: the dialog already validates these, but reject non-finite/non-positive values here
307+ // too so a corrupt message can't reach `Duration::from_secs_f64` (which panics on NaN/negative/huge).
308+ if !animation. fps . is_finite ( ) || animation. fps <= 0. || !animation. start_seconds . is_finite ( ) {
309+ return Err ( "Animation export rejected: fps and start time must be finite, with fps > 0" . to_string ( ) ) ;
310+ }
311+
312+ // Allocate an export ID and accumulator, then queue one execution per frame.
313+ // TODO: All encoded frames are held in `pending_animation_exports` until the last frame arrives, so peak
314+ // memory grows with `total_frames * encoded_frame_size`. For long/high-resolution animations, this could
315+ // exhaust memory. A bounded cap is enforced upstream in the export dialog (ANIMATION_EXPORT_MAX_FRAMES),
316+ // but a true fix would stream frames out incrementally instead of accumulating.
307317 let export_id = self . next_animation_export_id ;
308318 self . next_animation_export_id = self . next_animation_export_id . wrapping_add ( 1 ) ;
309319
@@ -325,7 +335,10 @@ impl NodeGraphExecutor {
325335
326336 for frame_index in 0 ..animation. total_frames {
327337 let frame_seconds = animation. frame_time_seconds ( frame_index) ;
328- let animation_time = Duration :: from_secs_f64 ( frame_seconds. max ( 0. ) ) ;
338+ // `Duration::from_secs_f64` panics on negative/NaN/huge values; clamp defensively (we've already
339+ // validated `fps`/`start_seconds` above, but a far-future `start_seconds` could still overflow).
340+ let safe_seconds = if frame_seconds. is_finite ( ) { frame_seconds. clamp ( 0. , 1e9 ) } else { 0. } ;
341+ let animation_time = Duration :: from_secs_f64 ( safe_seconds) ;
329342 let timing = TimingInformation { time : frame_seconds, animation_time } ;
330343
331344 let frame_render_config = RenderConfig { time : timing, ..base_render_config } ;
@@ -382,6 +395,24 @@ impl NodeGraphExecutor {
382395 document. network_interface . update_click_targets ( HashMap :: new ( ) ) ;
383396 document. network_interface . update_outlines ( HashMap :: new ( ) ) ;
384397 document. network_interface . update_vector_modify ( HashMap :: new ( ) ) ;
398+
399+ // If this failure belongs to an animation export, drop its accumulator so the partially
400+ // rendered frames don't stay pinned in memory. Subsequent failed frames for the same
401+ // export are then silently ignored by `process_animation_frame`.
402+ // TODO: An export can also leak if it's interrupted by something *outside* this error
403+ // path — e.g. the document is closed mid-export. A proper fix would route a
404+ // cancellation through `pending_animation_exports`. Tracked separately.
405+ let leaked_export_id = self
406+ . futures
407+ . iter ( )
408+ . find ( |( fid, _) | * fid == execution_id)
409+ . and_then ( |( _, ctx) | ctx. export_config . as_ref ( ) )
410+ . and_then ( |cfg| cfg. animation_frame )
411+ . map ( |af| af. export_id ) ;
412+ if let Some ( export_id) = leaked_export_id {
413+ self . pending_animation_exports . remove ( & export_id) ;
414+ }
415+
385416 return Err ( format ! ( "Node graph evaluation failed:\n {e}" ) ) ;
386417 }
387418 } ;
@@ -609,11 +640,19 @@ impl NodeGraphExecutor {
609640 TaggedValue :: RenderOutput ( RenderOutput {
610641 data : RenderOutputType :: Buffer { data, width, height } ,
611642 ..
612- } ) if file_type != FileType :: Svg => {
613- let encoded = encode_raster_buffer ( file_type, data, width, height) ?;
614- ExportAnimationFrame :: Bytes ( serde_bytes:: ByteBuf :: from ( encoded) )
643+ } ) if file_type != FileType :: Svg => match encode_raster_buffer ( file_type, data, width, height) {
644+ Ok ( encoded) => ExportAnimationFrame :: Bytes ( serde_bytes:: ByteBuf :: from ( encoded) ) ,
645+ Err ( err) => {
646+ // Drop the partial accumulator so its already-received frames don't leak.
647+ self . pending_animation_exports . remove ( & animation_frame. export_id ) ;
648+ return Err ( err) ;
649+ }
650+ } ,
651+ other => {
652+ // Drop the partial accumulator so its already-received frames don't leak.
653+ self . pending_animation_exports . remove ( & animation_frame. export_id ) ;
654+ return Err ( format ! ( "Incorrect render type for animation frame ({file_type:?}, {other})" ) ) ;
615655 }
616- other => return Err ( format ! ( "Incorrect render type for animation frame ({file_type:?}, {other})" ) ) ,
617656 } ;
618657
619658 let Some ( accumulator) = self . pending_animation_exports . get_mut ( & animation_frame. export_id ) else {
@@ -635,9 +674,11 @@ impl NodeGraphExecutor {
635674
636675 // All frames received: drain the accumulator and emit a single message
637676 let accumulator = self . pending_animation_exports . remove ( & animation_frame. export_id ) . expect ( "Accumulator was present" ) ;
638- let base_name = match ( accumulator. artboard_name , accumulator. artboard_count ) {
639- ( Some ( artboard_name) , count) if count > 1 => format ! ( "{} - {}" , accumulator. name, artboard_name) ,
640- _ => accumulator. name ,
677+ // Sanitize before the name reaches filesystem joins or zip entry names downstream.
678+ let safe_doc_name = crate :: messages:: frontend:: utility_types:: sanitize_filename_component ( & accumulator. name ) ;
679+ let base_name = match ( accumulator. artboard_name . as_deref ( ) , accumulator. artboard_count ) {
680+ ( Some ( artboard_name) , count) if count > 1 => format ! ( "{safe_doc_name} - {}" , crate :: messages:: frontend:: utility_types:: sanitize_filename_component( artboard_name) ) ,
681+ _ => safe_doc_name,
641682 } ;
642683 let frames: Vec < _ > = accumulator
643684 . frames
0 commit comments