indicatrix-cut 0.6.2

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
//! Tilt performance video export: the "Export tilt video" collapsible section of
//! `performance_graph_dialog.slint`. Renders one high-quality frame per swept angle
//! (posed exactly like the dialog's own curve/hover-preview, see
//! `tilt_hover_preview::camera_pose_for_axis_tilt`), optionally overlays the tilt
//! performance values (`overlay`), writes a numbered PNG sequence, then muxes an MP4
//! (ffmpeg) or GIF (fallback) -- see `encode`'s own module doc comment for the full
//! encoding chain.
//!
//! Split into: [`params`] (pure sweep/resolution math), [`metrics`] (reading the
//! dialog's already-computed curves at an arbitrary angle), [`overlay`] (layout
//! selection and the bundled bitmap font), [`template`] (the output folder-name
//! template), [`render`] (per-frame rendering, sharing the still-image export's own
//! local/remote core), [`encode`] (PNG -> MP4/GIF/README), and [`run`] (the background
//! thread tying all of those together). This file keeps only the UI-thread wiring:
//! reading `TiltVideoExportModel`'s settings, validating them, and handing a
//! fully-resolved request to `run::spawn`.
//!
//! # Same compute configuration as the still-image export
//!
//! Every frame renders through `bridge::export_thread::render_accumulation` -- the
//! SAME local CPU/GPU/hybrid + remote-worker render core the still-image export uses,
//! not a video-only tracer (see `render`'s own doc comment). [`build_request`] reads
//! the compute configuration from the SAME two sources the still-image export reads:
//! `RenderContext::local_compute_target` (the app's persisted CPU/CPU+GPU/GPU setting)
//! and `AppSettings::remote` (the one configured remote endpoint), plus the section's
//! own "Transfer" choice (full data or final picture only) -- never a video-only compute picker. The
//! still-image export's own "Compute" pill
//! (`ComputeTarget::LocalOnly`/`RemoteOnly`/`Both`) is dialog-local UI state, not a
//! persisted setting (its own default is `Both` whenever a worker is configured,
//! `LocalOnly` otherwise -- see `export_dialog.slint`'s `compute_target` property);
//! since a video has no equivalent dialog control, `run::render_all_frames` always
//! requests `ComputeTarget::Both` and lets `render_accumulation`'s existing graceful
//! fallback decide, frame to frame, whether a worker is actually reachable -- the same
//! end state the dialog's own default reaches for the common case, without a
//! video-side probe of its own. Because the whole sweep now shares the GPU adapter and
//! remote workers with the rest of the app, it pauses the live viewport for its own
//! duration exactly as a still-image export does (`RenderContext::export_active`, see
//! `run`'s own doc comment) -- never running two GPU programs at once.

mod encode;
mod metrics;
mod overlay;
mod params;
mod render;
mod run;
mod template;

use crate::{
    ActivityModel, ExportModel, LibraryModel, MainWindow, TiltModel, TiltVideoExportModel,
    bridge::{
        export_thread::{
            ComputeTarget, RemoteSelection, SceneSnapshot, filename_template::TemplateContext,
        },
        render_thread::RenderContext,
    },
    settings::{ExportTransfer, SettingsPersister},
};
use indicatrix::color::{ColorSpace, metrics::PROFILE_AZIMUTHS_DEG};
use slint::{ComponentHandle, Model};
use std::{
    path::PathBuf,
    sync::{
        Arc, Mutex,
        atomic::{AtomicBool, Ordering},
    },
};

/// Whichever video-export run is currently in flight, if any: the
/// `crate::ActivityModel` id `handle_start_video_export` registered for it
/// paired with its own cancel flag -- see [`setup_video_export_callback`]'s own
/// doc comment on `current_run` for why both travel together.
type CurrentExportRun = Arc<Mutex<Option<(i32, Arc<AtomicBool>)>>>;

