From 26dbb159a572db93042a201acfc07199829f1e5c Mon Sep 17 00:00:00 2001 From: blackridder22 Date: Sun, 2 Aug 2026 22:40:06 -0800 Subject: [PATCH] Add video image-sequence export --- CONTRIBUTING_VISUAL_FIXTURES.md | 1 + frame-app/src/app/fixtures.rs | 29 ++- frame-app/src/app/settings_panel/images.rs | 95 +++++++- frame-app/src/app/settings_panel/panel.rs | 1 + frame-app/src/app/tests.rs | 29 +++ frame-app/src/conversion_runner/config.rs | 1 + .../src/conversion_runner/output_paths.rs | 92 +++++++ frame-app/src/conversion_runner/runner.rs | 48 ++-- frame-app/src/conversion_runner/tests.rs | 156 +++++++++++- frame-app/src/lib.rs | 6 + frame-app/src/settings/model.rs | 28 +++ frame-app/src/settings/options.rs | 84 ++++++- frame-app/src/settings/tabs.rs | 13 +- frame-app/src/settings/tests.rs | 226 +++++++++++++++++- frame-app/src/settings/updates.rs | 18 +- frame-core/src/args.rs | 72 +++++- frame-core/src/filters.rs | 1 + frame-core/src/media_filters.rs | 1 + frame-core/src/preview.rs | 1 + frame-core/src/types.rs | 8 + frame-core/tests/media_integration.rs | 50 ++++ 21 files changed, 899 insertions(+), 61 deletions(-) diff --git a/CONTRIBUTING_VISUAL_FIXTURES.md b/CONTRIBUTING_VISUAL_FIXTURES.md index ac82930c..1b3c4961 100644 --- a/CONTRIBUTING_VISUAL_FIXTURES.md +++ b/CONTRIBUTING_VISUAL_FIXTURES.md @@ -83,6 +83,7 @@ the final GPUI rendering. | `settings-video` | Video settings tab with custom resolution and CRF mode | Video codec controls, custom width and height inputs, CRF controls, bitrate mode layout, and dense control grouping. | | `settings-audio` | Audio settings tab with an audio source and tracks | Audio codec controls, VBR quality, channel selection, volume and normalize controls, track selection rows, and audio-only source treatment. | | `settings-images` | Image settings tab with a selected PNG source | Image output controls, custom image dimensions, image-source metadata, and non-video settings visibility. | +| `settings-image-sequence` | Image settings tab with a 30-second, 30-fps video targeting JPEG | Single/sequence controls, an estimated 900-frame count, VFR guidance, and sequence size recommendations. | | `settings-metadata` | Metadata tab with source tags and output metadata drafts | Source metadata presentation, editable metadata fields, long value wrapping, and tag/value alignment. | | `settings-subtitles` | Subtitles tab with selectable sidecars, source tracks, and burn-in styling | External subtitle rows and metadata editor, default/forced states, source track rows, burn-in file state, font controls, color swatches, outline color, position controls, and selected track state. | | `settings-subtitles-popover` | Subtitles tab with the font color picker open | Color picker popover placement, swatch state, HSV draft color, popover layering, and focus treatment inside the settings panel. | diff --git a/frame-app/src/app/fixtures.rs b/frame-app/src/app/fixtures.rs index 6a27b4b4..08ad8283 100644 --- a/frame-app/src/app/fixtures.rs +++ b/frame-app/src/app/fixtures.rs @@ -1,6 +1,6 @@ use super::*; -use crate::settings::{DeinterlaceMode, FilterStrength}; +use crate::settings::{DeinterlaceMode, FilterStrength, ImageOutputMode}; use frame_updater::{PlatformAssetKey, UpdateAsset}; use semver::Version; @@ -58,6 +58,9 @@ impl FrameRoot { self.apply_settings_audio_filters_fixture(); } Some(VisualFixture::SettingsImages) => self.apply_settings_images_fixture(), + Some(VisualFixture::SettingsImageSequence) => { + self.apply_settings_image_sequence_fixture(); + } Some(VisualFixture::SettingsMetadata) => self.apply_settings_metadata_fixture(), Some(VisualFixture::SettingsOutput) => self.apply_settings_output_fixture(), Some(VisualFixture::SettingsPresets) => self.apply_settings_presets_fixture(), @@ -284,6 +287,30 @@ impl FrameRoot { } self.settings_ui.active_tab = SettingsTab::Images; } + pub(super) fn apply_settings_image_sequence_fixture(&mut self) { + self.apply_preview_ready_fixture(); + self.source_metadata.mark_ready( + "fixture-preview".to_string(), + SourceMetadata { + media_kind: Some(SourceKind::Video), + duration: Some("30.000000".to_string()), + bitrate: Some("12000000".to_string()), + video_codec: Some("h264".to_string()), + resolution: Some("1920x1080".to_string()), + frame_rate: Some(30.0), + width: Some(1920), + height: Some(1080), + ..SourceMetadata::default() + }, + ); + if let Some(file) = self.file_queue.selected_file_mut() { + file.config.container = "jpg".to_string(); + file.config.video_codec = "mjpeg".to_string(); + file.config.image_output_mode = ImageOutputMode::Sequence; + file.config.image_jpeg_quality = 88; + } + self.settings_ui.active_tab = SettingsTab::Images; + } pub(super) fn apply_settings_metadata_fixture(&mut self) { self.apply_preview_ready_fixture(); self.settings_ui.active_tab = SettingsTab::Metadata; diff --git a/frame-app/src/app/settings_panel/images.rs b/frame-app/src/app/settings_panel/images.rs index c55423c0..cccd6dc7 100644 --- a/frame-app/src/app/settings_panel/images.rs +++ b/frame-app/src/app/settings_panel/images.rs @@ -1,16 +1,19 @@ use super::{ ClickEvent, Context, ConversionConfig, DragMoveEvent, FocusHandle, FrameRoot, ParentElement, - Render, StatefulInteractiveElement, Styled, Window, apply_image_jpeg_huffman, - apply_image_jpeg_quality, apply_image_png_compression, apply_image_png_prediction, - apply_image_tiff_compression, apply_image_webp_compression, apply_image_webp_lossless, - apply_image_webp_preset, apply_image_webp_quality, apply_pixel_format, color, div, - frame_choice_button, frame_list_item_with_caption, frame_slider, frame_slider_handle, - image_jpeg_huffman_options, image_png_prediction_options, image_tiff_compression_options, - image_webp_preset_options, range_fraction, range_value_for_key, range_value_from_fraction, - settings_field_label, settings_hint_text, settings_section, settings_value_badge, - settings_video_resolution_section, settings_video_scaling_section, theme, + Render, SourceKind, SourceMetadata, StatefulInteractiveElement, Styled, Window, + apply_image_jpeg_huffman, apply_image_jpeg_quality, apply_image_png_compression, + apply_image_png_prediction, apply_image_tiff_compression, apply_image_webp_compression, + apply_image_webp_lossless, apply_image_webp_preset, apply_image_webp_quality, + apply_pixel_format, color, div, frame_choice_button, frame_list_item_with_caption, + frame_slider, frame_slider_handle, image_jpeg_huffman_options, image_png_prediction_options, + image_tiff_compression_options, image_webp_preset_options, range_fraction, range_value_for_key, + range_value_from_fraction, settings_field_label, settings_hint_text, settings_section, + settings_value_badge, settings_video_resolution_section, settings_video_scaling_section, theme, timeline_slider_percent_from_bounds, video_pixel_format_options, }; +use crate::settings::{ + ImageOutputMode, apply_image_output_mode, estimated_image_sequence_frame_count, source_kind_for, +}; use gpui::{AppContext, InteractiveElement, prelude::FluentBuilder}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -36,8 +39,13 @@ impl Render for SettingsImageRangeDragPreview { } } +#[expect( + clippy::too_many_arguments, + reason = "the images tab receives source metadata alongside its existing settings render contract" +)] pub(in crate::app) fn settings_images_tab( config: &ConversionConfig, + metadata: Option<&SourceMetadata>, settings_disabled: bool, video_width_focus: Option<&FocusHandle>, video_height_focus: Option<&FocusHandle>, @@ -49,6 +57,16 @@ pub(in crate::app) fn settings_images_tab( .flex() .flex_col() .gap_4() + .when(source_kind_for(metadata) == SourceKind::Video, |this| { + this.child(settings_image_output_mode_section( + config, + metadata, + settings_disabled, + palette, + window, + cx, + )) + }) .child(settings_video_resolution_section( config, settings_disabled, @@ -81,6 +99,65 @@ pub(in crate::app) fn settings_images_tab( )) } +fn settings_image_output_mode_section( + config: &ConversionConfig, + metadata: Option<&SourceMetadata>, + settings_disabled: bool, + palette: &'static theme::ThemePalette, + window: &mut Window, + cx: &mut Context, +) -> gpui::Div { + let mut grid = div().grid().grid_cols(2).gap_2(); + for mode in [ImageOutputMode::Single, ImageOutputMode::Sequence] { + let enabled = !settings_disabled; + grid = grid.child( + frame_choice_button( + format!("image-output-mode-{}", mode.id()), + mode.label(), + config.image_output_mode == mode, + enabled, + palette, + window, + cx, + ) + .on_click(cx.listener(move |root, _: &ClickEvent, _window, cx| { + cx.stop_propagation(); + if enabled + && root.update_selected_config(|config| apply_image_output_mode(config, mode)) + { + cx.notify(); + } + })), + ); + } + + settings_section("Image output", palette) + .child(grid) + .when(config.image_output_mode == ImageOutputMode::Sequence, |this| { + let estimate = estimated_image_sequence_frame_count(config, metadata) + .map_or_else( + || "Estimated frame count unavailable".to_string(), + |count| format!("Estimated frames: ≈ {count}"), + ); + this.child(sequence_hint_text(estimate, palette)) + .child(settings_hint_text( + "Variable-frame-rate sources may produce a different actual frame count.", + palette, + )) + .child(settings_hint_text( + "JPEG and WebP are recommended for smaller sequences. PNG and TIFF can use substantial disk space.", + palette, + )) + }) +} + +fn sequence_hint_text(text: String, palette: &'static theme::ThemePalette) -> gpui::Div { + div() + .text_size(theme::ui_rem(theme::TEXT_UI_BASE_SIZE)) + .text_color(color(palette.text_muted)) + .child(text) +} + fn settings_images_pixel_format_section( config: &ConversionConfig, settings_disabled: bool, diff --git a/frame-app/src/app/settings_panel/panel.rs b/frame-app/src/app/settings_panel/panel.rs index c1a5f315..76e3d88a 100644 --- a/frame-app/src/app/settings_panel/panel.rs +++ b/frame-app/src/app/settings_panel/panel.rs @@ -237,6 +237,7 @@ pub(in crate::app) fn settings_tab_content( )), SettingsTab::Images => content.child(settings_images_tab( settings.config, + settings.metadata, settings.settings_disabled, settings.video_width_focus, settings.video_height_focus, diff --git a/frame-app/src/app/tests.rs b/frame-app/src/app/tests.rs index 9021a66d..920c8587 100644 --- a/frame-app/src/app/tests.rs +++ b/frame-app/src/app/tests.rs @@ -3864,6 +3864,35 @@ mod visual_fixtures { ); } + #[test] + fn settings_image_sequence_fixture_shows_video_sequence_with_900_frame_estimate() { + let mut root = FrameRoot::new(); + + root.apply_visual_fixture(Some(VisualFixture::SettingsImageSequence)); + + let selected = root + .file_queue + .selected_file() + .expect("image sequence fixture should select a file"); + let metadata = root + .selected_source_metadata() + .expect("image sequence fixture should have metadata"); + assert_eq!(root.settings_ui.active_tab, SettingsTab::Images); + assert_eq!(metadata.source_kind(), SourceKind::Video); + assert_eq!(selected.config.container, "jpg"); + assert_eq!( + selected.config.image_output_mode, + crate::settings::ImageOutputMode::Sequence + ); + assert_eq!( + crate::settings::estimated_image_sequence_frame_count( + &selected.config, + Some(&metadata) + ), + Some(900) + ); + } + #[test] fn settings_subtitles_fixture_opens_subtitles_tab_with_tracks() { let mut root = FrameRoot::new(); diff --git a/frame-app/src/conversion_runner/config.rs b/frame-app/src/conversion_runner/config.rs index 78d1890a..e907fe1f 100644 --- a/frame-app/src/conversion_runner/config.rs +++ b/frame-app/src/conversion_runner/config.rs @@ -100,6 +100,7 @@ pub fn core_config_from_gpui(config: &GpuiConversionConfig) -> CoreConversionCon videotoolbox_allow_sw: config.videotoolbox_allow_sw, hw_decode: config.hw_decode, pixel_format: non_empty_or(&config.pixel_format, DEFAULT_PIXEL_FORMAT), + image_output_mode: config.image_output_mode.id().to_string(), image_jpeg_quality: config.image_jpeg_quality.clamp(1, 100), image_jpeg_huffman: config.image_jpeg_huffman.clone(), image_webp_lossless: config.image_webp_lossless, diff --git a/frame-app/src/conversion_runner/output_paths.rs b/frame-app/src/conversion_runner/output_paths.rs index 23ed8776..fffc1414 100644 --- a/frame-app/src/conversion_runner/output_paths.rs +++ b/frame-app/src/conversion_runner/output_paths.rs @@ -2,12 +2,104 @@ use std::{collections::HashSet, path::Path}; use frame_core::{args::build_output_path, types::ConversionTask}; +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct PreparedOutputTarget { + pub result_path: String, + pub ffmpeg_sink_path: String, + pub directory_to_create: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct PreparedConversionTask { + pub task: ConversionTask, + pub output: PreparedOutputTarget, +} + +/// Resolves filesystem and in-batch collisions and prepares the distinct path +/// reported to the app and path consumed by `FFmpeg`. +pub(super) fn prepare_conversion_tasks(tasks: Vec) -> Vec { + let mut claimed_paths = HashSet::with_capacity(tasks.len()); + let mut prepared = Vec::with_capacity(tasks.len()); + + for mut task in tasks { + let desired_path = task_output_path(&task); + let output_stem = output_stem_from_path(&desired_path); + let is_sequence = task.config.image_output_mode == "sequence"; + + for suffix in 1_u64.. { + let candidate_name = if suffix == 1 { + output_stem.to_string() + } else { + format!("{output_stem}_{suffix}") + }; + let output = if is_sequence { + sequence_output_target(&task, output_stem, suffix) + } else { + let result_path = build_output_path( + &task.output_directory, + &task.config.container, + Some(&candidate_name), + ); + PreparedOutputTarget { + ffmpeg_sink_path: result_path.clone(), + result_path, + directory_to_create: None, + } + }; + + if output_path_is_available(&output.result_path, &claimed_paths) { + claimed_paths.insert(output_path_key(&output.result_path)); + if !is_sequence && suffix > 1 { + task.output_name = Some(candidate_name); + } + prepared.push(PreparedConversionTask { task, output }); + break; + } + } + } + + prepared +} + +fn sequence_output_target( + task: &ConversionTask, + output_stem: &str, + suffix: u64, +) -> PreparedOutputTarget { + let directory_name = if suffix == 1 { + format!("{output_stem}_frames") + } else { + format!("{output_stem}_frames_{suffix}") + }; + let result_file_shape = build_output_path( + &task.output_directory, + &task.config.container, + Some(&directory_name), + ); + let extension = format!(".{}", task.config.container); + let result_path = result_file_shape + .strip_suffix(&extension) + .unwrap_or(&result_file_shape) + .to_string(); + let ffmpeg_sink_path = + build_output_path(&result_path, &task.config.container, Some("frame_%06d")); + + PreparedOutputTarget { + ffmpeg_sink_path, + result_path: result_path.clone(), + directory_to_create: Some(result_path), + } +} + /// Assigns deterministic suffixes to output names that would collide with an /// earlier task or an existing filesystem entry. pub fn disambiguate_output_paths(tasks: &mut [ConversionTask]) { let mut claimed_paths = HashSet::with_capacity(tasks.len()); for task in tasks { + if task.config.image_output_mode == "sequence" { + continue; + } let desired_path = task_output_path(task); if output_path_is_available(&desired_path, &claimed_paths) { claimed_paths.insert(output_path_key(&desired_path)); diff --git a/frame-app/src/conversion_runner/runner.rs b/frame-app/src/conversion_runner/runner.rs index 6f8daec9..1bad0fa8 100644 --- a/frame-app/src/conversion_runner/runner.rs +++ b/frame-app/src/conversion_runner/runner.rs @@ -1,5 +1,6 @@ use std::{ collections::VecDeque, + fs, io::Read, process::{Command, Stdio}, sync::mpsc::{self, RecvTimeoutError}, @@ -8,7 +9,7 @@ use std::{ }; use frame_core::{ - args::{build_ffmpeg_args, build_output_path, validate_task_input}, + args::{build_ffmpeg_args, validate_task_input}, error::ConversionError, events::ConversionEvent, probe::{ffprobe_json_args, parse_ffprobe_stdout}, @@ -18,7 +19,10 @@ use frame_core::{ use crate::runtime_binaries::{ffmpeg_executable, ffprobe_executable}; -use super::{controller::ConversionProcessController, output_paths::disambiguate_output_paths}; +use super::{ + controller::ConversionProcessController, + output_paths::{PreparedConversionTask, prepare_conversion_tasks}, +}; /// Runs a single conversion task with a default process controller. /// @@ -40,12 +44,11 @@ pub fn run_conversion_task( /// Returns an error when the controller state cannot be read or the worker /// channel disconnects before all tasks complete. pub fn run_conversion_batch_with_control( - mut tasks: Vec, + tasks: Vec, controller: &ConversionProcessController, mut emit: impl FnMut(ConversionEvent), ) -> Result<(), ConversionError> { - disambiguate_output_paths(&mut tasks); - let mut pending = VecDeque::from(tasks); + let mut pending = VecDeque::from(prepare_conversion_tasks(tasks)); let mut running_count = 0_usize; let (event_tx, event_rx) = mpsc::channel::(); let (done_tx, done_rx) = mpsc::channel::<(String, Result<(), ConversionError>)>(); @@ -98,19 +101,24 @@ pub fn run_conversion_batch_with_control( /// Returns an error when task validation, probing, process spawning, process /// registration, log reading, cancellation handling, or `FFmpeg` execution fails. pub fn run_conversion_task_with_control( - mut task: ConversionTask, + task: ConversionTask, controller: &ConversionProcessController, emit: &mut impl FnMut(ConversionEvent), ) -> Result<(), ConversionError> { - disambiguate_output_paths(std::slice::from_mut(&mut task)); - run_prepared_conversion_task_with_control(task, controller, emit) + let Some(prepared) = prepare_conversion_tasks(vec![task]).into_iter().next() else { + return Err(ConversionError::Worker( + "conversion task could not be prepared".to_string(), + )); + }; + run_prepared_conversion_task_with_control(prepared, controller, emit) } fn run_prepared_conversion_task_with_control( - task: ConversionTask, + prepared: PreparedConversionTask, controller: &ConversionProcessController, emit: &mut impl FnMut(ConversionEvent), ) -> Result<(), ConversionError> { + let PreparedConversionTask { task, output } = prepared; if controller.take_cancelled(&task.id)? { emit_cancelled_task(&task.id, emit); return Ok(()); @@ -119,12 +127,12 @@ fn run_prepared_conversion_task_with_control( validate_task_input(&task.file_path, &task.config)?; let probe = probe_media_file(&task.file_path)?; - let output_path = build_output_path( - &task.output_directory, - &task.config.container, - task.output_name.as_deref(), - ); - let args = build_ffmpeg_args(&task.file_path, &output_path, &task.config, &probe)?; + let args = build_ffmpeg_args( + &task.file_path, + &output.ffmpeg_sink_path, + &task.config, + &probe, + )?; let executable = ffmpeg_executable(); emit(ConversionEvent::log( @@ -132,6 +140,10 @@ fn run_prepared_conversion_task_with_control( format!("[INFO] Running {executable} {}", args.join(" ")), )); + if let Some(directory) = &output.directory_to_create { + fs::create_dir(directory).map_err(ConversionError::Io)?; + } + let mut child = Command::new(&executable) .args(&args) .stdin(Stdio::null()) @@ -167,7 +179,7 @@ fn run_prepared_conversion_task_with_control( stream_result?; let status = status?; if status.success() { - emit(ConversionEvent::completed(task.id, output_path)); + emit(ConversionEvent::completed(task.id, output.result_path)); Ok(()) } else { Err(ConversionError::Worker(format!( @@ -177,12 +189,12 @@ fn run_prepared_conversion_task_with_control( } fn spawn_batch_worker( - task: ConversionTask, + task: PreparedConversionTask, controller: ConversionProcessController, event_tx: mpsc::Sender, done_tx: mpsc::Sender<(String, Result<(), ConversionError>)>, ) { - let task_id = task.id.clone(); + let task_id = task.task.id.clone(); thread::spawn(move || { let result = run_prepared_conversion_task_with_control(task, &controller, &mut |event| { let _ = event_tx.send(event); diff --git a/frame-app/src/conversion_runner/tests.rs b/frame-app/src/conversion_runner/tests.rs index 14c22f83..e0ee08c9 100644 --- a/frame-app/src/conversion_runner/tests.rs +++ b/frame-app/src/conversion_runner/tests.rs @@ -6,8 +6,8 @@ use super::*; use crate::settings::{ AudioFiltersConfig, CropSettings, DeinterlaceMode, ExternalSubtitleTrack, FilterStrength, - FilterValue, MetadataConfig, MetadataMode, ProcessingMode, VideoColorFiltersConfig, - VideoFiltersConfig, + FilterValue, ImageOutputMode, MetadataConfig, MetadataMode, ProcessingMode, + VideoColorFiltersConfig, VideoFiltersConfig, }; use std::{ fs, @@ -167,6 +167,7 @@ fn core_config_from_gpui_preserves_active_conversion_fields() { quality: 60, preset: "slow".to_string(), pixel_format: "yuv420p10le".to_string(), + image_output_mode: ImageOutputMode::Sequence, image_jpeg_quality: 92, image_jpeg_huffman: "optimal".to_string(), image_webp_lossless: true, @@ -205,6 +206,7 @@ fn core_config_from_gpui_preserves_active_conversion_fields() { assert_eq!(core.quality, 60); assert_eq!(core.preset, "slow"); assert_eq!(core.pixel_format, "yuv420p10le"); + assert_eq!(core.image_output_mode, "sequence"); assert_eq!(core.image_jpeg_quality, 92); assert_eq!(core.image_jpeg_huffman, "optimal"); assert!(core.image_webp_lossless); @@ -359,6 +361,106 @@ fn disambiguate_output_paths_uses_next_free_suffix_deterministically() { ); } +#[test] +fn prepare_sequence_output_builds_folder_and_numbered_sink() { + let sandbox = ConversionRunnerSandbox::new("sequence-output-shape"); + let mut file = FileItem::from_path("mov", "/A/clip.mov", 1); + file.config.container = "png".to_string(); + file.config.video_codec = "png".to_string(); + file.config.image_output_mode = ImageOutputMode::Sequence; + let output_directory = sandbox.root.to_string_lossy(); + + let prepared = + prepare_conversion_tasks(vec![conversion_task_from_file(&file, &output_directory)]); + + assert_eq!( + prepared[0].output.result_path, + sandbox.path("clip_converted_frames").to_string_lossy() + ); + assert_eq!( + prepared[0].output.ffmpeg_sink_path, + sandbox + .path("clip_converted_frames/frame_%06d.png") + .to_string_lossy() + ); + assert_eq!( + prepared[0].output.directory_to_create.as_deref(), + Some( + sandbox + .path("clip_converted_frames") + .to_string_lossy() + .as_ref() + ) + ); +} + +#[test] +fn prepare_sequence_output_preserves_windows_style_separators() { + let mut config = core_config_from_gpui(&GpuiConversionConfig::default()); + config.container = "jpg".to_string(); + config.video_codec = "mjpeg".to_string(); + config.image_output_mode = "sequence".to_string(); + let task = ConversionTask { + id: "windows-sequence".to_string(), + file_path: r"C:\media\clip.mov".to_string(), + output_directory: r"\\server\share\exports".to_string(), + output_name: Some("clip".to_string()), + config, + }; + + let prepared = prepare_conversion_tasks(vec![task]); + + assert_eq!( + prepared[0].output.result_path, + r"\\server\share\exports\clip_frames" + ); + assert_eq!( + prepared[0].output.ffmpeg_sink_path, + r"\\server\share\exports\clip_frames\frame_%06d.jpg" + ); +} + +#[test] +fn prepare_sequence_output_resolves_existing_and_batch_collisions() { + let sandbox = ConversionRunnerSandbox::new("sequence-output-collisions"); + fs::create_dir(sandbox.path("clip_converted_frames")) + .expect("partial sequence folder should be created"); + fs::write( + sandbox.path("clip_converted_frames/frame_000001.png"), + b"partial", + ) + .expect("partial frame should be retained"); + let mut first = FileItem::from_path("mov", "/A/clip.mov", 1); + let mut second = FileItem::from_path("mkv", "/B/clip.mkv", 1); + for file in [&mut first, &mut second] { + file.config.container = "png".to_string(); + file.config.video_codec = "png".to_string(); + file.config.image_output_mode = ImageOutputMode::Sequence; + } + let output_directory = sandbox.root.to_string_lossy(); + + let prepared = prepare_conversion_tasks(vec![ + conversion_task_from_file(&first, &output_directory), + conversion_task_from_file(&second, &output_directory), + ]); + + assert_eq!( + prepared + .iter() + .map(|task| task.output.result_path.as_str()) + .collect::>(), + vec![ + sandbox.path("clip_converted_frames_2").to_string_lossy(), + sandbox.path("clip_converted_frames_3").to_string_lossy(), + ] + ); + assert_eq!( + fs::read(sandbox.path("clip_converted_frames/frame_000001.png")) + .expect("partial frame should remain readable"), + b"partial" + ); +} + #[test] fn ffmpeg_progress_uses_duration_line_before_time_line() { let mut duration = None; @@ -606,6 +708,56 @@ fn run_conversion_task_should_emit_completed_for_real_image_encoding_job() { ); } +#[test] +#[ignore = "requires FFmpeg/FFprobe; run with --ignored"] +fn run_conversion_task_should_emit_numbered_image_sequence_and_folder_result() { + let sandbox = ConversionRunnerSandbox::new("real-image-sequence-job"); + let input = sandbox.path("source.mp4"); + let result = sandbox.path("runner-sequence_frames"); + generate_runner_source(&input); + let config = GpuiConversionConfig { + container: "png".to_string(), + video_codec: "png".to_string(), + image_output_mode: ImageOutputMode::Sequence, + ..GpuiConversionConfig::default() + }; + let task = ConversionTask { + id: "task-sequence-real".to_string(), + file_path: input.to_string_lossy().into_owned(), + output_directory: sandbox.root.to_string_lossy().into_owned(), + output_name: Some("runner-sequence".to_string()), + config: core_config_from_gpui(&config), + }; + let mut events = Vec::new(); + + run_conversion_task(task, |event| events.push(event)) + .expect("real ffmpeg image sequence conversion should succeed"); + + let mut frame_names = fs::read_dir(&result) + .expect("sequence result folder should be readable") + .map(|entry| { + entry + .expect("sequence frame entry should be readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + frame_names.sort(); + assert_eq!( + frame_names, + (1..=6) + .map(|number| format!("frame_{number:06}.png")) + .collect::>() + ); + assert!( + events.iter().any( + |event| matches!(event, ConversionEvent::Completed(payload) if payload.output_path == result.to_string_lossy()) + ), + "completed event should report the sequence folder" + ); +} + #[test] #[ignore = "requires FFmpeg/FFprobe; run with --ignored"] fn run_conversion_batch_should_create_distinct_outputs_for_same_stem_sources() { diff --git a/frame-app/src/lib.rs b/frame-app/src/lib.rs index fd89c5fb..dec63dd4 100644 --- a/frame-app/src/lib.rs +++ b/frame-app/src/lib.rs @@ -103,6 +103,7 @@ pub enum VisualFixture { SettingsAudio, SettingsAudioFilters, SettingsImages, + SettingsImageSequence, SettingsMetadata, SettingsOutput, SettingsPresets, @@ -130,6 +131,7 @@ pub fn visual_fixture_from_env_value(value: Option<&str>) -> Option Some(VisualFixture::SettingsAudio), Some("settings-audio-filters") => Some(VisualFixture::SettingsAudioFilters), Some("settings-images") => Some(VisualFixture::SettingsImages), + Some("settings-image-sequence") => Some(VisualFixture::SettingsImageSequence), Some("settings-metadata") => Some(VisualFixture::SettingsMetadata), Some("settings-output") => Some(VisualFixture::SettingsOutput), Some("settings-presets") => Some(VisualFixture::SettingsPresets), @@ -324,6 +326,10 @@ mod tests { VisualFixture::SettingsAudioFilters, ), ("settings-images", VisualFixture::SettingsImages), + ( + "settings-image-sequence", + VisualFixture::SettingsImageSequence, + ), ("settings-metadata", VisualFixture::SettingsMetadata), ("settings-output", VisualFixture::SettingsOutput), ("settings-presets", VisualFixture::SettingsPresets), diff --git a/frame-app/src/settings/model.rs b/frame-app/src/settings/model.rs index bdc94c7e..82cfe6c0 100644 --- a/frame-app/src/settings/model.rs +++ b/frame-app/src/settings/model.rs @@ -322,6 +322,32 @@ impl ProcessingMode { } } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ImageOutputMode { + #[default] + Single, + Sequence, +} + +impl ImageOutputMode { + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Single => "single", + Self::Sequence => "sequence", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Single => "Single image", + Self::Sequence => "Image sequence", + } + } +} + #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub enum MetadataMode { @@ -664,6 +690,7 @@ pub struct ConversionConfig { pub quality: u32, pub preset: String, pub pixel_format: String, + pub image_output_mode: ImageOutputMode, pub image_jpeg_quality: u32, pub image_jpeg_huffman: String, pub image_webp_lossless: bool, @@ -725,6 +752,7 @@ impl Default for ConversionConfig { quality: DEFAULT_QUALITY, preset: DEFAULT_PRESET.to_string(), pixel_format: DEFAULT_PIXEL_FORMAT.to_string(), + image_output_mode: ImageOutputMode::Single, image_jpeg_quality: DEFAULT_IMAGE_JPEG_QUALITY, image_jpeg_huffman: DEFAULT_IMAGE_JPEG_HUFFMAN.to_string(), image_webp_lossless: false, diff --git a/frame-app/src/settings/options.rs b/frame-app/src/settings/options.rs index a4ce0ffc..0c3287f9 100644 --- a/frame-app/src/settings/options.rs +++ b/frame-app/src/settings/options.rs @@ -6,11 +6,11 @@ use super::{ AudioTrackOption, ConversionConfig, FPS_OPTIONS, GIF_COLOR_OPTIONS, GIF_DITHER_OPTIONS, GIF_FPS_OPTIONS, IMAGE_JPEG_HUFFMAN_OPTIONS, IMAGE_PNG_PREDICTION_OPTIONS, IMAGE_TIFF_COMPRESSION_OPTIONS, IMAGE_WEBP_PRESET_OPTIONS, ImageEncodingOption, - METADATA_FIELDS, METADATA_MODES, MetadataConfig, MetadataField, MetadataFieldOption, - MetadataMode, MetadataModeOption, OPTIONAL_AUDIO_CODEC_DEFINITIONS, OutputContainerOption, - OutputModeOption, PresetDefinition, PresetOption, ProcessingMode, RESOLUTION_OPTIONS, - SCALING_ALGORITHM_OPTIONS, SUBTITLE_FONT_SIZES, SUBTITLE_POSITIONS, SourceKind, - SourceMetadata, SubtitleFontOption, SubtitleFontSizeOption, SubtitlePosition, + ImageOutputMode, METADATA_FIELDS, METADATA_MODES, MetadataConfig, MetadataField, + MetadataFieldOption, MetadataMode, MetadataModeOption, OPTIONAL_AUDIO_CODEC_DEFINITIONS, + OutputContainerOption, OutputModeOption, PresetDefinition, PresetOption, ProcessingMode, + RESOLUTION_OPTIONS, SCALING_ALGORITHM_OPTIONS, SUBTITLE_FONT_SIZES, SUBTITLE_POSITIONS, + SourceKind, SourceMetadata, SubtitleFontOption, SubtitleFontSizeOption, SubtitlePosition, SubtitlePositionOption, SubtitleTrackOption, VIDEO_CODEC_DEFINITIONS, VIDEO_PIXEL_FORMAT_DEFINITIONS, VIDEO_PRESETS, VideoCodecCapability, VideoCodecOption, VideoPixelFormatOption, VideoPresetOption, @@ -32,29 +32,87 @@ pub fn output_processing_mode_options( disabled: bool, ) -> [OutputModeOption; 2] { let is_source_image = source_kind_for(metadata) == SourceKind::Image; + let is_image_output = is_image_container(&config.container); [ output_mode_option(ProcessingMode::Reencode, config, disabled), - output_mode_option(ProcessingMode::Copy, config, disabled || is_source_image), + output_mode_option( + ProcessingMode::Copy, + config, + disabled || is_source_image || is_image_output, + ), ] } #[must_use] pub fn visible_output_containers(metadata: Option<&SourceMetadata>) -> Vec { - let is_source_image = source_kind_for(metadata) == SourceKind::Image; + let source_kind = source_kind_for(metadata); media_rules::all_containers() .iter() - .filter(|container| { - if is_source_image { - is_image_container(container) || is_gif_container(container) - } else { - !is_image_container(container) - } + .filter(|container| match source_kind { + SourceKind::Image => is_image_container(container) || is_gif_container(container), + SourceKind::Audio => !is_image_container(container), + SourceKind::Video => true, }) .cloned() .collect() } +#[must_use] +pub fn estimated_image_sequence_frame_count( + config: &ConversionConfig, + metadata: Option<&SourceMetadata>, +) -> Option { + if config.image_output_mode != ImageOutputMode::Sequence { + return None; + } + + let metadata = metadata?; + let frame_rate = metadata + .frame_rate + .filter(|value| value.is_finite() && *value > 0.0)?; + let source_duration = metadata + .duration + .as_deref() + .and_then(parse_duration_seconds) + .filter(|value| value.is_finite() && *value > 0.0)?; + let start = config + .start_time + .as_deref() + .and_then(parse_duration_seconds) + .unwrap_or(0.0) + .clamp(0.0, source_duration); + let end = config + .end_time + .as_deref() + .and_then(parse_duration_seconds) + .unwrap_or(source_duration) + .clamp(start, source_duration); + let estimate = ((end - start) * frame_rate).round(); + + if !estimate.is_finite() || estimate < 0.0 { + return None; + } + + format!("{estimate:.0}").parse::().ok() +} + +fn parse_duration_seconds(value: &str) -> Option { + let value = value.trim(); + if let Ok(seconds) = value.parse::() { + return Some(seconds); + } + + let mut parts = value.split(':').rev(); + let seconds = parts.next()?.parse::().ok()?; + let minutes = parts.next().unwrap_or("0").parse::().ok()?; + let hours = parts.next().unwrap_or("0").parse::().ok()?; + parts + .next() + .is_none() + .then_some(hours.mul_add(3600.0, minutes.mul_add(60.0, seconds))) +} + #[must_use] pub fn output_container_options( config: &ConversionConfig, diff --git a/frame-app/src/settings/tabs.rs b/frame-app/src/settings/tabs.rs index e70d5899..29a5ad72 100644 --- a/frame-app/src/settings/tabs.rs +++ b/frame-app/src/settings/tabs.rs @@ -5,7 +5,7 @@ use super::{ }, rules::{ container_supports_audio, container_supports_subtitles, is_audio_only_container, - source_kind_for, + is_image_container, source_kind_for, }, }; @@ -19,14 +19,19 @@ pub fn visible_settings_tabs( let is_source_image = source_kind == SourceKind::Image; let is_copy_mode = config.processing_mode == ProcessingMode::Copy; let is_audio_container = is_audio_only_container(&config.container); + let is_image_output = is_image_container(&config.container); let supports_audio = container_supports_audio(&config.container) && !is_source_image; let supports_subtitles = !is_source_audio_only && !is_source_image && container_supports_subtitles(&config.container); - let supports_video_tab = - !is_source_audio_only && !is_source_image && !is_audio_container && !is_copy_mode; + let supports_video_tab = !is_source_audio_only + && !is_source_image + && !is_audio_container + && !is_image_output + && !is_copy_mode; let supports_video_filters_tab = !is_source_audio_only && !is_audio_container && !is_copy_mode; - let supports_images_tab = is_source_image && !is_audio_container && !is_copy_mode; + let supports_images_tab = + !is_source_audio_only && is_image_output && !is_audio_container && !is_copy_mode; let supports_audio_filters_tab = supports_audio && !is_copy_mode; ALL_SETTINGS_TABS diff --git a/frame-app/src/settings/tests.rs b/frame-app/src/settings/tests.rs index 77803f39..86d4a6bc 100644 --- a/frame-app/src/settings/tests.rs +++ b/frame-app/src/settings/tests.rs @@ -4,6 +4,37 @@ fn tab_ids(tabs: Vec) -> Vec<&'static str> { tabs.into_iter().map(SettingsTab::id).collect() } +mod image_output_mode_persistence { + use super::*; + + #[test] + fn legacy_presets_default_to_single_image_output() { + let mut value = serde_json::to_value(ConversionConfig::default()) + .expect("default conversion config should serialize"); + value + .as_object_mut() + .expect("conversion config should be an object") + .remove("imageOutputMode"); + + let restored: ConversionConfig = + serde_json::from_value(value).expect("legacy conversion config should deserialize"); + + assert_eq!(restored.image_output_mode, ImageOutputMode::Single); + } + + #[test] + fn sequence_mode_serializes_as_camel_case_field_and_value() { + let config = ConversionConfig { + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + + let value = serde_json::to_value(config).expect("conversion config should serialize"); + + assert_eq!(value["imageOutputMode"], "sequence"); + } +} + mod source_metadata { use super::*; @@ -81,14 +112,22 @@ mod output_options { } #[test] - fn visible_output_containers_for_video_exclude_image_formats() { - assert_eq!( - visible_output_containers(None), - vec![ - "mp4", "mkv", "webm", "mov", "m2t", "mts", "m2ts", "gif", "mp3", "m4a", "wav", - "flac" - ] - ); + fn visible_output_containers_for_video_include_all_still_image_formats() { + let containers = visible_output_containers(None); + + for format in ["jpg", "webp", "png", "bmp", "tiff"] { + assert!(containers.iter().any(|container| container == format)); + } + } + + #[test] + fn visible_output_containers_for_audio_exclude_still_image_formats() { + let metadata = audio_metadata("aac"); + let containers = visible_output_containers(Some(&metadata)); + + for format in ["jpg", "webp", "png", "bmp", "tiff"] { + assert!(!containers.iter().any(|container| container == format)); + } } #[test] @@ -110,6 +149,84 @@ mod output_options { assert!(options[1].is_disabled); } + #[test] + fn processing_mode_options_disable_copy_for_still_image_output() { + let config = ConversionConfig { + container: "png".to_string(), + ..ConversionConfig::default() + }; + let options = output_processing_mode_options(&config, Some(&video_metadata()), false); + + assert!(options[1].is_disabled); + } + + #[test] + fn image_sequence_estimates_are_fps_and_trim_aware() { + let metadata = SourceMetadata { + duration: Some("30".to_string()), + frame_rate: Some(30.0), + ..video_metadata() + }; + let mut config = ConversionConfig { + container: "png".to_string(), + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + + assert_eq!( + estimated_image_sequence_frame_count(&config, Some(&metadata)), + Some(900) + ); + + config.start_time = Some("00:00:05.000".to_string()); + config.end_time = Some("00:00:15.000".to_string()); + assert_eq!( + estimated_image_sequence_frame_count(&config, Some(&metadata)), + Some(300) + ); + } + + #[test] + fn image_sequence_estimate_rounds_fractional_frame_rates() { + let metadata = SourceMetadata { + duration: Some("30".to_string()), + frame_rate: Some(29.97), + ..video_metadata() + }; + let config = ConversionConfig { + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + + assert_eq!( + estimated_image_sequence_frame_count(&config, Some(&metadata)), + Some(899) + ); + } + + #[test] + fn image_sequence_estimate_requires_valid_duration_and_frame_rate() { + let config = ConversionConfig { + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + let missing = video_metadata(); + let invalid = SourceMetadata { + duration: Some("not-a-duration".to_string()), + frame_rate: Some(f64::NAN), + ..video_metadata() + }; + + assert_eq!( + estimated_image_sequence_frame_count(&config, Some(&missing)), + None + ); + assert_eq!( + estimated_image_sequence_frame_count(&config, Some(&invalid)), + None + ); + } + #[test] fn processing_mode_options_disable_all_when_settings_are_locked() { let options = output_processing_mode_options( @@ -1805,6 +1922,72 @@ mod output_config { assert_eq!(config.processing_mode, ProcessingMode::Reencode); } + #[test] + fn normalize_output_config_reencodes_video_still_image_outputs_and_clears_tracks() { + let metadata = SourceMetadata { + media_kind: Some(SourceKind::Video), + ..SourceMetadata::default() + }; + let mut config = ConversionConfig { + processing_mode: ProcessingMode::Copy, + container: "png".to_string(), + selected_audio_tracks: vec![1], + selected_subtitle_tracks: vec![2], + ..ConversionConfig::default() + }; + + normalize_output_config(&mut config, Some(&metadata)); + + assert_eq!(config.processing_mode, ProcessingMode::Reencode); + assert!(config.selected_audio_tracks.is_empty()); + assert!(config.selected_subtitle_tracks.is_empty()); + } + + #[test] + fn normalize_output_config_resets_sequence_mode_for_incompatible_targets() { + let video = SourceMetadata { + media_kind: Some(SourceKind::Video), + ..SourceMetadata::default() + }; + let image = SourceMetadata { + media_kind: Some(SourceKind::Image), + ..SourceMetadata::default() + }; + let mut video_config = ConversionConfig { + container: "mp4".to_string(), + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + let mut image_config = ConversionConfig { + container: "png".to_string(), + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + + normalize_output_config(&mut video_config, Some(&video)); + normalize_output_config(&mut image_config, Some(&image)); + + assert_eq!(video_config.image_output_mode, ImageOutputMode::Single); + assert_eq!(image_config.image_output_mode, ImageOutputMode::Single); + } + + #[test] + fn normalize_output_config_keeps_sequence_mode_for_video_still_image_output() { + let metadata = SourceMetadata { + media_kind: Some(SourceKind::Video), + ..SourceMetadata::default() + }; + let mut config = ConversionConfig { + container: "png".to_string(), + image_output_mode: ImageOutputMode::Sequence, + ..ConversionConfig::default() + }; + + normalize_output_config(&mut config, Some(&metadata)); + + assert_eq!(config.image_output_mode, ImageOutputMode::Sequence); + } + #[test] fn apply_output_container_falls_back_to_default_audio_codec_when_needed() { let mut config = ConversionConfig { @@ -2126,6 +2309,33 @@ mod visible_settings_tabs { ); } + #[test] + fn video_to_still_image_shows_images_and_filters_but_hides_video() { + let metadata = SourceMetadata { + media_kind: Some(SourceKind::Video), + video_codec: Some("h264".to_string()), + ..SourceMetadata::default() + }; + let config = ConversionConfig { + container: "png".to_string(), + ..ConversionConfig::default() + }; + + let tabs = tab_ids(super::visible_settings_tabs(&config, Some(&metadata))); + + assert_eq!( + tabs, + vec![ + "source", + "output", + "video-filters", + "images", + "metadata", + "presets" + ] + ); + } + #[test] fn copy_mode_hides_video_tab_but_keeps_audio_and_subtitles_when_supported() { let config = ConversionConfig { diff --git a/frame-app/src/settings/updates.rs b/frame-app/src/settings/updates.rs index 9625caf7..a8730322 100644 --- a/frame-app/src/settings/updates.rs +++ b/frame-app/src/settings/updates.rs @@ -7,7 +7,7 @@ use super::{ DEFAULT_PIXEL_FORMAT, DEFAULT_RESOLUTION, DEFAULT_VIDEO_BITRATE_MODE, ExternalSubtitleTrack, FPS_OPTIONS, GIF_DITHER_OPTIONS, GIF_FPS_OPTIONS, IMAGE_JPEG_HUFFMAN_OPTIONS, IMAGE_PNG_PREDICTION_OPTIONS, IMAGE_TIFF_COMPRESSION_OPTIONS, - IMAGE_WEBP_PRESET_OPTIONS, MAX_AUDIO_VOLUME, MAX_GIF_COLORS, MAX_GIF_LOOP, + IMAGE_WEBP_PRESET_OPTIONS, ImageOutputMode, MAX_AUDIO_VOLUME, MAX_GIF_COLORS, MAX_GIF_LOOP, MAX_IMAGE_JPEG_QUALITY, MAX_IMAGE_PNG_COMPRESSION, MAX_IMAGE_WEBP_COMPRESSION, MAX_IMAGE_WEBP_QUALITY, MetadataField, MetadataMode, PresetDefinition, ProcessingMode, RESOLUTION_OPTIONS, SCALING_ALGORITHM_OPTIONS, SUBTITLE_FONT_SIZES, SourceKind, @@ -756,6 +756,15 @@ pub fn apply_processing_mode( changed | normalize_output_config(config, metadata) } +pub fn apply_image_output_mode(config: &mut ConversionConfig, mode: ImageOutputMode) -> bool { + if config.image_output_mode == mode { + return false; + } + + config.image_output_mode = mode; + true +} + pub fn apply_output_container(config: &mut ConversionConfig, container: &str) -> bool { let changed = !config.container.eq_ignore_ascii_case(container); config.container = container.to_ascii_lowercase(); @@ -801,6 +810,7 @@ pub fn normalize_output_config( ) -> bool { let before = config.clone(); let source_kind = source_kind_for(metadata); + let is_image_output = is_image_container(&config.container); if source_kind == SourceKind::Audio && !is_audio_only_container(&config.container) { config.container = "mp3".to_string(); @@ -830,12 +840,16 @@ pub fn normalize_output_config( reset_subtitle_settings(config); } - if (source_kind == SourceKind::Image || is_gif_container(&config.container)) + if (source_kind == SourceKind::Image || is_gif_container(&config.container) || is_image_output) && config.processing_mode == ProcessingMode::Copy { config.processing_mode = ProcessingMode::Reencode; } + if source_kind != SourceKind::Video || !is_image_output { + config.image_output_mode = ImageOutputMode::Single; + } + if config.processing_mode == ProcessingMode::Copy { reset_audio_filter_settings(config); reset_video_filter_settings(config); diff --git a/frame-core/src/args.rs b/frame-core/src/args.rs index 60ce55fe..aa6f5bcc 100644 --- a/frame-core/src/args.rs +++ b/frame-core/src/args.rs @@ -647,10 +647,11 @@ pub fn build_ffmpeg_args( } else { "0:v:0".to_string() }); - args.push("-frames:v".to_string()); - args.push("1".to_string()); - args.push("-update".to_string()); - args.push("1".to_string()); + if config.image_output_mode == "sequence" { + args.extend(["-fps_mode", "passthrough", "-start_number", "1"].map(str::to_string)); + } else { + args.extend(["-frames:v", "1", "-update", "1"].map(str::to_string)); + } } else { add_video_codec_args(&mut args, config); if has_custom_pixel_format(config) { @@ -942,6 +943,12 @@ pub fn validate_task_input( "Invalid processing mode: {processing_mode}" ))); } + if !matches!(config.image_output_mode.as_str(), "single" | "sequence") { + return Err(ConversionError::InvalidInput(format!( + "Invalid image output mode: {}", + config.image_output_mode + ))); + } validate_media_filters(config)?; if !container_supports_subtitles(&config.container) && (!config.selected_subtitle_tracks.is_empty() @@ -1270,6 +1277,10 @@ pub fn validate_task_input( if is_image_output { validate_image_encoding_settings(config)?; + } else if config.image_output_mode == "sequence" { + return Err(ConversionError::InvalidInput( + "Image sequence mode requires a still-image output container".to_string(), + )); } Ok(()) @@ -1404,6 +1415,7 @@ mod tests { videotoolbox_allow_sw: false, hw_decode: false, pixel_format: "auto".to_string(), + image_output_mode: "single".to_string(), image_jpeg_quality: 85, image_jpeg_huffman: "optimal".to_string(), image_webp_lossless: false, @@ -1433,6 +1445,45 @@ mod tests { } } + #[test] + fn single_image_arguments_keep_the_existing_one_frame_contract() { + let config = sample_config("png", "png"); + + let args = build_ffmpeg_args("input.mov", "output.png", &config, &sample_probe()) + .expect("single-image arguments should build"); + + assert!(args.windows(2).any(|pair| pair == ["-frames:v", "1"])); + assert!(args.windows(2).any(|pair| pair == ["-update", "1"])); + assert!(!args.iter().any(|arg| arg == "-fps_mode")); + assert!(!args.iter().any(|arg| arg == "-start_number")); + } + + #[test] + fn image_sequence_arguments_preserve_source_timing_and_number_from_one() { + let mut config = sample_config("png", "png"); + config.image_output_mode = "sequence".to_string(); + + let args = build_ffmpeg_args( + "input.mov", + "output/frame_%06d.png", + &config, + &sample_probe(), + ) + .expect("image-sequence arguments should build"); + + assert!( + args.windows(2) + .any(|pair| pair == ["-fps_mode", "passthrough"]) + ); + assert!(args.windows(2).any(|pair| pair == ["-start_number", "1"])); + assert!(!args.iter().any(|arg| arg == "-frames:v")); + assert!(!args.iter().any(|arg| arg == "-update")); + assert_eq!( + args.last().map(String::as_str), + Some("output/frame_%06d.png") + ); + } + #[test] fn build_ffmpeg_args_adds_even_dimensions_guard_for_default_video_reencode() { let config = sample_config("mp4", "libx264"); @@ -2105,6 +2156,19 @@ mod tests { assert!(error.to_string().contains("WebP compression effort")); } + #[test] + fn validate_task_input_rejects_unknown_image_output_mode() { + let path = temporary_input_file("invalid-image-output-mode"); + let mut config = sample_config("png", "png"); + config.image_output_mode = "every-frame-ish".to_string(); + + let error = validate_task_input(&path.to_string_lossy(), &config) + .expect_err("unknown image output mode should be rejected"); + + let _ = fs::remove_file(path); + assert!(error.to_string().contains("Invalid image output mode")); + } + fn args_contains_pair(args: &[String], key: &str, value: &str) -> bool { args.windows(2) .any(|window| window[0] == key && window[1] == value) diff --git a/frame-core/src/filters.rs b/frame-core/src/filters.rs index 9cc104a7..769f7ad9 100644 --- a/frame-core/src/filters.rs +++ b/frame-core/src/filters.rs @@ -421,6 +421,7 @@ mod tests { videotoolbox_allow_sw: false, hw_decode: false, pixel_format: "auto".to_string(), + image_output_mode: "single".to_string(), image_jpeg_quality: 85, image_jpeg_huffman: "optimal".to_string(), image_webp_lossless: false, diff --git a/frame-core/src/media_filters.rs b/frame-core/src/media_filters.rs index 135854a3..e10c6ee3 100644 --- a/frame-core/src/media_filters.rs +++ b/frame-core/src/media_filters.rs @@ -537,6 +537,7 @@ mod tests { videotoolbox_allow_sw: false, hw_decode: false, pixel_format: "auto".to_string(), + image_output_mode: "single".to_string(), image_jpeg_quality: 85, image_jpeg_huffman: "optimal".to_string(), image_webp_lossless: false, diff --git a/frame-core/src/preview.rs b/frame-core/src/preview.rs index a3aa7bac..3df8758a 100644 --- a/frame-core/src/preview.rs +++ b/frame-core/src/preview.rs @@ -561,6 +561,7 @@ mod tests { videotoolbox_allow_sw: false, hw_decode: false, pixel_format: "auto".to_string(), + image_output_mode: "single".to_string(), image_jpeg_quality: 85, image_jpeg_huffman: "optimal".to_string(), image_webp_lossless: false, diff --git a/frame-core/src/types.rs b/frame-core/src/types.rs index 28bcf29b..d7a6b7bb 100644 --- a/frame-core/src/types.rs +++ b/frame-core/src/types.rs @@ -374,6 +374,8 @@ pub struct ConversionConfig { pub hw_decode: bool, #[serde(default = "default_pixel_format")] pub pixel_format: String, + #[serde(default = "default_image_output_mode")] + pub image_output_mode: String, #[serde(default = "default_image_jpeg_quality")] pub image_jpeg_quality: u32, #[serde(default = "default_image_jpeg_huffman")] @@ -436,6 +438,10 @@ fn default_pixel_format() -> String { "auto".to_string() } +fn default_image_output_mode() -> String { + "single".to_string() +} + const fn default_image_jpeg_quality() -> u32 { 85 } @@ -695,6 +701,7 @@ mod tests { assert_eq!(config.quality, 50); assert_eq!(config.rotation, "0"); assert_eq!(config.pixel_format, "auto"); + assert_eq!(config.image_output_mode, "single"); assert_eq!(config.image_jpeg_quality, 85); assert_eq!(config.image_jpeg_huffman, "optimal"); assert!(!config.image_webp_lossless); @@ -720,6 +727,7 @@ mod tests { assert_eq!(serialized["processingMode"], "reencode"); assert_eq!(serialized["audioBitrateMode"], "bitrate"); + assert_eq!(serialized["imageOutputMode"], "single"); assert_eq!(serialized["imageJpegQuality"], 85); assert_eq!(serialized["imageWebpPreset"], "default"); assert_eq!(serialized["imagePngPrediction"], "paeth"); diff --git a/frame-core/tests/media_integration.rs b/frame-core/tests/media_integration.rs index 273ef796..eee66229 100644 --- a/frame-core/tests/media_integration.rs +++ b/frame-core/tests/media_integration.rs @@ -768,6 +768,55 @@ fn image_container_matrix_should_write_single_frame_outputs() -> TestResult { Ok(()) } +#[test] +#[ignore = "requires FFmpeg/FFprobe; run with --ignored"] +fn image_sequence_container_matrix_should_write_numbered_frames() -> TestResult { + let tools = Toolchain::discover()?; + let sandbox = Sandbox::new("image_sequence_container_matrix")?; + let input = sandbox.path("source.mp4"); + generate_h264_aac_source(&tools, &input, 0.5, 64, 48)?; + + for (container, codec) in [ + ("png", "png"), + ("jpg", "mjpeg"), + ("webp", "libwebp"), + ("bmp", "bmp"), + ("tiff", "tiff"), + ] { + if !encoder_available(&tools, codec)? { + eprintln!("skipping unavailable image encoder {codec}"); + continue; + } + let result = sandbox.path(&format!("{container}_frames")); + fs::create_dir(&result).map_err(|error| error.to_string())?; + let output = result.join(format!("frame_%06d.{container}")); + let mut config = image_config(container, codec); + config.image_output_mode = "sequence".to_string(); + let input_arg = path_arg(&input); + let output_arg = path_arg(&output); + validate_task_input(&input_arg, &config).map_err(|error| error.to_string())?; + let probe = probe_media(&tools, &input)?; + let args = build_ffmpeg_args(&input_arg, &output_arg, &config, &probe) + .map_err(|error| error.to_string())?; + run_tool(&tools.ffmpeg, &args) + .map_err(|error| format!("{container} image sequence failed: {error}"))?; + + let mut frames = fs::read_dir(&result) + .map_err(|error| error.to_string())? + .map(|entry| { + entry + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .map_err(|error| error.to_string()) + }) + .collect::, _>>()?; + frames.sort(); + assert!(!frames.is_empty(), "{container} should emit frames"); + assert_eq!(frames[0], format!("frame_000001.{container}")); + } + + Ok(()) +} + #[test] #[ignore = "requires FFmpeg/FFprobe; run with --ignored"] fn gif_output_should_write_palette_gif_video() -> TestResult { @@ -1579,6 +1628,7 @@ fn base_config(container: &str, video_codec: &str) -> ConversionConfig { videotoolbox_allow_sw: false, hw_decode: false, pixel_format: "auto".to_string(), + image_output_mode: "single".to_string(), image_jpeg_quality: 85, image_jpeg_huffman: "optimal".to_string(), image_webp_lossless: false,