Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING_VISUAL_FIXTURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
29 changes: 28 additions & 1 deletion frame-app/src/app/fixtures.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down
95 changes: 86 additions & 9 deletions frame-app/src/app/settings_panel/images.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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>,
Expand All @@ -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,
Expand Down Expand Up @@ -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<FrameRoot>,
) -> 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,
Expand Down
1 change: 1 addition & 0 deletions frame-app/src/app/settings_panel/panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions frame-app/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions frame-app/src/conversion_runner/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions frame-app/src/conversion_runner/output_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[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<ConversionTask>) -> Vec<PreparedConversionTask> {
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));
Expand Down
Loading