/// Wires `TiltVideoExportModel`'s callbacks. Called from
/// `tilt_profile::setup_tilt_profile_callback` (both are wired from the same
/// `gui::mod::run_gui` call site -- see this module's own `mod.rs`-adjacent doc
/// comment in `tilt_profile.rs`) rather than directly from `gui::mod`.
pub(in crate::gui) fn setup_video_export_callback(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    // `Some((activity_id, cancel))` for whichever run is currently in flight --
    // `on_cancel_video_export` signals THIS one; a fresh `on_start_video_export`
    // replaces it with a new pair once the previous run has finished (guarded by
    // `TiltVideoExportModel.is_exporting`, so two runs are never in flight at once).
    // `activity_id` is the `crate::ActivityModel` id `handle_start_video_export`
    // registered via `invoke_start_external` -- `on_external_cancel` below
    // reaches back through this SAME `Arc<AtomicBool>` when a cancel click lands
    // on the status strip's own activity chip instead of this dialog's own
    // Cancel button.
    let current_run: CurrentExportRun = Arc::new(Mutex::new(None));

    ui.global::<TiltVideoExportModel>()
        .on_frame_count(|start, end, step| {
            params::frame_count(f64::from(start), f64::from(end), f64::from(step)) as i32
        });

    ui.global::<TiltVideoExportModel>()
        .on_parse_step_deg(|raw| {
            raw.trim()
                .parse::<f64>()
                .map_or(1.0, params::clamp_step_deg) as f32
        });

    // The single source of truth for "which `resolution_preset` index means Custom" --
    // `performance_graph_dialog.slint`'s combo-box custom-size fields read this instead
    // of hard-coding the preset table's length a second time.
    ui.global::<TiltVideoExportModel>()
        .on_custom_resolution_index(|| params::CUSTOM_RESOLUTION_INDEX);

    // Builds the SAME `TemplateContext`/`VideoTemplateExtras` pair `build_request`
    // uses for the real export (`build_video_template_context`, shared below),
    // from the currently open design/material/settings and the axis the dialog
    // passes in, so the "Resolves to" preview always names the design actually
    // open rather than a fixed placeholder. `selected_axis_index` is dialog-local
    // Slint state (`performance_graph_dialog.slint`'s own doc comment on that
    // property), not something `TiltModel`/`TiltVideoExportModel` track, so it
    // travels as this callback's second argument rather than being re-derived here.
    let ui_weak_preview = ui.as_weak();
    let render_ctx_preview = render_ctx.clone();
    ui.global::<TiltVideoExportModel>()
        .on_preview_video_folder_name(move |raw_template, axis_index| {
            let Some(ui) = ui_weak_preview.upgrade() else {
                // No live window to read settings from (e.g. mid-teardown) -- falls
                // back to the fixed sample rather than panicking.
                return template::resolve_folder_name(
                    &raw_template,
                    &sample_template_context(),
                    &sample_template_extras(),
                )
                .into();
            };
            let (ctx, extras) = live_template_context(&ui, &render_ctx_preview, axis_index);
            template::resolve_folder_name(&raw_template, &ctx, &extras).into()
        });

    let ui_weak_start = ui.as_weak();
    let render_ctx_start = render_ctx.clone();
    let settings_store_start = settings_store.clone();
    let current_run_start = current_run.clone();
    ui.global::<TiltVideoExportModel>()
        .on_start_video_export(move |axis_index: i32| {
            let Some(ui) = ui_weak_start.upgrade() else {
                return;
            };
            handle_start_video_export(
                &ui,
                &render_ctx_start,
                &settings_store_start,
                &current_run_start,
                axis_index,
            );
        });

    let current_run_cancel = current_run.clone();
    ui.global::<TiltVideoExportModel>()
        .on_cancel_video_export(move || {
            if let Some((_, cancel)) = current_run
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner)
                .as_ref()
            {
                cancel.store(true, Ordering::Relaxed);
            }
        });

    // The reverse half of `ui/models/activity.slint`'s own bridge doc comment --
    // a Cancel click on the status strip's `ActivityChip` (as opposed to this
    // dialog's own Cancel button, which calls `cancel_video_export` above
    // directly) reaches `ActivityRegistry`'s central `on_cancel` handler first,
    // which finds no local Rust closure for an id started via
    // `invoke_start_external` and instead invokes THIS callback. Only this
    // module registers it (see that same doc comment on why a second
    // externally-cancellable activity kind would need to filter by id/kind
    // itself).
    ui.global::<ActivityModel>().on_external_cancel(move |id| {
        let run = current_run_cancel
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if let Some((running_id, cancel)) = run.as_ref()
            && *running_id == id
        {
            cancel.store(true, Ordering::Relaxed);
        }
    });
}

