Skip to content

Commit 5a2ff42

Browse files
committed
Merge branch 'master' into release
2 parents 5a3fbda + 39e89ef commit 5a2ff42

31 files changed

Lines changed: 1910 additions & 1062 deletions

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.20.0] - 2026-02-08
11+
12+
### Added
13+
- **Codec-Container Compatibility:** Video encoders are now automatically filtered and disabled based on the selected output container (e.g., WebM only shows VP9/AV1 codecs). Incompatible encoders display an "Incompatible container" message and switch to a compatible codec automatically.
14+
15+
### Changed
16+
- **Backend Refactoring:** Split monolithic `ffmpeg.rs` (1048 lines) into focused modules: `utils.rs`, `args.rs`, `upscale.rs`, `worker.rs`. Improves maintainability without changing functionality.
17+
18+
### Fixed
19+
- **MKV Metadata Parsing:** Fixed metadata tags (Artist, Album, Genre, Date, Comment) not being read from MKV files. The parser now correctly handles both uppercase (MKV) and lowercase (MP4) tag variants.
20+
- **Progress Display:** Resolved an issue where the UI would remain stuck on "Queued" status during the ML upscaling decode phase. A new `conversion-started` event now immediately updates the status to "Converting" when processing begins.
21+
- **Windows Progress Indicator:** Fixed progress percentage not updating for h264 and h264_nvenc codecs on Windows. The FFmpeg stderr parser now correctly handles Windows-style carriage return (`\r`) line separators.
22+
- **ML Upscale Parameter Parity:** The AI upscaling pipeline now supports all parameters from the standard conversion: rotation, flip, subtitle burn, FPS change, NVENC/VideoToolbox options, audio processing (codec, bitrate, volume, normalize, channels), metadata handling, and subtitle track selection.
23+
- **ML Upscale Temp Cleanup:** Temporary PNG frame files are now properly deleted when an upscaling task fails or is cancelled from the UI.
24+
- **Progress Reporting:** Fixed an issue where progress would remain at 0% for some files due to strict time parsing. The parser now correctly handles FFmpeg output with raw seconds or flexible time formats.
25+
1026
## [0.19.0] - 2026-02-07
1127

1228
### Added
@@ -443,7 +459,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
443459
- Automatic media metadata probing via FFprobe.
444460
- Preset-based configuration system.
445461

446-
[Unreleased]: https://github.com/66HEX/frame/compare/0.19.0...HEAD
462+
[Unreleased]: https://github.com/66HEX/frame/compare/0.20.0...HEAD
463+
[0.20.0]: https://github.com/66HEX/frame/compare/0.19.0...0.20.0
447464
[0.19.0]: https://github.com/66HEX/frame/compare/0.18.1...0.19.0
448465
[0.18.1]: https://github.com/66HEX/frame/compare/0.18.0...0.18.1
449466
[0.18.0]: https://github.com/66HEX/frame/compare/0.17.0...0.18.0

