filmcraft/crates/ui-egui/src/panels/export_mode.rs

1291 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Export mode (header "Export"), laid out like Premiere 26: Destinations and the export queue on
//! the left; the Media File settings in the middle (File Name, Location, Preset, Format, then the
//! Video, Audio, Multiplexer, Captions, Effects and Metadata sections); the preview, Range,
//! Scaling, the Summary with the estimated file size, and Send to Queue / Export on the right.
//! Also the Preset Manager (Preset ▸ More presets…) and the header's Quick Export popup.
//!
//! Every setting lives in [`ExportUi`] (serde, `ui.inspect` / `ui.set {"export": {…}}`) and every
//! action runs an engine command (`file.exportMedia`, `export.queue.*`, `export.presets.*`,
//! `export.quick`).
//!
//! Automation ids: `export.fileName`, `export.location`, `export.preset`, `export.preset.more`,
//! `export.format`, `export.section.<video|audio|multiplexer|captions|effects|metadata>`,
//! `export.video.*`, `export.audio.*`, `export.effects.*`, `export.metadata.*`, `export.range`,
//! `export.scaling`, `export.summary`, `export.estimate`, `export.sendToQueue`, `export.button`,
//! `export.queue.start|stop|clear`, `export.queue.item.<id>` and `….<id>.<up|down|cancel|retry|remove>`;
//! Preset Manager: `presetManager.search`, `presetManager.favoritesOnly`,
//! `presetManager.item.<name>`, `presetManager.favorite.<name>`, `presetManager.saveName`,
//! `presetManager.save`, `presetManager.delete`, `presetManager.import`, `presetManager.export`,
//! `presetManager.ok`, `presetManager.cancel`; Quick Export: `quickExport.path`,
//! `quickExport.preset.<name>`, `quickExport.go`, `quickExport.close`.
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
use filmcraft_engine::export::{
AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, HardwareEncoding, Multiplexer, MxfVideoCodec, Placement, Scaling, TextOverlay,
builtin_presets, format_bytes,
};
use filmcraft_engine::time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::frames::{FrameKey, Target};
use crate::theme::Tokens;
/// Export mode state.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportUi {
/// The preset the settings came from ("Custom" once edited).
pub preset: String,
pub settings: ExportSettings,
/// File name without extension, and the folder.
pub file_name: String,
pub location: String,
/// `entire` | `inOut` | `workArea` | `custom`.
pub range: String,
pub custom_start: f64,
pub custom_end: f64,
/// Expanded settings sections.
pub open_sections: Vec<String>,
/// The sequence the file name was taken from.
pub for_sequence: Option<u64>,
/// Preset Manager (None = closed).
pub manager: Option<PresetManager>,
/// Quick Export popup.
pub quick_open: bool,
pub quick_path: String,
pub quick_preset: String,
}
impl Default for ExportUi {
fn default() -> Self {
let preset = filmcraft_engine::export::presets::find_builtin(DEFAULT_PRESET).map(|p| p.settings).unwrap_or_default();
ExportUi {
preset: DEFAULT_PRESET.into(),
settings: preset,
file_name: String::new(),
location: String::new(),
range: "entire".into(),
custom_start: 0.0,
custom_end: 10.0,
open_sections: vec!["video".into(), "audio".into()],
for_sequence: None,
manager: None,
quick_open: false,
quick_path: String::new(),
quick_preset: String::new(),
}
}
}
/// The Preset Manager dialog.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PresetManager {
pub query: String,
pub favorites_only: bool,
pub selected: String,
pub save_name: String,
}
pub const CUSTOM: &str = "Custom";
/// Collected automation ids (registered after drawing, so widgets can borrow the app freely).
#[derive(Default)]
struct Reg(Vec<(String, Rect, String)>);
impl Reg {
fn add(&mut self, id: impl Into<String>, r: Rect, label: impl Into<String>) {
self.0.push((id.into(), r, label.into()));
}
fn flush(self, app: &mut FilmcraftApp) {
for (id, r, l) in self.0 {
app.auto.add(&id, r, &l);
}
}
}
fn settings_json(s: &ExportSettings) -> Value {
serde_json::to_value(s).unwrap_or_default()
}
/// Apply a preset by name (from the engine's library).
pub fn apply_preset(app: &mut FilmcraftApp, name: &str) -> bool {
match app.session.export_presets.find(name) {
Some(p) => {
app.ui.export.settings = p.settings;
app.ui.export.preset = p.name;
true
}
None => {
app.ui.status = format!("No export preset named `{name}`");
false
}
}
}
/// Full output path of the current settings.
pub fn output_path(ex: &ExportUi) -> String {
let name = if ex.file_name.trim().is_empty() { "Export" } else { ex.file_name.trim() };
let dir = expand_home(ex.location.trim());
let file = format!("{name}.{}", ex.settings.extension());
if dir.is_empty() { file } else { std::path::Path::new(&dir).join(file).to_string_lossy().to_string() }
}
/// Engine params for the current settings (`file.exportMedia` / `export.queue.add`).
pub fn export_params(app: &FilmcraftApp) -> Value {
let ex = &app.ui.export;
let mut p = json!({"path": output_path(ex), "settings": settings_json(&ex.settings), "range": ex.range});
if ex.range == "custom" {
p["startSeconds"] = json!(ex.custom_start);
p["endSeconds"] = json!(ex.custom_end);
}
p
}
fn sync_sequence(app: &mut FilmcraftApp) {
let Some(seq) = app.session.state.active_sequence else { return };
if app.ui.export.for_sequence != Some(seq.0) || app.ui.export.file_name.is_empty() {
app.ui.export.file_name = app.session.project.item(seq).map(|i| i.name.clone()).unwrap_or_else(|| "Sequence".into());
app.ui.export.for_sequence = Some(seq.0);
}
if app.ui.export.location.is_empty() {
app.ui.export.location = filmcraft_engine::export_tools::default_export_dir(&app.session).to_string_lossy().to_string();
}
}
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let left = Rect::from_min_size(rect.min, vec2(300.0, rect.height()));
let right = Rect::from_min_max(pos2(rect.max.x - (rect.width() * 0.4).clamp(360.0, 640.0), rect.min.y), rect.max);
let mid = Rect::from_min_max(pos2(left.max.x + 4.0, rect.min.y), pos2(right.min.x - 4.0, rect.max.y));
for r in [left, mid, right] {
ui.painter().rect_filled(r, t.radius, t.panel_bg);
}
let Some(seq_id) = app.session.state.active_sequence else {
crate::dock::placeholder(ui, mid, &t, "Open a sequence to export");
queue_column(app, ui, left);
return;
};
sync_sequence(app);
let before = settings_json(&app.ui.export.settings);
queue_column(app, ui, left);
settings_column(app, ui, mid);
preview_column(app, ui, right, seq_id);
// editing any setting turns the preset into "Custom"
if settings_json(&app.ui.export.settings) != before
&& app.ui.export.preset != CUSTOM
&& app.session.export_presets.find(&app.ui.export.preset).is_none_or(|p| settings_json(&p.settings) != settings_json(&app.ui.export.settings))
{
app.ui.export.preset = CUSTOM.into();
}
preset_manager(app, ui.ctx());
if app.session.export_queue.is_active() || app.session.jobs.iter().any(|j| !j.progress.finished.load(std::sync::atomic::Ordering::Relaxed)) {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
}
// ---------------------------------------------------------------------------------------------
// left: destinations + queue
// ---------------------------------------------------------------------------------------------
fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let mut reg = Reg::default();
let p = ui.painter();
p.text(rect.min + vec2(14.0, 20.0), Align2::LEFT_CENTER, "Destinations", Tokens::semibold(13.0), t.text);
let mf = Rect::from_min_size(rect.min + vec2(8.0, 38.0), vec2(rect.width() - 16.0, 26.0));
p.rect_filled(mf, 4.0, t.row_selected);
p.text(pos2(mf.min.x + 10.0, mf.center().y), Align2::LEFT_CENTER, "Media File", Tokens::ui(12.5), t.text);
reg.add("export.destination.mediaFile", mf, "Media File");
let qtop = mf.max.y + 18.0;
let n = app.session.export_queue.items.len();
p.text(pos2(rect.min.x + 14.0, qtop), Align2::LEFT_CENTER, format!("Queue ({n})"), Tokens::semibold(13.0), t.text);
let body = Rect::from_min_max(pos2(rect.min.x + 8.0, qtop + 14.0), pos2(rect.max.x - 8.0, rect.max.y - 40.0));
let items: Vec<Value> = app.session.execute("export.queue.list", json!({})).ok().and_then(|v| v["items"].as_array().cloned()).unwrap_or_default();
let mut action: Option<(&'static str, Value)> = None;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("export-queue"));
egui::ScrollArea::vertical().id_salt("export-queue-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
if items.is_empty() {
ui.label(egui::RichText::new("Send exports here with Send to Queue, then start the queue.").color(t.text_dim).size(11.5));
}
for (i, it) in items.iter().enumerate() {
let id = it["id"].as_u64().unwrap_or(0);
let status = it["status"].as_str().unwrap_or("");
let frame = egui::Frame::NONE.fill(if i % 2 == 0 { t.row_alt } else { Color32::TRANSPARENT }).inner_margin(egui::Margin::same(6));
let r = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
let file = std::path::Path::new(it["path"].as_str().unwrap_or("")).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
ui.label(egui::RichText::new(format!("{} → {file}", it["sequenceName"].as_str().unwrap_or(""))).size(12.0).color(t.text));
let preset = it["preset"].as_str().filter(|s| !s.is_empty()).unwrap_or(CUSTOM);
let (label, col) = match status {
"ready" => ("Ready".to_string(), t.text_dim),
"encoding" => (format!("Encoding {:.0}%", it["progress"].as_f64().unwrap_or(0.0) * 100.0), t.accent),
"done" => ("Done".to_string(), t.render_green),
"failed" => (format!("Failed: {}", it["error"].as_str().unwrap_or("")), t.danger),
_ => ("Cancelled".to_string(), t.text_faint),
};
ui.label(egui::RichText::new(format!("{preset} · {label}")).size(11.0).color(col));
if status == "encoding" {
ui.add(egui::ProgressBar::new(it["progress"].as_f64().unwrap_or(0.0) as f32).desired_height(6.0));
}
ui.horizontal(|ui| {
let mut b = |ui: &mut egui::Ui, key: &'static str, text: &str, enabled: bool, cmd: &'static str, params: Value| {
let r = ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(11.0)).small());
reg.add(format!("export.queue.item.{id}.{key}"), r.rect, text);
if r.clicked() {
action = Some((cmd, params));
}
};
b(ui, "up", "Up", i > 0 && status != "encoding", "export.queue.move", json!({"id": id, "by": -1}));
b(ui, "down", "Down", i + 1 < items.len(), "export.queue.move", json!({"id": id, "by": 1}));
if matches!(status, "ready" | "encoding") {
b(ui, "cancel", "Cancel", true, "export.queue.cancel", json!({"id": id}));
} else {
b(ui, "retry", "Retry", true, "export.queue.retry", json!({"id": id, "start": true}));
}
b(ui, "remove", "Remove", status != "encoding", "export.queue.remove", json!({"id": id}));
});
})
.response;
reg.add(format!("export.queue.item.{id}"), r.rect, status);
}
});
// footer
let running = app.session.export_queue.running;
let has_ready = items.iter().any(|i| i["status"] == "ready");
let foot = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.max.y - 34.0), pos2(rect.max.x - 8.0, rect.max.y - 6.0));
let mut f = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::left_to_right(egui::Align::Center)));
let r = f.add_enabled(!running && has_ready, egui::Button::new("Start Queue"));
reg.add("export.queue.start", r.rect, "Start Queue");
if r.clicked() {
action = Some(("export.queue.start", json!({})));
}
let r = f.add_enabled(running, egui::Button::new("Stop"));
reg.add("export.queue.stop", r.rect, "Stop");
if r.clicked() {
action = Some(("export.queue.stop", json!({})));
}
let r = f.add_enabled(items.iter().any(|i| matches!(i["status"].as_str(), Some("done" | "failed" | "cancelled"))), egui::Button::new("Clear Finished"));
reg.add("export.queue.clear", r.rect, "Clear Finished");
if r.clicked() {
action = Some(("export.queue.clear", json!({})));
}
reg.flush(app);
if let Some((cmd, p)) = action
&& let Err(e) = app.session.execute(cmd, p)
{
app.ui.status = e.to_string();
}
}
// ---------------------------------------------------------------------------------------------
// middle: settings
// ---------------------------------------------------------------------------------------------
const LABEL_W: f32 = 132.0;
/// A labelled settings row.
fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.horizontal(|ui| {
ui.add_sized(vec2(LABEL_W, 20.0), egui::Label::new(egui::RichText::new(label).color(t.text_dim).size(12.0)).truncate());
add(ui)
})
.inner
}
/// A combo box over `options` (label, enabled); returns the chosen index.
fn combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, bool)], width: f32) -> Option<usize> {
let mut chosen = None;
let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(width).show_ui(ui, |ui| {
for (i, (label, enabled)) in options.iter().enumerate() {
let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, label));
let resp = if *enabled { resp } else { resp.on_disabled_hover_text("Not supported by FilmCraft's encoders yet") };
reg.add(format!("{id}.option.{i}"), resp.rect, label.clone());
if resp.clicked() {
chosen = Some(i);
}
}
});
reg.add(id, r.response.rect, selected);
chosen
}
fn opts(labels: &[&str]) -> Vec<(String, bool)> {
labels.iter().map(|l| (l.to_string(), true)).collect()
}
fn section(ui: &mut egui::Ui, reg: &mut Reg, open: &mut Vec<String>, key: &str, title: &str, t: &Tokens) -> bool {
let is_open = open.iter().any(|k| k == key);
let (r, now) = crate::widgets::section_header(ui, egui::Id::new(("export-sec", key)), title, is_open, t, true);
reg.add(format!("export.section.{key}"), r.rect, title);
if now != is_open {
if now {
open.push(key.into());
} else {
open.retain(|k| k != key);
}
}
now
}
fn drag(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut f64, range: std::ops::RangeInclusive<f64>, speed: f64, suffix: &str, decimals: usize) -> bool {
let r = ui.add(egui::DragValue::new(v).range(range).speed(speed).suffix(suffix).max_decimals(decimals));
reg.add(id, r.rect, format!("{v}"));
r.changed()
}
fn check(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut bool, label: &str) -> bool {
let r = ui.checkbox(v, label);
reg.add(id, r.rect, label);
r.changed()
}
fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str) {
let r = ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY));
reg.add(id, r.rect, hint);
}
const RATES: [(&str, FrameRate); 11] = [
("10", FrameRate { num: 10, den: 1 }),
("12", FrameRate { num: 12, den: 1 }),
("15", FrameRate { num: 15, den: 1 }),
("23.976", FrameRate::FPS_23_976),
("24", FrameRate::FPS_24),
("25", FrameRate::FPS_25),
("29.97", FrameRate::FPS_29_97),
("30", FrameRate::FPS_30),
("50", FrameRate::FPS_50),
("59.94", FrameRate::FPS_59_94),
("60", FrameRate::FPS_60),
];
const PARS: [(&str, Option<(u32, u32)>); 6] = [
("Square Pixels (1.0)", None),
("D1/DV NTSC (0.9091)", Some((10, 11))),
("D1/DV PAL (1.0940)", Some((59, 54))),
("HD Anamorphic 1080 (1.333)", Some((4, 3))),
("DVCPRO HD (1.5)", Some((3, 2))),
("Anamorphic 2:1 (2.0)", Some((2, 1))),
];
fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let mut reg = Reg::default();
let mut open_manager = false;
let mut chosen_preset: Option<String> = None;
let (seq_w, seq_h, seq_rate, seq_sr) = app
.session
.active_sequence()
.map(|q| (q.settings.width, q.settings.height, q.settings.frame_rate, q.settings.sample_rate))
.unwrap_or((1920, 1080, FrameRate::FPS_24, 48_000));
let has_captions = app.session.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty());
let all_presets = app.session.export_presets.all();
let favs: Vec<String> = all_presets.iter().filter(|p| app.session.export_presets.is_favorite(&p.name)).map(|p| p.name.clone()).collect();
let mut pick_folder = false;
let mut pick_overlay = false;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 10.0))).id_salt("export-settings"));
let ex = &mut app.ui.export;
egui::ScrollArea::vertical().id_salt("export-settings-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
ui.label(egui::RichText::new("Media File").strong().size(14.0));
ui.add_space(6.0);
row(ui, &t, "File Name", |ui| text(ui, &mut reg, "export.fileName", &mut ex.file_name, "Sequence name"));
row(ui, &t, "Location", |ui| {
let r = ui.small_button("…");
reg.add("export.location.browse", r.rect, "Choose folder");
pick_folder = r.clicked();
text(ui, &mut reg, "export.location", &mut ex.location, "Folder");
});
// Preset: favourites, then every preset by category, then More presets…
row(ui, &t, "Preset", |ui| {
let r = egui::ComboBox::from_id_salt("export.preset").selected_text(&ex.preset).width(ui.available_width() - 4.0).show_ui(ui, |ui| {
if !favs.is_empty() {
ui.label(egui::RichText::new("Favorites").color(t.text_dim).size(11.0));
for f in &favs {
if ui.selectable_label(*f == ex.preset, f).clicked() {
chosen_preset = Some(f.clone());
}
}
ui.separator();
}
let mut cat = String::new();
for p in &all_presets {
if p.category != cat {
cat = p.category.clone();
ui.label(egui::RichText::new(&cat).color(t.text_dim).size(11.0));
}
if ui.selectable_label(p.name == ex.preset, &p.name).clicked() {
chosen_preset = Some(p.name.clone());
}
}
ui.separator();
if ui.button("More presets…").clicked() {
open_manager = true;
}
});
reg.add("export.preset", r.response.rect, ex.preset.clone());
});
ui.horizontal(|ui| {
ui.add_space(LABEL_W + 8.0);
let r = ui.link("More presets…");
reg.add("export.preset.more", r.rect, "More presets…");
if r.clicked() {
open_manager = true;
}
});
row(ui, &t, "Format", |ui| {
let labels: Vec<(String, bool)> = Format::ALL.iter().map(|f| (f.label().to_string(), filmcraft_engine::export::available(*f))).collect();
if let Some(i) = combo(ui, &mut reg, "export.format", ex.settings.format.label(), &labels, ui.available_width() - 4.0) {
let f = Format::ALL[i];
if f != ex.settings.format {
// a format switch starts from that format's defaults (keeping size / rate / range)
let keep = ex.settings.clone();
ex.settings = ExportSettings {
format: f,
frame_size: keep.frame_size,
frame_rate: keep.frame_rate,
effects: keep.effects,
metadata: keep.metadata,
..Default::default()
};
}
}
});
ui.add_space(8.0);
let s = &mut ex.settings;
if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) {
video_section(ui, &mut reg, s, &t, seq_w, seq_h);
}
if (s.has_audio()
|| !s.has_video()
|| s.format.is_h26x()
|| matches!(s.format, Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
|| s.format.is_mxf())
&& !s.is_image_sequence()
&& s.format != Format::Gif
&& section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t)
{
audio_section(ui, &mut reg, s, &t, seq_sr);
}
if s.format.is_h26x() && section(ui, &mut reg, &mut ex.open_sections, "multiplexer", "Multiplexer", &t) {
row(ui, &t, "Multiplexer", |ui| {
let cur = if s.multiplexer == Multiplexer::Mp4 { "MP4" } else { "QuickTime" };
if let Some(i) = combo(ui, &mut reg, "export.multiplexer", cur, &opts(&["MP4", "QuickTime"]), 160.0) {
s.multiplexer = if i == 0 { Multiplexer::Mp4 } else { Multiplexer::Mov };
}
});
}
if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "captions", "Captions", &t) {
if !has_captions {
ui.label(egui::RichText::new("The sequence has no caption track.").color(t.text_dim).size(11.5));
}
row(ui, &t, "Export Options", |ui| {
let cur = if s.burn_captions {
"Burn Captions Into Video"
} else if s.caption_sidecar.is_some() {
"Create Sidecar File"
} else {
"None"
};
if let Some(i) = combo(ui, &mut reg, "export.captions.mode", cur, &opts(&["None", "Burn Captions Into Video", "Create Sidecar File"]), 220.0) {
s.burn_captions = i == 1;
s.caption_sidecar = (i == 2).then(|| s.caption_sidecar.clone().unwrap_or_else(|| "srt".into()));
}
});
if let Some(kind) = s.caption_sidecar.clone() {
row(ui, &t, "File Format", |ui| {
let cur = if kind == "vtt" { "WebVTT (.vtt)" } else { "SubRip Subtitle (.srt)" };
if let Some(i) = combo(ui, &mut reg, "export.captions.format", cur, &opts(&["SubRip Subtitle (.srt)", "WebVTT (.vtt)"]), 220.0) {
s.caption_sidecar = Some(if i == 0 { "srt" } else { "vtt" }.into());
}
});
}
}
if section(ui, &mut reg, &mut ex.open_sections, "effects", "Effects", &t) {
pick_overlay = effects_section(ui, &mut reg, s, &t);
}
if matches!(s.format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
&& section(ui, &mut reg, &mut ex.open_sections, "metadata", "Metadata", &t)
{
let m = &mut s.metadata;
for (key, label, v) in [
("title", "Title", &mut m.title),
("creator", "Creator", &mut m.creator),
("copyright", "Copyright", &mut m.copyright),
("description", "Description", &mut m.description),
("comment", "Comment", &mut m.comment),
] {
row(ui, &t, label, |ui| text(ui, &mut reg, &format!("export.metadata.{key}"), v, ""));
}
}
let _ = seq_rate;
});
if let Some(name) = chosen_preset {
apply_preset(app, &name);
}
if open_manager {
app.ui.export.manager = Some(PresetManager { selected: app.ui.export.preset.clone(), ..Default::default() });
}
if pick_folder && let Some(d) = app.hooks.pick_folder.as_mut().and_then(|f| f()) {
app.ui.export.location = d;
}
if pick_overlay && let Some(f) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Image", &["png", "jpg", "jpeg"])) {
app.ui.export.settings.effects.image_overlay.path = f;
app.ui.export.settings.effects.image_overlay.enabled = true;
}
reg.flush(app);
}
fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_w: u32, seq_h: u32) {
// frame size
let mut match_size = s.frame_size.is_none();
row(ui, t, "Frame Size", |ui| {
if check(ui, reg, "export.video.matchSize", &mut match_size, "Match Source") {
s.frame_size = if match_size { None } else { Some((seq_w, seq_h)) };
}
});
if let Some((w, h)) = s.frame_size.as_mut() {
row(ui, t, "", |ui| {
let (mut fw, mut fh) = (*w as f64, *h as f64);
drag(ui, reg, "export.video.width", &mut fw, 16.0..=8192.0, 1.0, "", 0);
ui.label("×");
drag(ui, reg, "export.video.height", &mut fh, 16.0..=8192.0, 1.0, "", 0);
(*w, *h) = (fw as u32, fh as u32);
});
}
// frame rate
let mut match_rate = s.frame_rate.is_none();
row(ui, t, "Frame Rate", |ui| {
if check(ui, reg, "export.video.matchRate", &mut match_rate, "Match Source") {
s.frame_rate = if match_rate { None } else { Some(FrameRate::FPS_29_97) };
}
if let Some(r) = s.frame_rate {
let cur = RATES.iter().find(|(_, x)| *x == r).map(|(l, _)| l.to_string()).unwrap_or_else(|| r.label());
let labels: Vec<(String, bool)> = RATES.iter().map(|(l, _)| (l.to_string(), true)).collect();
if let Some(i) = combo(ui, reg, "export.video.fps", &cur, &labels, 90.0) {
s.frame_rate = Some(RATES[i].1);
}
}
});
row(ui, t, "Field Order", |ui| {
let o = [FieldOrder::Progressive, FieldOrder::UpperFirst, FieldOrder::LowerFirst];
let labels: Vec<(String, bool)> = o.iter().map(|f| (f.label().to_string(), *f == FieldOrder::Progressive)).collect();
if let Some(i) = combo(ui, reg, "export.video.fieldOrder", s.field_order.label(), &labels, 160.0) {
s.field_order = o[i];
}
});
row(ui, t, "Aspect", |ui| {
let cur = PARS.iter().find(|(_, p)| *p == s.pixel_aspect).map(|(l, _)| l.to_string()).unwrap_or_else(|| "Custom".into());
let labels: Vec<(String, bool)> = PARS.iter().map(|(l, _)| (l.to_string(), true)).collect();
if let Some(i) = combo(ui, reg, "export.video.aspect", &cur, &labels, 220.0) {
s.pixel_aspect = PARS[i].1;
}
});
if s.format.is_mxf() {
row(ui, t, "Video Codec", |ui| {
let labels: Vec<(String, bool)> = MxfVideoCodec::ALL.iter().map(|c| (c.label().to_string(), true)).collect();
if let Some(i) = combo(ui, reg, "export.video.mxfCodec", s.mxf_video_codec.label(), &labels, 180.0) {
s.mxf_video_codec = MxfVideoCodec::ALL[i];
}
});
}
match s.video_format() {
f @ (Format::H264 | Format::Hevc) => {
let hevc = f == Format::Hevc;
if hevc {
// the hardware encoder writes one profile: Main, 8-bit 4:2:0, level chosen by the encoder
row(ui, t, "Profile", |ui| ui.label("Main (8-bit)"));
row(ui, t, "Encoder", |ui| ui.label("Hardware (H.265 has no software encoder)"));
} else {
row(ui, t, "Profile", |ui| {
let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High];
let labels: Vec<(String, bool)> = o.iter().map(|p| (p.label().to_string(), true)).collect();
if let Some(i) = combo(ui, reg, "export.video.profile", s.h264_profile.label(), &labels, 120.0) {
s.h264_profile = o[i];
}
});
row(ui, t, "Level", |ui| {
let levels: [Option<u8>; 13] =
[None, Some(30), Some(31), Some(32), Some(40), Some(41), Some(42), Some(50), Some(51), Some(52), Some(60), Some(61), Some(62)];
let lab = |l: &Option<u8>| l.map(|l| format!("{}.{}", l / 10, l % 10)).unwrap_or_else(|| "Auto".into());
let labels: Vec<(String, bool)> = levels.iter().map(|l| (lab(l), true)).collect();
if let Some(i) = combo(ui, reg, "export.video.level", &lab(&s.h264_level), &labels, 120.0) {
s.h264_level = levels[i];
}
});
}
row(ui, t, "Bitrate Encoding", |ui| {
// a hardware encoder has one pass: H.265 offers no two-pass mode
let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass];
let labels: Vec<(String, bool)> = o.iter().map(|m| (m.label().to_string(), !(hevc && *m == BitrateMode::Vbr2Pass))).collect();
if let Some(i) = combo(ui, reg, "export.video.bitrateMode", s.bitrate_mode.label(), &labels, 140.0) {
s.bitrate_mode = o[i];
}
});
if let Some(bpp) = s.adaptive_bitrate {
row(ui, t, "Target Bitrate", |ui| {
ui.label(egui::RichText::new(format!("Adaptive ({bpp} bits per pixel)")).size(12.0));
let r = ui.small_button("Set");
reg.add("export.video.fixedBitrate", r.rect, "Set a fixed bitrate");
if r.clicked() {
s.adaptive_bitrate = None;
}
});
} else {
row(ui, t, "Target Bitrate", |ui| {
let mut mbps = s.bitrate_kbps as f64 / 1000.0;
if drag(ui, reg, "export.video.target", &mut mbps, 0.1..=800.0, 0.1, " Mbps", 1) {
s.bitrate_kbps = (mbps * 1000.0).round() as u32;
}
});
if s.bitrate_mode != BitrateMode::Cbr {
row(ui, t, "Maximum Bitrate", |ui| {
let mut mbps = s.max_bitrate_kbps.unwrap_or(s.bitrate_kbps * 3 / 2) as f64 / 1000.0;
if drag(ui, reg, "export.video.max", &mut mbps, 0.1..=1000.0, 0.1, " Mbps", 1) {
s.max_bitrate_kbps = Some((mbps * 1000.0).round() as u32);
}
});
}
}
row(ui, t, "Key Frame Distance", |ui| {
let mut on = s.keyframe_distance.is_some();
if check(ui, reg, "export.video.keyframeOn", &mut on, "") {
s.keyframe_distance = on.then_some(48);
}
if let Some(k) = s.keyframe_distance.as_mut() {
let mut v = *k as f64;
drag(ui, reg, "export.video.keyframe", &mut v, 1.0..=600.0, 1.0, " frames", 0);
*k = v as u32;
}
});
// The operating system's hardware encoder where there is one (macOS); everything it does
// not take (two-pass, HDR, MXF) and every machine without one keeps the built-in encoder.
// H.265 has only the hardware encoder: choosing the format is the opt-in.
if !hevc {
row(ui, t, "Hardware Encoding", |ui| {
let mut on = s.hardware_encoding == HardwareEncoding::Auto;
if check(ui, reg, "export.video.hardwareEncoding", &mut on, "Use the hardware encoder when available") {
s.hardware_encoding = if on { HardwareEncoding::Auto } else { HardwareEncoding::Off };
}
});
}
}
Format::ProRes => {
row(ui, t, "Profile", |ui| {
let keys = ["proxy", "lt", "standard", "hq"];
let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"];
let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3);
if let Some(i) = combo(ui, reg, "export.video.proresProfile", labels[cur], &opts(&labels), 220.0) {
s.prores_profile = keys[i].into();
}
});
}
Format::DnxHr => {
row(ui, t, "Profile", |ui| {
let keys = ["lb", "sq", "hq", "hqx"];
let labels = ["DNxHR LB", "DNxHR SQ", "DNxHR HQ", "DNxHR HQX (10-bit)"];
let cur = keys.iter().position(|k| *k == s.dnx_profile).unwrap_or(2);
if let Some(i) = combo(ui, reg, "export.video.dnxProfile", labels[cur], &opts(&labels), 220.0) {
s.dnx_profile = keys[i].into();
}
});
}
Format::Apv => {
row(ui, t, "Profile", |ui| {
let keys = ["422-10", "422-12", "444-10", "444-12"];
let labels = ["APV 422-10", "APV 422-12", "APV 444-10", "APV 444-12"];
let cur = keys.iter().position(|k| *k == s.apv_profile).unwrap_or(0);
if let Some(i) = combo(ui, reg, "export.video.apvProfile", labels[cur], &opts(&labels), 220.0) {
s.apv_profile = keys[i].into();
}
});
}
Format::Mjpeg => {
row(ui, t, "Quality", |ui| {
let mut q = s.quality as f64;
drag(ui, reg, "export.video.quality", &mut q, 1.0..=100.0, 1.0, "", 0);
s.quality = q as u8;
});
}
_ => {}
}
row(ui, t, "", |ui| check(ui, reg, "export.video.maxDepth", &mut s.render_at_max_depth, "Render at Maximum Depth"));
row(ui, t, "", |ui| check(ui, reg, "export.video.maxQuality", &mut s.max_render_quality, "Use Maximum Render Quality"));
}
fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_sr: u32) {
let audio_only = !s.has_video();
if !audio_only {
row(ui, t, "", |ui| check(ui, reg, "export.audio.include", &mut s.include_audio, "Export Audio"));
if !s.include_audio {
return;
}
}
row(ui, t, "Audio Format", |ui| {
let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf();
let cur = match s.audio_codec() {
AudioCodec::Aac => "AAC",
_ => "Uncompressed (PCM)",
};
let labels = vec![("AAC".to_string(), !audio_only), ("Uncompressed (PCM)".to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))];
if fixed {
ui.label(cur);
} else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) {
s.audio.codec = if i == 0 { AudioCodec::Aac } else { AudioCodec::Pcm };
}
});
row(ui, t, "Sample Rate", |ui| {
let rates: [Option<u32>; 5] = [None, Some(32_000), Some(44_100), Some(48_000), Some(96_000)];
let lab = |r: &Option<u32>| r.map(|r| format!("{r} Hz")).unwrap_or_else(|| format!("Match Source ({seq_sr} Hz)"));
let labels: Vec<(String, bool)> = rates.iter().map(|r| (lab(r), true)).collect();
if let Some(i) = combo(ui, reg, "export.audio.sampleRate", &lab(&s.audio.sample_rate), &labels, 180.0) {
s.audio.sample_rate = rates[i];
}
});
row(ui, t, "Channels", |ui| {
let cur = match s.audio.channels {
1 => "Mono",
6 => "5.1",
_ => "Stereo",
};
if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&["Mono", "Stereo", "5.1"]), 120.0) {
s.audio.channels = [1, 2, 6][i.min(2)];
}
});
if s.audio_codec() == AudioCodec::Aac {
row(ui, t, "Audio Bitrate", |ui| {
let rates = [128u32, 160, 192, 256, 320];
let labels: Vec<(String, bool)> = rates.iter().map(|r| (format!("{r} kbps"), true)).collect();
if let Some(i) = combo(ui, reg, "export.audio.bitrate", &format!("{} kbps", s.audio.bitrate_kbps), &labels, 120.0) {
s.audio.bitrate_kbps = rates[i];
}
});
} else {
row(ui, t, "Sample Size", |ui| {
let cur = if s.audio.bits >= 24 { "24 bit" } else { "16 bit" };
if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&["16 bit", "24 bit"]), 120.0) {
s.audio.bits = if i == 0 { 16 } else { 24 };
}
});
}
}
fn placement_combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, p: &mut Placement) {
let labels: Vec<(String, bool)> = Placement::ALL.iter().map(|x| (x.label().to_string(), true)).collect();
if let Some(i) = combo(ui, reg, id, p.label(), &labels, 150.0) {
*p = Placement::ALL[i];
}
}
fn text_overlay(ui: &mut egui::Ui, reg: &mut Reg, key: &str, o: &mut TextOverlay, t: &Tokens, text_label: &str) {
row(ui, t, text_label, |ui| text(ui, reg, &format!("export.effects.{key}.text"), &mut o.text, ""));
row(ui, t, "Position", |ui| placement_combo(ui, reg, &format!("export.effects.{key}.position"), &mut o.placement));
row(ui, t, "Size / Opacity", |ui| {
let (mut sz, mut op) = (o.size_percent as f64, o.opacity as f64);
drag(ui, reg, &format!("export.effects.{key}.size"), &mut sz, 1.0..=50.0, 0.2, " %", 1);
drag(ui, reg, &format!("export.effects.{key}.opacity"), &mut op, 0.0..=100.0, 1.0, " %", 0);
(o.size_percent, o.opacity) = (sz as f32, op as f32);
});
}
/// Returns whether the image overlay's Browse… was clicked.
fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens) -> bool {
let mut browse = false;
let audio = s.has_audio() || !s.has_video();
let video = s.has_video();
let fx = &mut s.effects;
if video {
check(ui, reg, "export.effects.imageOverlay", &mut fx.image_overlay.enabled, "Image Overlay");
if fx.image_overlay.enabled {
let o = &mut fx.image_overlay;
row(ui, t, "Image", |ui| {
let r = ui.small_button("Browse…");
reg.add("export.effects.imageOverlay.browse", r.rect, "Browse…");
browse = r.clicked();
text(ui, reg, "export.effects.imageOverlay.path", &mut o.path, "PNG or JPEG file");
});
row(ui, t, "Position", |ui| placement_combo(ui, reg, "export.effects.imageOverlay.position", &mut o.placement));
row(ui, t, "Size / Opacity", |ui| {
let (mut sz, mut op) = (o.size_percent as f64, o.opacity as f64);
drag(ui, reg, "export.effects.imageOverlay.size", &mut sz, 1.0..=100.0, 0.5, " %", 1);
drag(ui, reg, "export.effects.imageOverlay.opacity", &mut op, 0.0..=100.0, 1.0, " %", 0);
(o.size_percent, o.opacity) = (sz as f32, op as f32);
});
}
check(ui, reg, "export.effects.nameOverlay", &mut fx.name_overlay.enabled, "Name Overlay");
if fx.name_overlay.enabled {
text_overlay(ui, reg, "nameOverlay", &mut fx.name_overlay, t, "Name");
}
check(ui, reg, "export.effects.timecodeOverlay", &mut fx.timecode_overlay.enabled, "Timecode Overlay");
if fx.timecode_overlay.enabled {
text_overlay(ui, reg, "timecodeOverlay", &mut fx.timecode_overlay, t, "Prefix");
}
check(ui, reg, "export.effects.videoLimiter", &mut fx.video_limiter.enabled, "Video Limiter");
if fx.video_limiter.enabled {
row(ui, t, "Luma Min / Max", |ui| {
let (mut lo, mut hi) = (fx.video_limiter.min_percent as f64, fx.video_limiter.max_percent as f64);
drag(ui, reg, "export.effects.videoLimiter.min", &mut lo, 0.0..=50.0, 0.5, " %", 1);
drag(ui, reg, "export.effects.videoLimiter.max", &mut hi, 50.0..=100.0, 0.5, " %", 1);
(fx.video_limiter.min_percent, fx.video_limiter.max_percent) = (lo as f32, hi as f32);
});
}
}
if audio {
check(ui, reg, "export.effects.loudness", &mut fx.loudness.enabled, "Loudness Normalization");
if fx.loudness.enabled {
row(ui, t, "Target Loudness", |ui| {
drag(ui, reg, "export.effects.loudness.target", &mut fx.loudness.target_lufs, -40.0..=-5.0, 0.1, " LUFS", 1);
});
row(ui, t, "True Peak Limit", |ui| {
drag(ui, reg, "export.effects.loudness.truePeak", &mut fx.loudness.true_peak_dbtp, -9.0..=0.0, 0.1, " dBTP", 1);
});
}
}
browse
}
// ---------------------------------------------------------------------------------------------
// right: preview, range, summary, buttons
// ---------------------------------------------------------------------------------------------
const RANGES: [(&str, &str); 4] = [("entire", "Entire Source"), ("inOut", "Source In/Out"), ("workArea", "Work Area"), ("custom", "Custom")];
fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: filmcraft_engine::project::ItemId) {
let t = app.tokens;
let mut reg = Reg::default();
let Some(q) = app.session.active_sequence().cloned() else { return };
let rate = q.settings.frame_rate;
// preview (aspect of the output frame)
let r = app.ui.export.settings.resolve(q.settings.width, q.settings.height, rate, q.settings.sample_rate);
let pv_area = Rect::from_min_size(rect.min + vec2(14.0, 14.0), vec2(rect.width() - 28.0, (rect.height() * 0.42).max(160.0)));
let pic = crate::panels::monitor::fit(pv_area, r.width as f32, r.height as f32);
ui.painter().rect_filled(pic, 0.0, Color32::BLACK);
if app.ui.export.settings.has_video() {
let frame = rate.frame_at(app.session.playhead());
let key = FrameKey { target: Target::Sequence(seq_id), frame, size: 500, revision: app.session.revision, draft: false };
let project = app.session.project.clone();
app.frames.request(key, rate.tick_of(frame), 0.5, &project, 0);
if let Some(img) = app.frames.get(&key) {
let ctx = ui.ctx().clone();
let tex = app.texture_for(&ctx, "export-preview", key, &img);
let inner = match app.ui.export.settings.scaling {
Scaling::StretchToFill => pic,
Scaling::ScaleToFit => crate::panels::monitor::fit(pic, q.settings.width as f32, q.settings.height as f32),
Scaling::ScaleToFill => {
let s = (pic.width() / q.settings.width as f32).max(pic.height() / q.settings.height as f32);
Rect::from_center_size(pic.center(), vec2(q.settings.width as f32 * s, q.settings.height as f32 * s))
}
};
ui.painter().with_clip_rect(pic).image(tex, inner, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
} else {
ui.painter().text(pic.center(), Align2::CENTER_CENTER, "Audio only", Tokens::ui(13.0), t.text_dim);
}
reg.add("export.preview", pic, "Preview");
// range + scaling
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(rect.min.x + 14.0, pv_area.max.y + 8.0), pos2(rect.max.x - 14.0, rect.max.y - 50.0)))
.id_salt("export-right"),
);
let ex = &mut app.ui.export;
let has_work_area = q.work_area.is_some();
child.horizontal(|ui| {
ui.label(egui::RichText::new("Range").color(t.text_dim));
let cur = RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or("Entire Source");
let labels: Vec<(String, bool)> = RANGES.iter().map(|(k, l)| (l.to_string(), *k != "workArea" || has_work_area)).collect();
if let Some(i) = combo(ui, &mut reg, "export.range", cur, &labels, 140.0) {
ex.range = RANGES[i].0.into();
}
if ex.range == "custom" {
drag(ui, &mut reg, "export.range.start", &mut ex.custom_start, 0.0..=86_400.0, 0.04, " s", 2);
drag(ui, &mut reg, "export.range.end", &mut ex.custom_end, 0.0..=86_400.0, 0.04, " s", 2);
}
});
if ex.settings.has_video() {
child.horizontal(|ui| {
ui.label(egui::RichText::new("Scaling").color(t.text_dim));
let o = [(Scaling::ScaleToFit, "Scale To Fit"), (Scaling::ScaleToFill, "Scale To Fill"), (Scaling::StretchToFill, "Stretch To Fill")];
let cur = o.iter().find(|(x, _)| *x == ex.settings.scaling).map(|x| x.1).unwrap_or("Scale To Fit");
let labels: Vec<(String, bool)> = o.iter().map(|(_, l)| (l.to_string(), true)).collect();
if let Some(i) = combo(ui, &mut reg, "export.scaling", cur, &labels, 140.0) {
ex.settings.scaling = o[i].0;
}
});
}
// summary
let range = range_ticks(ex, &q);
let sum = ex.settings.summary(q.settings.width, q.settings.height, rate, q.settings.sample_rate, range.1 - range.0);
child.add_space(8.0);
let resp = egui::Frame::NONE
.fill(t.field_bg)
.corner_radius(4.0)
.inner_margin(egui::Margin::same(10))
.show(&mut child, |ui| {
ui.set_width(ui.available_width());
ui.label(egui::RichText::new("Summary").strong());
egui::Grid::new("export-summary-grid").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
let line = |ui: &mut egui::Ui, k: &str, v: &str| {
ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5));
ui.add(egui::Label::new(egui::RichText::new(v).size(11.5)).wrap_mode(egui::TextWrapMode::Wrap).halign(egui::Align::Min));
ui.end_row();
};
line(ui, "Output", &output_path(ex));
line(ui, "", &sum.format);
line(ui, "", &sum.video);
line(ui, "", &sum.audio);
line(ui, "Source", &format!("Sequence, {}x{}, {} fps, {} Hz", q.settings.width, q.settings.height, rate.label(), q.settings.sample_rate));
line(
ui,
"Range",
&format!("{} ({:.2} s)", RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or(""), (range.1 - range.0).seconds()),
);
});
})
.response;
reg.add("export.summary", resp.rect, sum.video.clone());
child.add_space(4.0);
let est = child.label(egui::RichText::new(format!("Estimated file size: {}", format_bytes(sum.estimated_bytes))).color(t.text_dim));
reg.add("export.estimate", est.rect, sum.estimated_size.clone());
// buttons
let go = Rect::from_min_size(pos2(rect.max.x - 120.0, rect.max.y - 44.0), vec2(104.0, 30.0));
let queue = Rect::from_min_size(pos2(go.min.x - 132.0, go.min.y), vec2(122.0, 30.0));
let qresp = ui.interact(queue, egui::Id::new("export-queue-send"), Sense::click());
ui.painter().rect_stroke(queue, 15.0, egui::Stroke::new(1.0, if qresp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside);
ui.painter().text(queue.center(), Align2::CENTER_CENTER, "Send to Queue", Tokens::semibold(12.5), t.text);
reg.add("export.sendToQueue", queue, "Send to Queue");
let resp = ui.interact(go, egui::Id::new("export-go"), Sense::click());
ui.painter().rect_filled(go, 15.0, if resp.hovered() { t.accent_hover } else { t.accent });
ui.painter().text(go.center(), Align2::CENTER_CENTER, "Export", Tokens::semibold(13.0), Color32::WHITE);
reg.add("export.button", go, "Export");
reg.flush(app);
if resp.clicked() {
let r = app.session.execute("file.exportMedia", export_params(app));
match r {
Ok(v) => app.ui.status = format!("Exporting {}", v["path"].as_str().unwrap_or("")),
Err(e) => app.ui.status = e.to_string(),
}
}
if qresp.clicked() {
let mut p = export_params(app);
if app.ui.export.preset != CUSTOM {
// the queue shows which preset an item came from
p["preset"] = json!(app.ui.export.preset);
}
match app.session.execute("export.queue.add", p) {
Ok(_) => app.ui.status = "Added to the export queue".into(),
Err(e) => app.ui.status = e.to_string(),
}
}
}
/// The export range in sequence ticks (for the summary).
fn range_ticks(ex: &ExportUi, q: &filmcraft_engine::project::Sequence) -> (Tick, Tick) {
let fd = q.settings.frame_rate.frame_duration();
let end = q.duration().max(fd);
match ex.range.as_str() {
"inOut" => {
let a = q.mark_in.unwrap_or(Tick::ZERO);
(a, q.mark_out.map(|o| o + fd).unwrap_or(end).max(a + fd))
}
"workArea" => q.work_area.map(|w| (w.start, w.end())).unwrap_or((Tick::ZERO, end)),
"custom" => (Tick::from_seconds_f64(ex.custom_start.max(0.0)), Tick::from_seconds_f64(ex.custom_end.max(ex.custom_start + 0.001))),
_ => (Tick::ZERO, end),
}
}
// ---------------------------------------------------------------------------------------------
// Preset Manager
// ---------------------------------------------------------------------------------------------
fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut m) = app.ui.export.manager.clone() else { return };
let t = app.tokens;
let mut reg = Reg::default();
let mut open = true;
let mut close = false;
let mut apply: Option<String> = None;
let mut cmd: Option<(&'static str, Value)> = None;
let mut pick: Option<&'static str> = None;
let all = app.session.export_presets.all();
let favorite = |n: &str| app.session.export_presets.is_favorite(n);
let q = m.query.to_ascii_lowercase();
egui::Window::new("Preset Manager")
.id(egui::Id::new("preset-manager"))
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_size(vec2(560.0, 520.0))
.pivot(Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
ui.horizontal(|ui| {
let r = crate::widgets::search_field(ui, &mut m.query, "Search presets", 300.0, &t);
reg.add("presetManager.search", r.rect, "Search");
check(ui, &mut reg, "presetManager.favoritesOnly", &mut m.favorites_only, "Favorites only");
});
ui.separator();
egui::ScrollArea::vertical().max_height(340.0).auto_shrink([false, false]).show(ui, |ui| {
let mut cat = String::new();
for p in all.iter().filter(|p| {
(q.is_empty()
|| p.name.to_ascii_lowercase().contains(&q)
|| p.description.to_ascii_lowercase().contains(&q)
|| p.category.to_ascii_lowercase().contains(&q))
&& (!m.favorites_only || favorite(&p.name))
}) {
if p.category != cat {
cat = p.category.clone();
ui.add_space(4.0);
ui.label(egui::RichText::new(&cat).strong().color(t.text_dim).size(11.5));
}
ui.horizontal(|ui| {
let fav = favorite(&p.name);
let (sr, sresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
paint_star(ui.painter(), sr.shrink(2.0), fav, if fav { Color32::from_rgb(0xf5, 0xc2, 0x42) } else { t.text_faint });
reg.add(format!("presetManager.favorite.{}", p.name), sr, if fav { "Remove from favorites" } else { "Add to favorites" });
if sresp.on_hover_text("Show in the Preset menu").clicked() {
cmd = Some(("export.presets.favorite", json!({"name": p.name})));
}
let sel = preset_key(&m.selected) == preset_key(&p.name);
let r = list_row(ui, &t, sel, &p.name, 300.0);
reg.add(format!("presetManager.item.{}", p.name), r.rect, p.name.clone());
if r.clicked() {
m.selected = p.name.clone();
}
if r.double_clicked() {
apply = Some(p.name.clone());
}
let r = r.on_hover_text(&p.description);
let _ = r;
ui.label(egui::RichText::new(p.settings.format.label()).color(t.text_dim).size(11.0));
if !p.builtin {
ui.label(egui::RichText::new("User").color(t.accent).size(10.5));
}
});
}
});
ui.separator();
let sel = all.iter().find(|p| preset_key(&p.name) == preset_key(&m.selected)).cloned();
if let Some(p) = &sel {
ui.label(egui::RichText::new(&p.description).color(t.text_dim).size(11.5));
}
ui.horizontal(|ui| {
ui.label("Save current settings as:");
let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text("Preset name").desired_width(200.0));
reg.add("presetManager.saveName", r.rect, "Preset name");
let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new("Save"));
reg.add("presetManager.save", r.rect, "Save");
if r.clicked() {
cmd = Some(("export.presets.save", json!({"name": m.save_name.trim(), "settings": settings_json(&app.ui.export.settings)})));
m.selected = m.save_name.trim().to_string();
}
});
ui.horizontal(|ui| {
let r = ui.button("Import…");
reg.add("presetManager.import", r.rect, "Import…");
if r.clicked() {
pick = Some("import");
}
let r = ui.add_enabled(sel.is_some(), egui::Button::new("Export…"));
reg.add("presetManager.export", r.rect, "Export…");
if r.clicked() {
pick = Some("export");
}
let r = ui.add_enabled(sel.as_ref().is_some_and(|p| !p.builtin), egui::Button::new("Delete"));
reg.add("presetManager.delete", r.rect, "Delete");
if r.clicked() {
cmd = Some(("export.presets.delete", json!({"name": m.selected})));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add_enabled(sel.is_some(), egui::Button::new("OK"));
reg.add("presetManager.ok", r.rect, "OK");
if r.clicked() {
apply = Some(m.selected.clone());
}
let r = ui.button("Cancel");
reg.add("presetManager.cancel", r.rect, "Cancel");
close |= r.clicked();
});
});
});
reg.flush(app);
if let Some((c, p)) = cmd
&& let Err(e) = app.session.execute(c, p)
{
app.ui.status = e.to_string();
}
match pick {
Some("import") => match app.hooks.pick_open_file.as_mut().and_then(|f| f("Export presets", &["json"])) {
Some(path) => {
if let Err(e) = app.session.execute("export.presets.import", json!({"path": path})) {
app.ui.status = e.to_string();
}
}
None => app.ui.status = "Import: no file chosen (agents: export.presets.import {path})".into(),
},
Some(_) => match app.hooks.pick_save_as.as_mut().and_then(|f| f("Export presets", &["json"], &format!("{}.json", m.selected))) {
Some(path) => {
if let Err(e) = app.session.execute("export.presets.export", json!({"path": path, "names": [m.selected]})) {
app.ui.status = e.to_string();
}
}
None => app.ui.status = "Export: no file chosen (agents: export.presets.export {path, names})".into(),
},
None => {}
}
if let Some(name) = apply
&& apply_preset(app, &name)
{
close = true;
}
app.ui.export.manager = if close || !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { None } else { Some(m) };
}
/// A five-pointed star (favourite marker), drawn from scratch.
fn paint_star(p: &egui::Painter, r: Rect, filled: bool, color: Color32) {
let c = r.center();
let (ro, ri) = (r.width().min(r.height()) / 2.0, r.width().min(r.height()) / 5.0);
let pts: Vec<egui::Pos2> = (0..10)
.map(|i| {
let a = std::f32::consts::PI * (i as f32 / 5.0) - std::f32::consts::FRAC_PI_2;
let rr = if i % 2 == 0 { ro } else { ri };
pos2(c.x + rr * a.cos(), c.y + rr * a.sin())
})
.collect();
if filled {
// triangles from the centre keep the concave outline exact
for i in 0..10 {
p.add(egui::Shape::convex_polygon(vec![c, pts[i], pts[(i + 1) % 10]], color, egui::Stroke::NONE));
}
}
p.add(egui::Shape::closed_line(pts, egui::Stroke::new(1.0, color)));
}
// ---------------------------------------------------------------------------------------------
// Quick Export (header)
// ---------------------------------------------------------------------------------------------
/// Presets offered by Quick Export: favourites first, then the common delivery presets.
fn quick_presets(app: &FilmcraftApp) -> Vec<String> {
let mut v: Vec<String> =
app.session.export_presets.all().iter().filter(|p| app.session.export_presets.is_favorite(&p.name)).map(|p| p.name.clone()).collect();
for p in builtin_presets().into_iter().filter(|p| p.category == "Match Source" || p.category == "Web & Social").map(|p| p.name) {
if !v.contains(&p) {
v.push(p);
}
}
v
}
/// The Quick Export popup under the header button at `anchor`.
pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::Pos2) {
if !app.ui.export.quick_open {
return;
}
let t = app.tokens;
let Some(seq) = app.session.state.active_sequence else {
app.ui.export.quick_open = false;
app.ui.status = "Quick Export: open a sequence first".into();
return;
};
if app.ui.export.quick_preset.is_empty() {
app.ui.export.quick_preset = app.session.export_queue.quick_preset.clone().unwrap_or_else(|| DEFAULT_PRESET.to_string());
}
let preset = app.session.export_presets.find(&app.ui.export.quick_preset);
let ext = preset.as_ref().map(|p| p.settings.extension()).unwrap_or("mp4");
if app.ui.export.quick_path.is_empty() || !app.ui.export.quick_path.ends_with(&format!(".{ext}")) {
let name = app.session.project.item(seq).map(|i| i.name.clone()).unwrap_or_else(|| "Sequence".into());
let dir = match app.ui.export.quick_path.rsplit_once(['/', '\\']) {
Some((d, _)) if !d.is_empty() => d.to_string(),
_ => filmcraft_engine::export_tools::default_export_dir(&app.session).to_string_lossy().to_string(),
};
app.ui.export.quick_path = std::path::Path::new(&dir).join(format!("{name}.{ext}")).to_string_lossy().to_string();
}
let est = app
.session
.execute("export.resolve", json!({"preset": app.ui.export.quick_preset}))
.ok()
.and_then(|v| v["summary"]["estimated_size"].as_str().map(str::to_string));
let presets = quick_presets(app);
let mut reg = Reg::default();
let mut go = false;
let mut close = false;
let q = &mut app.ui.export;
let area = egui::Area::new(egui::Id::new("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(340.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Quick Export").strong().size(14.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.small_button("Close");
reg.add("quickExport.close", r.rect, "Close");
close = r.clicked();
});
});
ui.label(egui::RichText::new("File Name & Location").color(t.text_dim).size(11.5));
let r = ui.add(egui::TextEdit::singleline(&mut q.quick_path).desired_width(f32::INFINITY));
reg.add("quickExport.path", r.rect, "File Name & Location");
ui.add_space(4.0);
ui.label(egui::RichText::new("Preset").color(t.text_dim).size(11.5));
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for name in &presets {
let r = list_row(ui, &t, preset_key(name) == preset_key(&q.quick_preset), name, 320.0);
reg.add(format!("quickExport.preset.{name}"), r.rect, name.clone());
if r.clicked() {
q.quick_preset = name.clone();
}
}
});
ui.add_space(4.0);
ui.label(egui::RichText::new(format!("Estimated file size: {}", est.as_deref().unwrap_or("–"))).color(t.text_dim).size(11.5));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui
.add(egui::Button::new(egui::RichText::new("Export").color(Color32::WHITE)).fill(t.accent).corner_radius(12.0).min_size(vec2(90.0, 26.0)));
reg.add("quickExport.go", r.rect, "Export");
go = r.clicked();
});
});
});
let clicked_elsewhere = area.response.clicked_elsewhere();
// the header click that opened the popup is not a click elsewhere
let toggled = ctx.data_mut(|d| d.remove_temp::<bool>(egui::Id::new("quick-export-toggled")).unwrap_or(false));
reg.flush(app);
if go {
let p = json!({"preset": app.ui.export.quick_preset, "path": app.ui.export.quick_path});
match app.session.execute("export.quick", p) {
Ok(v) => app.ui.status = format!("Quick Export: {}", v["path"].as_str().unwrap_or("")),
Err(e) => app.ui.status = e.to_string(),
}
close = true;
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) || (clicked_elsewhere && !toggled) {
app.ui.export.quick_open = false;
}
}
/// A left-aligned selectable list row of a fixed width.
fn list_row(ui: &mut egui::Ui, t: &Tokens, selected: bool, text: &str, width: f32) -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(width, 20.0), Sense::click());
let bg = if selected {
t.accent
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(r, 3.0, bg);
ui.painter().text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.5), if selected { Color32::WHITE } else { t.text });
resp
}
fn expand_home(p: &str) -> String {
match (p.strip_prefix("~/"), std::env::var("HOME")) {
(Some(rest), Ok(h)) => format!("{h}/{rest}"),
_ => p.to_string(),
}
}