/// `on_start_video_export`'s handler body: validates every setting, and either starts
/// the background render (`run::spawn`) or reports why it couldn't. Split out of
/// `setup_video_export_callback` purely to keep that function short.
///
/// [`resolve_export_directory_then`]'s own folder picker (when the export
/// folder isn't already remembered) runs off the UI thread, so this whole
/// function is itself continuation-passing: the folder is resolved FIRST, and
/// [`build_request`] (validating everything else -- angle range, the tilt
/// curves being ready, resolution/fps settings) only runs once it's in hand.
/// A cutter who has no remembered export folder therefore picks one before
/// being told the sweep hasn't finished computing yet, rather than after -- a
/// minor UX tradeoff accepted rather than adding a second, folder-independent
/// validation pass purely to check that first.
fn handle_start_video_export(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
    current_run: &CurrentExportRun,
    axis_index: i32,
) {
    let model = ui.global::<TiltVideoExportModel>();
    if model.get_is_exporting() {
        // The Start button is disabled while exporting (`performance_graph_dialog.slint`),
        // so this is only reachable via a stray double-invoke -- guarded defensively
        // rather than trusted to the UI alone.
        return;
    }

    let render_ctx = Arc::clone(render_ctx);
    let settings_store = settings_store.clone();
    let current_run = Arc::clone(current_run);
    resolve_export_directory_then(ui, move |ui, export_dir| {
        let model = ui.global::<TiltVideoExportModel>();
        let Some(export_dir) = export_dir else {
            // The cutter's own explicit cancel -- no error banner.
            return;
        };

        let request = match build_request(ui, &render_ctx, &settings_store, axis_index, &export_dir)
        {
            Ok(request) => request,
            Err(message) => {
                model.set_has_error(true);
                model.set_status_message(message.into());
                return;
            }
        };

        let cancel = Arc::new(AtomicBool::new(false));
        // Real progress (`run::run`'s own frame counter -- frames done / total,
        // `report_progress`) and a real cancel handle -- registered only once
        // the request itself is known good, so a request that fails to build
        // (an unset curve) never leaves an orphaned activity behind.
        let activity_id = ui.global::<ActivityModel>().invoke_start_external(
            "tilt_video".into(),
            "Tilt video export".into(),
            true,
        );
        *current_run
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner) =
            Some((activity_id, cancel.clone()));

        model.set_is_exporting(true);
        model.set_has_error(false);
        model.set_status_message("Rendering...".into());
        model.set_current_frame(0);
        model.set_total_frames(request.total_frames as i32);
        model.set_progress(0.0);
        model.set_eta_text(String::new().into());

        // Pauses the live viewport for the WHOLE video, matching the still-image
        // export's own `export_active_count` increment in `gui::render::render_export::
        // wiring::finish_start_export` -- `run::spawn`'s every exit path (completion,
        // cancel, error, caught panic) decrements this exactly once, mirroring
        // `finish_export_queue`'s single decrement point.
        RenderContext::lock(&render_ctx).export_active_count += 1;
        run::spawn(ui.as_weak(), render_ctx, request, cancel, activity_id);
    });
}