bun.lock

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "frame",
3-
"version": "0.19.0",
3+
"version": "0.20.0",
44
"private": true,
55
"type": "module",
66
"scripts": {
@@ -30,7 +30,6 @@
3030
"@tauri-apps/plugin-updater": "~2",
3131
"class-variance-authority": "^0.7.1",
3232
"clsx": "^2.1.1",
33-
"lucide-svelte": "^0.562.0",
3433
"marked": "^17.0.1",
3534
"svelte-i18n": "^4.0.1",
3635
"tailwind-merge": "^3.4.0",

src-tauri/Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "frame"
3-
version = "0.19.0"
3+
version = "0.20.0"
44
description = "A simple FFmpeg GUI"
55
edition = "2024"
66
authors = ["Marek Jóźwiak <hexthecoder@gmail.com>"]
@@ -20,6 +20,7 @@ tauri-plugin-opener = "2.5.3"
2020
serde = { version = "1.0.228", features = ["derive"] }
2121
serde_json = "1.0.149"
2222
regex = "1.12.2"
23+
once_cell = "1.21.3"
2324
tauri-plugin-dialog = "2.6.0"
2425
tauri-plugin-fs = "2.4.5"
2526
tauri-plugin-store = "2.3.0"

src-tauri/src/conversion/args.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
use std::path::{Path, PathBuf};
2+
3+
use crate::conversion::codec::{add_audio_codec_args, add_fps_args, add_subtitle_copy_args, add_video_codec_args};
4+
use crate::conversion::error::ConversionError;
5+
use crate::conversion::filters::{build_audio_filters, build_video_filters};
6+
use crate::conversion::types::{ConversionConfig, MetadataConfig, MetadataMode};
7+
use crate::conversion::utils::{is_audio_only_container, parse_time};
8+
9+
pub fn build_ffmpeg_args(input: &str, output: &str, config: &ConversionConfig) -> Vec<String> {
10+
let mut args = Vec::new();
11+
12+
if let Some(start) = &config.start_time {
13+
if !start.is_empty() {
14+
args.push("-ss".to_string());
15+
args.push(start.clone());
16+
}
17+
}
18+
19+
args.push("-i".to_string());
20+
args.push(input.to_string());
21+
22+
if let Some(end_str) = &config.end_time {
23+
if !end_str.is_empty() {
24+
if let Some(start_str) = &config.start_time {
25+
if !start_str.is_empty() {
26+
if let (Some(start_t), Some(end_t)) =
27+
(parse_time(start_str), parse_time(end_str))
28+
{
29+
let duration = end_t - start_t;
30+
if duration > 0.0 {
31+
args.push("-t".to_string());
32+
args.push(format!("{:.3}", duration));
33+
}
34+
}
35+
} else {
36+
args.push("-to".to_string());
37+
args.push(end_str.clone());
38+
}
39+
} else {
40+
args.push("-to".to_string());
41+
args.push(end_str.clone());
42+
}
43+
}
44+
}
45+
46+
match config.metadata.mode {
47+
MetadataMode::Clean => {
48+
args.push("-map_metadata".to_string());
49+
args.push("-1".to_string());
50+
}
51+
MetadataMode::Replace => {
52+
args.push("-map_metadata".to_string());
53+
args.push("-1".to_string());
54+
add_metadata_flags(&mut args, &config.metadata);
55+
}
56+
MetadataMode::Preserve => {
57+
add_metadata_flags(&mut args, &config.metadata);
58+
}
59+
}
60+
61+
let is_audio_only = is_audio_only_container(&config.container);
62+
63+
if is_audio_only {
64+
args.push("-vn".to_string());
65+
} else {
66+
add_video_codec_args(&mut args, config);
67+
68+
let video_filters = build_video_filters(config, true);
69+
if !video_filters.is_empty() {
70+
args.push("-vf".to_string());
71+
args.push(video_filters.join(","));
72+
}
73+
74+
add_fps_args(&mut args, config);
75+
}
76+
77+
if (!config.selected_audio_tracks.is_empty() || !config.selected_subtitle_tracks.is_empty())
78+
&& !is_audio_only
79+
{
80+
args.push("-map".to_string());
81+
args.push("0:v:0".to_string());
82+
}
83+
84+
if !config.selected_audio_tracks.is_empty() {
85+
for track_index in &config.selected_audio_tracks {
86+
args.push("-map".to_string());
87+
args.push(format!("0:{}", track_index));
88+
}
89+
}
90+
91+
if !config.selected_audio_tracks.is_empty() {
92+
add_audio_codec_args(&mut args, config);
93+
}
94+
95+
if !config.selected_subtitle_tracks.is_empty() {
96+
for track_index in &config.selected_subtitle_tracks {
97+
args.push("-map".to_string());
98+
args.push(format!("0:{}", track_index));
99+
}
100+
} else if !is_audio_only {
101+
args.push("-map".to_string());
102+
args.push("0:s?".to_string());
103+
}
104+
105+
add_subtitle_copy_args(&mut args, config);
106+
107+
let audio_filters = build_audio_filters(config);
108+
if !audio_filters.is_empty() {
109+
args.push("-af".to_string());
110+
args.push(audio_filters.join(","));
111+
}
112+
113+
args.push("-y".to_string());
114+
args.push(output.to_string());
115+
116+
args
117+
}
118+
119+
pub fn add_metadata_flags(args: &mut Vec<String>, metadata: &MetadataConfig) {
120+
if let Some(v) = &metadata.title {
121+
if !v.is_empty() {
122+
args.push("-metadata".to_string());
123+
args.push(format!("title={}", v));
124+
}
125+
}
126+
if let Some(v) = &metadata.artist {
127+
if !v.is_empty() {
128+
args.push("-metadata".to_string());
129+
args.push(format!("artist={}", v));
130+
}
131+
}
132+
if let Some(v) = &metadata.album {
133+
if !v.is_empty() {
134+
args.push("-metadata".to_string());
135+
args.push(format!("album={}", v));
136+
}
137+
}
138+
if let Some(v) = &metadata.genre {
139+
if !v.is_empty() {
140+
args.push("-metadata".to_string());
141+
args.push(format!("genre={}", v));
142+
}
143+
}
144+
if let Some(v) = &metadata.date {
145+
if !v.is_empty() {
146+
args.push("-metadata".to_string());
147+
args.push(format!("date={}", v));
148+
}
149+
}
150+
if let Some(v) = &metadata.comment {
151+
if !v.is_empty() {
152+
args.push("-metadata".to_string());
153+
args.push(format!("comment={}", v));
154+
}
155+
}
156+
}
157+
158+
pub fn build_output_path(file_path: &str, container: &str, output_name: Option<String>) -> String {
159+
if let Some(custom) = output_name.and_then(|name| {
160+
let trimmed = name.trim();
161+
if trimmed.is_empty() {
162+
None
163+
} else {
164+
Some(trimmed.to_string())
165+
}
166+
}) {
167+
let input_path = Path::new(file_path);
168+
let mut output: PathBuf = match input_path.parent() {
169+
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
170+
_ => PathBuf::new(),
171+
};
172+
output.push(custom);
173+
if output.extension().is_none() {
174+
output.set_extension(container);
175+
}
176+
output.to_string_lossy().to_string()
177+
} else {
178+
format!("{}_converted.{}", file_path, container)
179+
}
180+
}
181+
182+
pub fn validate_task_input(
183+
file_path: &str,
184+
config: &ConversionConfig,
185+
) -> Result<(), ConversionError> {
186+
let input_path = Path::new(file_path);
187+
if !input_path.exists() {
188+
return Err(ConversionError::InvalidInput(format!(
189+
"Input file does not exist: {}",
190+
file_path
191+
)));
192+
}
193+
if !input_path.is_file() {
194+
return Err(ConversionError::InvalidInput(format!(
195+
"Input path is not a file: {}",
196+
file_path
197+
)));
198+
}
199+
200+
if config.resolution == "custom" {
201+
let w_str = config.custom_width.as_deref().unwrap_or("-1");
202+
let h_str = config.custom_height.as_deref().unwrap_or("-1");
203+
204+
let w = w_str.parse::<i32>().map_err(|_| {
205+
ConversionError::InvalidInput(format!("Invalid custom width: {}", w_str))
206+
})?;
207+
let h = h_str.parse::<i32>().map_err(|_| {
208+
ConversionError::InvalidInput(format!("Invalid custom height: {}", h_str))
209+
})?;
210+
211+
if w == 0 || h == 0 {
212+
return Err(ConversionError::InvalidInput(
213+
"Resolution dimensions cannot be zero".to_string(),
214+
));
215+
}
216+
if w < -1 || h < -1 {
217+
return Err(ConversionError::InvalidInput(
218+
"Resolution dimensions cannot be negative (except -1 for auto)".to_string(),
219+
));
220+
}
221+
}
222+
223+
if config.video_bitrate_mode == "bitrate" && !is_audio_only_container(&config.container) {
224+
let bitrate = config.video_bitrate.parse::<f64>().map_err(|_| {
225+
ConversionError::InvalidInput(format!(
226+
"Invalid video bitrate: {}",
227+
config.video_bitrate
228+
))
229+
})?;
230+
if bitrate <= 0.0 {
231+
return Err(ConversionError::InvalidInput(
232+
"Video bitrate must be positive".to_string(),
233+
));
234+
}
235+
}
236+
237+
Ok(())
238+
}

0 commit comments

Comments
 (0)