/// Gathers and validates every `TiltVideoExportModel` setting into a fully-resolved
/// [`run::VideoExportRequest`], or an `Err` message for whichever setting made the
/// request unrunnable (an empty sweep or a not-yet-computed curve). `export_dir`
/// is already resolved by the caller ([`handle_start_video_export`], via
/// [`resolve_export_directory_then`]): that picker runs off the UI thread, so it
/// can no longer be resolved synchronously from inside this otherwise-synchronous
/// builder.
fn build_request(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
    axis_index: i32,
    export_dir: &std::path::Path,
) -> Result<run::VideoExportRequest, String> {
    let model = ui.global::<TiltVideoExportModel>();

    let start_deg = params::clamp_angle_deg(f64::from(model.get_start_angle_deg()));
    let end_deg = params::clamp_angle_deg(f64::from(model.get_end_angle_deg()));
    let step_deg = params::clamp_step_deg(f64::from(model.get_step_deg()));
    // (`start_angle_deg`/`end_angle_deg` are `int` in Slint -- `SpinBox`'s own
    // `-90..=90` bounds already keep them in range; `clamp_angle_deg` here is
    // defense-in-depth, not the primary guard.)
    let total_frames = params::frame_count(start_deg, end_deg, step_deg);
    if total_frames == 0 {
        return Err("The angle range/step must produce at least one frame.".to_string());
    }
    if total_frames > params::CONFIRM_FRAME_THRESHOLD {
        // The dialog itself already made the user confirm a run this large
        // (`confirmed_large_run`/`needs_confirmation` in `performance_graph_dialog.slint`)
        // -- this is just an observability breadcrumb for whoever reads the log of a
        // multi-hour run, not a second gate.
        tracing::info!("Starting a large tilt video export: {total_frames} frames");
    }

    let curves = read_axis_curves(ui, axis_index)
        .ok_or_else(|| "The tilt sweep hasn't finished computing yet.".to_string())?;

    let fps = params::resolve_fps(model.get_fps_index());
    let (width, height) = params::resolve_resolution(
        model.get_resolution_preset(),
        model.get_custom_width(),
        model.get_custom_height(),
    );
    let samples_per_pixel = model.get_sample_exponent_raw().round().exp2().round() as u32;
    let max_bounces = params::resolve_max_bounces(model.get_selected_bounce_index());
    let color_space = crate::gui::color_space_from_index(model.get_selected_color_space_index());

    // The export's OWN bounce cap, not whatever the live viewport happens to be set to
    // -- same reasoning as `gui::render_export::apply_export_bounce_cap` (that helper
    // itself is `pub(super)` to a different module, so this overrides the field
    // directly rather than importing it).
    let mut scene = SceneSnapshot::capture(render_ctx)?;
    scene.max_bounces = max_bounces;

    // The SAME compute configuration source the still-image export reads (see this
    // module's own doc comment): `RenderContext::local_compute_target` is the app's
    // persisted CPU/CPU+GPU/GPU setting (a second, short-lived lock -- the video's own
    // scene capture above already released its lock, so this doesn't extend it), and
    // `remote` is the same endpoint `gui::render::render_export::wiring` reads.
    // `compute_target` has no persisted dialog source of its own for a video (see this
    // module's doc comment on why `Both` is the right stand-in).
    let local_compute = RenderContext::lock(render_ctx).local_compute_target;
    let remote = RemoteSelection {
        compute_target: ComputeTarget::Both,
        worker: settings_store.snapshot().settings.remote_worker(),
        transfer: ExportTransfer::from_index(model.get_transfer_index()),
        contribute_local: settings_store
            .snapshot()
            .settings
            .contribute_to_final_picture,
    };

    let selection = metrics::MetricSelection {
        brilliance: model.get_show_values() && model.get_metric_brilliance(),
        windowing: model.get_show_values() && model.get_metric_windowing(),
        extinction: model.get_show_values() && model.get_metric_extinction(),
        tilt_brilliance: model.get_show_values() && model.get_metric_tilt_brilliance(),
        angle: model.get_show_values() && model.get_metric_angle(),
    };

    let detail = ui.global::<LibraryModel>().get_current_detail();
    let (template_ctx, extras) = build_video_template_context(
        detail.title.as_str(),
        detail.designer.as_str(),
        detail.shape.as_str(),
        detail.ri.as_str(),
        scene.material.name.as_str(),
        &scene,
        VideoTemplateSettings {
            axis_index,
            fps,
            width,
            height,
            spp: samples_per_pixel,
            bounces: max_bounces,
            color_space,
            step_deg,
            start_deg,
            end_deg,
            total_frames,
        },
    );
    let raw_template = model.get_filename_template();
    let raw_template = if raw_template.is_empty() {
        template::DEFAULT_VIDEO_TEMPLATE
    } else {
        raw_template.as_str()
    };
    let folder_name = template::resolve_folder_name(raw_template, &template_ctx, &extras);
    let out_dir = template::unique_folder_path(export_dir, &folder_name);
    std::fs::create_dir_all(&out_dir)
        .map_err(|e| format!("Could not create {}: {e}", out_dir.display()))?;

    Ok(run::VideoExportRequest {
        scene,
        axis_index: axis_index.max(0) as usize,
        start_deg,
        end_deg,
        step_deg,
        total_frames,
        fps,
        width,
        height,
        samples_per_pixel,
        color_space,
        out_dir,
        out_name: folder_name,
        selection,
        curves,
        keep_frames: model.get_keep_frames(),
        remote,
        local_compute,
    })
}

/// Reads `axis_index`'s already-computed 181-point curves out of `TiltModel` (populated
/// by `gui::tilt::tilt_profile`'s background sweep, which the dialog always launches
/// before the video export section can be interacted with). `None` if the axis is out
/// of range or the sweep for it hasn't landed yet (fewer than 181 points).
fn read_axis_curves(ui: &MainWindow, axis_index: i32) -> Option<metrics::MetricCurves> {
    if axis_index < 0 {
        return None;
    }
    let idx = axis_index as usize;
    let tilt = ui.global::<TiltModel>();
    let to_vec = |row: slint::ModelRc<f32>| row.iter().collect::<Vec<f32>>();

    let brilliance = to_vec(tilt.get_graph_brilliance_extra_axes().row_data(idx)?);
    let windowing = to_vec(tilt.get_graph_windowing_extra_axes().row_data(idx)?);
    let extinction = to_vec(tilt.get_graph_extinction_extra_axes().row_data(idx)?);
    if brilliance.len() < 181 || windowing.len() < 181 || extinction.len() < 181 {
        return None;
    }
    Some(metrics::MetricCurves {
        brilliance,
        windowing,
        extinction,
    })
}

/// The output directory: reuses `ExportModel.directory` (the still-image export's own
/// remembered folder) when one is already set, or prompts a native folder picker and
/// seeds it back into `ExportModel.directory` for consistency -- the exact same
/// picker/flow `gui::render_export::wiring`'s own `on_start_export` uses, duplicated
/// here rather than invoked cross-module since that helper is private to its own
/// module. Not persisted to the on-disk settings store (unlike the still-image
/// export's own directory): a documented, session-only scope simplification,
/// since reaching the `SettingsStore` handle would mean touching `gui::mod`'s
/// wiring call site.
///
/// The folder picker itself runs off the UI thread via `gui::pickers::pick` --
/// `on_done`'s `None` is either "already had a remembered folder" turned into
/// `Some` synchronously (no picker shown at all) or a real cancel/dismiss of
/// the native dialog; [`handle_start_video_export`] treats both `None` cases
/// identically, since its own caller cannot tell them apart.
fn resolve_export_directory_then(
    ui: &MainWindow,
    on_done: impl FnOnce(&MainWindow, Option<PathBuf>) + 'static,
) {
    let export_model = ui.global::<ExportModel>();
    let current = export_model.get_directory();
    if !current.is_empty() {
        on_done(ui, Some(PathBuf::from(current.as_str())));
        return;
    }
    crate::gui::pickers::pick(
        ui,
        crate::gui::pickers::PickerRequest {
            kind: crate::gui::pickers::PickerKind::PickFolder,
            title: Some("Choose an export folder".to_string()),
            filters: Vec::new(),
            default_file_name: None,
            starting_dir: None,
        },
        move |ui, dir| {
            if let Some(dir) = &dir {
                ui.global::<ExportModel>()
                    .set_directory(dir.to_string_lossy().into_owned().into());
            }
            on_done(ui, dir);
        },
    );
}

/// `axis_index` -> its azimuth label (e.g. `"45"`) for `{axis}` in the folder-name
/// template.
fn axis_label_for_index(axis_index: i32) -> String {
    if axis_index < 0 {
        return "0".to_string();
    }
    PROFILE_AZIMUTHS_DEG
        .get(axis_index as usize)
        .map_or_else(|| "0".to_string(), |deg| format!("{deg:.0}"))
}

/// `ColorSpace`'s own display label -- mirrors
/// `gui::render_export::queue::colorspace_label` (that one is `pub(super)` to a
/// different module, so this is a small, deliberate duplicate rather than a
/// shared call).
const fn colorspace_label(cs: ColorSpace) -> &'static str {
    match cs {
        ColorSpace::Srgb => "sRGB",
        ColorSpace::DisplayP3 => "Display P3",
        ColorSpace::Rec2020 => "Rec.2020",
        ColorSpace::AcesCg => "ACEScg",
    }
}

/// Plain, already-resolved settings for [`build_video_template_context`] -- grouped into
/// one `Copy` struct (rather than passed as individual arguments) purely to keep that
/// function's own argument list under `clippy::too_many_arguments`'s default threshold.
#[derive(Debug, Clone, Copy)]
struct VideoTemplateSettings {
    axis_index: i32,
    fps: u32,
    width: u32,
    height: u32,
    spp: u32,
    bounces: u32,
    color_space: ColorSpace,
    step_deg: f64,
    start_deg: f64,
    end_deg: f64,
    total_frames: usize,
}

/// Builds the `TemplateContext`/`VideoTemplateExtras` pair for a given
/// design/designer/shape/RI/material/scene/settings -- the ONE place both the real
/// export (`build_request`) and the dialog's own live "Resolves to" preview
/// (`on_preview_video_folder_name`, via `live_template_context` below) turn "the
/// currently open design" into a `TemplateContext`, so the two can never disagree
/// about what that means. Pure/plain-argument on purpose so it stays testable
/// without a live `MainWindow` (see this module's own tests).
fn build_video_template_context(
    design: &str,
    designer: &str,
    shape: &str,
    ri: &str,
    material: &str,
    scene: &SceneSnapshot,
    settings: VideoTemplateSettings,
) -> (TemplateContext, template::VideoTemplateExtras) {
    let ctx = TemplateContext {
        design: design.to_string(),
        designer: designer.to_string(),
        shape: shape.to_string(),
        material: material.to_string(),
        ri: ri.to_string(),
        width: settings.width,
        height: settings.height,
        spp: settings.spp,
        bounces: settings.bounces,
        colorspace: colorspace_label(settings.color_space).to_string(),
        preset: String::new(),
        lighting: scene.lighting_preset.label().to_string(),
        yaw_deg: scene.yaw.to_degrees(),
        pitch_deg: scene.pitch.to_degrees(),
        distance: scene.distance,
        exposure: scene.exposure,
    };
    let extras = template::VideoTemplateExtras {
        axis_label: axis_label_for_index(settings.axis_index),
        fps: settings.fps,
        step_deg: settings.step_deg,
        start_deg: settings.start_deg,
        end_deg: settings.end_deg,
        total_frames: settings.total_frames,
    };
    (ctx, extras)
}

/// Wiring layer for [`build_video_template_context`]: reads the currently open
/// design (`LibraryModel`), the current scene/material (`SceneSnapshot::capture`), and
/// the video export's own current settings (`TiltVideoExportModel`) straight off the
/// live UI, for the dialog's "Resolves to" preview. `axis_index` travels as a plain
/// argument rather than being read off `TiltVideoExportModel` because the dialog's
/// `selected_axis_index` is Slint-local state that Rust never otherwise sees (see
/// `performance_graph_dialog.slint`'s own doc comment on that property).
fn live_template_context(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    axis_index: i32,
) -> (TemplateContext, template::VideoTemplateExtras) {
    let model = ui.global::<TiltVideoExportModel>();
    let detail = ui.global::<LibraryModel>().get_current_detail();
    // A refused material (see `SceneSnapshot::capture`'s own doc comment) has nothing
    // real to preview a filename for -- falls back to the same fixed sample the
    // no-live-window branch above uses rather than showing a stale or wrong-material
    // name; the actual export start (`build_request`) refuses for real.
    let Ok(scene) = SceneSnapshot::capture(render_ctx) else {
        return (sample_template_context(), sample_template_extras());
    };

    let fps = params::resolve_fps(model.get_fps_index());
    let (width, height) = params::resolve_resolution(
        model.get_resolution_preset(),
        model.get_custom_width(),
        model.get_custom_height(),
    );
    let spp = model.get_sample_exponent_raw().round().exp2().round() as u32;
    let bounces = params::resolve_max_bounces(model.get_selected_bounce_index());
    let color_space = crate::gui::color_space_from_index(model.get_selected_color_space_index());
    let step_deg = params::clamp_step_deg(f64::from(model.get_step_deg()));
    let start_deg = params::clamp_angle_deg(f64::from(model.get_start_angle_deg()));
    let end_deg = params::clamp_angle_deg(f64::from(model.get_end_angle_deg()));
    let total_frames = params::frame_count(start_deg, end_deg, step_deg);

    build_video_template_context(
        detail.title.as_str(),
        detail.designer.as_str(),
        detail.shape.as_str(),
        detail.ri.as_str(),
        scene.material.name.as_str(),
        &scene,
        VideoTemplateSettings {
            axis_index,
            fps,
            width,
            height,
            spp,
            bounces,
            color_space,
            step_deg,
            start_deg,
            end_deg,
            total_frames,
        },
    )
}

/// A fixed example `TemplateContext`, for the dialog's live "Resolves to" preview when
/// no live window is reachable to read the current design/settings from (e.g. mid
/// teardown) -- the same fallback shape `ExportModel.preview_filename_template` uses.
fn sample_template_context() -> TemplateContext {
    TemplateContext {
        design: "Sample Design".to_string(),
        designer: "Sample Designer".to_string(),
        shape: "Round".to_string(),
        material: "Diamond".to_string(),
        ri: "2.42".to_string(),
        width: 1920,
        height: 1080,
        spp: 64,
        bounces: 12,
        colorspace: "sRGB".to_string(),
        preset: String::new(),
        lighting: "Gem Studio Ring Lights".to_string(),
        yaw_deg: 45.0,
        pitch_deg: 30.0,
        distance: 2.4,
        exposure: 1.0,
    }
}

fn sample_template_extras() -> template::VideoTemplateExtras {
    template::VideoTemplateExtras {
        axis_label: "0".to_string(),
        fps: 30,
        step_deg: 1.0,
        start_deg: -90.0,
        end_deg: 90.0,
        total_frames: 181,
    }
}

#[cfg(test)]
mod tests;