M8.9 + M12.6: Lumetri Scopes complete; Metadata, Timecode, Events, Progress, Reference Monitor panels
M8.9 (scopes):
- New L3 crate filmcraft-scopes (wasm-clean): Waveform RGB/Luma/YC/YC no Chroma,
Parade RGB/YUV/RGB-White, Histogram, Vectorscope YUV (75/100 % targets, skin-tone
line) and HLS; Rec. 601/709/2100 matrices, Clamp Signal, 8-bit/float/HDR (PQ cd/m2)
scales; numeric summaries; density painting. < 1 ms per scope at 1080p.
- Lumetri Scopes panel rewritten: multi-scope grid, wrench / right-click menu (presets,
scopes, parade/waveform type, targets, colour space, brightness, scale), footer
(colour space, Clamp Signal, 8 Bit/Float/HDR). Settings in UiState::panels.
- scopes.read engine query returns levels, histogram bins, waveform/parade columns and
vectorscope peaks.
M12.6 (panels):
- Metadata panel with editable Name/Label/log fields (metadata.get / metadata.set,
one undo step, stored in ProjectItem::metadata; no schema change, stays v11).
- Timecode panel (rows: current/media/duration/in-out/remaining, any time display).
- Events panel on a new engine event log (failed/disabled commands, toasts, auto-save
errors, jobs started/finished/failed/cancelled; events.list / events.clear).
- Progress panel (jobs with progress and Cancel).
- Reference Monitor (parked or ganged to Program; picture or scopes).
- All open from Window > menu, have automation ids; ui.set {"panels": ...} merges state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0fafd53a99
commit
45b8c0c2a0
|
|
@ -1823,6 +1823,7 @@ dependencies = [
|
|||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-scopes",
|
||||
"filmcraft-speech",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-text",
|
||||
|
|
@ -2053,6 +2054,15 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2115,6 +2125,7 @@ dependencies = [
|
|||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-scopes",
|
||||
"filmcraft-text",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ filmcraft-captions = { path = "crates/captions" }
|
|||
filmcraft-speech = { path = "crates/speech" }
|
||||
filmcraft-render = { path = "crates/render" }
|
||||
filmcraft-gpu = { path = "crates/gpu" }
|
||||
filmcraft-scopes = { path = "crates/scopes" }
|
||||
filmcraft-export = { path = "crates/export" }
|
||||
filmcraft-format = { path = "crates/format" }
|
||||
filmcraft-engine = { path = "crates/engine" }
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
| M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Full ~150-effect catalogue, WGSL parity for all effects and masks in the live GPU path, Warp Stabilizer, Morph Cut | 20–30 |
|
||||
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 + own AAC; ProRes, MJPEG, PNG, GIF, WAV; background jobs | Preset library, queue UI, smart render | 6–8 |
|
||||
| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); scopes incl. HDR nits waveform | Parade/histogram/HLS scopes, HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import) | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, Ogg Opus files, MXF, hardware decode | 12–20 |
|
||||
| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute | Responsive design pins, rolls/crawls, per-character styles, motion graphics templates, MCC/STL/TTML, 608/708 embedding, speech-to-text | 12–18 |
|
||||
| M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 |
|
||||
|
|
@ -77,6 +77,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
|
||||
## Log
|
||||
|
||||
- **2026-10-02 (panels):** complete Lumetri Scopes (new `filmcraft-scopes` crate: Vectorscope YUV/HLS, Histogram, Parade RGB/YUV/RGB-White, Waveform RGB/Luma/YC/YC no Chroma; wrench menu with presets, colour space, brightness, scale; < 1 ms per scope at 1080p; `scopes.read` returns the numbers); Metadata panel (editable log fields, undoable `metadata.set`), Timecode panel, Events panel (event log of failed commands, jobs, auto-save), Progress panel (jobs with Cancel), Reference Monitor (parked or ganged, picture or scopes).
|
||||
- **2026-10-02 (later):** all 93 video effects (+Legacy/Obsolete bins), all 53 audio effects with Parametric/Graphic EQ, Multiband Compressor and Dynamics editor windows, all 84 video transitions (+21 Legacy), Settings dialog (16 categories, most wired), remaining menu items (Scene Edit Detection, Find, Normalize Mix Track, Simplify Sequence, Automate to Sequence, search bins, templates, Project Settings with scratch disks, ALE and selection-as-project export, system report); project schema v11. 1258 tests.
|
||||
|
||||
- **2026-10-02:** Premiere menu long tail: Sequence/Markers (gaps, split edits, through edits, subsequence, Delete Tracks, range/chapter markers, ripple sequence markers), Clip/Edit/File (Paste/Remove Attributes, subclips, Frame Hold Options, Time Interpolation with frame blending, Audio Channels, Breakout to Mono, Extract Audio, Replace With Clip, Remove Unused, Consolidate Duplicates), View/monitors (paused resolution, alpha/RGB display modes, comparison view, magnification, rulers, guides + templates, snapping), Graphics and Titles (vertical text, shape layers, align/distribute/arrange). Transcripts and text-based editing (Text panel, optional local Whisper). AV1 tile/frame/post-filter threading. Agent-friendly CLI (`exec`, `inspect`, `describe`, `import`, `export`, `run -`, `--save`, `--bridge`). Project schema v10.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ filmcraft-codecs = { workspace = true }
|
|||
filmcraft-project = { workspace = true }
|
||||
filmcraft-edit = { workspace = true }
|
||||
filmcraft-render = { workspace = true }
|
||||
filmcraft-scopes = { workspace = true }
|
||||
filmcraft-text = { workspace = true }
|
||||
filmcraft-audio-dsp = { workspace = true }
|
||||
filmcraft-export = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -2092,6 +2092,8 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::masks::commands());
|
||||
v.extend(crate::presets::commands());
|
||||
v.extend(crate::transcript::commands());
|
||||
v.extend(crate::panels::commands());
|
||||
v.extend(crate::scopes::commands());
|
||||
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
|
||||
// and their menu order
|
||||
crate::clip_ops::apply_layout(&mut v);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub mod masks;
|
|||
pub mod media_pool;
|
||||
pub mod mixer;
|
||||
pub mod multicam;
|
||||
pub mod panels;
|
||||
pub mod perf;
|
||||
pub mod presets;
|
||||
pub mod previews;
|
||||
|
|
@ -29,6 +30,7 @@ pub mod project_tools;
|
|||
pub mod proxies;
|
||||
pub mod relink;
|
||||
pub mod scene_detect;
|
||||
pub mod scopes;
|
||||
pub mod sequence_extras;
|
||||
pub mod sequence_tools;
|
||||
pub mod settings;
|
||||
|
|
@ -278,6 +280,8 @@ pub struct Session {
|
|||
/// Speech recogniser for `transcript.generate` (None = the Whisper model named by the command,
|
||||
/// feature `whisper`). Hosts and tests install one here.
|
||||
pub transcriber: Option<Arc<dyn filmcraft_speech::Transcriber>>,
|
||||
/// The Events panel log: failed commands, job results, auto-save errors, messages.
|
||||
pub log: panels::EventLog,
|
||||
/// Nesting depth of [`Session::execute`] (commands that run other commands).
|
||||
exec_depth: u32,
|
||||
}
|
||||
|
|
@ -359,6 +363,7 @@ impl Session {
|
|||
presets: Default::default(),
|
||||
stepped: Vec::new(),
|
||||
transcriber: None,
|
||||
log: Default::default(),
|
||||
exec_depth: 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -442,6 +447,7 @@ impl Session {
|
|||
proxies::poll(self);
|
||||
masks::poll(self);
|
||||
scene_detect::poll(self);
|
||||
panels::log_jobs(self);
|
||||
let Some(p) = self.persistence.as_mut() else { return };
|
||||
for ev in p.drain_events() {
|
||||
match ev {
|
||||
|
|
@ -450,7 +456,10 @@ impl Session {
|
|||
self.saved_revision = revision;
|
||||
}
|
||||
}
|
||||
autosave::WorkerEvent::Error(m) => self.events.push(Event::Toast { message: m, error: true }),
|
||||
autosave::WorkerEvent::Error(m) => {
|
||||
self.log.push(panels::Level::Error, "autosave", m.clone());
|
||||
self.events.push(Event::Toast { message: m, error: true });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -506,10 +515,21 @@ impl Session {
|
|||
if self.exec_depth == 0 && spec.journal && self.trim_play.active() {
|
||||
self.settle_trim_playback(id);
|
||||
}
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
let e = EngineError::Disabled(id.to_string(), why);
|
||||
if self.exec_depth == 0 {
|
||||
self.log.push(panels::Level::Warning, id, e.to_string());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.exec_depth += 1;
|
||||
let r = (spec.run)(self, ¶ms);
|
||||
self.exec_depth -= 1;
|
||||
if self.exec_depth == 0
|
||||
&& let Err(e) = &r
|
||||
{
|
||||
self.log.push(panels::Level::Error, id, e.to_string());
|
||||
}
|
||||
self.sync_persistence();
|
||||
// playback reads the newest snapshot (mixer moves, mutes… are heard while playing)
|
||||
self.previews.live.publish_project(self.project.clone());
|
||||
|
|
@ -655,7 +675,16 @@ impl Session {
|
|||
}
|
||||
|
||||
pub fn toast(&mut self, msg: impl Into<String>) {
|
||||
self.events.push(Event::Toast { message: msg.into(), error: false });
|
||||
let message = msg.into();
|
||||
self.log.push(panels::Level::Info, "app", message.clone());
|
||||
self.events.push(Event::Toast { message, error: false });
|
||||
}
|
||||
|
||||
/// An error message for the user (a toast, and an Events panel entry from `source`).
|
||||
pub fn error_toast(&mut self, source: &str, msg: impl Into<String>) {
|
||||
let message = msg.into();
|
||||
self.log.push(panels::Level::Error, source, message.clone());
|
||||
self.events.push(Event::Toast { message, error: true });
|
||||
}
|
||||
|
||||
pub fn drain_events(&mut self) -> Vec<Event> {
|
||||
|
|
@ -760,6 +789,8 @@ mod mixer_tests;
|
|||
#[cfg(test)]
|
||||
mod multicam_tests;
|
||||
#[cfg(test)]
|
||||
mod panels_tests;
|
||||
#[cfg(test)]
|
||||
mod presets_tests;
|
||||
#[cfg(test)]
|
||||
mod previews_tests;
|
||||
|
|
@ -770,6 +801,8 @@ mod proxies_tests;
|
|||
#[cfg(test)]
|
||||
mod relink_tests;
|
||||
#[cfg(test)]
|
||||
mod scopes_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod settings_tests;
|
||||
|
|
|
|||
|
|
@ -672,7 +672,7 @@ pub fn poll(s: &mut Session) {
|
|||
match r {
|
||||
Ok(()) => s.mask_jobs[i].applied += n_new,
|
||||
Err(e) => {
|
||||
s.events.push(crate::Event::Toast { message: e.to_string(), error: true });
|
||||
s.error_toast("masks.track", e.to_string());
|
||||
if let Some(j) = s.jobs.iter().find(|j| j.id == s.mask_jobs[i].job) {
|
||||
j.progress.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
//! Engine side of the M12.6 panels (the scopes are in [`crate::scopes`]).
|
||||
//!
|
||||
//! | Id | What |
|
||||
//! |---|---|
|
||||
//! | `events.list` / `events.clear` | the Events panel: warnings and errors of commands, background jobs and the auto-save worker ([`EventLog`]) |
|
||||
//! | `metadata.get` / `metadata.set` | the Metadata panel: an item's clip and file properties; editable log fields (undoable) |
|
||||
//!
|
||||
//! The Progress panel uses `jobs.list` / `jobs.cancel`; the Timecode panel and the Reference
|
||||
//! Monitor are frontend state only.
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use filmcraft_project::{ItemId, ItemKind, Label, MediaRef, Project};
|
||||
use filmcraft_time::{Tick, TimeDisplay, format_time};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, item_p, str_p, u64_p};
|
||||
use crate::{Result, Session};
|
||||
|
||||
// ------------------------------------------------------------------------------------ events log
|
||||
|
||||
/// Severity of an Events panel entry.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Level {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Level::Info => "Info",
|
||||
Level::Warning => "Warning",
|
||||
Level::Error => "Error",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Level> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"info" => Some(Level::Info),
|
||||
"warning" | "warn" => Some(Level::Warning),
|
||||
"error" => Some(Level::Error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One Events panel entry. Repeats of the newest entry (same level, source and message) bump
|
||||
/// `count` instead of adding a row.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogEntry {
|
||||
pub id: u64,
|
||||
pub level: Level,
|
||||
/// Command id, `job`, `autosave` or `app`.
|
||||
pub source: String,
|
||||
pub message: String,
|
||||
pub count: u32,
|
||||
/// Wall-clock time of the last occurrence (ms since the Unix epoch).
|
||||
pub time_ms: u64,
|
||||
}
|
||||
|
||||
/// The session's event log (newest last, at most [`EventLog::LIMIT`] entries).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EventLog {
|
||||
pub entries: VecDeque<LogEntry>,
|
||||
next: u64,
|
||||
/// Jobs already reported: id → finished.
|
||||
jobs: BTreeMap<u64, bool>,
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
pub const LIMIT: usize = 1000;
|
||||
|
||||
pub fn push(&mut self, level: Level, source: &str, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
let t = now_ms();
|
||||
if let Some(last) = self.entries.back_mut()
|
||||
&& last.level == level
|
||||
&& last.source == source
|
||||
&& last.message == message
|
||||
{
|
||||
last.count += 1;
|
||||
last.time_ms = t;
|
||||
return;
|
||||
}
|
||||
self.next += 1;
|
||||
self.entries.push_back(LogEntry { id: self.next, level, source: source.to_string(), message, count: 1, time_ms: t });
|
||||
while self.entries.len() > Self::LIMIT {
|
||||
self.entries.pop_front();
|
||||
}
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
pub fn count(&self, level: Level) -> usize {
|
||||
self.entries.iter().filter(|e| e.level == level).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Report background jobs that started or finished since the last call.
|
||||
pub fn log_jobs(s: &mut Session) {
|
||||
let mut out = Vec::new();
|
||||
for j in &s.jobs {
|
||||
let finished = j.result.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let known = s.log.jobs.get(&j.id).copied();
|
||||
if known.is_none() {
|
||||
out.push((Level::Info, format!("Started: {}", j.label)));
|
||||
}
|
||||
if known != Some(true)
|
||||
&& let Some(r) = finished
|
||||
{
|
||||
let cancelled = j.progress.cancel.load(std::sync::atomic::Ordering::Relaxed);
|
||||
out.push(match r {
|
||||
Ok(_) => (Level::Info, format!("Finished: {}", j.label)),
|
||||
Err(_) if cancelled => (Level::Warning, format!("Cancelled: {}", j.label)),
|
||||
Err(e) => (Level::Error, format!("Failed: {}: {e}", j.label)),
|
||||
});
|
||||
s.log.jobs.insert(j.id, true);
|
||||
} else {
|
||||
s.log.jobs.entry(j.id).or_insert(false);
|
||||
}
|
||||
}
|
||||
for (l, m) in out {
|
||||
s.log.push(l, "job", m);
|
||||
}
|
||||
}
|
||||
|
||||
fn events_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
log_jobs(s);
|
||||
let min = str_p(p, "level").and_then(Level::from_name).unwrap_or(Level::Info);
|
||||
let since = u64_p(p, "since").unwrap_or(0);
|
||||
let entries: Vec<&LogEntry> = s.log.entries.iter().filter(|e| e.level >= min && e.id > since).collect();
|
||||
Ok(json!({
|
||||
"entries": entries,
|
||||
"counts": {"info": s.log.count(Level::Info), "warning": s.log.count(Level::Warning), "error": s.log.count(Level::Error)},
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ metadata
|
||||
|
||||
/// The log fields the Metadata panel edits (stored in `ProjectItem::metadata`), in panel order.
|
||||
pub const LOG_FIELDS: [&str; 8] = ["Description", "Scene", "Shot", "Log Note", "Comment", "Tape Name", "Client", "Camera Angle"];
|
||||
|
||||
/// One Metadata panel row.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Field {
|
||||
/// "Clip" or "File".
|
||||
pub section: &'static str,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// The item the Metadata panel describes: `item`, else the item of timeline clip `clip`, else the
|
||||
/// Project panel selection, else the timeline selection.
|
||||
pub fn metadata_target(s: &Session, p: &Value) -> Option<ItemId> {
|
||||
if let Some(i) = item_p(p, "item") {
|
||||
return Some(i);
|
||||
}
|
||||
let clip = u64_p(p, "clip").map(filmcraft_project::ClipId);
|
||||
let seq = s.active_sequence();
|
||||
let clip_item = |c| seq.and_then(|q| q.find_item(c)).map(|(_, it)| it.item);
|
||||
if let Some(c) = clip {
|
||||
return clip_item(c);
|
||||
}
|
||||
s.state.project_selection.first().copied().or_else(|| s.state.selection.first().and_then(|c| clip_item(*c)))
|
||||
}
|
||||
|
||||
fn tc(t: Tick, rate: filmcraft_time::FrameRate) -> String {
|
||||
format_time(t, rate, false, TimeDisplay::Timecode, 48000)
|
||||
}
|
||||
|
||||
/// Every Metadata panel row of an item (Clip section, then File section).
|
||||
pub fn metadata_fields(p: &Project, id: ItemId) -> Vec<Field> {
|
||||
let Some(it) = p.item(id) else { return Vec::new() };
|
||||
let media = match &it.kind {
|
||||
ItemKind::Media(m) => Some(m),
|
||||
ItemKind::Subclip { parent, .. } => p.item(*parent).and_then(|x| x.as_media()),
|
||||
_ => None,
|
||||
};
|
||||
let rate = match &it.kind {
|
||||
ItemKind::Subclip { parent, .. } => p.item(*parent).map(|x| x.frame_rate()).unwrap_or_default(),
|
||||
_ => it.frame_rate(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut add = |section, name: &str, value: String, editable| out.push(Field { section, name: name.to_string(), value, editable });
|
||||
add("Clip", "Name", it.name.clone(), true);
|
||||
add("Clip", "Label", it.label.name().to_string(), true);
|
||||
add("Clip", "Media Type", it.type_label().to_string(), false);
|
||||
let col = |c: &str| filmcraft_project::find::column_text(p, it, c);
|
||||
add("Clip", "Frame Rate", col("Frame Rate"), false);
|
||||
// media start / end / duration in the item's own timecode
|
||||
let start = media.and_then(|m| m.info.start_timecode).map(|f| rate.tick_of(f)).unwrap_or(Tick::ZERO);
|
||||
let (offset, dur) = match &it.kind {
|
||||
ItemKind::Subclip { range, .. } => (range.start, range.duration),
|
||||
_ => (Tick::ZERO, it.duration()),
|
||||
};
|
||||
let first = start + offset;
|
||||
add("Clip", "Media Start", tc(first, rate), false);
|
||||
let last = if dur > Tick::ZERO { first + dur - rate.frame_duration() } else { first };
|
||||
add("Clip", "Media End", tc(last, rate), false);
|
||||
add("Clip", "Media Duration", tc(dur, rate), false);
|
||||
let marks = match &it.kind {
|
||||
ItemKind::Media(m) => (m.mark_in, m.mark_out),
|
||||
ItemKind::Sequence(q) => (q.mark_in, q.mark_out),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let (Some(i), Some(o)) = marks {
|
||||
add("Clip", "In Point", tc(start + i, rate), false);
|
||||
add("Clip", "Out Point", tc(start + o - rate.frame_duration(), rate), false);
|
||||
}
|
||||
if let Some(v) = media.and_then(|m| m.info.video.as_ref()) {
|
||||
add("Clip", "Video Info", format!("{} x {} ({:.4})", v.width, v.height, v.par.0 as f64 / v.par.1.max(1) as f64), false);
|
||||
add("Clip", "Video Codec", v.codec.clone(), false);
|
||||
let detected = filmcraft_color::ColorSpace::from_info(&v.color);
|
||||
let cs = media.and_then(|m| m.interpret.color_space).unwrap_or(detected);
|
||||
add("Clip", "Color Space", cs.label().to_string(), false);
|
||||
}
|
||||
if let Some(a) = media.and_then(|m| m.info.audio.as_ref()) {
|
||||
add("Clip", "Audio Info", format!("{} Hz - {} ch", a.sample_rate, a.channels), false);
|
||||
add("Clip", "Audio Codec", a.codec.clone(), false);
|
||||
}
|
||||
if let ItemKind::Sequence(q) = &it.kind {
|
||||
add("Clip", "Video Info", format!("{} x {}", q.settings.width, q.settings.height), false);
|
||||
add("Clip", "Color Space", q.settings.color.working.label().to_string(), false);
|
||||
}
|
||||
for k in LOG_FIELDS {
|
||||
add("Clip", k, it.metadata.get(k).cloned().unwrap_or_default(), true);
|
||||
}
|
||||
// other stored fields (from imports, Edit Offline, agents…)
|
||||
for (k, v) in &it.metadata {
|
||||
if !LOG_FIELDS.contains(&k.as_str()) {
|
||||
add("Clip", k, v.clone(), true);
|
||||
}
|
||||
}
|
||||
if let Some(m) = media {
|
||||
let path = match &m.media {
|
||||
MediaRef::File { path } => path.clone(),
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
add("File", "File Path", path, false);
|
||||
add("File", "Format", m.info.container.clone(), false);
|
||||
if let Some(sz) = m.info.file_size {
|
||||
add("File", "File Size", format_bytes(sz), false);
|
||||
}
|
||||
if let Some(v) = &m.info.video
|
||||
&& let Some(b) = v.bitrate
|
||||
{
|
||||
add("File", "Video Bitrate", format!("{:.2} Mbit/s", b as f64 / 1e6), false);
|
||||
}
|
||||
add("File", "Status", if m.offline { "Offline".into() } else { "Online".into() }, false);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn format_bytes(b: u64) -> String {
|
||||
match b {
|
||||
b if b >= 1 << 30 => format!("{:.2} GB", b as f64 / (1u64 << 30) as f64),
|
||||
b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
|
||||
b if b >= 1 << 10 => format!("{:.1} KB", b as f64 / 1024.0),
|
||||
b => format!("{b} bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = metadata_target(s, p).ok_or_else(|| bad("metadata.get", "no item: pass `item` or `clip`, or select one"))?;
|
||||
let it = s.project.item(id).ok_or_else(|| bad("metadata.get", "no such item"))?;
|
||||
let fields = metadata_fields(&s.project, id);
|
||||
Ok(json!({"item": id.0, "name": it.name, "fields": fields, "metadata": it.metadata}))
|
||||
}
|
||||
|
||||
/// `metadata.set {item?, field, value}` or `{item?, fields: {name: value}}`: one undo step.
|
||||
/// Name renames the item, Label sets its label, every other name is a log field (an empty value
|
||||
/// removes it).
|
||||
fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = metadata_target(s, p).ok_or_else(|| bad("metadata.set", "no item: pass `item` or `clip`, or select one"))?;
|
||||
let mut changes: Vec<(String, String)> = Vec::new();
|
||||
if let Some(f) = str_p(p, "field") {
|
||||
let v = p.get("value").map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
|
||||
changes.push((f.to_string(), v));
|
||||
}
|
||||
if let Some(m) = p.get("fields").and_then(Value::as_object) {
|
||||
for (k, v) in m {
|
||||
changes.push((k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return Err(bad("metadata.set", "need `field` + `value` or `fields`"));
|
||||
}
|
||||
for (k, v) in &changes {
|
||||
if k.trim().is_empty() {
|
||||
return Err(bad("metadata.set", "empty field name"));
|
||||
}
|
||||
if k.eq_ignore_ascii_case("label") && Label::from_name(v).is_none() {
|
||||
return Err(bad("metadata.set", format!("unknown label `{v}`")));
|
||||
}
|
||||
if k.eq_ignore_ascii_case("name") && v.trim().is_empty() {
|
||||
return Err(bad("metadata.set", "the name cannot be empty"));
|
||||
}
|
||||
if metadata_fields(&s.project, id).iter().any(|f| f.name.eq_ignore_ascii_case(k) && !f.editable) {
|
||||
return Err(bad("metadata.set", format!("`{k}` is read-only")));
|
||||
}
|
||||
}
|
||||
let unchanged = {
|
||||
let it = s.project.item(id).ok_or_else(|| bad("metadata.set", "no such item"))?;
|
||||
changes.iter().all(|(k, v)| match k.to_ascii_lowercase().as_str() {
|
||||
"name" => it.name == *v,
|
||||
"label" => Label::from_name(v) == Some(it.label),
|
||||
_ => it.metadata.get(k).map(String::as_str).unwrap_or("") == v.as_str(),
|
||||
})
|
||||
};
|
||||
if !unchanged {
|
||||
s.edit("Edit Metadata", |pr, _| {
|
||||
let it = pr.item_mut(id).ok_or_else(|| bad("metadata.set", "no such item"))?;
|
||||
for (k, v) in &changes {
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"name" => it.name = v.trim().to_string(),
|
||||
"label" => it.label = Label::from_name(v).unwrap_or(it.label),
|
||||
_ if v.is_empty() => {
|
||||
it.metadata.remove(k);
|
||||
}
|
||||
_ => {
|
||||
it.metadata.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let it = s.project.item(id).ok_or_else(|| bad("metadata.set", "no such item"))?;
|
||||
Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ registry
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
let q = |id, label, params, run| CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false };
|
||||
vec![
|
||||
q("events.list", "List Events", r#"{"level":"info|warning|error"?,"since":id?}"#, events_list),
|
||||
CommandSpec {
|
||||
id: "events.clear",
|
||||
label: "Clear All Events",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{}",
|
||||
enabled: always,
|
||||
run: |s, _| {
|
||||
let n = s.log.entries.len();
|
||||
s.log.clear();
|
||||
Ok(json!({"cleared": n}))
|
||||
},
|
||||
journal: true,
|
||||
},
|
||||
q("metadata.get", "Get Metadata", r#"{"item":id?,"clip":id?}"#, metadata_get),
|
||||
CommandSpec {
|
||||
id: "metadata.set",
|
||||
label: "Edit Metadata",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"item":id?,"clip":id?,"field":"Name|Label|Description|Scene|Shot|Log Note|Comment|Tape Name|Client|Camera Angle|…","value":str} | {"item":id?,"fields":{name:str}}"#,
|
||||
enabled: always,
|
||||
run: metadata_set,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
//! Tests of the panel commands (`panels`): Events log, Metadata edits with undo.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_project::{ItemId, Label};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::panels::Level;
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn first_media(s: &Session) -> ItemId {
|
||||
s.project.items.values().filter(|i| i.as_media().is_some()).map(|i| i.id).min().expect("a media item")
|
||||
}
|
||||
|
||||
fn field<'a>(v: &'a Value, name: &str) -> &'a Value {
|
||||
v["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap_or_else(|| panic!("no field {name}: {v}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_edits_are_undoable_and_saved_in_the_project() {
|
||||
let mut s = demo();
|
||||
let id = first_media(&s);
|
||||
let name = s.project.item(id).unwrap().name.clone();
|
||||
let g = s.execute("metadata.get", json!({"item": id.0})).unwrap();
|
||||
assert_eq!(field(&g, "Name")["value"], json!(name));
|
||||
assert_eq!(field(&g, "Media Start")["editable"], json!(false));
|
||||
assert_eq!(field(&g, "Description")["value"], json!(""));
|
||||
for f in ["Frame Rate", "Media End", "Media Duration", "Video Info", "Video Codec", "Color Space", "File Path", "Scene", "Shot", "Log Note", "Tape Name"] {
|
||||
field(&g, f);
|
||||
}
|
||||
|
||||
let undo0 = s.history.undo.len();
|
||||
s.execute("metadata.set", json!({"item": id.0, "field": "Description", "value": "Wide establishing shot"})).unwrap();
|
||||
assert_eq!(s.project.item(id).unwrap().metadata["Description"], "Wide establishing shot");
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1);
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Edit Metadata");
|
||||
// several fields in one step, including the name and the label
|
||||
s.execute("metadata.set", json!({"item": id.0, "fields": {"Scene": "12", "Shot": "4B", "Name": "Hero Shot", "Label": "Mango"}})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo0 + 2);
|
||||
let it = s.project.item(id).unwrap();
|
||||
assert_eq!((it.name.as_str(), it.label, it.metadata["Scene"].as_str(), it.metadata["Shot"].as_str()), ("Hero Shot", Label::Mango, "12", "4B"));
|
||||
// the same value again is not a new undo step
|
||||
s.execute("metadata.set", json!({"item": id.0, "field": "Scene", "value": "12"})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo0 + 2);
|
||||
// undo / redo
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let it = s.project.item(id).unwrap();
|
||||
assert_eq!((it.name.as_str(), it.metadata.get("Scene")), (name.as_str(), None));
|
||||
assert_eq!(it.metadata["Description"], "Wide establishing shot");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.item(id).unwrap().name, "Hero Shot");
|
||||
// an empty value removes the field
|
||||
s.execute("metadata.set", json!({"item": id.0, "field": "Shot", "value": ""})).unwrap();
|
||||
assert!(!s.project.item(id).unwrap().metadata.contains_key("Shot"));
|
||||
// errors: read-only fields, bad labels, empty names; nothing changes
|
||||
let n = s.history.undo.len();
|
||||
for bad in [
|
||||
json!({"field": "Media Start", "value": "01:00:00:00"}),
|
||||
json!({"field": "Label", "value": "Plaid"}),
|
||||
json!({"field": "Name", "value": " "}),
|
||||
json!({}),
|
||||
] {
|
||||
let mut p = bad.clone();
|
||||
p["item"] = json!(id.0);
|
||||
assert!(s.execute("metadata.set", p).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), n);
|
||||
// saved in the project file and read back
|
||||
let bytes = filmcraft_format::encode(&s.project, false);
|
||||
let back = filmcraft_format::decode(&bytes).unwrap().project;
|
||||
assert_eq!(back.item(id).unwrap().metadata["Scene"], "12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_targets_follow_the_selection() {
|
||||
let mut s = demo();
|
||||
let seq = s.active_sequence().unwrap().clone();
|
||||
let clip = &seq.video_tracks[0].items[0];
|
||||
// nothing selected in the Project panel: the timeline selection
|
||||
s.state.project_selection.clear();
|
||||
s.state.selection = vec![clip.id];
|
||||
let g = s.execute("metadata.get", json!({})).unwrap();
|
||||
assert_eq!(g["item"], json!(clip.item.0));
|
||||
// a timeline clip by id
|
||||
let g = s.execute("metadata.get", json!({"clip": clip.id.0})).unwrap();
|
||||
assert_eq!(g["item"], json!(clip.item.0));
|
||||
s.execute("metadata.set", json!({"clip": clip.id.0, "field": "Log Note", "value": "soft focus"})).unwrap();
|
||||
assert_eq!(s.project.item(clip.item).unwrap().metadata["Log Note"], "soft focus");
|
||||
// the Project panel selection wins
|
||||
let other = first_media(&s);
|
||||
s.state.project_selection = vec![other];
|
||||
assert_eq!(s.execute("metadata.get", json!({})).unwrap()["item"], json!(other.0));
|
||||
s.state.project_selection.clear();
|
||||
s.state.selection.clear();
|
||||
assert!(s.execute("metadata.get", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_log_failed_commands_jobs_and_messages() {
|
||||
let mut s = demo();
|
||||
s.execute("events.clear", json!({})).unwrap();
|
||||
assert!(s.execute("clip.rename", json!({})).is_err());
|
||||
assert!(s.execute("clip.rename", json!({})).is_err());
|
||||
let l = s.execute("events.list", json!({})).unwrap();
|
||||
let e = &l["entries"].as_array().unwrap()[0];
|
||||
assert_eq!((e["level"].as_str(), e["source"].as_str(), e["count"].as_u64()), (Some("error"), Some("clip.rename"), Some(2)));
|
||||
assert!(e["message"].as_str().unwrap().contains("name"), "{e}");
|
||||
// a disabled command is a warning
|
||||
s.history.undo.clear();
|
||||
assert!(s.execute("edit.undo", json!({})).is_err());
|
||||
assert_eq!(s.log.entries.back().map(|e| (e.level, e.source.as_str())), Some((Level::Warning, "edit.undo")));
|
||||
// messages are info
|
||||
s.toast("Saved");
|
||||
s.error_toast("autosave", "disk full");
|
||||
let l = s.execute("events.list", json!({"level": "error"})).unwrap();
|
||||
assert!(l["entries"].as_array().unwrap().iter().all(|e| e["level"] == "error"));
|
||||
assert_eq!(l["counts"]["error"], json!(2));
|
||||
assert_eq!(l["counts"]["info"], json!(1));
|
||||
let last = l["entries"].as_array().unwrap().last().unwrap()["id"].as_u64().unwrap();
|
||||
assert!(s.execute("events.list", json!({"since": last})).unwrap()["entries"].as_array().unwrap().is_empty());
|
||||
// nested commands report only the outer failure; queries and successes add nothing
|
||||
let before = s.log.entries.len();
|
||||
s.execute("project.inspect", json!({})).unwrap();
|
||||
assert_eq!(s.log.entries.len(), before);
|
||||
s.execute("events.clear", json!({})).unwrap();
|
||||
assert!(s.log.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_report_background_jobs() {
|
||||
let mut s = demo();
|
||||
s.execute("events.clear", json!({})).unwrap();
|
||||
let job = |id, label: &str| crate::Job { id, label: label.into(), progress: Arc::new(Default::default()), result: Arc::new(std::sync::Mutex::new(None)) };
|
||||
let (a, b, c) = (job(901, "Export a.mp4"), job(902, "Create Proxies"), job(903, "Scene Edit Detection"));
|
||||
s.jobs.extend([a.clone(), b.clone(), c.clone()]);
|
||||
s.poll_persistence();
|
||||
assert_eq!(s.log.entries.iter().filter(|e| e.message.starts_with("Started:")).count(), 3);
|
||||
*a.result.lock().unwrap() = Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0 }));
|
||||
*b.result.lock().unwrap() = Some(Err("disk full".into()));
|
||||
c.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
*c.result.lock().unwrap() = Some(Err("cancelled".into()));
|
||||
let l = s.execute("events.list", json!({})).unwrap();
|
||||
let msgs: Vec<(String, String)> =
|
||||
l["entries"].as_array().unwrap().iter().map(|e| (e["level"].as_str().unwrap().into(), e["message"].as_str().unwrap().into())).collect();
|
||||
assert!(msgs.contains(&("info".into(), "Finished: Export a.mp4".into())), "{msgs:?}");
|
||||
assert!(msgs.contains(&("error".into(), "Failed: Create Proxies: disk full".into())), "{msgs:?}");
|
||||
assert!(msgs.contains(&("warning".into(), "Cancelled: Scene Edit Detection".into())), "{msgs:?}");
|
||||
// reported once
|
||||
s.poll_persistence();
|
||||
assert_eq!(s.log.entries.len(), 6);
|
||||
}
|
||||
|
|
@ -274,7 +274,7 @@ pub fn poll(s: &mut Session) {
|
|||
OnDone::Nothing => Ok(()),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
s.events.push(crate::Event::Toast { message: e.to_string(), error: true });
|
||||
s.error_toast("job", e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ pub fn poll(s: &mut Session) {
|
|||
let pj = s.scene_jobs.remove(i);
|
||||
let Some(results) = lock(&pj.results).take().filter(|_| !cancelled) else { continue };
|
||||
if let Err(e) = apply_results(s, pj.seq, pj.apply, results) {
|
||||
s.events.push(crate::Event::Toast { message: format!("Scene Edit Detection: {e}"), error: true });
|
||||
s.error_toast("clip.sceneEditDetection", format!("Scene Edit Detection: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
//! `scopes.read`: Lumetri Scopes as numbers (M8.9), so agents can check colour without looking.
|
||||
//! It renders the active sequence at the playhead (or `time`), computes the scopes with
|
||||
//! `filmcraft-scopes` and returns levels in percent (per channel and per column range), histogram
|
||||
//! bins, and the densest vectorscope spots with their angle and saturation.
|
||||
|
||||
use filmcraft_scopes::{self as scopes, ColorSpace, ParadeType, Params, ScopeKind, Signal, WaveformType, summary};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, bad, bool_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The scope signal of the active sequence at `t` (`scale` = render scale): R'G'B' code values
|
||||
/// of the monitor picture, or PQ code values of the working-space render for HDR scopes.
|
||||
pub fn scope_signal(s: &Session, t: Tick, scale: f32, hdr: bool) -> Option<Signal> {
|
||||
let seq = s.state.active_sequence?;
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
if hdr {
|
||||
let opts = filmcraft_render::RenderOptions { scale, working_output: true, ..Default::default() };
|
||||
let img = filmcraft_render::render_sequence(&s.project, seq, t, opts, &provider);
|
||||
return Some(Signal::from_rgba_f32_with(img.w, img.h, &img.px, scopes::MAX_W, scopes::MAX_H, scopes::linear_to_pq));
|
||||
}
|
||||
let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() };
|
||||
let img = filmcraft_render::render_sequence(&s.project, seq, t, opts, &provider);
|
||||
Some(Signal::from_rgba8(img.w, img.h, &img.over_black_rgba8()))
|
||||
}
|
||||
|
||||
fn levels_json(w: &scopes::Waveform, columns: usize) -> Value {
|
||||
let mut o = serde_json::Map::new();
|
||||
for g in &w.traces {
|
||||
o.insert(g.name.to_string(), json!(summary::trace_columns(w, g, columns)));
|
||||
}
|
||||
json!({"lo": (w.lo as f64 * 100.0).round(), "hi": (w.hi as f64 * 100.0).round(), "columns": columns, "traces": o})
|
||||
}
|
||||
|
||||
fn vectorscope_json(v: &scopes::Vectorscope, peaks: usize) -> Value {
|
||||
json!({
|
||||
"samples": v.samples,
|
||||
"mean": [(v.mean[0] as f64 * 10_000.0).round() / 100.0, (v.mean[1] as f64 * 10_000.0).round() / 100.0],
|
||||
"meanAngleDeg": (scopes::angle_deg(v.mean[0], v.mean[1]) as f64 * 100.0).round() / 100.0,
|
||||
"peaks": summary::peaks(v, peaks, 0.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn scopes_read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq = s.active_sequence().ok_or(crate::EngineError::NoSequence)?;
|
||||
let hdr_seq = seq.settings.color.working.is_hdr();
|
||||
let rate = seq.settings.frame_rate;
|
||||
let space = match str_p(p, "colorSpace") {
|
||||
Some(c) => ColorSpace::from_name(c).ok_or_else(|| bad("scopes.read", format!("unknown colorSpace `{c}` (auto, 601, 709, 2100)")))?,
|
||||
None => ColorSpace::Auto,
|
||||
}
|
||||
.resolve(hdr_seq);
|
||||
let hdr = space == ColorSpace::Rec2100 && hdr_seq;
|
||||
let t = rate.snap(time_p(s, p, "").unwrap_or(s.playhead()));
|
||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(0.5).clamp(1.0 / 32.0, 1.0) as f32;
|
||||
let params = Params { matrix: space.matrix(), clamp: bool_p(p, "clamp").unwrap_or(true), ..Params::default() };
|
||||
let wt = match str_p(p, "waveformType") {
|
||||
Some(n) => WaveformType::from_name(n).ok_or_else(|| bad("scopes.read", format!("unknown waveformType `{n}`")))?,
|
||||
None => WaveformType::Rgb,
|
||||
};
|
||||
let pt = match str_p(p, "paradeType") {
|
||||
Some(n) => ParadeType::from_name(n).ok_or_else(|| bad("scopes.read", format!("unknown paradeType `{n}`")))?,
|
||||
None => ParadeType::Rgb,
|
||||
};
|
||||
let kinds: Vec<ScopeKind> = match p.get("scopes").and_then(Value::as_array) {
|
||||
Some(a) => a
|
||||
.iter()
|
||||
.map(|v| v.as_str().and_then(ScopeKind::from_name).ok_or_else(|| bad("scopes.read", format!("unknown scope {v}"))))
|
||||
.collect::<Result<_>>()?,
|
||||
None => ScopeKind::ALL.to_vec(),
|
||||
};
|
||||
let columns = u64_p(p, "columns").unwrap_or(8).clamp(1, 512) as usize;
|
||||
let npeaks = u64_p(p, "peaks").unwrap_or(8).clamp(1, 64) as usize;
|
||||
let bins = bool_p(p, "bins").unwrap_or(true);
|
||||
let sig = scope_signal(s, t, scale, hdr).ok_or(crate::EngineError::NoSequence)?;
|
||||
let mut out = json!({
|
||||
"time": t.0,
|
||||
"frame": rate.frame_at(t),
|
||||
"samples": [sig.w, sig.h],
|
||||
"colorSpace": space.label(),
|
||||
"matrix": format!("{:?}", space.matrix()),
|
||||
"clamp": params.clamp,
|
||||
"unit": if hdr { "pq%" } else { "%" },
|
||||
"stats": summary::channel_stats(&sig, params.matrix),
|
||||
});
|
||||
for k in kinds {
|
||||
let v = match scopes::compute(k, &sig, ¶ms, wt, pt) {
|
||||
scopes::Computed::Waveform(w) => {
|
||||
let mut v = levels_json(&w, columns);
|
||||
v["type"] = json!(wt);
|
||||
v
|
||||
}
|
||||
scopes::Computed::Parade(w) => {
|
||||
let mut v = levels_json(&w, columns);
|
||||
v["type"] = json!(pt);
|
||||
v
|
||||
}
|
||||
scopes::Computed::Histogram(h) => {
|
||||
let peak = |b: &[u32]| b.iter().enumerate().max_by_key(|(i, c)| (**c, usize::MAX - i)).map(|(i, _)| i).unwrap_or(0);
|
||||
let mut v = json!({
|
||||
"samples": h.samples,
|
||||
"below": h.below, "above": h.above,
|
||||
"peakBin": {"r": peak(&h.r), "g": peak(&h.g), "b": peak(&h.b), "y": peak(&h.y)},
|
||||
});
|
||||
if bins {
|
||||
v["r"] = json!(h.r);
|
||||
v["g"] = json!(h.g);
|
||||
v["b"] = json!(h.b);
|
||||
v["y"] = json!(h.y);
|
||||
}
|
||||
v
|
||||
}
|
||||
scopes::Computed::Vectorscope(vs) => vectorscope_json(&vs, npeaks),
|
||||
};
|
||||
out[k.name()] = v;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: "scopes.read",
|
||||
label: "Read Lumetri Scopes",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"scopes":["waveform","parade","histogram","vectorscopeYuv","vectorscopeHls"]?,"waveformType":"rgb|luma|yc|ycNoChroma"?,"paradeType":"rgb|yuv|rgbWhite"?,"colorSpace":"auto|601|709|2100"?,"clamp":bool=true,"columns":n=8,"peaks":n=8,"bins":bool=true,"scale":0.5,"time":ticks?|"frame"|"seconds"|"timecode"}"#,
|
||||
enabled: has_seq,
|
||||
run: scopes_read,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
//! `scopes.read` on generated sequences with known colours (a colour matte, colour bars).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn matte_sequence(hex: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("file.newColorMatte", json!({"color": hex, "seconds": 2})).unwrap()["item"].as_u64().unwrap();
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [id]})).unwrap();
|
||||
assert!(s.active_sequence().is_some());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_read_reports_exact_levels_of_a_colour_matte() {
|
||||
let mut s = matte_sequence("#c86432");
|
||||
let r = s.execute("scopes.read", json!({"columns": 4})).unwrap();
|
||||
assert_eq!(r["colorSpace"], "Rec. 709");
|
||||
let h = &r["histogram"];
|
||||
assert_eq!(h["peakBin"], json!({"r": 200, "g": 100, "b": 50, "y": h["peakBin"]["y"].clone()}));
|
||||
let n = h["samples"].as_u64().unwrap();
|
||||
assert_eq!(h["r"][200].as_u64(), Some(n), "every sample in bin 200");
|
||||
// levels in percent: 200 / 255 = 78.43 %
|
||||
assert_eq!(r["stats"]["r"]["mean"], json!(78.43));
|
||||
assert_eq!(r["stats"]["b"]["max"], json!(19.61));
|
||||
for col in r["waveform"]["traces"]["R"].as_array().unwrap() {
|
||||
assert!((col["mean"].as_f64().unwrap() - 78.43).abs() < 0.01, "{col}");
|
||||
}
|
||||
assert_eq!(r["parade"]["traces"]["G"].as_array().unwrap().len(), 4);
|
||||
// the vectorscope: one spot at the matte's chroma
|
||||
let [_, cb, cr] = filmcraft_color::rgb_to_ycbcr(200.0 / 255.0, 100.0 / 255.0, 50.0 / 255.0, filmcraft_color::Matrix::Bt709);
|
||||
let pk = &r["vectorscopeYuv"]["peaks"][0];
|
||||
assert_eq!(pk["share"], json!(1.0));
|
||||
assert!((pk["cb"].as_f64().unwrap() - cb as f64 * 100.0).abs() < 0.3 && (pk["cr"].as_f64().unwrap() - cr as f64 * 100.0).abs() < 0.3, "{pk}");
|
||||
// Rec. 601 moves luma; the matrix is reported
|
||||
let r601 = s.execute("scopes.read", json!({"colorSpace": "601", "scopes": ["histogram"]})).unwrap();
|
||||
assert_eq!(r601["matrix"], "Bt601");
|
||||
assert_ne!(r601["histogram"]["peakBin"]["y"], h["peakBin"]["y"]);
|
||||
assert!(r601.get("waveform").is_none());
|
||||
// options and errors
|
||||
let r = s.execute("scopes.read", json!({"scopes": ["waveform"], "waveformType": "yc", "bins": false})).unwrap();
|
||||
assert_eq!(r["waveform"]["type"], "yc");
|
||||
assert!(r["waveform"]["traces"]["C"].is_array());
|
||||
assert!(s.execute("scopes.read", json!({"scopes": ["oscilloscope"]})).is_err());
|
||||
assert!(s.execute("scopes.read", json!({"colorSpace": "P3"})).is_err());
|
||||
assert!(Session::default().execute("scopes.read", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_read_finds_the_colour_bar_targets() {
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("file.newBarsAndTone", json!({"seconds": 2})).unwrap()["item"].as_u64().unwrap();
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [id]})).unwrap();
|
||||
let r = s.execute("scopes.read", json!({"scopes": ["vectorscopeYuv"], "scale": 0.25})).unwrap();
|
||||
let peaks = r["vectorscopeYuv"]["peaks"].as_array().unwrap();
|
||||
let cells: Vec<(u64, u64)> = peaks.iter().take(7).map(|p| (p["x"].as_u64().unwrap(), p["y"].as_u64().unwrap())).collect();
|
||||
let p = filmcraft_scopes::Params::default();
|
||||
for t in filmcraft_scopes::targets(filmcraft_color::Matrix::Bt709, 191.0 / 255.0) {
|
||||
let (x, y) = filmcraft_scopes::cell_of(t.cb, t.cr, p.vector_size, filmcraft_scopes::VECTOR_EXTENT).unwrap();
|
||||
assert!(cells.contains(&(x as u64, y as u64)), "{} target ({x}, {y}) not among {cells:?}", t.name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "filmcraft-scopes"
|
||||
description = "Video scopes maths: waveform, parade, histogram and vectorscopes from a frame's R'G'B' values"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
filmcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# filmcraft-scopes
|
||||
|
||||
Video scope maths for the Lumetri Scopes panel (layer L3; depends only on `filmcraft-color` and
|
||||
`serde`; no UI, builds for wasm32). The UI (`crates/ui-egui/src/panels/scopes.rs`) draws what this
|
||||
crate computes; `scopes.read` (`crates/engine/src/scopes.rs`) returns it as numbers for agents.
|
||||
|
||||
| Item | What |
|
||||
|---|---|
|
||||
| `Signal` | a frame's R'G'B' code values, decimated by nearest-sample picking to at most 480 × 270 (`from_rgba8`, `from_rgba_f32_with` + `linear_to_pq` for HDR, `from_fn`) |
|
||||
| `waveform` | RGB / Luma / YC / YC no Chroma: one count grid per trace (columns = signal width, `rows` levels) |
|
||||
| `parade` | RGB / YUV / RGB-White traces (drawn side by side) |
|
||||
| `histogram` | 256 bins of R', G', B', Y'; samples below 0 / above 1 counted separately |
|
||||
| `vectorscope_yuv` | Cb right, Cr up, ±0.6; `targets(matrix, amplitude)` gives the six colour-bar targets, `SKIN_TONE_DEG` = 123° |
|
||||
| `vectorscope_hls` | hue as the angle (red at the matrix's YUV red angle, then yellow, green, cyan, blue, magenta counter-clockwise), HLS saturation as the radius |
|
||||
| `summary` | levels (min / max / mean in percent) per channel and per column range, densest vectorscope cells with angle and magnitude, coarse vectorscope |
|
||||
| `paint` | count grids → premultiplied RGBA8 (log intensity, additive traces, Brightness gain; vectorscope points spread over 3 × 3) |
|
||||
|
||||
Settings types (all serde, camelCase): `ScopeKind`, `WaveformType`, `ParadeType`, `ColorSpace`
|
||||
(Automatic / Rec. 601 / Rec. 709 / Rec. 2100 → BT.601 / BT.709 / BT.2020 NCL matrices),
|
||||
`Scale` (8 Bit / Float / HDR), `Brightness`, `Targets` (75 % / 100 %).
|
||||
|
||||
## Definitions
|
||||
|
||||
- Y'CbCr from R'G'B' with the matrix's Kr/Kb (ITU-R BT.601-7, BT.709-6, BT.2020-2 NCL), Cb and Cr in
|
||||
−0.5…0.5; YC chroma amplitude = √(Cb² + Cr²), plotted at Y' ± C.
|
||||
- Waveform rows span 0…1 with Clamp Signal (values clamped first) or −0.1…1.1 without (values
|
||||
outside are dropped). With 256 rows an 8-bit code value `c` lands on row `c`.
|
||||
- HDR: linear working values (1.0 = 203 cd/m², BT.2408) → SMPTE ST 2084 PQ code values; the axis is
|
||||
0…10 000 cd/m². SDR frames on the HDR scale use BT.1886 (γ 2.4, 100 cd/m² white).
|
||||
|
||||
## Tests
|
||||
|
||||
`src/tests.rs` uses generated frames with known values: a flat colour lands in exact histogram bins;
|
||||
a 256-step ramp fills every bin and puts column `x` on row `x`; 75 % colour bars put each parade
|
||||
column on its code value and each bar on its vectorscope target cell (BT.601, BT.709, BT.2020), with
|
||||
the centre holding white + black; BT.709 red sits at (103, 21) on the 255² grid and 102.91°
|
||||
(BT.601: 108.65°); HLS hues land at red + 60° steps on the outer ring; YC chroma brackets luma;
|
||||
Clamp Signal; decimation; NaN and empty frames; summaries; painting.
|
||||
|
||||
## Speed
|
||||
|
||||
`cargo test --release -p filmcraft-scopes perf -- --ignored --nocapture` (1920×1080 RGBA8 noise,
|
||||
Apple M4 Pro, one thread, machine shared with other builds):
|
||||
|
||||
| Step | ms |
|
||||
|---|---|
|
||||
| decimate 1080p → 480 × 270 | 0.30 |
|
||||
| waveform RGB / YC | 0.75 / 0.88 |
|
||||
| parade RGB-White | 0.67 |
|
||||
| histogram | 0.35 |
|
||||
| vectorscope YUV / HLS | 0.67 / 0.81 |
|
||||
|
||||
Every scope is well under the 3 ms budget; the UI recomputes only when the frame or a setting changes.
|
||||
|
|
@ -0,0 +1,723 @@
|
|||
//! Video scopes maths (layer L3, no UI): the waveform, parade, histogram and the YUV and HLS
|
||||
//! vectorscopes of a frame, as count grids ("how many samples land on this cell"), plus numeric
|
||||
//! summaries for agents ([`summary`]) and the density-to-pixels mapping the UI draws ([`paint`]).
|
||||
//!
|
||||
//! The input is a [`Signal`]: the frame's R'G'B' code values (display-encoded, 0..1 nominal;
|
||||
//! values outside it are super-whites / sub-blacks of float or HDR frames), decimated to at most
|
||||
//! [`MAX_W`] × [`MAX_H`] samples by nearest-sample picking (no averaging, so flat colours and
|
||||
//! test patterns land on exact cells). Y'CbCr comes from the scope's colour space matrix
|
||||
//! (BT.601, BT.709 or BT.2020 NCL; `filmcraft_color::rgb_to_ycbcr`).
|
||||
//!
|
||||
//! | Scope | Grid | Columns × rows |
|
||||
//! |---|---|---|
|
||||
//! | Waveform RGB / Luma / YC / YC no Chroma | per trace: R, G, B / Y / Y and C (Y ± \|CbCr\|) / Y | signal width × `rows` |
|
||||
//! | Parade RGB / YUV / RGB-White | per trace: R, G, B / Y, Cb + ½, Cr + ½ / R, G, B, Y | signal width × `rows` (drawn side by side) |
|
||||
//! | Histogram | 256 bins per channel R, G, B, Y | — |
|
||||
//! | Vectorscope YUV | Cb horizontal, Cr up, ±[`VECTOR_EXTENT`] | `vector_size`² |
|
||||
//! | Vectorscope HLS | hue as the angle (red at the YUV red angle), HLS saturation as the radius (1 = 0.5) | `vector_size`² |
|
||||
//!
|
||||
//! Waveform rows cover `[lo, hi]` = `[0, 1]` with Clamp Signal on (values clamped first) and
|
||||
//! `[-0.1, 1.1]` without (values outside are dropped). With 256 rows over `[0, 1]`, an 8-bit code
|
||||
//! value `c` lands exactly on row `c`.
|
||||
|
||||
pub mod paint;
|
||||
pub mod summary;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use filmcraft_color::Matrix;
|
||||
use filmcraft_color::rgb_to_ycbcr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Largest decimated signal (a 1080p frame is sampled every 4th pixel both ways).
|
||||
pub const MAX_W: usize = 480;
|
||||
pub const MAX_H: usize = 270;
|
||||
|
||||
/// Half-width of the vectorscope plane in Cb/Cr units (the 100 % targets of every matrix fit).
|
||||
pub const VECTOR_EXTENT: f32 = 0.6;
|
||||
|
||||
/// The skin-tone line (the "I" axis of NTSC): 123° counter-clockwise from +Cb.
|
||||
pub const SKIN_TONE_DEG: f32 = 123.0;
|
||||
|
||||
// ------------------------------------------------------------------------------------ settings
|
||||
|
||||
/// A scope of the Lumetri Scopes panel.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ScopeKind {
|
||||
VectorscopeYuv,
|
||||
VectorscopeHls,
|
||||
Histogram,
|
||||
Parade,
|
||||
#[default]
|
||||
Waveform,
|
||||
}
|
||||
|
||||
impl ScopeKind {
|
||||
pub const ALL: [ScopeKind; 5] = [ScopeKind::VectorscopeYuv, ScopeKind::VectorscopeHls, ScopeKind::Histogram, ScopeKind::Parade, ScopeKind::Waveform];
|
||||
/// Stable id (`"vectorscopeYuv"`…), as in JSON.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ScopeKind::VectorscopeYuv => "vectorscopeYuv",
|
||||
ScopeKind::VectorscopeHls => "vectorscopeHls",
|
||||
ScopeKind::Histogram => "histogram",
|
||||
ScopeKind::Parade => "parade",
|
||||
ScopeKind::Waveform => "waveform",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ScopeKind::VectorscopeYuv => "Vectorscope YUV",
|
||||
ScopeKind::VectorscopeHls => "Vectorscope HLS",
|
||||
ScopeKind::Histogram => "Histogram",
|
||||
ScopeKind::Parade => "Parade",
|
||||
ScopeKind::Waveform => "Waveform",
|
||||
}
|
||||
}
|
||||
/// By id or label, ignoring case, spaces and dashes (`"vectorscope"` = the YUV one).
|
||||
pub fn from_name(s: &str) -> Option<ScopeKind> {
|
||||
let n = norm(s);
|
||||
if n == "vectorscope" || n == "yuv" {
|
||||
return Some(ScopeKind::VectorscopeYuv);
|
||||
}
|
||||
if n == "hls" {
|
||||
return Some(ScopeKind::VectorscopeHls);
|
||||
}
|
||||
Self::ALL.into_iter().find(|k| norm(k.name()) == n || norm(k.label()) == n)
|
||||
}
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Waveform Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WaveformType {
|
||||
#[default]
|
||||
Rgb,
|
||||
Luma,
|
||||
Yc,
|
||||
YcNoChroma,
|
||||
}
|
||||
|
||||
impl WaveformType {
|
||||
pub const ALL: [WaveformType; 4] = [WaveformType::Rgb, WaveformType::Luma, WaveformType::Yc, WaveformType::YcNoChroma];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
WaveformType::Rgb => "RGB",
|
||||
WaveformType::Luma => "Luma",
|
||||
WaveformType::Yc => "YC",
|
||||
WaveformType::YcNoChroma => "YC no Chroma",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
let n = norm(s);
|
||||
Self::ALL.into_iter().find(|k| norm(k.label()) == n || norm(&format!("{k:?}")) == n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parade Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParadeType {
|
||||
#[default]
|
||||
Rgb,
|
||||
Yuv,
|
||||
RgbWhite,
|
||||
}
|
||||
|
||||
impl ParadeType {
|
||||
pub const ALL: [ParadeType; 3] = [ParadeType::Rgb, ParadeType::Yuv, ParadeType::RgbWhite];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ParadeType::Rgb => "RGB",
|
||||
ParadeType::Yuv => "YUV",
|
||||
ParadeType::RgbWhite => "RGB-White",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
let n = norm(s);
|
||||
Self::ALL.into_iter().find(|k| norm(k.label()) == n || norm(&format!("{k:?}")) == n)
|
||||
}
|
||||
}
|
||||
|
||||
/// The scopes' colour space (the Y'CbCr matrix; Rec. 2100 also means an HDR signal).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColorSpace {
|
||||
/// Follow the sequence: Rec. 2100 for an HDR working space, else Rec. 709.
|
||||
#[default]
|
||||
Auto,
|
||||
Rec601,
|
||||
Rec709,
|
||||
Rec2100,
|
||||
}
|
||||
|
||||
impl ColorSpace {
|
||||
pub const ALL: [ColorSpace; 4] = [ColorSpace::Auto, ColorSpace::Rec601, ColorSpace::Rec709, ColorSpace::Rec2100];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorSpace::Auto => "Automatic",
|
||||
ColorSpace::Rec601 => "Rec. 601",
|
||||
ColorSpace::Rec709 => "Rec. 709",
|
||||
ColorSpace::Rec2100 => "Rec. 2100",
|
||||
}
|
||||
}
|
||||
/// `Auto` resolved for a sequence (`hdr`: its working space is PQ/HLG).
|
||||
pub fn resolve(self, hdr: bool) -> ColorSpace {
|
||||
match self {
|
||||
ColorSpace::Auto if hdr => ColorSpace::Rec2100,
|
||||
ColorSpace::Auto => ColorSpace::Rec709,
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
pub fn matrix(self) -> Matrix {
|
||||
match self {
|
||||
ColorSpace::Rec601 => Matrix::Bt601,
|
||||
ColorSpace::Rec2100 => Matrix::Bt2020Ncl,
|
||||
_ => Matrix::Bt709,
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
let n = norm(s);
|
||||
Self::ALL.into_iter().find(|k| norm(k.label()) == n || norm(&format!("{k:?}")) == n || norm(k.label()).trim_start_matches("rec") == n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal scale (the bit-depth dropdown): the labels of the levels axis.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Scale {
|
||||
/// 0–255 code values.
|
||||
#[default]
|
||||
Bits8,
|
||||
/// 0.0–1.0.
|
||||
Float,
|
||||
/// cd/m² on a PQ axis (0–10 000).
|
||||
Hdr,
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
pub const ALL: [Scale; 3] = [Scale::Bits8, Scale::Float, Scale::Hdr];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Scale::Bits8 => "8 Bit",
|
||||
Scale::Float => "Float",
|
||||
Scale::Hdr => "HDR",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
let n = norm(s);
|
||||
Self::ALL.into_iter().find(|k| norm(k.label()) == n || norm(&format!("{k:?}")) == n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace brightness.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Brightness {
|
||||
Dimmed,
|
||||
#[default]
|
||||
Normal,
|
||||
Bright,
|
||||
}
|
||||
|
||||
impl Brightness {
|
||||
pub const ALL: [Brightness; 3] = [Brightness::Dimmed, Brightness::Normal, Brightness::Bright];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Brightness::Dimmed => "Dimmed",
|
||||
Brightness::Normal => "Normal",
|
||||
Brightness::Bright => "Bright",
|
||||
}
|
||||
}
|
||||
/// Multiplier of the trace intensity.
|
||||
pub fn gain(self) -> f32 {
|
||||
match self {
|
||||
Brightness::Dimmed => 0.55,
|
||||
Brightness::Normal => 1.0,
|
||||
Brightness::Bright => 1.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which colour-bar targets the YUV vectorscope draws.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Targets {
|
||||
#[default]
|
||||
#[serde(rename = "75")]
|
||||
Percent75,
|
||||
#[serde(rename = "100")]
|
||||
Percent100,
|
||||
}
|
||||
|
||||
impl Targets {
|
||||
pub fn amplitude(self) -> f32 {
|
||||
match self {
|
||||
Targets::Percent75 => 0.75,
|
||||
Targets::Percent100 => 1.0,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Targets::Percent75 => "75%",
|
||||
Targets::Percent100 => "100%",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How scopes are computed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Params {
|
||||
pub matrix: Matrix,
|
||||
/// Clamp Signal: values are clamped to 0..1 before they are plotted.
|
||||
pub clamp: bool,
|
||||
/// Waveform / parade levels.
|
||||
pub rows: usize,
|
||||
/// Vectorscope grid size (square; odd, so the centre is a cell).
|
||||
pub vector_size: usize,
|
||||
}
|
||||
|
||||
impl Default for Params {
|
||||
fn default() -> Self {
|
||||
Params { matrix: Matrix::Bt709, clamp: true, rows: 256, vector_size: 255 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Params {
|
||||
/// The plotted value range of waveform rows.
|
||||
pub fn range(&self) -> (f32, f32) {
|
||||
if self.clamp { (0.0, 1.0) } else { (-0.1, 1.1) }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ signal
|
||||
|
||||
/// A frame's R'G'B' samples (decimated), row-major.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Signal {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub rgb: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
fn steps(w: usize, h: usize, max_w: usize, max_h: usize) -> (usize, usize) {
|
||||
(w.div_ceil(max_w.max(1)).max(1), h.div_ceil(max_h.max(1)).max(1))
|
||||
}
|
||||
|
||||
impl Signal {
|
||||
/// From straight RGBA8 (code / 255), decimated to [`MAX_W`] × [`MAX_H`].
|
||||
pub fn from_rgba8(w: usize, h: usize, px: &[u8]) -> Signal {
|
||||
Self::from_rgba8_max(w, h, px, MAX_W, MAX_H)
|
||||
}
|
||||
|
||||
pub fn from_rgba8_max(w: usize, h: usize, px: &[u8], max_w: usize, max_h: usize) -> Signal {
|
||||
if w == 0 || h == 0 || px.len() < w * h * 4 {
|
||||
return Signal::default();
|
||||
}
|
||||
let lut: [f32; 256] = std::array::from_fn(|i| i as f32 / 255.0);
|
||||
let (sx, sy) = steps(w, h, max_w, max_h);
|
||||
let (ow, oh) = (w.div_ceil(sx), h.div_ceil(sy));
|
||||
let mut rgb = Vec::with_capacity(ow * oh);
|
||||
for y in (0..h).step_by(sy) {
|
||||
let row = &px[y * w * 4..(y + 1) * w * 4];
|
||||
for x in (0..w).step_by(sx) {
|
||||
let p = &row[x * 4..x * 4 + 3];
|
||||
rgb.push([lut[p[0] as usize], lut[p[1] as usize], lut[p[2] as usize]]);
|
||||
}
|
||||
}
|
||||
Signal { w: ow, h: oh, rgb }
|
||||
}
|
||||
|
||||
/// From RGBA f32 pixels through `map` (e.g. linear light → PQ code values), decimated.
|
||||
pub fn from_rgba_f32_with(w: usize, h: usize, px: &[f32], max_w: usize, max_h: usize, map: impl Fn([f32; 4]) -> [f32; 3]) -> Signal {
|
||||
if w == 0 || h == 0 || px.len() < w * h * 4 {
|
||||
return Signal::default();
|
||||
}
|
||||
let (sx, sy) = steps(w, h, max_w, max_h);
|
||||
let (ow, oh) = (w.div_ceil(sx), h.div_ceil(sy));
|
||||
let mut rgb = Vec::with_capacity(ow * oh);
|
||||
for y in (0..h).step_by(sy) {
|
||||
for x in (0..w).step_by(sx) {
|
||||
let i = (y * w + x) * 4;
|
||||
rgb.push(map([px[i], px[i + 1], px[i + 2], px[i + 3]]));
|
||||
}
|
||||
}
|
||||
Signal { w: ow, h: oh, rgb }
|
||||
}
|
||||
|
||||
/// A synthetic signal (tests, generated patterns).
|
||||
pub fn from_fn(w: usize, h: usize, f: impl Fn(usize, usize) -> [f32; 3]) -> Signal {
|
||||
let mut rgb = Vec::with_capacity(w * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
rgb.push(f(x, y));
|
||||
}
|
||||
}
|
||||
Signal { w, h, rgb }
|
||||
}
|
||||
|
||||
/// Every sample through `f` (e.g. SDR code values → PQ for the HDR scale).
|
||||
pub fn map(&self, f: impl Fn([f32; 3]) -> [f32; 3]) -> Signal {
|
||||
Signal { w: self.w, h: self.h, rgb: self.rgb.iter().map(|c| f(*c)).collect() }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.rgb.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rgb.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear light (1.0 = reference white = 203 cd/m², premultiplied RGBA) → PQ code values, for
|
||||
/// HDR scopes: 0..1 on the axis is 0..10 000 cd/m².
|
||||
pub fn linear_to_pq([r, g, b, a]: [f32; 4]) -> [f32; 3] {
|
||||
let a = if a > 1e-6 { a } else { 1.0 };
|
||||
let f = |v: f32| filmcraft_color::pq_inverse_eotf(((v / a).max(0.0) * filmcraft_color::REFERENCE_WHITE_NITS as f32 / 10_000.0).clamp(0.0, 1.0));
|
||||
[f(r), f(g), f(b)]
|
||||
}
|
||||
|
||||
/// An SDR code value (BT.1886, 100 cd/m² white) on the PQ axis (the HDR scale of an SDR frame).
|
||||
pub fn sdr_to_pq(v: f32) -> f32 {
|
||||
filmcraft_color::pq_inverse_eotf((v.clamp(0.0, 1.0).powf(2.4) * 100.0 / 10_000.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// cd/m² → height on the PQ axis (0..1).
|
||||
pub fn nits_to_pq(nits: f32) -> f32 {
|
||||
filmcraft_color::pq_inverse_eotf((nits / 10_000.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ grids
|
||||
|
||||
/// A count grid: `data[row * cols + col]`, row 0 = the lowest level.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Grid {
|
||||
pub name: &'static str,
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
pub data: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
pub fn new(name: &'static str, cols: usize, rows: usize) -> Grid {
|
||||
Grid { name, cols, rows, data: vec![0; cols * rows] }
|
||||
}
|
||||
pub fn at(&self, col: usize, row: usize) -> u32 {
|
||||
self.data[row * self.cols + col]
|
||||
}
|
||||
pub fn total(&self) -> u64 {
|
||||
self.data.iter().map(|&c| c as u64).sum()
|
||||
}
|
||||
pub fn max(&self) -> u32 {
|
||||
self.data.iter().copied().max().unwrap_or(0)
|
||||
}
|
||||
/// The rows hit in a column, lowest first.
|
||||
pub fn rows_in(&self, col: usize) -> Vec<usize> {
|
||||
(0..self.rows).filter(|&r| self.at(col, r) > 0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waveform or parade: one grid per trace, columns = the signal's width.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Waveform {
|
||||
pub lo: f32,
|
||||
pub hi: f32,
|
||||
/// Samples per column (the signal's height).
|
||||
pub per_column: usize,
|
||||
pub traces: Vec<Grid>,
|
||||
}
|
||||
|
||||
impl Waveform {
|
||||
pub fn trace(&self, name: &str) -> Option<&Grid> {
|
||||
self.traces.iter().find(|t| t.name == name)
|
||||
}
|
||||
/// The value at the centre of a row.
|
||||
pub fn level(&self, row: usize, rows: usize) -> f32 {
|
||||
self.lo + (self.hi - self.lo) * row as f32 / (rows.max(2) - 1) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Row of value `v` in `[lo, hi]` over `rows` levels (None outside, or NaN).
|
||||
#[inline]
|
||||
pub fn row_of(v: f32, lo: f32, hi: f32, rows: usize) -> Option<usize> {
|
||||
let t = (v - lo) / (hi - lo);
|
||||
if !(0.0..=1.0).contains(&t) {
|
||||
return None;
|
||||
}
|
||||
Some(((t * (rows - 1) as f32).round() as usize).min(rows - 1))
|
||||
}
|
||||
|
||||
/// Up to 4 (trace, value) pairs per sample.
|
||||
type Hits = ([(u8, f32); 4], usize);
|
||||
|
||||
fn build(s: &Signal, p: &Params, names: &[&'static str], sample: impl Fn([f32; 3]) -> Hits) -> Waveform {
|
||||
let (lo, hi) = p.range();
|
||||
let rows = p.rows.max(2);
|
||||
let mut traces: Vec<Grid> = names.iter().map(|n| Grid::new(n, s.w, rows)).collect();
|
||||
for y in 0..s.h {
|
||||
for x in 0..s.w {
|
||||
let mut c = s.rgb[y * s.w + x];
|
||||
if p.clamp {
|
||||
c = c.map(|v| v.clamp(0.0, 1.0));
|
||||
}
|
||||
let (hits, n) = sample(c);
|
||||
for &(t, v) in &hits[..n] {
|
||||
let v = if p.clamp { v.clamp(0.0, 1.0) } else { v };
|
||||
if let Some(r) = row_of(v, lo, hi, rows) {
|
||||
let g = &mut traces[t as usize];
|
||||
g.data[r * g.cols + x] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Waveform { lo, hi, per_column: s.h, traces }
|
||||
}
|
||||
|
||||
/// The waveform of a signal.
|
||||
pub fn waveform(s: &Signal, kind: WaveformType, p: &Params) -> Waveform {
|
||||
let m = p.matrix;
|
||||
let z = (0u8, 0.0f32);
|
||||
match kind {
|
||||
WaveformType::Rgb => build(s, p, &["R", "G", "B"], |[r, g, b]| ([(0, r), (1, g), (2, b), z], 3)),
|
||||
WaveformType::Luma => build(s, p, &["Y"], |[r, g, b]| ([(0, rgb_to_ycbcr(r, g, b, m)[0]), z, z, z], 1)),
|
||||
WaveformType::YcNoChroma => build(s, p, &["Y"], |[r, g, b]| ([(0, rgb_to_ycbcr(r, g, b, m)[0]), z, z, z], 1)),
|
||||
WaveformType::Yc => build(s, p, &["Y", "C"], |[r, g, b]| {
|
||||
let [y, cb, cr] = rgb_to_ycbcr(r, g, b, m);
|
||||
let c = (cb * cb + cr * cr).sqrt();
|
||||
([(0, y), (1, y + c), (1, y - c), z], if c > 1e-6 { 3 } else { 2 })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parade of a signal (the traces are drawn side by side).
|
||||
pub fn parade(s: &Signal, kind: ParadeType, p: &Params) -> Waveform {
|
||||
let m = p.matrix;
|
||||
let z = (0u8, 0.0f32);
|
||||
match kind {
|
||||
ParadeType::Rgb => build(s, p, &["R", "G", "B"], |[r, g, b]| ([(0, r), (1, g), (2, b), z], 3)),
|
||||
ParadeType::RgbWhite => build(s, p, &["R", "G", "B", "Y"], |[r, g, b]| ([(0, r), (1, g), (2, b), (3, rgb_to_ycbcr(r, g, b, m)[0])], 4)),
|
||||
ParadeType::Yuv => build(s, p, &["Y", "Cb", "Cr"], |[r, g, b]| {
|
||||
let [y, cb, cr] = rgb_to_ycbcr(r, g, b, m);
|
||||
([(0, y), (1, cb + 0.5), (2, cr + 0.5), z], 3)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ histogram
|
||||
|
||||
/// 256-bin histograms of R', G', B' and Y'.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Histogram {
|
||||
pub r: Vec<u32>,
|
||||
pub g: Vec<u32>,
|
||||
pub b: Vec<u32>,
|
||||
pub y: Vec<u32>,
|
||||
pub samples: u32,
|
||||
/// Samples below 0 / above 1 per channel (R, G, B, Y); with Clamp Signal they are also
|
||||
/// counted in the first / last bin.
|
||||
pub below: [u32; 4],
|
||||
pub above: [u32; 4],
|
||||
}
|
||||
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
/// Bin of a value (None when outside 0..1).
|
||||
#[inline]
|
||||
pub fn bin_of(v: f32) -> Option<usize> {
|
||||
if (0.0..=1.0 + 1e-6).contains(&v) { Some(((v * 255.0).round() as usize).min(BINS - 1)) } else { None }
|
||||
}
|
||||
|
||||
pub fn histogram(s: &Signal, p: &Params) -> Histogram {
|
||||
let mut h = Histogram { r: vec![0; BINS], g: vec![0; BINS], b: vec![0; BINS], y: vec![0; BINS], samples: s.len() as u32, below: [0; 4], above: [0; 4] };
|
||||
for &[r, g, b] in &s.rgb {
|
||||
let y = rgb_to_ycbcr(r, g, b, p.matrix)[0];
|
||||
for (i, v) in [r, g, b, y].into_iter().enumerate() {
|
||||
let bins = match i {
|
||||
0 => &mut h.r,
|
||||
1 => &mut h.g,
|
||||
2 => &mut h.b,
|
||||
_ => &mut h.y,
|
||||
};
|
||||
match bin_of(v) {
|
||||
Some(k) => bins[k] += 1,
|
||||
None if v.is_nan() => {}
|
||||
None => {
|
||||
let low = v < 0.0;
|
||||
if low {
|
||||
h.below[i] += 1;
|
||||
} else {
|
||||
h.above[i] += 1;
|
||||
}
|
||||
if p.clamp {
|
||||
bins[if low { 0 } else { BINS - 1 }] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ vectorscopes
|
||||
|
||||
/// A vectorscope: `data[y * size + x]`, x = Cb (right = +), y = Cr (row 0 = top = +Cr).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Vectorscope {
|
||||
pub size: usize,
|
||||
pub extent: f32,
|
||||
pub data: Vec<u32>,
|
||||
pub samples: u32,
|
||||
/// Mean (Cb, Cr) of the plotted points.
|
||||
pub mean: [f32; 2],
|
||||
}
|
||||
|
||||
/// Cell of a (Cb, Cr) point on a `size`² plane spanning ±`extent`.
|
||||
#[inline]
|
||||
pub fn cell_of(cb: f32, cr: f32, size: usize, extent: f32) -> Option<(usize, usize)> {
|
||||
let n = (size - 1) as f32;
|
||||
let fx = (cb / extent * 0.5 + 0.5) * n;
|
||||
let fy = (0.5 - cr / extent * 0.5) * n;
|
||||
if !(0.0..=n).contains(&fx) || !(0.0..=n).contains(&fy) {
|
||||
return None;
|
||||
}
|
||||
Some((fx.round() as usize, fy.round() as usize))
|
||||
}
|
||||
|
||||
impl Vectorscope {
|
||||
fn new(size: usize) -> Self {
|
||||
Vectorscope { size, extent: VECTOR_EXTENT, data: vec![0; size * size], samples: 0, mean: [0.0; 2] }
|
||||
}
|
||||
pub fn at(&self, x: usize, y: usize) -> u32 {
|
||||
self.data[y * self.size + x]
|
||||
}
|
||||
pub fn cell(&self, cb: f32, cr: f32) -> Option<(usize, usize)> {
|
||||
cell_of(cb, cr, self.size, self.extent)
|
||||
}
|
||||
/// (Cb, Cr) at the centre of a cell.
|
||||
pub fn point(&self, x: usize, y: usize) -> [f32; 2] {
|
||||
let n = (self.size - 1) as f32;
|
||||
[(x as f32 / n - 0.5) * 2.0 * self.extent, (0.5 - y as f32 / n) * 2.0 * self.extent]
|
||||
}
|
||||
fn plot(&mut self, cb: f32, cr: f32, sum: &mut [f64; 2]) {
|
||||
if let Some((x, y)) = self.cell(cb, cr) {
|
||||
self.data[y * self.size + x] += 1;
|
||||
self.samples += 1;
|
||||
sum[0] += cb as f64;
|
||||
sum[1] += cr as f64;
|
||||
}
|
||||
}
|
||||
fn finish(mut self, sum: [f64; 2]) -> Self {
|
||||
if self.samples > 0 {
|
||||
self.mean = [(sum[0] / self.samples as f64) as f32, (sum[1] / self.samples as f64) as f32];
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The YUV vectorscope (Cb, Cr of each sample).
|
||||
pub fn vectorscope_yuv(s: &Signal, p: &Params) -> Vectorscope {
|
||||
let mut v = Vectorscope::new(p.vector_size.max(2));
|
||||
let mut sum = [0f64; 2];
|
||||
for &c in &s.rgb {
|
||||
let [r, g, b] = if p.clamp { c.map(|x| x.clamp(0.0, 1.0)) } else { c };
|
||||
let [_, cb, cr] = rgb_to_ycbcr(r, g, b, p.matrix);
|
||||
v.plot(cb, cr, &mut sum);
|
||||
}
|
||||
v.finish(sum)
|
||||
}
|
||||
|
||||
/// Angle (degrees, counter-clockwise from +Cb, 0..360) of a chroma point.
|
||||
pub fn angle_deg(cb: f32, cr: f32) -> f32 {
|
||||
cr.atan2(cb).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
|
||||
/// The angle of pure red on a matrix's vectorscope (BT.709 ≈ 103°): the HLS scope puts hue 0 there.
|
||||
pub fn red_angle(m: Matrix) -> f32 {
|
||||
let [_, cb, cr] = rgb_to_ycbcr(1.0, 0.0, 0.0, m);
|
||||
angle_deg(cb, cr)
|
||||
}
|
||||
|
||||
/// A sample's point on the HLS vectorscope: hue as the angle from red (counter-clockwise, red →
|
||||
/// yellow → green → cyan → blue → magenta), HLS saturation as the radius (1 = 0.5).
|
||||
pub fn hls_point(rgb: [f32; 3], m: Matrix) -> [f32; 2] {
|
||||
hls_at(rgb, red_angle(m))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hls_at([r, g, b]: [f32; 3], red: f32) -> [f32; 2] {
|
||||
let [h, s, _] = filmcraft_color::rgb_to_hsl(r, g, b);
|
||||
let a = (red + h * 360.0).to_radians();
|
||||
let rad = s.clamp(0.0, 1.0) * 0.5;
|
||||
[rad * a.cos(), rad * a.sin()]
|
||||
}
|
||||
|
||||
/// The HLS vectorscope.
|
||||
pub fn vectorscope_hls(s: &Signal, p: &Params) -> Vectorscope {
|
||||
let mut v = Vectorscope::new(p.vector_size.max(2));
|
||||
let mut sum = [0f64; 2];
|
||||
// hue → unit direction, tabulated at 0.1° (trigonometry per sample is the slow part)
|
||||
let red = red_angle(p.matrix);
|
||||
let dirs: Vec<[f32; 2]> = (0..=3600).map(|i| (red + i as f32 / 10.0).to_radians()).map(|a| [a.cos(), a.sin()]).collect();
|
||||
for &c in &s.rgb {
|
||||
let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0));
|
||||
let [h, sat, _] = filmcraft_color::rgb_to_hsl(r, g, b);
|
||||
let [dx, dy] = dirs[((h * 3600.0).round() as usize).min(3600)];
|
||||
let rad = sat.clamp(0.0, 1.0) * 0.5;
|
||||
v.plot(rad * dx, rad * dy, &mut sum);
|
||||
}
|
||||
v.finish(sum)
|
||||
}
|
||||
|
||||
/// A colour-bar target of the YUV vectorscope.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct Target {
|
||||
pub name: &'static str,
|
||||
pub rgb: [f32; 3],
|
||||
pub cb: f32,
|
||||
pub cr: f32,
|
||||
}
|
||||
|
||||
/// The six colour-bar targets (R, Mg, B, Cy, G, Yl) at `amplitude` (0.75 or 1.0).
|
||||
pub fn targets(m: Matrix, amplitude: f32) -> [Target; 6] {
|
||||
let a = amplitude;
|
||||
let t = |name, rgb: [f32; 3]| {
|
||||
let [_, cb, cr] = rgb_to_ycbcr(rgb[0], rgb[1], rgb[2], m);
|
||||
Target { name, rgb, cb, cr }
|
||||
};
|
||||
[t("R", [a, 0.0, 0.0]), t("Mg", [a, 0.0, a]), t("B", [0.0, 0.0, a]), t("Cy", [0.0, a, a]), t("G", [0.0, a, 0.0]), t("Yl", [a, a, 0.0])]
|
||||
}
|
||||
|
||||
/// Hue labels of the HLS vectorscope at radius 0.5: (name, Cb, Cr).
|
||||
pub fn hls_targets(m: Matrix) -> [(&'static str, f32, f32); 6] {
|
||||
let red = red_angle(m);
|
||||
let at = |name, h: f32| {
|
||||
let a = (red + h).to_radians();
|
||||
(name, 0.5 * a.cos(), 0.5 * a.sin())
|
||||
};
|
||||
[at("R", 0.0), at("Yl", 60.0), at("G", 120.0), at("Cy", 180.0), at("B", 240.0), at("Mg", 300.0)]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ all scopes
|
||||
|
||||
/// Every scope the panel can show, computed for a set of settings.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Computed {
|
||||
Waveform(Waveform),
|
||||
Parade(Waveform),
|
||||
Histogram(Histogram),
|
||||
Vectorscope(Vectorscope),
|
||||
}
|
||||
|
||||
/// Compute one scope.
|
||||
pub fn compute(kind: ScopeKind, s: &Signal, p: &Params, waveform_type: WaveformType, parade_type: ParadeType) -> Computed {
|
||||
match kind {
|
||||
ScopeKind::Waveform => Computed::Waveform(waveform(s, waveform_type, p)),
|
||||
ScopeKind::Parade => Computed::Parade(parade(s, parade_type, p)),
|
||||
ScopeKind::Histogram => Computed::Histogram(histogram(s, p)),
|
||||
ScopeKind::VectorscopeYuv => Computed::Vectorscope(vectorscope_yuv(s, p)),
|
||||
ScopeKind::VectorscopeHls => Computed::Vectorscope(vectorscope_hls(s, p)),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
//! Count grids → pixels: the trace images the UI uploads as textures.
|
||||
//!
|
||||
//! A cell's intensity grows with the logarithm of its count relative to a reference count (a
|
||||
//! "full" cell: a flat colour puts a whole column of samples on one waveform cell), so sparse
|
||||
//! detail stays visible next to flat areas, like a phosphor trace. Traces of several grids add
|
||||
//! up (R + G + B traces that coincide read white).
|
||||
|
||||
use crate::{Grid, Vectorscope};
|
||||
|
||||
/// Intensity 0..1 of a cell holding `count` samples (`reference` = a full cell, `gain` = the
|
||||
/// Brightness setting).
|
||||
#[inline]
|
||||
pub fn intensity(count: u32, reference: f32, gain: f32) -> f32 {
|
||||
if count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let r = (1.0 + reference.max(1.0)).ln();
|
||||
(gain * (0.12 + 0.88 * (1.0 + count as f32).ln() / r)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Paint grids of equal size with colours (0..1 RGB), additively, into premultiplied RGBA8 with the
|
||||
/// highest row at the top. Empty cells are transparent.
|
||||
pub fn grids(layers: &[(&Grid, [f32; 3])], reference: f32, gain: f32) -> (usize, usize, Vec<u8>) {
|
||||
let Some((first, _)) = layers.first() else { return (0, 0, Vec::new()) };
|
||||
let (w, h) = (first.cols, first.rows);
|
||||
let mut out = vec![0u8; w * h * 4];
|
||||
let mut acc = vec![[0f32; 3]; w * h];
|
||||
for (g, col) in layers {
|
||||
if g.cols != w || g.rows != h {
|
||||
continue;
|
||||
}
|
||||
for row in 0..h {
|
||||
let dst = (h - 1 - row) * w;
|
||||
for x in 0..w {
|
||||
let c = g.data[row * w + x];
|
||||
if c > 0 {
|
||||
let i = intensity(c, reference, gain);
|
||||
let a = &mut acc[dst + x];
|
||||
for k in 0..3 {
|
||||
a[k] += col[k] * i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (o, a) in out.chunks_exact_mut(4).zip(&acc) {
|
||||
let m = a[0].max(a[1]).max(a[2]);
|
||||
if m <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
o[0] = (a[0].min(1.0) * 255.0).round() as u8;
|
||||
o[1] = (a[1].min(1.0) * 255.0).round() as u8;
|
||||
o[2] = (a[2].min(1.0) * 255.0).round() as u8;
|
||||
o[3] = (m.min(1.0) * 255.0).round() as u8;
|
||||
}
|
||||
(w, h, out)
|
||||
}
|
||||
|
||||
/// The reference count of a waveform cell: half a column.
|
||||
pub fn waveform_reference(per_column: usize) -> f32 {
|
||||
(per_column as f32 / 2.0).max(1.0)
|
||||
}
|
||||
|
||||
/// Paint a vectorscope: each point takes the colour of its own position (hue = angle) when
|
||||
/// `colorize`, else `col`, and spreads over its 3 × 3 neighbourhood (falling off), so isolated
|
||||
/// points stay visible when the scope is drawn smaller than its grid. Premultiplied RGBA8, row
|
||||
/// 0 = +Cr.
|
||||
pub fn vectorscope(v: &Vectorscope, col: [f32; 3], gain: f32, colorize: bool) -> (usize, usize, Vec<u8>) {
|
||||
let n = v.size;
|
||||
let reference = (v.samples as f32 / 64.0).max(1.0);
|
||||
let mut level = vec![0f32; n * n];
|
||||
let mut tint = vec![[0f32; 3]; n * n];
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let c = v.at(x, y);
|
||||
if c == 0 {
|
||||
continue;
|
||||
}
|
||||
let i = intensity(c, reference, gain);
|
||||
let t = if colorize {
|
||||
let [cb, cr] = v.point(x, y);
|
||||
// the colour at this chroma with mid luma, desaturated towards white
|
||||
let [r, g, b] = filmcraft_color::ycbcr_to_rgb(0.6, cb * 0.8, cr * 0.8, filmcraft_color::Matrix::Bt709);
|
||||
[0.55 + 0.45 * r.clamp(0.0, 1.0), 0.55 + 0.45 * g.clamp(0.0, 1.0), 0.55 + 0.45 * b.clamp(0.0, 1.0)]
|
||||
} else {
|
||||
col
|
||||
};
|
||||
for (dy, dx, w) in
|
||||
[(0i64, 0i64, 1.0f32), (-1, 0, 0.6), (1, 0, 0.6), (0, -1, 0.6), (0, 1, 0.6), (-1, -1, 0.35), (-1, 1, 0.35), (1, -1, 0.35), (1, 1, 0.35)]
|
||||
{
|
||||
let (yy, xx) = (y as i64 + dy, x as i64 + dx);
|
||||
if yy < 0 || xx < 0 || yy >= n as i64 || xx >= n as i64 {
|
||||
continue;
|
||||
}
|
||||
let k = yy as usize * n + xx as usize;
|
||||
if i * w > level[k] {
|
||||
level[k] = i * w;
|
||||
tint[k] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![0u8; n * n * 4];
|
||||
for ((o, l), t) in out.chunks_exact_mut(4).zip(&level).zip(&tint) {
|
||||
if *l > 0.0 {
|
||||
o.copy_from_slice(&[
|
||||
(t[0] * l * 255.0).round() as u8,
|
||||
(t[1] * l * 255.0).round() as u8,
|
||||
(t[2] * l * 255.0).round() as u8,
|
||||
(l * 255.0).round() as u8,
|
||||
]);
|
||||
}
|
||||
}
|
||||
(n, n, out)
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
//! Numeric summaries of scopes, for agents checking colour without looking (`scopes.read`).
|
||||
//! Levels are in percent of full scale (IRE-like: code value / 255 × 100 for 8-bit signals).
|
||||
|
||||
use filmcraft_color::rgb_to_ycbcr;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Grid, Matrix, Signal, Vectorscope, Waveform, angle_deg};
|
||||
|
||||
/// Minimum, maximum and mean of a set of levels (percent).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct Level {
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub mean: f64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Acc {
|
||||
min: f32,
|
||||
max: f32,
|
||||
sum: f64,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
impl Acc {
|
||||
fn add(&mut self, v: f32, count: u64) {
|
||||
if self.n == 0 {
|
||||
self.min = v;
|
||||
self.max = v;
|
||||
} else {
|
||||
self.min = self.min.min(v);
|
||||
self.max = self.max.max(v);
|
||||
}
|
||||
self.sum += v as f64 * count as f64;
|
||||
self.n += count;
|
||||
}
|
||||
fn level(&self) -> Option<Level> {
|
||||
(self.n > 0).then(|| Level { min: round2(self.min), max: round2(self.max), mean: round2(self.sum / self.n as f64) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounded to 0.01 (as f64, so JSON shows `78.43`, not the f32's `78.43000030517578`).
|
||||
fn round2(v: impl Into<f64>) -> f64 {
|
||||
(v.into() * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
/// Per-channel levels of the signal itself (R', G', B', Y'), exact (not from grid cells).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelStats {
|
||||
pub r: Level,
|
||||
pub g: Level,
|
||||
pub b: Level,
|
||||
pub y: Level,
|
||||
/// Mean chroma (Cb, Cr) in −50..50 and its angle / magnitude (percent of 0.5).
|
||||
pub chroma_mean: [f64; 2],
|
||||
pub hue_deg: f64,
|
||||
pub saturation: f64,
|
||||
}
|
||||
|
||||
pub fn channel_stats(s: &Signal, m: Matrix) -> Option<ChannelStats> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: [Acc; 4] = Default::default();
|
||||
let (mut cb_sum, mut cr_sum) = (0f64, 0f64);
|
||||
for &[r, g, b] in &s.rgb {
|
||||
let [y, cb, cr] = rgb_to_ycbcr(r, g, b, m);
|
||||
for (a, v) in acc.iter_mut().zip([r, g, b, y]) {
|
||||
a.add(v * 100.0, 1);
|
||||
}
|
||||
cb_sum += cb as f64;
|
||||
cr_sum += cr as f64;
|
||||
}
|
||||
let n = s.len() as f64;
|
||||
let (cb, cr) = ((cb_sum / n) as f32, (cr_sum / n) as f32);
|
||||
Some(ChannelStats {
|
||||
r: acc[0].level()?,
|
||||
g: acc[1].level()?,
|
||||
b: acc[2].level()?,
|
||||
y: acc[3].level()?,
|
||||
chroma_mean: [round2(cb * 100.0), round2(cr * 100.0)],
|
||||
hue_deg: round2(angle_deg(cb, cr)),
|
||||
saturation: round2((cb * cb + cr * cr).sqrt() / 0.5 * 100.0),
|
||||
})
|
||||
}
|
||||
|
||||
/// A trace split into `buckets` column ranges, each with the levels its cells hold (None when a
|
||||
/// range holds no samples).
|
||||
pub fn trace_columns(w: &Waveform, g: &Grid, buckets: usize) -> Vec<Option<Level>> {
|
||||
let buckets = buckets.clamp(1, g.cols.max(1));
|
||||
(0..buckets)
|
||||
.map(|b| {
|
||||
let (c0, c1) = (b * g.cols / buckets, ((b + 1) * g.cols / buckets).max(b * g.cols / buckets + 1).min(g.cols));
|
||||
let mut a = Acc::default();
|
||||
for col in c0..c1 {
|
||||
for row in 0..g.rows {
|
||||
let n = g.at(col, row);
|
||||
if n > 0 {
|
||||
a.add(w.level(row, g.rows) * 100.0, n as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
a.level()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A dense spot of a vectorscope.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Peak {
|
||||
/// Cell coordinates on the scope's grid.
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
/// Cb, Cr at the cell centre (percent: −50..50 is the nominal range).
|
||||
pub cb: f64,
|
||||
pub cr: f64,
|
||||
pub angle_deg: f64,
|
||||
/// Distance from the centre in percent of 0.5 (100 % red in BT.709 ≈ 102.6).
|
||||
pub magnitude: f64,
|
||||
/// Share of the plotted samples in this cell (0..1).
|
||||
pub share: f64,
|
||||
}
|
||||
|
||||
/// The `n` densest cells holding at least `min_share` of the samples, densest first.
|
||||
pub fn peaks(v: &Vectorscope, n: usize, min_share: f32) -> Vec<Peak> {
|
||||
let total = v.samples.max(1) as f32;
|
||||
let mut cells: Vec<(u32, usize)> = v.data.iter().enumerate().filter(|(_, c)| **c > 0).map(|(i, c)| (*c, i)).collect();
|
||||
cells.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
cells
|
||||
.into_iter()
|
||||
.filter(|(c, _)| *c as f32 / total >= min_share)
|
||||
.take(n)
|
||||
.map(|(c, i)| {
|
||||
let (x, y) = (i % v.size, i / v.size);
|
||||
let [cb, cr] = v.point(x, y);
|
||||
Peak {
|
||||
x,
|
||||
y,
|
||||
cb: round2(cb * 100.0),
|
||||
cr: round2(cr * 100.0),
|
||||
angle_deg: round2(angle_deg(cb, cr)),
|
||||
magnitude: round2((cb * cb + cr * cr).sqrt() / 0.5 * 100.0),
|
||||
share: (c as f64 / total as f64 * 10_000.0).round() / 10_000.0,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sparse cells of a vectorscope re-binned to `size`² (for a coarse numeric picture): `[x, y, count]`.
|
||||
pub fn coarse(v: &Vectorscope, size: usize) -> Vec<[u32; 3]> {
|
||||
let size = size.clamp(1, v.size);
|
||||
let mut out = vec![0u32; size * size];
|
||||
for y in 0..v.size {
|
||||
for x in 0..v.size {
|
||||
let c = v.at(x, y);
|
||||
if c > 0 {
|
||||
out[(y * size / v.size) * size + x * size / v.size] += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.iter().enumerate().filter(|(_, c)| **c > 0).map(|(i, c)| [(i % size) as u32, (i / size) as u32, *c]).collect()
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
//! Scope maths on generated frames with known values.
|
||||
|
||||
use crate::*;
|
||||
use filmcraft_color::rgb_to_ycbcr;
|
||||
|
||||
fn rgba8(w: usize, h: usize, mut f: impl FnMut(usize, usize) -> [u8; 3]) -> Vec<u8> {
|
||||
let mut px = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let [r, g, b] = f(x, y);
|
||||
px.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn solid(w: usize, h: usize, c: [u8; 3]) -> Signal {
|
||||
Signal::from_rgba8(w, h, &rgba8(w, h, |_, _| c))
|
||||
}
|
||||
|
||||
/// 75 % colour bars (code 191): white, yellow, cyan, green, magenta, red, blue, black; 32 px each.
|
||||
const BAR: u8 = 191;
|
||||
const BARS: [[u8; 3]; 8] = [[BAR, BAR, BAR], [BAR, BAR, 0], [0, BAR, BAR], [0, BAR, 0], [BAR, 0, BAR], [BAR, 0, 0], [0, 0, BAR], [0, 0, 0]];
|
||||
|
||||
fn bars() -> Signal {
|
||||
Signal::from_rgba8(256, 16, &rgba8(256, 16, |x, _| BARS[x / 32]))
|
||||
}
|
||||
|
||||
fn code(v: u8) -> f32 {
|
||||
v as f32 / 255.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_colour_lands_in_exact_histogram_bins() {
|
||||
let s = solid(64, 36, [200, 100, 50]);
|
||||
let n = 64 * 36;
|
||||
let h = histogram(&s, &Params::default());
|
||||
assert_eq!(h.samples, n);
|
||||
assert_eq!((h.r[200], h.g[100], h.b[50]), (n, n, n));
|
||||
assert_eq!(h.r.iter().sum::<u32>(), n);
|
||||
let y = rgb_to_ycbcr(code(200), code(100), code(50), Matrix::Bt709)[0];
|
||||
assert_eq!(h.y[(y * 255.0).round() as usize], n);
|
||||
assert_eq!((h.below, h.above), ([0; 4], [0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_fills_every_bin_and_waveform_row_equals_code_value() {
|
||||
let s = Signal::from_rgba8(256, 10, &rgba8(256, 10, |x, _| [x as u8; 3]));
|
||||
let p = Params::default();
|
||||
let h = histogram(&s, &p);
|
||||
for k in 0..256 {
|
||||
assert_eq!((h.r[k], h.g[k], h.b[k], h.y[k]), (10, 10, 10, 10), "bin {k}");
|
||||
}
|
||||
for kind in [WaveformType::Luma, WaveformType::YcNoChroma] {
|
||||
let w = waveform(&s, kind, &p);
|
||||
let y = w.trace("Y").unwrap();
|
||||
for x in 0..256 {
|
||||
assert_eq!(y.rows_in(x), vec![x], "{kind:?} column {x}");
|
||||
assert_eq!(y.at(x, x), 10);
|
||||
}
|
||||
}
|
||||
let w = waveform(&s, WaveformType::Rgb, &p);
|
||||
assert_eq!(w.traces.len(), 3);
|
||||
for t in &w.traces {
|
||||
assert!((0..256).all(|x| t.rows_in(x) == vec![x]), "{}", t.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_bars_parade_levels_and_vectorscope_targets() {
|
||||
let s = bars();
|
||||
let p = Params::default();
|
||||
// RGB parade: every bar column holds its channel's code value exactly
|
||||
let par = parade(&s, ParadeType::Rgb, &p);
|
||||
for (ch, name) in ["R", "G", "B"].iter().enumerate() {
|
||||
let t = par.trace(name).unwrap();
|
||||
for (i, bar) in BARS.iter().enumerate() {
|
||||
for x in i * 32..(i + 1) * 32 {
|
||||
assert_eq!(t.rows_in(x), vec![bar[ch] as usize], "{name} bar {i}");
|
||||
assert_eq!(t.at(x, bar[ch] as usize), 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
// RGB-White adds the luma trace; YUV has Y, Cb, Cr
|
||||
assert_eq!(parade(&s, ParadeType::RgbWhite, &p).traces.iter().map(|t| t.name).collect::<Vec<_>>(), ["R", "G", "B", "Y"]);
|
||||
assert_eq!(parade(&s, ParadeType::Yuv, &p).traces.iter().map(|t| t.name).collect::<Vec<_>>(), ["Y", "Cb", "Cr"]);
|
||||
// Vectorscope: each coloured bar on its 75 % target cell, white and black in the centre
|
||||
for m in [Matrix::Bt601, Matrix::Bt709, Matrix::Bt2020Ncl] {
|
||||
let p = Params { matrix: m, ..Params::default() };
|
||||
let v = vectorscope_yuv(&s, &p);
|
||||
assert_eq!(v.samples, 256 * 16);
|
||||
let bar_samples = 32 * 16;
|
||||
for t in targets(m, code(BAR)) {
|
||||
let (x, y) = v.cell(t.cb, t.cr).unwrap();
|
||||
assert_eq!(v.at(x, y), bar_samples, "{m:?} {}", t.name);
|
||||
}
|
||||
let (cx, cy) = v.cell(0.0, 0.0).unwrap();
|
||||
assert_eq!(v.at(cx, cy), 2 * bar_samples);
|
||||
// the densest spots: the centre (two bars), then the six targets
|
||||
let pk = summary::peaks(&v, 10, 0.01);
|
||||
assert_eq!(pk.len(), 7);
|
||||
assert_eq!((pk[0].x, pk[0].y, pk[0].share), (cx, cy, 0.25));
|
||||
assert!(pk[1..].iter().all(|k| k.share == 0.125));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectorscope_positions_of_primaries_are_known() {
|
||||
let p = Params::default();
|
||||
// BT.709 red: Cb = −0.2126 / 1.8556 = −0.11457, Cr = 0.5
|
||||
let v = vectorscope_yuv(&solid(8, 8, [255, 0, 0]), &p);
|
||||
let pk = summary::peaks(&v, 1, 0.0)[0];
|
||||
assert_eq!((pk.x, pk.y), (103, 21));
|
||||
assert_eq!((pk.share, v.samples), (1.0, 64));
|
||||
assert!((red_angle(Matrix::Bt709) - 102.906).abs() < 0.01, "{}", red_angle(Matrix::Bt709));
|
||||
assert!((red_angle(Matrix::Bt601) - 108.645).abs() < 0.01, "{}", red_angle(Matrix::Bt601));
|
||||
// the mean chroma of a flat colour is its own
|
||||
let [_, cb, cr] = rgb_to_ycbcr(1.0, 0.0, 0.0, Matrix::Bt709);
|
||||
assert!((v.mean[0] - cb).abs() < 1e-6 && (v.mean[1] - cr).abs() < 1e-6);
|
||||
// greys sit in the centre for every matrix
|
||||
for m in [Matrix::Bt601, Matrix::Bt709, Matrix::Bt2020Ncl] {
|
||||
let v = vectorscope_yuv(&solid(4, 4, [77, 77, 77]), &Params { matrix: m, ..p });
|
||||
assert_eq!(v.at(127, 127), 16, "{m:?}");
|
||||
}
|
||||
// the skin-tone line sits between red and yellow
|
||||
let yl = targets(Matrix::Bt709, 1.0)[5];
|
||||
assert!(red_angle(Matrix::Bt709) < SKIN_TONE_DEG && SKIN_TONE_DEG < angle_deg(yl.cb, yl.cr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hls_vectorscope_puts_hue_on_the_angle_and_saturation_on_the_radius() {
|
||||
let p = Params::default();
|
||||
let red = red_angle(p.matrix);
|
||||
for (rgb, hue) in [([255, 0, 0], 0.0f32), ([255, 255, 0], 60.0), ([0, 255, 0], 120.0), ([0, 255, 255], 180.0), ([0, 0, 255], 240.0), ([255, 0, 255], 300.0)]
|
||||
{
|
||||
let v = vectorscope_hls(&solid(4, 4, rgb), &p);
|
||||
let a = (red + hue).to_radians();
|
||||
let want = cell_of(0.5 * a.cos(), 0.5 * a.sin(), p.vector_size, VECTOR_EXTENT).unwrap();
|
||||
let pk = summary::peaks(&v, 1, 0.0)[0];
|
||||
assert_eq!((pk.x, pk.y), want, "{rgb:?}");
|
||||
assert!((pk.magnitude - 100.0).abs() < 0.6, "{rgb:?} {}", pk.magnitude);
|
||||
}
|
||||
// half-saturated red (HSL s = 0.5) at half the radius; grey in the centre
|
||||
let v = vectorscope_hls(&solid(4, 4, [191, 64, 64]), &p);
|
||||
let pk = summary::peaks(&v, 1, 0.0)[0];
|
||||
assert!((pk.magnitude - 50.0).abs() < 1.0 && (pk.angle_deg - red as f64).abs() < 0.5, "{pk:?}");
|
||||
let v = vectorscope_hls(&solid(4, 4, [90, 90, 90]), &p);
|
||||
assert_eq!(v.at(127, 127), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yc_waveform_draws_chroma_around_luma() {
|
||||
let p = Params::default();
|
||||
let w = waveform(&solid(4, 4, [128, 128, 128]), WaveformType::Yc, &p);
|
||||
assert_eq!((w.trace("Y").unwrap().rows_in(0), w.trace("C").unwrap().rows_in(0)), (vec![128], vec![128]));
|
||||
let w = waveform(&solid(4, 4, [255, 0, 0]), WaveformType::Yc, &p);
|
||||
let [y, cb, cr] = rgb_to_ycbcr(1.0, 0.0, 0.0, Matrix::Bt709);
|
||||
let c = (cb * cb + cr * cr).sqrt();
|
||||
let row = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize;
|
||||
assert_eq!(w.trace("Y").unwrap().rows_in(2), vec![row(y)]);
|
||||
assert_eq!(w.trace("C").unwrap().rows_in(2), vec![row(y - c), row(y + c)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv_parade_offsets_chroma_to_mid_scale() {
|
||||
let w = parade(&solid(4, 4, [128, 128, 128]), ParadeType::Yuv, &Params::default());
|
||||
assert_eq!(w.trace("Y").unwrap().rows_in(0), vec![128]);
|
||||
assert_eq!(w.trace("Cb").unwrap().rows_in(0), vec![128]);
|
||||
assert_eq!(w.trace("Cr").unwrap().rows_in(0), vec![128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimation_keeps_whole_samples() {
|
||||
let px = rgba8(1920, 1080, |x, y| [(x % 256) as u8, (y % 256) as u8, 7]);
|
||||
let s = Signal::from_rgba8(1920, 1080, &px);
|
||||
assert_eq!((s.w, s.h), (480, 270));
|
||||
assert_eq!(s.rgb[1], [code(4), 0.0, code(7)]);
|
||||
assert_eq!(s.rgb[480], [0.0, code(4), code(7)]);
|
||||
let s = Signal::from_rgba8(1921, 9, &rgba8(1921, 9, |_, _| [1, 2, 3]));
|
||||
assert_eq!((s.w, s.h), (385, 9));
|
||||
assert!(Signal::from_rgba8(10, 10, &[0; 12]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_signal_controls_out_of_range_values() {
|
||||
let s = Signal::from_fn(4, 4, |x, _| if x < 2 { [1.2, 0.5, -0.05] } else { [0.5, 0.5, 0.5] });
|
||||
let on = Params::default();
|
||||
let off = Params { clamp: false, ..on };
|
||||
let h = histogram(&s, &on);
|
||||
assert_eq!((h.r[255], h.above[0], h.b[0], h.below[2]), (8, 8, 8, 8));
|
||||
let h = histogram(&s, &off);
|
||||
assert_eq!((h.r[255], h.above[0], h.b[0], h.below[2]), (0, 8, 0, 8));
|
||||
// waveform: clamped onto the top row, or plotted above 100 % within −10..110
|
||||
let w = waveform(&s, WaveformType::Rgb, &on);
|
||||
assert_eq!(w.trace("R").unwrap().rows_in(0), vec![255]);
|
||||
let w = waveform(&s, WaveformType::Rgb, &off);
|
||||
assert_eq!((w.lo, w.hi), (-0.1, 1.1));
|
||||
assert!(w.trace("R").unwrap().rows_in(0).is_empty(), "1.2 is outside −10..110");
|
||||
let r = w.trace("B").unwrap().rows_in(0);
|
||||
assert_eq!(r, vec![row_of(-0.05, -0.1, 1.1, 256).unwrap()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_axis_helpers() {
|
||||
assert!(nits_to_pq(0.0) < 1e-5);
|
||||
assert!((nits_to_pq(10_000.0) - 1.0).abs() < 1e-6);
|
||||
// reference white (203 cd/m²) ≈ 58 % of the PQ range (BT.2408)
|
||||
let w = linear_to_pq([1.0, 1.0, 1.0, 1.0])[0];
|
||||
assert!((w - nits_to_pq(203.0)).abs() < 1e-6 && (w - 0.58).abs() < 0.01, "{w}");
|
||||
// premultiplied: half-covered white is still white
|
||||
assert!((linear_to_pq([0.5, 0.5, 0.5, 0.5])[0] - w).abs() < 1e-6);
|
||||
assert!((sdr_to_pq(1.0) - nits_to_pq(100.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_and_empty_signals_do_not_panic() {
|
||||
let s = Signal::from_fn(3, 3, |x, _| if x == 1 { [f32::NAN; 3] } else { [0.5; 3] });
|
||||
for clamp in [true, false] {
|
||||
let p = Params { clamp, ..Params::default() };
|
||||
for k in ScopeKind::ALL {
|
||||
let _ = compute(k, &s, &p, WaveformType::Yc, ParadeType::RgbWhite);
|
||||
let _ = compute(k, &Signal::default(), &p, WaveformType::Rgb, ParadeType::Rgb);
|
||||
}
|
||||
}
|
||||
assert!(summary::channel_stats(&Signal::default(), Matrix::Bt709).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summaries_report_levels_in_percent() {
|
||||
let s = solid(8, 8, [200, 100, 50]);
|
||||
let st = summary::channel_stats(&s, Matrix::Bt709).unwrap();
|
||||
assert_eq!((st.r.mean, st.g.mean, st.b.mean), (78.43, 39.22, 19.61));
|
||||
assert_eq!((st.r.min, st.r.max), (78.43, 78.43));
|
||||
// a two-level frame: left half black, right half white
|
||||
let s = Signal::from_rgba8(100, 4, &rgba8(100, 4, |x, _| if x < 50 { [0; 3] } else { [255; 3] }));
|
||||
let w = waveform(&s, WaveformType::Luma, &Params::default());
|
||||
let cols = summary::trace_columns(&w, w.trace("Y").unwrap(), 4);
|
||||
assert_eq!(cols.len(), 4);
|
||||
assert_eq!(cols[0].unwrap(), summary::Level { min: 0.0, max: 0.0, mean: 0.0 });
|
||||
assert_eq!(cols[3].unwrap(), summary::Level { min: 100.0, max: 100.0, mean: 100.0 });
|
||||
assert_eq!((cols[1].unwrap().max, cols[2].unwrap().min), (0.0, 100.0));
|
||||
let c = summary::coarse(&vectorscope_yuv(&bars(), &Params::default()), 16);
|
||||
assert_eq!(c.iter().map(|e| e[2]).sum::<u32>(), 256 * 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_adds_traces_and_leaves_empty_cells_transparent() {
|
||||
let s = solid(4, 8, [128, 128, 128]);
|
||||
let w = waveform(&s, WaveformType::Rgb, &Params::default());
|
||||
let layers: Vec<(&Grid, [f32; 3])> = w.traces.iter().zip([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).collect();
|
||||
let (pw, ph, px) = paint::grids(&layers, paint::waveform_reference(w.per_column), 1.0);
|
||||
assert_eq!((pw, ph, px.len()), (4, 256, 4 * 256 * 4));
|
||||
// row 128 from the bottom = image row 127; R+G+B coincide → white
|
||||
let i = (127 * 4) * 4;
|
||||
assert_eq!(&px[i..i + 4], &[255, 255, 255, 255]);
|
||||
assert_eq!(&px[0..4], &[0, 0, 0, 0]);
|
||||
// dimmed is darker than bright
|
||||
assert!(paint::intensity(3, 100.0, Brightness::Dimmed.gain()) < paint::intensity(3, 100.0, Brightness::Bright.gain()));
|
||||
let v = vectorscope_yuv(&bars(), &Params::default());
|
||||
let (n, _, vp) = paint::vectorscope(&v, [1.0; 3], 1.0, true);
|
||||
assert_eq!(vp.len(), n * n * 4);
|
||||
// seven spots of 3 × 3 texels each (none touch)
|
||||
assert_eq!(vp.chunks_exact(4).filter(|p| p[3] > 0).count(), 7 * 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_round_trip() {
|
||||
for k in ScopeKind::ALL {
|
||||
assert_eq!(ScopeKind::from_name(k.name()), Some(k));
|
||||
assert_eq!(ScopeKind::from_name(k.label()), Some(k));
|
||||
assert_eq!(serde_json::to_value(k).unwrap(), serde_json::Value::from(k.name()));
|
||||
}
|
||||
assert_eq!(ScopeKind::from_name("vectorscope"), Some(ScopeKind::VectorscopeYuv));
|
||||
assert_eq!(WaveformType::from_name("YC no Chroma"), Some(WaveformType::YcNoChroma));
|
||||
assert_eq!(ParadeType::from_name("rgb-white"), Some(ParadeType::RgbWhite));
|
||||
assert_eq!(ColorSpace::from_name("709"), Some(ColorSpace::Rec709));
|
||||
assert_eq!(ColorSpace::from_name("Rec. 2100"), Some(ColorSpace::Rec2100));
|
||||
assert_eq!(Scale::from_name("8 bit"), Some(Scale::Bits8));
|
||||
assert_eq!(serde_json::to_value(Targets::Percent100).unwrap(), serde_json::Value::from("100"));
|
||||
assert_eq!(ColorSpace::Auto.resolve(true), ColorSpace::Rec2100);
|
||||
assert_eq!(ColorSpace::Auto.resolve(false).matrix(), Matrix::Bt709);
|
||||
}
|
||||
|
||||
/// `cargo test --release -p filmcraft-scopes perf -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn perf_scopes_at_1080p() {
|
||||
let mut seed = 0x1234_5678u32;
|
||||
let px = rgba8(1920, 1080, |_, _| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
[seed as u8, (seed >> 8) as u8, (seed >> 16) as u8]
|
||||
});
|
||||
let reps = 20;
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..reps {
|
||||
std::hint::black_box(Signal::from_rgba8(1920, 1080, &px));
|
||||
}
|
||||
println!("decimate 1920x1080: {:.3} ms", t.elapsed().as_secs_f64() * 1000.0 / reps as f64);
|
||||
let s = Signal::from_rgba8(1920, 1080, &px);
|
||||
let p = Params::default();
|
||||
for (k, wt, pt) in [
|
||||
(ScopeKind::Waveform, WaveformType::Rgb, ParadeType::Rgb),
|
||||
(ScopeKind::Waveform, WaveformType::Yc, ParadeType::Rgb),
|
||||
(ScopeKind::Parade, WaveformType::Rgb, ParadeType::RgbWhite),
|
||||
(ScopeKind::Histogram, WaveformType::Rgb, ParadeType::Rgb),
|
||||
(ScopeKind::VectorscopeYuv, WaveformType::Rgb, ParadeType::Rgb),
|
||||
(ScopeKind::VectorscopeHls, WaveformType::Rgb, ParadeType::Rgb),
|
||||
] {
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..reps {
|
||||
std::hint::black_box(compute(k, &s, &p, wt, pt));
|
||||
}
|
||||
println!("{k:?} {wt:?}/{pt:?}: {:.3} ms", t.elapsed().as_secs_f64() * 1000.0 / reps as f64);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
|
|||
filmcraft-audio-dsp = { workspace = true }
|
||||
filmcraft-engine = { workspace = true }
|
||||
filmcraft-gpu = { workspace = true }
|
||||
filmcraft-scopes = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -183,6 +183,15 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
|||
Err(e) => return err(format!("`{k}`: {e}")),
|
||||
}
|
||||
}
|
||||
// panel settings (`panels::panel_state`): {"scopes": {...}, "timecode": {...}, …}, merged
|
||||
if let Some(patch) = p.get("panels") {
|
||||
let mut cur = serde_json::to_value(&app.ui.panels).unwrap_or_default();
|
||||
merge(&mut cur, patch);
|
||||
match serde_json::from_value(cur) {
|
||||
Ok(v) => app.ui.panels = v,
|
||||
Err(e) => return err(format!("`panels`: {e}")),
|
||||
}
|
||||
}
|
||||
// fields of the open Edit / Clip / File dialog (`panels::clip_dialogs`)
|
||||
if let Some(m) = p.get("clipDialog").and_then(Value::as_object) {
|
||||
let Some(d) = app.ui.clip_dialog.as_mut() else { return err("no clip dialog is open") };
|
||||
|
|
@ -333,6 +342,23 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
|||
}
|
||||
}
|
||||
|
||||
/// Merge `patch` into `v` (objects recursively; other values replace).
|
||||
fn merge(v: &mut Value, patch: &Value) {
|
||||
match (v, patch) {
|
||||
(Value::Object(o), Value::Object(p)) => {
|
||||
for (k, pv) in p {
|
||||
match o.get_mut(k) {
|
||||
Some(cur) => merge(cur, pv),
|
||||
None => {
|
||||
o.insert(k.clone(), pv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(v, p) => *v = p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value {
|
||||
let size = ctx.content_rect().size();
|
||||
json!({
|
||||
|
|
|
|||
|
|
@ -32,10 +32,14 @@ pub enum PanelKind {
|
|||
EssentialSound,
|
||||
Properties,
|
||||
Text,
|
||||
Events,
|
||||
Progress,
|
||||
ReferenceMonitor,
|
||||
Timecode,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub const ALL: [PanelKind; 22] = [
|
||||
pub const ALL: [PanelKind; 26] = [
|
||||
PanelKind::Project,
|
||||
PanelKind::MediaBrowser,
|
||||
PanelKind::Libraries,
|
||||
|
|
@ -58,6 +62,10 @@ impl PanelKind {
|
|||
PanelKind::EssentialSound,
|
||||
PanelKind::Properties,
|
||||
PanelKind::Text,
|
||||
PanelKind::Events,
|
||||
PanelKind::Progress,
|
||||
PanelKind::ReferenceMonitor,
|
||||
PanelKind::Timecode,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -83,6 +91,10 @@ impl PanelKind {
|
|||
PanelKind::EssentialSound => "Essential Sound",
|
||||
PanelKind::Properties => "Properties",
|
||||
PanelKind::Text => "Text",
|
||||
PanelKind::Events => "Events",
|
||||
PanelKind::Progress => "Progress",
|
||||
PanelKind::ReferenceMonitor => "Reference Monitor",
|
||||
PanelKind::Timecode => "Timecode",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> String {
|
||||
|
|
|
|||
|
|
@ -353,7 +353,9 @@ impl FilmcraftApp {
|
|||
| PanelKind::Metadata
|
||||
| PanelKind::LumetriScopes
|
||||
| PanelKind::AudioTrackMixer
|
||||
| PanelKind::Text => PanelKind::Source,
|
||||
| PanelKind::Text
|
||||
| PanelKind::ReferenceMonitor
|
||||
| PanelKind::Timecode => PanelKind::Source,
|
||||
_ => PanelKind::Project,
|
||||
};
|
||||
self.ui.dock.open_near(p, near);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
//! Events panel (warnings and errors of commands, background jobs and the auto-save worker, from
|
||||
//! the engine's `Session::log`) and Progress panel (background jobs with progress and Cancel).
|
||||
//!
|
||||
//! Automation ids: Events — `events.filter`, `events.filter.<info|warning|error>`,
|
||||
//! `events.clearAll`, `events.row.<id>`, `events.details`; Progress — `progress.job.<id>`,
|
||||
//! `progress.cancel.<id>`, `progress.showFinished`. State: `ui.panels.events` / `ui.panels.progress`.
|
||||
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use filmcraft_engine::panels::{Level, LogEntry};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
fn level_color(l: Level) -> Color32 {
|
||||
match l {
|
||||
Level::Info => Color32::from_rgb(0x57, 0x94, 0xec),
|
||||
Level::Warning => Color32::from_rgb(0xe8, 0xb3, 0x3c),
|
||||
Level::Error => Color32::from_rgb(0xe0, 0x4f, 0x4f),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small level glyph: a dot (info), a triangle (warning), a square (error), drawn in code.
|
||||
fn glyph(p: &egui::Painter, c: egui::Pos2, l: Level) {
|
||||
let col = level_color(l);
|
||||
match l {
|
||||
Level::Info => {
|
||||
p.circle_filled(c, 5.0, col);
|
||||
}
|
||||
Level::Warning => {
|
||||
p.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -6.0), c + vec2(6.0, 5.0), c + vec2(-6.0, 5.0)], col, Stroke::NONE));
|
||||
}
|
||||
Level::Error => {
|
||||
p.rect_filled(Rect::from_center_size(c, vec2(10.0, 10.0)), 2.0, col);
|
||||
}
|
||||
}
|
||||
let mark = if l == Level::Info { "i" } else { "!" };
|
||||
p.text(c + vec2(0.0, if l == Level::Warning { 1.0 } else { 0.0 }), Align2::CENTER_CENTER, mark, Tokens::semibold(8.0), Color32::BLACK);
|
||||
}
|
||||
|
||||
fn ago(ms: u64) -> String {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(ms);
|
||||
let s = now.saturating_sub(ms) / 1000;
|
||||
match s {
|
||||
0..=4 => "just now".into(),
|
||||
5..=59 => format!("{s} s ago"),
|
||||
60..=3599 => format!("{} min ago", s / 60),
|
||||
_ => format!("{} h ago", s / 3600),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let st = app.ui.panels.events.clone();
|
||||
let min = Level::from_name(&st.level).unwrap_or(Level::Info);
|
||||
let entries: Vec<LogEntry> = app.session.log.entries.iter().rev().filter(|e| e.level >= min).cloned().collect();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 6.0))));
|
||||
let ui = &mut child;
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
let mut clear = false;
|
||||
let mut level = None;
|
||||
let mut select = None;
|
||||
// toolbar: [All ▾] counts … [Clear All]
|
||||
ui.horizontal(|ui| {
|
||||
let label = |l: Level| match l {
|
||||
Level::Info => "All Events",
|
||||
Level::Warning => "Warnings and Errors",
|
||||
Level::Error => "Errors Only",
|
||||
};
|
||||
let r = egui::ComboBox::from_id_salt("events-filter").selected_text(label(min)).width(150.0).show_ui(ui, |ui| {
|
||||
for l in [Level::Info, Level::Warning, Level::Error] {
|
||||
let r = ui.selectable_label(min == l, label(l));
|
||||
elems.push((format!("events.filter.{}", l.label().to_ascii_lowercase()), r.rect, label(l).into()));
|
||||
if r.clicked() {
|
||||
level = Some(l);
|
||||
}
|
||||
}
|
||||
});
|
||||
elems.push(("events.filter".into(), r.response.rect, label(min).into()));
|
||||
let log = &app.session.log;
|
||||
for l in [Level::Error, Level::Warning, Level::Info] {
|
||||
let (gr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
glyph(ui.painter(), gr.center(), l);
|
||||
ui.label(RichText::new(log.count(l).to_string()).color(t.text_dim));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.button("Clear All");
|
||||
elems.push(("events.clearAll".into(), r.rect, "Clear All".into()));
|
||||
clear = r.clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let details_h = if st.selected.is_some() { 70.0 } else { 0.0 };
|
||||
let list_h = (ui.available_height() - details_h).max(20.0);
|
||||
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| {
|
||||
if entries.is_empty() {
|
||||
ui.label(RichText::new("No events.").color(t.text_faint));
|
||||
}
|
||||
for (n, e) in entries.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
let selected = st.selected == Some(e.id);
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
} else if n % 2 == 1 {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_alt);
|
||||
}
|
||||
glyph(ui.painter(), pos2(r.min.x + 10.0, r.center().y), e.level);
|
||||
let when = ago(e.time_ms);
|
||||
let right = ui.painter().text(pos2(r.max.x - 6.0, r.center().y), Align2::RIGHT_CENTER, &when, Tokens::ui(11.0), t.text_faint);
|
||||
let msg = if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() };
|
||||
let clip = Rect::from_min_max(pos2(r.min.x + 22.0, r.min.y), pos2(right.min.x - 8.0, r.max.y));
|
||||
ui.painter().with_clip_rect(clip).text(pos2(clip.min.x, r.center().y), Align2::LEFT_CENTER, &msg, Tokens::ui(12.0), t.text);
|
||||
elems.push((format!("events.row.{}", e.id), r, msg));
|
||||
if resp.clicked() {
|
||||
select = Some(if selected { None } else { Some(e.id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(sel) = st.selected.and_then(|id| entries.iter().find(|e| e.id == id)) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), details_h - 4.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, 3.0, t.field_bg);
|
||||
let text = format!("{} · {} · {}\n{}", sel.level.label(), sel.source, ago(sel.time_ms), sel.message);
|
||||
let g = ui.painter().layout(text, Tokens::ui(11.5), t.text, r.width() - 12.0);
|
||||
ui.painter().with_clip_rect(r).galley(r.min + vec2(6.0, 4.0), g, t.text);
|
||||
elems.push(("events.details".into(), r, sel.message.clone()));
|
||||
}
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if let Some(l) = level {
|
||||
app.ui.panels.events.level = l.label().to_ascii_lowercase();
|
||||
}
|
||||
if let Some(s) = select {
|
||||
app.ui.panels.events.selected = s;
|
||||
}
|
||||
app.ui.panels.events.seen = app.session.log.entries.back().map(|e| e.id).unwrap_or(0);
|
||||
if clear {
|
||||
app.ui.panels.events.selected = None;
|
||||
if let Err(e) = app.session.execute("events.clear", json!({})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let show_finished = app.ui.panels.progress.show_finished;
|
||||
let jobs: Vec<serde_json::Value> = app.session.jobs.iter().rev().map(|j| j.to_json()).collect();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 6.0))));
|
||||
let ui = &mut child;
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
let mut cancel = None;
|
||||
let mut finished = show_finished;
|
||||
let r = ui.checkbox(&mut finished, "Show finished jobs");
|
||||
elems.push(("progress.showFinished".into(), r.rect, "Show finished jobs".into()));
|
||||
ui.add_space(4.0);
|
||||
let active = jobs.iter().filter(|j| j["finished"] != json!(true)).count();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let mut shown = 0;
|
||||
for j in &jobs {
|
||||
let done = j["finished"] == json!(true);
|
||||
if done && !show_finished {
|
||||
continue;
|
||||
}
|
||||
shown += 1;
|
||||
let id = j["id"].as_u64().unwrap_or(0);
|
||||
let label = j["label"].as_str().unwrap_or("Job");
|
||||
let frac = j["progress"].as_f64().unwrap_or(0.0).clamp(0.0, 1.0) as f32;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
|
||||
ui.painter().rect_filled(r.shrink(1.0), 3.0, t.row_alt);
|
||||
ui.painter().text(r.min + vec2(8.0, 6.0), Align2::LEFT_TOP, label, Tokens::semibold(12.0), t.text);
|
||||
let err = j["result"]["error"].as_str();
|
||||
let status = match (done, err) {
|
||||
(true, Some(e)) if e.to_ascii_lowercase().contains("cancel") => "Cancelled".to_string(),
|
||||
(true, Some(e)) => format!("Failed: {e}"),
|
||||
(true, None) => "Done".to_string(),
|
||||
(false, _) => {
|
||||
let s = j["status"].as_str().unwrap_or("");
|
||||
let n = format!("{} / {}", j["done"].as_u64().unwrap_or(0), j["total"].as_u64().unwrap_or(0));
|
||||
if s.is_empty() { format!("{:.0} % ({n})", frac * 100.0) } else { format!("{:.0} % {s}", frac * 100.0) }
|
||||
}
|
||||
};
|
||||
let bar = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 24.0), pos2(r.max.x - 36.0, r.min.y + 30.0));
|
||||
ui.painter().rect_filled(bar, 3.0, t.field_bg);
|
||||
let fill = if err.is_some() && done { t.danger } else { t.accent };
|
||||
let f = if done && err.is_none() { 1.0 } else { frac };
|
||||
ui.painter().rect_filled(Rect::from_min_max(bar.min, pos2(bar.min.x + bar.width() * f, bar.max.y)), 3.0, fill);
|
||||
ui.painter().text(
|
||||
pos2(r.min.x + 8.0, r.max.y - 4.0),
|
||||
Align2::LEFT_BOTTOM,
|
||||
&status,
|
||||
Tokens::ui(10.5),
|
||||
if err.is_some() { t.danger } else { t.text_dim },
|
||||
);
|
||||
elems.push((format!("progress.job.{id}"), r, format!("{label}: {status}")));
|
||||
if !done {
|
||||
let cr = Rect::from_center_size(pos2(r.max.x - 18.0, bar.center().y), vec2(20.0, 20.0));
|
||||
let resp = ui.interact(cr, egui::Id::new(("progress-cancel", id)), Sense::click());
|
||||
let col = if resp.hovered() { t.danger } else { t.icon };
|
||||
let s = Stroke::new(1.5, col);
|
||||
ui.painter().line_segment([cr.center() + vec2(-5.0, -5.0), cr.center() + vec2(5.0, 5.0)], s);
|
||||
ui.painter().line_segment([cr.center() + vec2(5.0, -5.0), cr.center() + vec2(-5.0, 5.0)], s);
|
||||
elems.push((format!("progress.cancel.{id}"), cr, format!("Cancel {label}")));
|
||||
if resp.on_hover_text("Cancel").clicked() {
|
||||
cancel = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
ui.label(RichText::new(if active == 0 { "No background jobs." } else { "" }).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
app.ui.panels.progress.show_finished = finished;
|
||||
if let Some(id) = cancel
|
||||
&& let Err(e) = app.session.execute("jobs.cancel", json!({"job": id}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if active > 0 {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
//! Metadata panel: the clip and file properties of the selected item (Project panel selection, or
|
||||
//! the clip selected in the Timeline when it has focus). Name, Label and the log fields
|
||||
//! (Description, Scene, Shot, Log Note, Comment, Tape Name, Client, Camera Angle) are editable;
|
||||
//! each committed edit is one `metadata.set` (undoable, saved in the project).
|
||||
//!
|
||||
//! Automation ids: `metadata.empty` (nothing selected), `metadata.header`, `metadata.field.<Name>` (spaces removed: `metadata.field.LogNote`),
|
||||
//! `metadata.label`, `metadata.section.<Clip|File>`.
|
||||
|
||||
use egui::{Rect, RichText, Sense, vec2};
|
||||
use filmcraft_engine::panels::{Field, metadata_fields, metadata_target};
|
||||
use filmcraft_project::{ItemId, Label};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::dock::PanelKind;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The item the panel shows.
|
||||
pub fn target(app: &FilmcraftApp) -> Option<ItemId> {
|
||||
let s = &app.session;
|
||||
if app.ui.focused == PanelKind::Timeline
|
||||
&& let Some(c) = s.state.selection.first()
|
||||
{
|
||||
return metadata_target(s, &json!({"clip": c.0}));
|
||||
}
|
||||
metadata_target(s, &json!({}))
|
||||
}
|
||||
|
||||
pub fn field_id(name: &str) -> String {
|
||||
format!("metadata.field.{}", name.replace(' ', ""))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let Some(item) = target(app) else {
|
||||
crate::dock::placeholder(ui, rect, &t, "Select a clip to view its metadata.");
|
||||
app.auto.add("metadata.empty", rect, "Select a clip to view its metadata.");
|
||||
return;
|
||||
};
|
||||
let fields = metadata_fields(&app.session.project, item);
|
||||
let name = app.session.project.item(item).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 8.0))));
|
||||
let ui = &mut child;
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
let mut commit: Option<(String, String)> = None;
|
||||
// header: "Clip: name" with a rule (like the Info panel)
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
|
||||
let g = ui.painter().layout_no_wrap(format!("Clip: {name}"), Tokens::semibold(14.0), t.text);
|
||||
let gw = g.size().x;
|
||||
ui.painter().galley(egui::pos2(hr.min.x, hr.center().y - g.size().y / 2.0), g, t.text);
|
||||
ui.painter().line_segment([egui::pos2(hr.min.x + gw + 10.0, hr.center().y), egui::pos2(hr.max.x, hr.center().y)], egui::Stroke::new(2.0, t.separator));
|
||||
elems.push(("metadata.header".into(), hr, format!("Clip: {name}")));
|
||||
let label_w = 120.0;
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let mut section = "";
|
||||
for f in &fields {
|
||||
if f.section != section {
|
||||
section = f.section;
|
||||
ui.add_space(4.0);
|
||||
let r = ui.label(RichText::new(section).color(t.text_dim).strong());
|
||||
elems.push((format!("metadata.section.{section}"), r.rect, section.to_string()));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 20.0), egui::Label::new(RichText::new(format!("{}:", f.name)).color(t.text_dim)).truncate());
|
||||
if f.name == "Label" {
|
||||
let cur = Label::from_name(&f.value).unwrap_or(Label::Violet);
|
||||
let mut sel = cur;
|
||||
let r =
|
||||
egui::ComboBox::from_id_salt(("md-label", item.0)).selected_text(cur.name()).width(ui.available_width().min(200.0)).show_ui(ui, |ui| {
|
||||
for l in Label::ALL {
|
||||
let c = l.rgb();
|
||||
let rr = ui.horizontal(|ui| {
|
||||
let (sw, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
|
||||
ui.painter().rect_filled(sw, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
|
||||
ui.selectable_value(&mut sel, l, l.name())
|
||||
});
|
||||
elems.push((format!("metadata.label.{}", l.name()), rr.inner.rect, l.name().to_string()));
|
||||
}
|
||||
});
|
||||
elems.push(("metadata.label".into(), r.response.rect, cur.name().to_string()));
|
||||
if sel != cur {
|
||||
commit = Some(("Label".into(), sel.name().to_string()));
|
||||
}
|
||||
} else if f.editable {
|
||||
if let Some(c) = text_field(ui, item, f, &mut elems) {
|
||||
commit = Some(c);
|
||||
}
|
||||
} else {
|
||||
let r = ui.add(egui::Label::new(RichText::new(&f.value).color(t.text)).truncate());
|
||||
elems.push((field_id(&f.name), r.rect, f.value.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if let Some((field, value)) = commit
|
||||
&& let Err(e) = app.session.execute("metadata.set", json!({"item": item.0, "field": field, "value": value}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// An editable value: a text field whose edit is committed when it loses focus (Enter, Tab or a
|
||||
/// click elsewhere). Returns (field, new value) on commit.
|
||||
fn text_field(ui: &mut egui::Ui, item: ItemId, f: &Field, elems: &mut Vec<(String, Rect, String)>) -> Option<(String, String)> {
|
||||
let id = egui::Id::new(("md-draft", item.0, f.name.clone()));
|
||||
let mut draft: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| f.value.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone())));
|
||||
elems.push((field_id(&f.name), r.rect, f.value.clone()));
|
||||
let mut out = None;
|
||||
if r.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(id, draft.clone()));
|
||||
} else {
|
||||
if r.lost_focus() && draft != f.value {
|
||||
out = Some((f.name.clone(), draft));
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
//! Smaller panels: History, Markers, Info, Media Browser, Lumetri Scopes.
|
||||
//! Smaller panels: History, Markers, Info, Media Browser.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use filmcraft_time::{TimeDisplay, format_time};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::frames::{FrameKey, Target};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
pub fn history(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
|
@ -255,122 +254,3 @@ pub fn media_browser(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let _ = t;
|
||||
}
|
||||
|
||||
/// Lumetri Scopes: luma waveform + vectorscope of the program frame.
|
||||
pub fn scopes(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let Some(seq_id) = app.session.state.active_sequence else { return };
|
||||
let rate = app.session.sequence_rate();
|
||||
let frame = rate.frame_at(app.session.playhead());
|
||||
let key = FrameKey { target: Target::Sequence(seq_id), frame, size: 250, revision: app.session.revision };
|
||||
let project = app.session.project.clone();
|
||||
// While playing, scopes come after the Program monitor's prefetch.
|
||||
let prio = if app.playback.playing { 40 } else { 2 };
|
||||
app.frames.request(key, rate.tick_of(frame), 0.25, &project, prio);
|
||||
let pipe = app.session.active_sequence().map(|q| q.settings.color).unwrap_or(filmcraft_color::ColorPipeline::REC709);
|
||||
if pipe.working.is_hdr() {
|
||||
hdr_scopes(app, ui, rect, (seq_id.0, frame, app.session.revision));
|
||||
return;
|
||||
}
|
||||
let Some(img) = app.frames.get(&key) else {
|
||||
crate::dock::placeholder(ui, rect, &t, "…");
|
||||
return;
|
||||
};
|
||||
let half = rect.width() / 2.0;
|
||||
let wf = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(half - 12.0, rect.height() - 16.0));
|
||||
let vs_size = (rect.height() - 16.0).min(half - 12.0);
|
||||
let vs = Rect::from_center_size(pos2(rect.min.x + half + half / 2.0, rect.center().y), vec2(vs_size, vs_size));
|
||||
ui.painter().rect_filled(wf, 0.0, Color32::BLACK);
|
||||
ui.painter().rect_filled(vs, vs_size / 2.0, Color32::BLACK);
|
||||
// waveform: plot luma per column
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let (w, h) = (img.w, img.h);
|
||||
let step_y = (h / 90).max(1);
|
||||
for x in (0..w).step_by((w / wf.width().max(1.0) as usize).max(1)) {
|
||||
for y in (0..h).step_by(step_y) {
|
||||
let i = (y * w + x) * 4;
|
||||
let l = 0.2126 * img.px[i] as f32 + 0.7152 * img.px[i + 1] as f32 + 0.0722 * img.px[i + 2] as f32;
|
||||
let px = wf.min.x + x as f32 / w as f32 * wf.width();
|
||||
let py = wf.max.y - l / 255.0 * wf.height();
|
||||
mesh.add_colored_rect(Rect::from_min_size(pos2(px, py), vec2(1.0, 1.0)), Color32::from_rgba_unmultiplied(120, 255, 140, 60));
|
||||
// vectorscope
|
||||
let (r, g, b) = (img.px[i] as f32 / 255.0, img.px[i + 1] as f32 / 255.0, img.px[i + 2] as f32 / 255.0);
|
||||
let ycc = filmcraft_color::rgb_to_ycbcr(r, g, b, filmcraft_color::Matrix::Bt709);
|
||||
let vx = vs.center().x + ycc[1] * vs_size;
|
||||
let vy = vs.center().y - ycc[2] * vs_size;
|
||||
mesh.add_colored_rect(Rect::from_min_size(pos2(vx, vy), vec2(1.0, 1.0)), Color32::from_rgba_unmultiplied(200, 255, 200, 50));
|
||||
}
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
for v in [0, 25, 50, 75, 100] {
|
||||
let y = wf.max.y - v as f32 / 100.0 * wf.height();
|
||||
ui.painter().line_segment([pos2(wf.min.x, y), pos2(wf.max.x, y)], egui::Stroke::new(0.5, Color32::from_white_alpha(30)));
|
||||
ui.painter().text(pos2(wf.min.x + 2.0, y - 6.0), Align2::LEFT_CENTER, v.to_string(), Tokens::ui(8.0), t.text_faint);
|
||||
}
|
||||
ui.painter().circle_stroke(vs.center(), vs_size / 2.0, egui::Stroke::new(1.0, Color32::from_white_alpha(40)));
|
||||
}
|
||||
|
||||
/// Nits → waveform height (0..1) on the PQ scale used by HDR waveforms.
|
||||
fn pq_axis(nits: f32) -> f32 {
|
||||
filmcraft_color::pq_inverse_eotf((nits / 10_000.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Scopes of an HDR (Rec. 2100 PQ/HLG) sequence: luminance waveform in cd/m² on a PQ-scaled
|
||||
/// axis (0–10 000 nits, reference white 203 marked) and a BT.2020 vectorscope. The frame is the
|
||||
/// sequence rendered in its working space (HDR values kept), small and cached per frame.
|
||||
fn hdr_scopes(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, key: (u64, i64, u64)) {
|
||||
use std::sync::Mutex;
|
||||
static CACHE: Mutex<Option<((u64, i64, u64), filmcraft_render::Image)>> = Mutex::new(None);
|
||||
let t = app.tokens;
|
||||
let img = {
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.as_ref().is_none_or(|(k, _)| *k != key)
|
||||
&& let Some(img) = app.session.render_program_working(0.125)
|
||||
{
|
||||
*c = Some((key, img));
|
||||
}
|
||||
c.as_ref().map(|(_, i)| i.clone())
|
||||
};
|
||||
let Some(img) = img else {
|
||||
crate::dock::placeholder(ui, rect, &t, "…");
|
||||
return;
|
||||
};
|
||||
let half = rect.width() / 2.0;
|
||||
let wf = Rect::from_min_size(rect.min + vec2(34.0, 8.0), vec2(half - 38.0, rect.height() - 16.0));
|
||||
let vs_size = (rect.height() - 16.0).min(half - 12.0);
|
||||
let vs = Rect::from_center_size(pos2(rect.min.x + half + half / 2.0, rect.center().y), vec2(vs_size, vs_size));
|
||||
ui.painter().rect_filled(wf, 0.0, Color32::BLACK);
|
||||
ui.painter().rect_filled(vs, vs_size / 2.0, Color32::BLACK);
|
||||
let luma = filmcraft_color::Gamut::Bt2020.luma();
|
||||
let (w, h) = (img.w, img.h);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let mut peak = 0f32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = img.get(x, y);
|
||||
let a = p[3].max(1e-6);
|
||||
let c = [p[0] / a, p[1] / a, p[2] / a];
|
||||
let nits = (luma[0] as f32 * c[0] + luma[1] as f32 * c[1] + luma[2] as f32 * c[2]).max(0.0) * filmcraft_color::REFERENCE_WHITE_NITS as f32;
|
||||
peak = peak.max(nits);
|
||||
let px = wf.min.x + x as f32 / w as f32 * wf.width();
|
||||
let py = wf.max.y - pq_axis(nits) * wf.height();
|
||||
mesh.add_colored_rect(Rect::from_min_size(pos2(px, py), vec2(1.5, 1.0)), Color32::from_rgba_unmultiplied(120, 255, 140, 70));
|
||||
// vectorscope on PQ-encoded BT.2020 Y'CbCr
|
||||
let e = c.map(|v| pq_axis(v.max(0.0) * filmcraft_color::REFERENCE_WHITE_NITS as f32));
|
||||
let ycc = filmcraft_color::rgb_to_ycbcr(e[0], e[1], e[2], filmcraft_color::Matrix::Bt2020Ncl);
|
||||
let vx = vs.center().x + ycc[1] * vs_size;
|
||||
let vy = vs.center().y - ycc[2] * vs_size;
|
||||
mesh.add_colored_rect(Rect::from_min_size(pos2(vx, vy), vec2(1.0, 1.0)), Color32::from_rgba_unmultiplied(200, 255, 200, 60));
|
||||
}
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
for (nits, label) in [(0.0, "0"), (10.0, "10"), (100.0, "100"), (203.0, "203"), (1000.0, "1000"), (4000.0, "4000"), (10_000.0, "10000")] {
|
||||
let y = wf.max.y - pq_axis(nits) * wf.height();
|
||||
let col = if nits == 203.0 { Color32::from_rgba_unmultiplied(255, 200, 80, 90) } else { Color32::from_white_alpha(30) };
|
||||
ui.painter().line_segment([pos2(wf.min.x, y), pos2(wf.max.x, y)], egui::Stroke::new(0.5, col));
|
||||
ui.painter().text(pos2(wf.min.x - 3.0, y), Align2::RIGHT_CENTER, label, Tokens::ui(8.0), t.text_faint);
|
||||
}
|
||||
ui.painter().text(wf.left_top() + vec2(4.0, 4.0), Align2::LEFT_TOP, format!("HDR · cd/m² · peak {peak:.0}"), Tokens::ui(9.0), t.text_dim);
|
||||
app.auto.add("scopes.hdrWaveform", wf, &format!("HDR waveform, peak {peak:.0} nits"));
|
||||
ui.painter().circle_stroke(vs.center(), vs_size / 2.0, egui::Stroke::new(1.0, Color32::from_white_alpha(40)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod dialogs;
|
|||
pub mod effect_controls;
|
||||
pub mod effects;
|
||||
pub mod essential_sound;
|
||||
pub mod events;
|
||||
pub mod export_mode;
|
||||
pub mod file_dialogs;
|
||||
pub mod graphics;
|
||||
|
|
@ -16,17 +17,22 @@ pub mod lumetri;
|
|||
pub mod masks;
|
||||
pub mod media_dialogs;
|
||||
pub mod menu_dialogs;
|
||||
pub mod metadata;
|
||||
pub mod meters;
|
||||
pub mod misc;
|
||||
pub mod mixer;
|
||||
pub mod monitor;
|
||||
pub mod monitor_view;
|
||||
pub mod multicam;
|
||||
pub mod panel_state;
|
||||
pub mod presets;
|
||||
pub mod project;
|
||||
pub mod reference;
|
||||
pub mod scopes;
|
||||
pub mod settings;
|
||||
pub mod shortcuts_dialog;
|
||||
pub mod text;
|
||||
pub mod timecode;
|
||||
pub mod timeline;
|
||||
pub mod timeline_automation;
|
||||
pub mod timeline_captions;
|
||||
|
|
@ -61,7 +67,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
|
|||
PanelKind::MediaBrowser => misc::media_browser(app, ui, rect),
|
||||
PanelKind::AudioTrackMixer => mixer::track_mixer(app, ui, rect),
|
||||
PanelKind::AudioClipMixer => mixer::clip_mixer(app, ui, rect),
|
||||
PanelKind::LumetriScopes => misc::scopes(app, ui, rect),
|
||||
PanelKind::LumetriScopes => scopes::show(app, ui, rect),
|
||||
PanelKind::Metadata => metadata::show(app, ui, rect),
|
||||
PanelKind::Timecode => timecode::show(app, ui, rect),
|
||||
PanelKind::Events => events::events(app, ui, rect),
|
||||
PanelKind::Progress => events::progress(app, ui, rect),
|
||||
PanelKind::ReferenceMonitor => reference::show(app, ui, rect),
|
||||
PanelKind::Text => text::show(app, ui, rect),
|
||||
PanelKind::EssentialSound => essential_sound::show(app, ui, rect),
|
||||
other => crate::dock::placeholder(ui, rect, &app.tokens, &format!("{} — coming in a later milestone", other.title())),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
//! Frontend state of the Lumetri Scopes, Timecode, Events, Progress and Reference Monitor panels
|
||||
//! (`UiState::panels`). Plain serde, so agents read it with `ui.inspect` (`ui.panels`) and change
|
||||
//! it with `ui.set {"panels": {"scopes": {...}, "timecode": {...}, ...}}` (fields merged).
|
||||
|
||||
use filmcraft_scopes::{Brightness, ColorSpace, ParadeType, Scale, ScopeKind, Targets, WaveformType};
|
||||
use filmcraft_time::TimeDisplay;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PanelsState {
|
||||
pub scopes: ScopesState,
|
||||
pub timecode: TimecodeState,
|
||||
pub reference: ReferenceState,
|
||||
pub events: EventsState,
|
||||
pub progress: ProgressState,
|
||||
}
|
||||
|
||||
/// Lumetri Scopes settings (the wrench / right-click menu and the footer).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ScopesState {
|
||||
/// The scopes shown, in layout order.
|
||||
pub shown: Vec<ScopeKind>,
|
||||
pub waveform_type: WaveformType,
|
||||
pub parade_type: ParadeType,
|
||||
pub color_space: ColorSpace,
|
||||
pub brightness: Brightness,
|
||||
/// The bit-depth dropdown: 8 Bit, Float or HDR.
|
||||
pub scale: Scale,
|
||||
/// Clamp Signal.
|
||||
pub clamp: bool,
|
||||
/// YUV vectorscope targets (75 % / 100 %).
|
||||
pub targets: Targets,
|
||||
/// The preset last applied ("" = custom).
|
||||
pub preset: String,
|
||||
}
|
||||
|
||||
impl Default for ScopesState {
|
||||
fn default() -> Self {
|
||||
ScopesState {
|
||||
shown: vec![ScopeKind::Parade],
|
||||
waveform_type: WaveformType::Rgb,
|
||||
parade_type: ParadeType::Rgb,
|
||||
color_space: ColorSpace::Auto,
|
||||
brightness: Brightness::Normal,
|
||||
scale: Scale::Bits8,
|
||||
clamp: true,
|
||||
targets: Targets::Percent75,
|
||||
preset: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scopes layout preset: (name, scopes, waveform type, parade type, scale, clamp).
|
||||
pub type ScopePreset = (&'static str, &'static [ScopeKind], WaveformType, ParadeType, Scale, bool);
|
||||
|
||||
/// The Presets submenu (our own names).
|
||||
pub const SCOPE_PRESETS: [ScopePreset; 8] = {
|
||||
use ScopeKind::*;
|
||||
[
|
||||
("Waveform (RGB)", &[Waveform], WaveformType::Rgb, ParadeType::Rgb, Scale::Bits8, true),
|
||||
("Waveform (Luma)", &[Waveform], WaveformType::Luma, ParadeType::Rgb, Scale::Bits8, true),
|
||||
("Parade (RGB)", &[Parade], WaveformType::Rgb, ParadeType::Rgb, Scale::Bits8, true),
|
||||
("Vectorscope (YUV)", &[VectorscopeYuv], WaveformType::Rgb, ParadeType::Rgb, Scale::Bits8, true),
|
||||
("Vectorscope + Waveform YC", &[VectorscopeYuv, Waveform], WaveformType::Yc, ParadeType::Rgb, Scale::Bits8, true),
|
||||
("Four Scopes (YUV, float, no clamp)", &[VectorscopeYuv, Histogram, Parade, Waveform], WaveformType::Yc, ParadeType::Yuv, Scale::Float, false),
|
||||
("Four Scopes (RGB, 8 bit)", &[VectorscopeYuv, Histogram, Parade, Waveform], WaveformType::Rgb, ParadeType::Rgb, Scale::Bits8, true),
|
||||
("HLS Vectorscope + Histogram", &[VectorscopeHls, Histogram], WaveformType::Rgb, ParadeType::Rgb, Scale::Bits8, true),
|
||||
]
|
||||
};
|
||||
|
||||
impl ScopesState {
|
||||
pub fn apply_preset(&mut self, name: &str) -> bool {
|
||||
let Some(p) = SCOPE_PRESETS.iter().find(|p| p.0.eq_ignore_ascii_case(name)) else { return false };
|
||||
self.shown = p.1.to_vec();
|
||||
self.waveform_type = p.2;
|
||||
self.parade_type = p.3;
|
||||
self.scale = p.4;
|
||||
self.clamp = p.5;
|
||||
self.preset = p.0.to_string();
|
||||
true
|
||||
}
|
||||
/// Show or hide a scope (the last one cannot be hidden).
|
||||
pub fn toggle(&mut self, k: ScopeKind) {
|
||||
if let Some(i) = self.shown.iter().position(|x| *x == k) {
|
||||
if self.shown.len() > 1 {
|
||||
self.shown.remove(i);
|
||||
}
|
||||
} else {
|
||||
// keep the menu's order
|
||||
self.shown.push(k);
|
||||
self.shown.sort_by_key(|x| ScopeKind::ALL.iter().position(|a| a == x));
|
||||
}
|
||||
self.preset.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Timecode panel row measures.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TcMode {
|
||||
/// The playhead (sequence time / source clip time).
|
||||
#[default]
|
||||
Current,
|
||||
/// The media timecode of the clip under the playhead (topmost video clip; the file's own
|
||||
/// timecode for the Source monitor).
|
||||
Media,
|
||||
/// The sequence's / clip's duration.
|
||||
Duration,
|
||||
/// In to Out duration (the whole duration when no marks).
|
||||
InOut,
|
||||
/// From the playhead to the end (or to the Out point).
|
||||
Remaining,
|
||||
}
|
||||
|
||||
impl TcMode {
|
||||
pub const ALL: [TcMode; 5] = [TcMode::Current, TcMode::Media, TcMode::Duration, TcMode::InOut, TcMode::Remaining];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TcMode::Current => "Current Time",
|
||||
TcMode::Media => "Media Time",
|
||||
TcMode::Duration => "Duration",
|
||||
TcMode::InOut => "In/Out Duration",
|
||||
TcMode::Remaining => "Remaining",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which monitor a Timecode row follows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TcSource {
|
||||
/// The Source monitor when it has focus, else the Program / Timeline.
|
||||
#[default]
|
||||
Active,
|
||||
Program,
|
||||
Source,
|
||||
}
|
||||
|
||||
impl TcSource {
|
||||
pub const ALL: [TcSource; 3] = [TcSource::Active, TcSource::Program, TcSource::Source];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TcSource::Active => "Active Monitor",
|
||||
TcSource::Program => "Program",
|
||||
TcSource::Source => "Source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TimecodeRow {
|
||||
pub source: TcSource,
|
||||
pub mode: TcMode,
|
||||
pub display: TimeDisplay,
|
||||
}
|
||||
|
||||
/// The Timecode panel: one large row plus optional smaller rows.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TimecodeState {
|
||||
pub rows: Vec<TimecodeRow>,
|
||||
/// Show the clip / sequence name under the timecode.
|
||||
pub show_name: bool,
|
||||
}
|
||||
|
||||
impl Default for TimecodeState {
|
||||
fn default() -> Self {
|
||||
TimecodeState { rows: vec![TimecodeRow::default()], show_name: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Reference Monitor shows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RefDisplay {
|
||||
#[default]
|
||||
Composite,
|
||||
/// Lumetri Scopes of the reference frame (the panel's scope selection).
|
||||
Scopes,
|
||||
}
|
||||
|
||||
/// The Reference Monitor: a second view of the active sequence, ganged to the Program monitor or
|
||||
/// parked on its own frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ReferenceState {
|
||||
/// Gang to Program Monitor: follow the playhead.
|
||||
pub ganged: bool,
|
||||
/// The reference time (ticks) when not ganged.
|
||||
pub time: i64,
|
||||
pub display: RefDisplay,
|
||||
/// Scopes shown in Scopes mode.
|
||||
pub scopes: Vec<ScopeKind>,
|
||||
}
|
||||
|
||||
impl Default for ReferenceState {
|
||||
fn default() -> Self {
|
||||
ReferenceState { ganged: false, time: 0, display: RefDisplay::Composite, scopes: vec![ScopeKind::VectorscopeYuv, ScopeKind::Waveform] }
|
||||
}
|
||||
}
|
||||
|
||||
/// The Events panel.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EventsState {
|
||||
/// Lowest level shown ("info", "warning", "error").
|
||||
pub level: String,
|
||||
/// The selected entry (details shown below the list).
|
||||
pub selected: Option<u64>,
|
||||
/// Entries seen when the panel was last shown (newer ones are "new").
|
||||
pub seen: u64,
|
||||
}
|
||||
|
||||
/// The Progress panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProgressState {
|
||||
/// Also list finished jobs.
|
||||
pub show_finished: bool,
|
||||
}
|
||||
|
||||
impl Default for ProgressState {
|
||||
fn default() -> Self {
|
||||
ProgressState { show_finished: true }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
//! Reference Monitor: a second view of the active sequence for comparing shots while grading. It
|
||||
//! is either ganged to the Program monitor (follows the playhead) or parked on its own frame,
|
||||
//! and shows the picture (Composite Video) or Lumetri Scopes of that frame.
|
||||
//!
|
||||
//! Automation ids: `reference.picture` (once a picture is shown), `reference.timecode`, `reference.stepBack`,
|
||||
//! `reference.stepForward`, `reference.matchPlayhead`, `reference.gang`, `reference.display`,
|
||||
//! `reference.display.<composite|scopes>`, `reference.scope.<kind>` (right-click menu in Scopes
|
||||
//! mode), `reference.scopes.view.<kind>`. State: `ui.panels.reference`.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use filmcraft_scopes::ScopeKind;
|
||||
use filmcraft_time::{Tick, TimeDisplay, format_time};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::frames::{FrameKey, Target};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::panel_state::RefDisplay;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The frame the Reference Monitor shows (None without a sequence).
|
||||
pub fn frame(app: &FilmcraftApp) -> Option<i64> {
|
||||
let q = app.session.active_sequence()?;
|
||||
let rate = q.settings.frame_rate;
|
||||
let r = &app.ui.panels.reference;
|
||||
let t = if r.ganged { app.session.playhead() } else { Tick(r.time) };
|
||||
let last = rate.frame_at(q.duration()).max(1) - 1;
|
||||
Some(rate.frame_at(t).clamp(0, last))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(seq_id) = app.session.state.active_sequence else {
|
||||
crate::dock::placeholder(ui, rect, &t, "(no sequences)");
|
||||
return;
|
||||
};
|
||||
let Some(frame) = frame(app) else { return };
|
||||
let q = app.session.active_sequence().expect("active").clone();
|
||||
let rate = q.settings.frame_rate;
|
||||
let bar_h = 34.0;
|
||||
let area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - bar_h));
|
||||
let st = app.ui.panels.reference.clone();
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
match st.display {
|
||||
RefDisplay::Composite => {
|
||||
let (w, h) = (q.settings.width.max(1) as f32, q.settings.height.max(1) as f32);
|
||||
let pic = crate::panels::monitor::fit(area, w, h);
|
||||
ui.painter().rect_filled(pic, 0.0, t.monitor_bg);
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let scale = crate::panels::monitor::quantize_scale((pic.width() * ppp / w).clamp(1.0 / 32.0, 1.0));
|
||||
let key = FrameKey { target: Target::Sequence(seq_id), frame, size: (scale * 1000.0) as u32, revision: app.session.revision };
|
||||
let project = app.session.project.clone();
|
||||
app.frames.request(key, rate.tick_of(frame), scale, &project, 3);
|
||||
let tex = match app.frames.get(&key) {
|
||||
Some(img) => Some(app.texture_for(&ctx, "reference-monitor", key, &img)),
|
||||
None => app.texture_existing("reference-monitor").map(|x| x.0),
|
||||
};
|
||||
if let Some(tex) = tex {
|
||||
ui.painter().image(tex, pic, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
elems.push(("reference.picture".into(), pic, "reference frame".into()));
|
||||
}
|
||||
}
|
||||
RefDisplay::Scopes => {
|
||||
ui.painter().rect_filled(area, 0.0, Color32::BLACK);
|
||||
let sst = app.ui.panels.scopes.clone();
|
||||
match crate::panels::scopes::frame_signal(app, &ctx, "reference", frame, &sst, 3) {
|
||||
Some(sf) => crate::panels::scopes::draw(app, ui, area, &st.scopes, &sf, &sst, "reference.scopes"),
|
||||
None => crate::dock::placeholder(ui, area, &t, "…"),
|
||||
}
|
||||
elems.push(("reference.picture".into(), area, "reference scopes".into()));
|
||||
let resp = ui.interact(area, egui::Id::new("reference-area"), Sense::click());
|
||||
egui::Popup::context_menu(&resp).show(|ui| {
|
||||
for k in ScopeKind::ALL {
|
||||
let on = st.scopes.contains(&k);
|
||||
let r = ui.selectable_label(on, k.label());
|
||||
elems.push((format!("reference.scope.{}", k.name()), r.rect, k.label().into()));
|
||||
if r.clicked() {
|
||||
let v = &mut app.ui.panels.reference.scopes;
|
||||
if on {
|
||||
if v.len() > 1 {
|
||||
v.retain(|x| *x != k);
|
||||
}
|
||||
} else {
|
||||
v.push(k);
|
||||
v.sort_by_key(|x| ScopeKind::ALL.iter().position(|a| a == x));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// ---- control bar: timecode · ◀ ▶ · Match Playhead · Gang · [Composite Video ▾]
|
||||
let bar = Rect::from_min_max(pos2(rect.min.x, area.max.y + 4.0), rect.max);
|
||||
let tc = format_time(rate.tick_of(frame), rate, q.settings.drop_frame, TimeDisplay::Timecode, 48000);
|
||||
let tr = ui.painter().text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, &tc, Tokens::semibold(15.0), t.timecode);
|
||||
elems.push(("reference.timecode".into(), tr, tc.clone()));
|
||||
let mut step = 0i64;
|
||||
let mut x = tr.max.x + 16.0;
|
||||
let mut button = |ui: &mut egui::Ui, id: &str, label: &str, icon: Option<Icon>, on: bool, elems: &mut Vec<(String, Rect, String)>| -> bool {
|
||||
let galley_w = if icon.is_some() { 0.0 } else { ui.painter().layout_no_wrap(label.into(), Tokens::ui(11.5), t.text).size().x };
|
||||
let w = if icon.is_some() { 24.0 } else { galley_w + 16.0 };
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w, 22.0));
|
||||
x = r.max.x + 4.0;
|
||||
let resp = ui.interact(r, egui::Id::new(("reference-btn", id.to_string())), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 4.0, t.pressed);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
match icon {
|
||||
Some(i) => icons::paint(ui.painter(), r.shrink(4.0), i, if on { t.icon_active } else { t.icon }),
|
||||
None => {
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), if on { t.tab_text_active } else { t.text });
|
||||
}
|
||||
}
|
||||
elems.push((id.to_string(), r, label.to_string()));
|
||||
resp.on_hover_text(label).clicked()
|
||||
};
|
||||
if button(ui, "reference.stepBack", "Step Back 1 Frame", Some(Icon::StepBack), false, &mut elems) {
|
||||
step = -1;
|
||||
}
|
||||
if button(ui, "reference.stepForward", "Step Forward 1 Frame", Some(Icon::StepFwd), false, &mut elems) {
|
||||
step = 1;
|
||||
}
|
||||
let matched = button(ui, "reference.matchPlayhead", "Match Playhead", None, false, &mut elems);
|
||||
let gang = button(ui, "reference.gang", "Gang to Program", None, st.ganged, &mut elems);
|
||||
// display dropdown at the right
|
||||
let dr = Rect::from_min_size(pos2(bar.max.x - 150.0, bar.center().y - 11.0), vec2(142.0, 22.0));
|
||||
let mut display = st.display;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(dr));
|
||||
let label = |d: RefDisplay| if d == RefDisplay::Composite { "Composite Video" } else { "Lumetri Scopes" };
|
||||
let cb = egui::ComboBox::from_id_salt("reference-display").selected_text(label(display)).width(134.0).show_ui(&mut child, |ui| {
|
||||
for d in [RefDisplay::Composite, RefDisplay::Scopes] {
|
||||
let r = ui.selectable_value(&mut display, d, label(d));
|
||||
elems.push((format!("reference.display.{}", if d == RefDisplay::Composite { "composite" } else { "scopes" }), r.rect, label(d).into()));
|
||||
}
|
||||
});
|
||||
elems.push(("reference.display".into(), cb.response.rect, label(display).into()));
|
||||
if st.ganged {
|
||||
ui.painter().rect_stroke(area, 0.0, Stroke::new(1.0, t.focus.gamma_multiply(0.5)), egui::StrokeKind::Inside);
|
||||
}
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
let r = &mut app.ui.panels.reference;
|
||||
r.display = display;
|
||||
if gang {
|
||||
r.ganged = !r.ganged;
|
||||
if !r.ganged {
|
||||
r.time = rate.tick_of(frame).0;
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
r.ganged = false;
|
||||
r.time = app.session.playhead().0;
|
||||
}
|
||||
if step != 0 {
|
||||
r.ganged = false;
|
||||
r.time = rate.tick_of((frame + step).max(0)).0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
//! Lumetri Scopes: Vectorscope YUV / HLS, Histogram, Parade (RGB / YUV / RGB-White) and Waveform
|
||||
//! (RGB / Luma / YC / YC no Chroma) of the Program frame, in a grid when several are shown.
|
||||
//!
|
||||
//! The maths is in `filmcraft-scopes` (count grids from the decimated frame); this module turns
|
||||
//! them into textures (cached per frame and settings) and draws the graticules, labels and the
|
||||
//! panel chrome: the footer (colour space, wrench, Clamp Signal, 8 Bit / Float / HDR) and the
|
||||
//! settings menu (wrench or right-click: Presets, the scopes, Waveform / Parade Type, Colour
|
||||
//! Space, Brightness, Vectorscope Targets).
|
||||
//!
|
||||
//! Automation ids: `scopes.view.<kind>` (each scope), `scopes.wrench`, `scopes.clamp`,
|
||||
//! `scopes.scale`, `scopes.space`; menu items `scopes.menu.<kind>`, `scopes.menu.preset.<n>`,
|
||||
//! `scopes.menu.waveformType.<type>`, `scopes.menu.paradeType.<type>`,
|
||||
//! `scopes.menu.colorSpace.<space>`, `scopes.menu.brightness.<b>`, `scopes.menu.targets.<75|100>`;
|
||||
//! `scopes.hdrWaveform` while levels are shown in cd/m². State: `ui.panels.scopes`.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, TextureHandle, TextureOptions, pos2, vec2};
|
||||
use filmcraft_scopes::{self as sc, Brightness, ColorSpace, ParadeType, Params, Scale, ScopeKind, Signal, Targets, WaveformType};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::frames::{FrameKey, Target};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::panel_state::{SCOPE_PRESETS, ScopesState};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const GRATICULE: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x2a);
|
||||
const LABEL: Color32 = Color32::from_rgb(0xb0, 0xb0, 0xb0);
|
||||
const FOOTER_H: f32 = 30.0;
|
||||
|
||||
/// A frame prepared for the scopes.
|
||||
#[derive(Clone)]
|
||||
pub struct ScopeFrame {
|
||||
pub signal: Arc<Signal>,
|
||||
/// Identity of the frame (cache key).
|
||||
pub key: u64,
|
||||
/// PQ code values of a working-space render (levels in cd/m²).
|
||||
pub pq: bool,
|
||||
pub space: ColorSpace,
|
||||
}
|
||||
|
||||
fn hash_of(v: impl Hash) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
v.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The active sequence's frame `frame` for the scopes: the monitor picture (¼ resolution, from
|
||||
/// the frame workers at priority `prio`) or, for Rec. 2100 scopes of an HDR sequence, a small
|
||||
/// working-space render. While a new frame renders the last one stays.
|
||||
pub fn frame_signal(app: &mut FilmcraftApp, ctx: &egui::Context, slot: &str, frame: i64, st: &ScopesState, prio: u32) -> Option<ScopeFrame> {
|
||||
let seq_id = app.session.state.active_sequence?;
|
||||
let q = app.session.active_sequence()?;
|
||||
let hdr_seq = q.settings.color.working.is_hdr();
|
||||
let space = st.color_space.resolve(hdr_seq);
|
||||
let pq = hdr_seq && space == ColorSpace::Rec2100;
|
||||
let rate = q.settings.frame_rate;
|
||||
let rev = app.session.revision;
|
||||
let id = egui::Id::new(("scope-signal", slot));
|
||||
let cached: Option<(u64, Arc<Signal>, bool)> = ctx.data(|d| d.get_temp(id));
|
||||
let key = hash_of((seq_id.0, frame, rev, pq));
|
||||
if let Some((k, s, p)) = &cached
|
||||
&& *k == key
|
||||
{
|
||||
return Some(ScopeFrame { signal: s.clone(), key, pq: *p, space });
|
||||
}
|
||||
let fresh = if pq {
|
||||
filmcraft_engine::scopes::scope_signal(&app.session, rate.tick_of(frame), 0.125, true)
|
||||
} else {
|
||||
let fkey = FrameKey { target: Target::Sequence(seq_id), frame, size: 250, revision: rev };
|
||||
let project = app.session.project.clone();
|
||||
app.frames.request(fkey, rate.tick_of(frame), 0.25, &project, prio);
|
||||
app.frames.get(&fkey).map(|img| Signal::from_rgba8(img.w, img.h, &img.px))
|
||||
};
|
||||
match fresh {
|
||||
Some(s) => {
|
||||
let s = Arc::new(s);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, s.clone(), pq)));
|
||||
Some(ScopeFrame { signal: s, key, pq, space })
|
||||
}
|
||||
None => cached.filter(|c| c.2 == pq).map(|(k, s, p)| ScopeFrame { signal: s, key: k, pq: p, space }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lumetri Scopes panel.
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let st = app.ui.panels.scopes.clone();
|
||||
let area = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - FOOTER_H));
|
||||
ui.painter().rect_filled(area, 0.0, Color32::BLACK);
|
||||
let ctx = ui.ctx().clone();
|
||||
let frame = app.session.state.active_sequence.map(|_| app.session.sequence_rate().frame_at(app.session.playhead()));
|
||||
// while playing, scopes come after the Program monitor's prefetch
|
||||
let prio = if app.playback.playing { 40 } else { 2 };
|
||||
let sf = frame.and_then(|f| frame_signal(app, &ctx, "program", f, &st, prio));
|
||||
match &sf {
|
||||
Some(sf) => draw(app, ui, area, &st.shown, sf, &st, "scopes"),
|
||||
None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { "(no sequence)" }),
|
||||
}
|
||||
// right-click anywhere on the scopes: the settings menu
|
||||
let resp = ui.interact(area, egui::Id::new("scopes-area"), Sense::click());
|
||||
egui::Popup::context_menu(&resp).show(|ui| settings_menu(app, ui));
|
||||
footer(app, ui, Rect::from_min_max(pos2(rect.min.x, area.max.y), rect.max), sf.as_ref().map(|s| s.space));
|
||||
}
|
||||
|
||||
fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option<ColorSpace>) {
|
||||
let t = app.tokens;
|
||||
let (scale0, clamp0, space0) = (app.ui.panels.scopes.scale, app.ui.panels.scopes.clamp, app.ui.panels.scopes.color_space);
|
||||
let space = space.unwrap_or(space0.resolve(false));
|
||||
let label = match space {
|
||||
ColorSpace::Rec2100 => "Rec. 2100".to_string(),
|
||||
s => s.label().to_string(),
|
||||
};
|
||||
let lr = ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, &label, Tokens::ui(12.0), t.text);
|
||||
app.auto.add("scopes.space", lr, &label);
|
||||
// right side: wrench · ☑ Clamp Signal · [8 Bit ▾]
|
||||
let scale_w = 92.0;
|
||||
let sr = Rect::from_min_size(pos2(r.max.x - scale_w - 6.0, r.min.y + 4.0), vec2(scale_w, 22.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sr));
|
||||
let mut scale = scale0;
|
||||
let cb = egui::ComboBox::from_id_salt("scopes-scale").selected_text(scale.label()).width(scale_w - 8.0).show_ui(&mut child, |ui| {
|
||||
for s in Scale::ALL {
|
||||
let r = ui.selectable_value(&mut scale, s, s.label());
|
||||
app.auto.add(&format!("scopes.menu.scale.{}", s.label().replace(' ', "")), r.rect, s.label());
|
||||
}
|
||||
});
|
||||
app.auto.add("scopes.scale", cb.response.rect, "Signal scale");
|
||||
let galley = ui.painter().layout_no_wrap("Clamp Signal".into(), Tokens::ui(12.0), t.text);
|
||||
let cw = galley.size().x + 24.0;
|
||||
let cr = Rect::from_min_size(pos2(sr.min.x - cw - 10.0, r.min.y + 4.0), vec2(cw, 22.0));
|
||||
let mut clamp = clamp0;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cr));
|
||||
let cresp = child.checkbox(&mut clamp, "Clamp Signal");
|
||||
app.auto.add("scopes.clamp", cresp.rect, "Clamp Signal");
|
||||
let wr = Rect::from_center_size(pos2(cr.min.x - 16.0, r.center().y), vec2(22.0, 22.0));
|
||||
let wresp = ui.interact(wr, egui::Id::new("scopes-wrench"), Sense::click());
|
||||
icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
app.auto.add("scopes.wrench", wr, "Scope settings");
|
||||
egui::Popup::menu(&wresp).show(|ui| settings_menu(app, ui));
|
||||
// only what the footer itself changed (the wrench menu above may have changed more)
|
||||
let st = &mut app.ui.panels.scopes;
|
||||
if scale != scale0 {
|
||||
st.scale = scale;
|
||||
st.preset.clear();
|
||||
}
|
||||
if clamp != clamp0 {
|
||||
st.clamp = clamp;
|
||||
st.preset.clear();
|
||||
}
|
||||
}
|
||||
|
||||
type Picks = Vec<(String, Rect, String, bool)>;
|
||||
|
||||
fn pick(ui: &mut egui::Ui, out: &mut Picks, id: &str, label: &str, on: bool) {
|
||||
let r = ui.selectable_label(on, label);
|
||||
out.push((id.to_string(), r.rect, label.to_string(), r.clicked()));
|
||||
}
|
||||
|
||||
/// The scopes' settings menu (wrench and right-click).
|
||||
pub fn settings_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
ui.set_min_width(220.0);
|
||||
let st = app.ui.panels.scopes.clone();
|
||||
let mut out: Picks = Vec::new();
|
||||
let pr = ui.menu_button("Presets", |ui| {
|
||||
for (i, p) in SCOPE_PRESETS.iter().enumerate() {
|
||||
pick(ui, &mut out, &format!("scopes.menu.preset.{i}"), p.0, st.preset == p.0);
|
||||
}
|
||||
});
|
||||
out.push(("scopes.menu.presets".into(), pr.response.rect, "Presets".into(), false));
|
||||
ui.separator();
|
||||
for k in ScopeKind::ALL {
|
||||
let label = match k {
|
||||
ScopeKind::Parade => format!("Parade ({})", st.parade_type.label()),
|
||||
ScopeKind::Waveform => format!("Waveform ({})", st.waveform_type.label()),
|
||||
k => k.label().to_string(),
|
||||
};
|
||||
pick(ui, &mut out, &format!("scopes.menu.{}", k.name()), &label, st.shown.contains(&k));
|
||||
}
|
||||
ui.separator();
|
||||
let sub = |ui: &mut egui::Ui, out: &mut Picks, id: &str, label: &str, f: &mut dyn FnMut(&mut egui::Ui, &mut Picks)| {
|
||||
let r = ui.menu_button(label, |ui| f(ui, out));
|
||||
out.push((format!("scopes.menu.{id}"), r.response.rect, label.into(), false));
|
||||
};
|
||||
sub(ui, &mut out, "paradeTypes", "Parade Type", &mut |ui, out| {
|
||||
for p in ParadeType::ALL {
|
||||
pick(ui, out, &format!("scopes.menu.paradeType.{}", serde_name(&p)), p.label(), st.parade_type == p);
|
||||
}
|
||||
});
|
||||
sub(ui, &mut out, "waveformTypes", "Waveform Type", &mut |ui, out| {
|
||||
for w in WaveformType::ALL {
|
||||
pick(ui, out, &format!("scopes.menu.waveformType.{}", serde_name(&w)), w.label(), st.waveform_type == w);
|
||||
}
|
||||
});
|
||||
sub(ui, &mut out, "vectorscopeTargets", "Vectorscope Targets", &mut |ui, out| {
|
||||
for v in [Targets::Percent75, Targets::Percent100] {
|
||||
pick(ui, out, &format!("scopes.menu.targets.{}", serde_name(&v)), v.label(), st.targets == v);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
sub(ui, &mut out, "colorSpaces", "Colour Space", &mut |ui, out| {
|
||||
for c in ColorSpace::ALL {
|
||||
pick(ui, out, &format!("scopes.menu.colorSpace.{}", serde_name(&c)), c.label(), st.color_space == c);
|
||||
}
|
||||
});
|
||||
sub(ui, &mut out, "brightnesses", "Brightness", &mut |ui, out| {
|
||||
for b in Brightness::ALL {
|
||||
pick(ui, out, &format!("scopes.menu.brightness.{}", serde_name(&b)), b.label(), st.brightness == b);
|
||||
}
|
||||
});
|
||||
sub(ui, &mut out, "scales", "Signal Scale", &mut |ui, out| {
|
||||
for s in Scale::ALL {
|
||||
pick(ui, out, &format!("scopes.menu.scale.{}", s.label().replace(' ', "")), s.label(), st.scale == s);
|
||||
}
|
||||
});
|
||||
pick(ui, &mut out, "scopes.menu.clamp", "Clamp Signal", st.clamp);
|
||||
let mut close = false;
|
||||
for (id, r, label, clicked) in out {
|
||||
app.auto.add(&id, r, &label);
|
||||
if clicked {
|
||||
apply_menu_id(&mut app.ui.panels.scopes, &id);
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
if close {
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn serde_name<T: serde::Serialize>(v: &T) -> String {
|
||||
serde_json::to_value(v).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Apply a settings menu item by its automation id (`scopes.menu.…`).
|
||||
pub fn apply_menu_id(st: &mut ScopesState, id: &str) -> bool {
|
||||
let Some(rest) = id.strip_prefix("scopes.menu.") else { return false };
|
||||
let from = |s: &str, all: &[String]| all.iter().position(|x| x == s);
|
||||
if let Some(i) = rest.strip_prefix("preset.").and_then(|n| n.parse::<usize>().ok()) {
|
||||
return SCOPE_PRESETS.get(i).is_some_and(|p| st.apply_preset(p.0));
|
||||
}
|
||||
if let Some(k) = ScopeKind::ALL.into_iter().find(|k| k.name() == rest) {
|
||||
st.toggle(k);
|
||||
return true;
|
||||
}
|
||||
let (group, name) = rest.split_once('.').unwrap_or((rest, ""));
|
||||
let ok = match group {
|
||||
"paradeType" => from(name, &ParadeType::ALL.map(|x| serde_name(&x))).map(|i| st.parade_type = ParadeType::ALL[i]).is_some(),
|
||||
"waveformType" => from(name, &WaveformType::ALL.map(|x| serde_name(&x))).map(|i| st.waveform_type = WaveformType::ALL[i]).is_some(),
|
||||
"targets" => from(name, &[Targets::Percent75, Targets::Percent100].map(|x| serde_name(&x)))
|
||||
.map(|i| st.targets = [Targets::Percent75, Targets::Percent100][i])
|
||||
.is_some(),
|
||||
"colorSpace" => from(name, &ColorSpace::ALL.map(|x| serde_name(&x))).map(|i| st.color_space = ColorSpace::ALL[i]).is_some(),
|
||||
"brightness" => from(name, &Brightness::ALL.map(|x| serde_name(&x))).map(|i| st.brightness = Brightness::ALL[i]).is_some(),
|
||||
"scale" => Scale::from_name(name).map(|s| st.scale = s).is_some(),
|
||||
"clamp" => {
|
||||
st.clamp = !st.clamp;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok && !matches!(group, "colorSpace" | "brightness" | "targets") {
|
||||
st.preset.clear();
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Cells for `n` scopes in `area`: the grid whose cells are closest to 4:3.
|
||||
pub fn layout(area: Rect, n: usize) -> Vec<Rect> {
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let best = (1..=n)
|
||||
.map(|cols| {
|
||||
let rows = n.div_ceil(cols);
|
||||
let a = (area.width() / cols as f32) / (area.height() / rows as f32).max(1.0);
|
||||
(cols, (a / 1.33).ln().abs() + if cols * rows > n { 0.15 } else { 0.0 })
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|x| x.0)
|
||||
.unwrap_or(1);
|
||||
let rows = n.div_ceil(best);
|
||||
let (cw, ch) = (area.width() / best as f32, area.height() / rows as f32);
|
||||
(0..n).map(|i| Rect::from_min_size(area.min + vec2((i % best) as f32 * cw, (i / best) as f32 * ch), vec2(cw, ch)).shrink(2.0)).collect()
|
||||
}
|
||||
|
||||
/// Draw `shown` scopes of a frame into `area` (black background expected).
|
||||
pub fn draw(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, shown: &[ScopeKind], sf: &ScopeFrame, st: &ScopesState, prefix: &str) {
|
||||
let cells = layout(area, shown.len());
|
||||
if shown.len() > 1 {
|
||||
for c in &cells {
|
||||
ui.painter().rect_stroke(c.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(24)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
for (k, cell) in shown.iter().zip(cells) {
|
||||
draw_one(app, ui, cell, *k, sf, st, prefix);
|
||||
app.auto.add(&format!("{prefix}.view.{}", k.name()), cell, k.label());
|
||||
}
|
||||
}
|
||||
|
||||
/// Scope parameters; `rows` = the plot's height in pixels (64–256), so levels are not resampled.
|
||||
fn params(st: &ScopesState, sf: &ScopeFrame, rows: usize) -> Params {
|
||||
Params { matrix: sf.space.matrix(), clamp: st.clamp, rows, ..Params::default() }
|
||||
}
|
||||
|
||||
/// Levels on a cd/m² axis: HDR frames, or the HDR scale.
|
||||
fn nits_axis(st: &ScopesState, sf: &ScopeFrame) -> bool {
|
||||
sf.pq || st.scale == Scale::Hdr
|
||||
}
|
||||
|
||||
/// The frame's signal on the axis the scale asks for (SDR frames on the HDR scale go to PQ).
|
||||
fn plotted(st: &ScopesState, sf: &ScopeFrame) -> Arc<Signal> {
|
||||
if st.scale == Scale::Hdr && !sf.pq { Arc::new(sf.signal.map(|c| c.map(sc::sdr_to_pq))) } else { sf.signal.clone() }
|
||||
}
|
||||
|
||||
/// A texture computed from (frame, settings, scope), kept until either changes.
|
||||
fn cached_texture(ctx: &egui::Context, slot: String, key: u64, make: impl FnOnce() -> (usize, usize, Vec<u8>)) -> Option<TextureHandle> {
|
||||
let id = egui::Id::new(("scope-tex", slot.clone()));
|
||||
if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(u64, TextureHandle)>(id))
|
||||
&& k == key
|
||||
{
|
||||
return Some(tex);
|
||||
}
|
||||
let (w, h, px) = make();
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let img = egui::ColorImage::from_rgba_premultiplied([w, h], &px);
|
||||
let tex = ctx.load_texture(slot, img, TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
Some(tex)
|
||||
}
|
||||
|
||||
fn settings_key(st: &ScopesState) -> u64 {
|
||||
hash_of(serde_json::to_string(st).unwrap_or_default())
|
||||
}
|
||||
|
||||
const RED: [f32; 3] = [1.0, 0.16, 0.16];
|
||||
const GREEN: [f32; 3] = [0.16, 1.0, 0.16];
|
||||
const BLUE: [f32; 3] = [0.3, 0.38, 1.0];
|
||||
const WHITE: [f32; 3] = [0.85, 0.85, 0.85];
|
||||
const LUMA: [f32; 3] = [0.55, 1.0, 0.7];
|
||||
|
||||
fn trace_color(name: &str, wt: Option<WaveformType>) -> [f32; 3] {
|
||||
match (name, wt) {
|
||||
("R", _) => RED,
|
||||
("G", _) => GREEN,
|
||||
("B", _) | ("Cb", _) | ("C", _) => BLUE,
|
||||
("Cr", _) => [1.0, 0.45, 0.45],
|
||||
("Y", Some(WaveformType::Luma)) => WHITE,
|
||||
("Y", Some(_)) => LUMA,
|
||||
_ => WHITE,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, sf: &ScopeFrame, st: &ScopesState, prefix: &str) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let rows = ((cell.height() - 20.0) * ctx.pixels_per_point()).clamp(64.0, 256.0) as usize;
|
||||
let key = hash_of((sf.key, settings_key(st), k, rows));
|
||||
let slot = format!("{prefix}-{}", k.name());
|
||||
let gain = st.brightness.gain();
|
||||
let p = params(st, sf, rows);
|
||||
match k {
|
||||
ScopeKind::Waveform | ScopeKind::Parade => {
|
||||
let plot = Rect::from_min_max(cell.min + vec2(34.0, 10.0), cell.max - vec2(34.0, 10.0));
|
||||
if plot.width() < 8.0 || plot.height() < 8.0 {
|
||||
return;
|
||||
}
|
||||
let tex = cached_texture(&ctx, slot, key, || {
|
||||
let sig = plotted(st, sf);
|
||||
if k == ScopeKind::Waveform {
|
||||
let w = sc::waveform(&sig, st.waveform_type, &p);
|
||||
let layers: Vec<(&sc::Grid, [f32; 3])> = w.traces.iter().map(|g| (g, trace_color(g.name, Some(st.waveform_type)))).collect();
|
||||
sc::paint::grids(&layers, sc::paint::waveform_reference(w.per_column), gain)
|
||||
} else {
|
||||
let w = sc::parade(&sig, st.parade_type, &p);
|
||||
parade_image(&w, gain)
|
||||
}
|
||||
});
|
||||
level_graticule(ui, plot, st, sf, p.range());
|
||||
if let Some(tex) = tex {
|
||||
ui.painter().image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
if k == ScopeKind::Parade {
|
||||
let n = match st.parade_type {
|
||||
ParadeType::RgbWhite => 4,
|
||||
_ => 3,
|
||||
};
|
||||
for i in 1..n {
|
||||
let x = plot.min.x + plot.width() * i as f32 / n as f32;
|
||||
ui.painter().line_segment([pos2(x, plot.min.y), pos2(x, plot.max.y)], Stroke::new(1.0, Color32::from_gray(40)));
|
||||
}
|
||||
}
|
||||
let title = match k {
|
||||
ScopeKind::Parade => format!("Parade ({})", st.parade_type.label()),
|
||||
_ => format!("Waveform ({})", st.waveform_type.label()),
|
||||
};
|
||||
ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, title, Tokens::ui(9.0), Color32::from_gray(110));
|
||||
if nits_axis(st, sf) {
|
||||
app.auto.add(&format!("{prefix}.hdrWaveform"), plot, "HDR levels (cd/m²)");
|
||||
}
|
||||
}
|
||||
ScopeKind::Histogram => {
|
||||
let plot = Rect::from_min_max(cell.min + vec2(34.0, 10.0), cell.max - vec2(34.0, 10.0));
|
||||
if plot.width() < 8.0 || plot.height() < 8.0 {
|
||||
return;
|
||||
}
|
||||
let tex = cached_texture(&ctx, slot, key, || histogram_image(&sc::histogram(&plotted(st, sf), &p), gain));
|
||||
level_graticule(ui, plot, st, sf, (0.0, 1.0));
|
||||
if let Some(tex) = tex {
|
||||
ui.painter().image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, "Histogram", Tokens::ui(9.0), Color32::from_gray(110));
|
||||
if nits_axis(st, sf) {
|
||||
app.auto.add(&format!("{prefix}.hdrWaveform"), plot, "HDR levels (cd/m²)");
|
||||
}
|
||||
}
|
||||
ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls => {
|
||||
let side = (cell.width().min(cell.height()) - 16.0).max(8.0);
|
||||
let sq = Rect::from_center_size(cell.center(), vec2(side, side));
|
||||
let hls = k == ScopeKind::VectorscopeHls;
|
||||
let tex = cached_texture(&ctx, slot, key, || {
|
||||
let v = if hls { sc::vectorscope_hls(&sf.signal, &p) } else { sc::vectorscope_yuv(&sf.signal, &p) };
|
||||
sc::paint::vectorscope(&v, WHITE, gain, true)
|
||||
});
|
||||
vector_graticule(ui, sq, sf.space, st.targets, hls);
|
||||
if let Some(tex) = tex {
|
||||
ui.painter().image(tex.id(), sq, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().text(cell.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, k.label(), Tokens::ui(9.0), Color32::from_gray(110));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parade's traces side by side in one image.
|
||||
fn parade_image(w: &sc::Waveform, gain: f32) -> (usize, usize, Vec<u8>) {
|
||||
let n = w.traces.len();
|
||||
let Some(first) = w.traces.first() else { return (0, 0, Vec::new()) };
|
||||
let (cw, rows) = (first.cols, first.rows);
|
||||
let gap = (cw / 24).max(2);
|
||||
let width = cw * n + gap * (n - 1);
|
||||
let mut out = vec![0u8; width * rows * 4];
|
||||
for (i, g) in w.traces.iter().enumerate() {
|
||||
let (_, _, px) = sc::paint::grids(&[(g, trace_color(g.name, None))], sc::paint::waveform_reference(w.per_column), gain);
|
||||
let x0 = i * (cw + gap);
|
||||
for y in 0..rows {
|
||||
let src = &px[y * cw * 4..(y + 1) * cw * 4];
|
||||
out[(y * width + x0) * 4..(y * width + x0 + cw) * 4].copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
(width, rows, out)
|
||||
}
|
||||
|
||||
/// The histogram as an image: levels bottom to top, counts to the right, R/G/B added.
|
||||
fn histogram_image(h: &sc::Histogram, gain: f32) -> (usize, usize, Vec<u8>) {
|
||||
let (w, rows) = (256usize, sc::BINS);
|
||||
let max = h.r.iter().chain(&h.g).chain(&h.b).copied().max().unwrap_or(0).max(1) as f32;
|
||||
let len = |c: u32| ((c as f32 / max).sqrt() * w as f32).round() as usize;
|
||||
let mut out = vec![0u8; w * rows * 4];
|
||||
for bin in 0..rows {
|
||||
let y = rows - 1 - bin;
|
||||
let (lr, lg, lb) = (len(h.r[bin]), len(h.g[bin]), len(h.b[bin]));
|
||||
for x in 0..w {
|
||||
let c = [if x < lr { RED } else { [0.0; 3] }, if x < lg { GREEN } else { [0.0; 3] }, if x < lb { BLUE } else { [0.0; 3] }];
|
||||
let s = [c[0][0] + c[1][0] + c[2][0], c[0][1] + c[1][1] + c[2][1], c[0][2] + c[1][2] + c[2][2]].map(|v| (v * 0.75 * gain).min(1.0));
|
||||
let a = s[0].max(s[1]).max(s[2]);
|
||||
if a > 0.0 {
|
||||
let o = &mut out[(y * w + x) * 4..(y * w + x) * 4 + 4];
|
||||
o.copy_from_slice(&[(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8, (a * 255.0) as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(w, rows, out)
|
||||
}
|
||||
|
||||
/// Horizontal graticule and labels of a levels scope (`range` = the value range of the plot).
|
||||
fn level_graticule(ui: &egui::Ui, plot: Rect, st: &ScopesState, sf: &ScopeFrame, (lo, hi): (f32, f32)) {
|
||||
let painter = ui.painter();
|
||||
let y_of = |v: f32| plot.max.y - (v - lo) / (hi - lo) * plot.height();
|
||||
let font = Tokens::ui(10.0);
|
||||
if nits_axis(st, sf) {
|
||||
for (nits, label) in [(0.0, "0"), (10.0, "10"), (100.0, "100"), (203.0, "203"), (1000.0, "1000"), (4000.0, "4000"), (10_000.0, "10000")] {
|
||||
let y = y_of(sc::nits_to_pq(nits));
|
||||
let col = if nits == 203.0 { Color32::from_rgba_unmultiplied(255, 200, 80, 90) } else { GRATICULE };
|
||||
painter.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, col));
|
||||
painter.text(pos2(plot.min.x - 4.0, y), Align2::RIGHT_CENTER, label, font.clone(), LABEL);
|
||||
}
|
||||
painter.text(pos2(plot.max.x + 4.0, plot.min.y), Align2::LEFT_TOP, "cd/m²", Tokens::ui(9.0), LABEL);
|
||||
return;
|
||||
}
|
||||
for i in 0..=10 {
|
||||
let v = i as f32 / 10.0;
|
||||
let y = y_of(v);
|
||||
painter.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, GRATICULE));
|
||||
painter.text(pos2(plot.min.x - 4.0, y), Align2::RIGHT_CENTER, format!("{}", i * 10), font.clone(), LABEL);
|
||||
let right = match st.scale {
|
||||
Scale::Float => format!("{v:.1}"),
|
||||
_ => format!("{}", (v * 255.0).round() as i32),
|
||||
};
|
||||
painter.text(pos2(plot.max.x + 4.0, y), Align2::LEFT_CENTER, right, font.clone(), LABEL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Circles, axes, colour targets (YUV) or hue labels (HLS) and the skin-tone line.
|
||||
fn vector_graticule(ui: &egui::Ui, sq: Rect, space: ColorSpace, targets: Targets, hls: bool) {
|
||||
let painter = ui.painter();
|
||||
let c = sq.center();
|
||||
let scale = sq.width() / 2.0 / sc::VECTOR_EXTENT;
|
||||
let at = |cb: f32, cr: f32| pos2(c.x + cb * scale, c.y - cr * scale);
|
||||
let ring = 0.5 * scale;
|
||||
painter.circle_stroke(c, ring, Stroke::new(1.0, Color32::from_gray(64)));
|
||||
painter.circle_stroke(c, ring * 0.5, Stroke::new(1.0, GRATICULE));
|
||||
painter.line_segment([pos2(c.x - ring, c.y), pos2(c.x + ring, c.y)], Stroke::new(1.0, GRATICULE));
|
||||
painter.line_segment([pos2(c.x, c.y - ring), pos2(c.x, c.y + ring)], Stroke::new(1.0, GRATICULE));
|
||||
// 10° ticks on the outer ring
|
||||
for d in (0..360).step_by(10) {
|
||||
let a = (d as f32).to_radians();
|
||||
let (ix, iy) = (a.cos(), a.sin());
|
||||
let l = if d % 30 == 0 { 0.94 } else { 0.97 };
|
||||
painter
|
||||
.line_segment([pos2(c.x + ix * ring * l, c.y - iy * ring * l), pos2(c.x + ix * ring, c.y - iy * ring)], Stroke::new(1.0, Color32::from_gray(64)));
|
||||
}
|
||||
let m = space.matrix();
|
||||
if hls {
|
||||
for (name, cb, cr) in sc::hls_targets(m) {
|
||||
let p = at(cb * 1.12, cr * 1.12);
|
||||
painter.text(p, Align2::CENTER_CENTER, name, Tokens::ui(10.0), LABEL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// skin-tone line
|
||||
let a = sc::SKIN_TONE_DEG.to_radians();
|
||||
painter.line_segment([c, pos2(c.x + a.cos() * ring, c.y - a.sin() * ring)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(220, 170, 120, 110)));
|
||||
for (amp, strong) in [(0.75, targets == Targets::Percent75), (1.0, targets == Targets::Percent100)] {
|
||||
for tg in sc::targets(m, amp) {
|
||||
let p = at(tg.cb, tg.cr);
|
||||
let col = Color32::from_rgb((tg.rgb[0] / amp * 200.0) as u8 + 40, (tg.rgb[1] / amp * 200.0) as u8 + 40, (tg.rgb[2] / amp * 200.0) as u8 + 40);
|
||||
let size = if strong { 9.0 } else { 5.0 };
|
||||
let col = if strong { col } else { col.gamma_multiply(0.45) };
|
||||
painter.rect_stroke(Rect::from_center_size(p, vec2(size, size)), 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle);
|
||||
if strong {
|
||||
let off = egui::Vec2::new(tg.cb, -tg.cr).normalized() * 12.0;
|
||||
painter.text(p + off, Align2::CENTER_CENTER, tg.name, Tokens::ui(10.0), col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
//! Timecode panel: the time of the active monitor in large type, plus optional smaller rows. Each
|
||||
//! row shows Current Time, Media Time (the source timecode of the clip under the playhead),
|
||||
//! Duration, In/Out Duration or Remaining, of the active monitor, the Program or the Source, in
|
||||
//! any time display (timecode, frames, feet + frames, samples, seconds). Right-click a row to
|
||||
//! change it; "+" adds a row.
|
||||
//!
|
||||
//! Automation ids: `timecode.row.<i>` (the value), `timecode.addRow`, and in a row's menu
|
||||
//! `timecode.row.<i>.mode.<mode>`, `.source.<source>`, `.display.<n>`, `.remove`.
|
||||
//! State: `ui.panels.timecode`.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use filmcraft_project::ItemKind;
|
||||
use filmcraft_time::{FrameRate, Tick, TimeDisplay, format_time};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::dock::PanelKind;
|
||||
use crate::panels::panel_state::{TcMode, TcSource, TimecodeRow};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// What a row shows: (value text, caption).
|
||||
pub fn row_text(app: &FilmcraftApp, row: &TimecodeRow) -> (String, String) {
|
||||
let s = &app.session;
|
||||
let source = match row.source {
|
||||
TcSource::Active if matches!(app.ui.focused, PanelKind::Source) && s.state.source_item.is_some() => TcSource::Source,
|
||||
TcSource::Active => TcSource::Program,
|
||||
x => x,
|
||||
};
|
||||
struct View {
|
||||
name: String,
|
||||
rate: FrameRate,
|
||||
df: bool,
|
||||
t: Tick,
|
||||
dur: Tick,
|
||||
marks: (Option<Tick>, Option<Tick>),
|
||||
/// The media timecode at `t` (None = no clip there).
|
||||
media: Option<(Tick, FrameRate)>,
|
||||
sample_rate: i64,
|
||||
}
|
||||
let view = match source {
|
||||
TcSource::Source => s.state.source_item.and_then(|i| s.project.item(i)).map(|it| {
|
||||
let start = it.as_media().and_then(|m| m.info.start_timecode).map(|f| it.frame_rate().tick_of(f)).unwrap_or_default();
|
||||
let marks = match &it.kind {
|
||||
ItemKind::Media(m) => (m.mark_in, m.mark_out),
|
||||
ItemKind::Sequence(q) => (q.mark_in, q.mark_out),
|
||||
_ => (None, None),
|
||||
};
|
||||
let sr = it.as_media().and_then(|m| m.info.audio.as_ref()).map(|a| a.sample_rate as i64).unwrap_or(48000);
|
||||
View {
|
||||
name: it.name.clone(),
|
||||
rate: it.frame_rate(),
|
||||
df: false,
|
||||
t: s.state.source_playhead,
|
||||
dur: it.duration(),
|
||||
marks,
|
||||
media: Some((start + s.state.source_playhead, it.frame_rate())),
|
||||
sample_rate: sr,
|
||||
}
|
||||
}),
|
||||
_ => s.active_sequence().map(|q| {
|
||||
let t = s.playhead();
|
||||
// the topmost enabled video clip under the playhead
|
||||
let media = q.video_tracks.iter().rev().find_map(|tr| tr.item_at(t).filter(|c| c.enabled)).and_then(|c| {
|
||||
let it = s.project.item(c.item)?;
|
||||
let m = it.as_media()?;
|
||||
let rate = m.frame_rate();
|
||||
let start = m.info.start_timecode.map(|f| rate.tick_of(f)).unwrap_or_default();
|
||||
Some((start + c.source_time_at(t), rate))
|
||||
});
|
||||
View {
|
||||
name: s.state.active_sequence.and_then(|i| s.project.item(i)).map(|i| i.name.clone()).unwrap_or_default(),
|
||||
rate: q.settings.frame_rate,
|
||||
df: q.settings.drop_frame,
|
||||
t,
|
||||
dur: q.duration(),
|
||||
marks: (q.mark_in, q.mark_out),
|
||||
media,
|
||||
sample_rate: q.settings.sample_rate as i64,
|
||||
}
|
||||
}),
|
||||
};
|
||||
let Some(v) = view else {
|
||||
return ("--:--:--:--".into(), format!("{} · no {}", row.mode.label(), if source == TcSource::Source { "clip" } else { "sequence" }));
|
||||
};
|
||||
let fmt = |t: Tick, rate: FrameRate, df: bool| format_time(t, rate, df, row.display, v.sample_rate);
|
||||
let (a, b) = (v.marks.0.unwrap_or(Tick::ZERO), v.marks.1.unwrap_or(v.dur));
|
||||
let value = match row.mode {
|
||||
TcMode::Current => fmt(v.t, v.rate, v.df),
|
||||
TcMode::Media => match v.media {
|
||||
Some((t, r)) => fmt(t, r, false),
|
||||
None => "--:--:--:--".into(),
|
||||
},
|
||||
TcMode::Duration => fmt(v.dur, v.rate, v.df),
|
||||
TcMode::InOut => fmt((b - a).max(Tick::ZERO), v.rate, v.df),
|
||||
TcMode::Remaining => fmt((b - v.t).max(Tick::ZERO), v.rate, v.df),
|
||||
};
|
||||
let src = match source {
|
||||
TcSource::Source => "Source",
|
||||
_ => "Program",
|
||||
};
|
||||
(value, format!("{src} · {} · {}", row.mode.label(), v.name))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let rows = app.ui.panels.timecode.rows.clone();
|
||||
let show_name = app.ui.panels.timecode.show_name;
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
let mut changes: Vec<(usize, Option<TimecodeRow>)> = Vec::new();
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let big = i == 0;
|
||||
let (value, caption) = row_text(app, row);
|
||||
let size = if big { (rect.height() * 0.32).clamp(22.0, 64.0).min(rect.width() / 7.0) } else { 20.0 };
|
||||
let h = size * 1.25 + if show_name { 16.0 } else { 4.0 };
|
||||
let r = Rect::from_min_size(pos2(rect.min.x + 8.0, y), vec2(rect.width() - 16.0, h));
|
||||
if r.max.y > rect.max.y - 24.0 && i > 0 {
|
||||
break;
|
||||
}
|
||||
let vr = ui.painter().text(pos2(r.center().x, r.min.y), Align2::CENTER_TOP, &value, Tokens::mono(size), if big { t.timecode } else { t.text });
|
||||
if show_name {
|
||||
ui.painter().text(pos2(r.center().x, vr.max.y + 2.0), Align2::CENTER_TOP, &caption, Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
let resp = ui.interact(r, egui::Id::new(("timecode-row", i)), Sense::click());
|
||||
elems.push((format!("timecode.row.{i}"), vr, value.clone()));
|
||||
egui::Popup::context_menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let mut nr = *row;
|
||||
let mut picks: Vec<(String, Rect, String, bool)> = Vec::new();
|
||||
let mut pick = |ui: &mut egui::Ui, id: String, label: &str, on: bool| {
|
||||
let r = ui.selectable_label(on, label);
|
||||
picks.push((id, r.rect, label.to_string(), r.clicked()));
|
||||
r.clicked()
|
||||
};
|
||||
for m in TcMode::ALL {
|
||||
if pick(ui, format!("timecode.row.{i}.mode.{}", serde_name(&m)), m.label(), row.mode == m) {
|
||||
nr.mode = m;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
for src in TcSource::ALL {
|
||||
if pick(ui, format!("timecode.row.{i}.source.{}", serde_name(&src)), src.label(), row.source == src) {
|
||||
nr.source = src;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
for (n, d) in TimeDisplay::ALL.into_iter().enumerate() {
|
||||
if pick(ui, format!("timecode.row.{i}.display.{n}"), d.label(), row.display == d) {
|
||||
nr.display = d;
|
||||
}
|
||||
}
|
||||
let mut remove = false;
|
||||
if rows.len() > 1 {
|
||||
ui.separator();
|
||||
remove = pick(ui, format!("timecode.row.{i}.remove"), "Remove Row", false);
|
||||
}
|
||||
for (id, r, l, _) in &picks {
|
||||
elems.push((id.clone(), *r, l.clone()));
|
||||
}
|
||||
if remove {
|
||||
changes.push((i, None));
|
||||
ui.close();
|
||||
} else if nr != *row {
|
||||
changes.push((i, Some(nr)));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
y = r.max.y + 8.0;
|
||||
}
|
||||
// "+" adds a row (same settings as the last one, Duration by default)
|
||||
let ar = Rect::from_min_size(pos2(rect.max.x - 28.0, rect.max.y - 26.0), vec2(22.0, 22.0));
|
||||
let aresp = ui.interact(ar, egui::Id::new("timecode-add"), Sense::click());
|
||||
ui.painter().text(ar.center(), Align2::CENTER_CENTER, "+", Tokens::ui(16.0), if aresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
elems.push(("timecode.addRow".into(), ar, "Add Row".into()));
|
||||
if aresp.on_hover_text("Add a timecode row").clicked() {
|
||||
app.ui.panels.timecode.rows.push(TimecodeRow { mode: TcMode::Duration, ..rows.last().copied().unwrap_or_default() });
|
||||
}
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
for (i, c) in changes {
|
||||
let rows = &mut app.ui.panels.timecode.rows;
|
||||
match c {
|
||||
Some(r) if i < rows.len() => rows[i] = r,
|
||||
None if i < rows.len() && rows.len() > 1 => {
|
||||
rows.remove(i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serde_name<T: serde::Serialize>(v: &T) -> String {
|
||||
serde_json::to_value(v).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -446,6 +446,10 @@ pub struct UiState {
|
|||
/// `panels::menu_dialogs`.
|
||||
#[serde(default)]
|
||||
pub extras: crate::panels::menu_dialogs::Extras,
|
||||
/// Lumetri Scopes, Timecode, Events, Progress and Reference Monitor settings. See
|
||||
/// `panels::panel_state`.
|
||||
#[serde(default)]
|
||||
pub panels: crate::panels::panel_state::PanelsState,
|
||||
}
|
||||
|
||||
/// An open Edit / Clip / File menu dialog: the engine command it runs on OK and the parameters
|
||||
|
|
@ -726,6 +730,7 @@ impl Default for UiState {
|
|||
guide_dialog: None,
|
||||
clip_dialog: None,
|
||||
extras: Default::default(),
|
||||
panels: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
//! Headless UI tests of the M8.9 / M12.6 panels: Lumetri Scopes (layouts, the settings menu,
|
||||
//! footer), Metadata (edit a field by typing, undo), Timecode, Events, Progress and the Reference
|
||||
//! Monitor, each opened from Window ▸ and driven by automation id through the control channel.
|
||||
//!
|
||||
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write `panels-*.png`.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
snapshots: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn with(session: Session) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
|
||||
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
|
||||
if snapshots.is_some() {
|
||||
b = b.wgpu();
|
||||
}
|
||||
let harness = b.build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx, snapshots };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
|
||||
fn demo() -> Self {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
Self::with(session)
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn click(&mut self, id: &str) {
|
||||
self.ok("ui.click", json!({"id": id}));
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
fn ids(&mut self, prefix: &str) -> Vec<String> {
|
||||
let v = self.ok("ui.elements", json!({"prefix": prefix}));
|
||||
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
fn has(&mut self, id: &str) -> bool {
|
||||
self.ids(id).iter().any(|i| i == id)
|
||||
}
|
||||
|
||||
fn label(&mut self, id: &str) -> String {
|
||||
let v = self.ok("ui.elements", json!({"prefix": id}));
|
||||
v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Step frames until `id` is registered (frames render on worker threads).
|
||||
fn wait_for(&mut self, id: &str) {
|
||||
for _ in 0..400 {
|
||||
if self.has(id) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
self.frames(1);
|
||||
}
|
||||
panic!("{id} never appeared: {:?}", self.ids(id.split('.').next().unwrap()));
|
||||
}
|
||||
|
||||
fn app(&mut self) -> &mut FilmcraftApp {
|
||||
self.harness.state_mut()
|
||||
}
|
||||
|
||||
fn ui_panels(&mut self) -> Value {
|
||||
self.ok("ui.inspect", json!({}))["ui"]["panels"].clone()
|
||||
}
|
||||
|
||||
/// Offscreen render of the window (or the bounding box of the elements under `prefix`).
|
||||
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
|
||||
let Some(dir) = self.snapshots.clone() else { return };
|
||||
let crop = prefix.map(|p| {
|
||||
let v = self.ok("ui.elements", json!({"prefix": p}));
|
||||
let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
|
||||
for e in v.as_array().unwrap() {
|
||||
let r = &e["rect"];
|
||||
let (x, y, w, h) = (r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32);
|
||||
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x + w), bb[3].max(y + h)];
|
||||
}
|
||||
bb
|
||||
});
|
||||
self.frames(2);
|
||||
let img = match self.harness.render() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("snapshot {name} skipped: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ppp = img.width() as f32 / 1600.0;
|
||||
let img = match crop {
|
||||
Some(bb) => {
|
||||
let x0 = ((bb[0] - 24.0) * ppp).max(0.0) as u32;
|
||||
let y0 = ((bb[1] - 30.0) * ppp).max(0.0) as u32;
|
||||
let x1 = (((bb[2] + 16.0) * ppp) as u32).min(img.width());
|
||||
let y1 = (((bb[3] + 46.0) * ppp) as u32).min(img.height());
|
||||
image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image()
|
||||
}
|
||||
None => img,
|
||||
};
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(format!("{name}.png"));
|
||||
img.save(&path).unwrap();
|
||||
eprintln!("snapshot: {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_panel_opens_from_the_window_menu() {
|
||||
let mut d = Driver::demo();
|
||||
let menu = d.ok("ui.menu.list", json!({}));
|
||||
let window: Vec<String> =
|
||||
menu.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Window"])).filter_map(|m| m["label"].as_str().map(str::to_string)).collect();
|
||||
for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Reference Monitor", "Timecode"] {
|
||||
assert!(window.iter().any(|w| w == p), "{p} not in Window: {window:?}");
|
||||
}
|
||||
for (panel, key) in [
|
||||
("LumetriScopes", "scopes.wrench"),
|
||||
("Metadata", "metadata."),
|
||||
("Timecode", "timecode.row.0"),
|
||||
("Events", "events.clearAll"),
|
||||
("Progress", "progress.showFinished"),
|
||||
("ReferenceMonitor", "reference.gang"),
|
||||
] {
|
||||
d.ok("ui.menu.invoke", json!({"id": format!("window.panel.{panel}")}));
|
||||
d.frames(3);
|
||||
assert!(d.has(&format!("panel.tab.{panel}")), "{panel} has no tab");
|
||||
assert!(!d.ids(key).is_empty(), "{panel}: no {key} widgets");
|
||||
assert_eq!(d.ok("ui.inspect", json!({}))["ui"]["focused"], json!(panel));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_scopes_layouts_menu_and_footer() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Color"}));
|
||||
d.ok("ui.panel.show", json!({"panel": "LumetriScopes"}));
|
||||
// the default: RGB parade, Rec. 709, 8 Bit, Clamp Signal
|
||||
d.wait_for("scopes.view.parade");
|
||||
assert_eq!(d.label("scopes.space"), "Rec. 709");
|
||||
let p = d.ui_panels();
|
||||
assert_eq!((p["scopes"]["shown"].clone(), p["scopes"]["scale"].clone(), p["scopes"]["clamp"].clone()), (json!(["parade"]), json!("bits8"), json!(true)));
|
||||
d.snapshot("panels-scopes-parade", Some("scopes."));
|
||||
// the wrench menu: add the YUV vectorscope, switch the waveform type through its submenu
|
||||
d.click("scopes.wrench");
|
||||
assert!(d.has("scopes.menu.vectorscopeYuv"), "{:?}", d.ids("scopes.menu"));
|
||||
d.click("scopes.menu.vectorscopeYuv");
|
||||
d.wait_for("scopes.view.vectorscopeYuv");
|
||||
assert!(d.has("scopes.view.parade"));
|
||||
// Clamp Signal in the footer
|
||||
d.click("scopes.clamp");
|
||||
assert_eq!(d.ui_panels()["scopes"]["clamp"], json!(false));
|
||||
// all five scopes in a grid, through ui.set; then a preset
|
||||
d.ok("ui.set", json!({"panels": {"scopes": {"shown": ["vectorscopeYuv", "vectorscopeHls", "histogram", "parade", "waveform"], "waveformType": "yc", "paradeType": "rgbWhite", "brightness": "bright", "targets": "100"}}}));
|
||||
for k in ["vectorscopeYuv", "vectorscopeHls", "histogram", "parade", "waveform"] {
|
||||
d.wait_for(&format!("scopes.view.{k}"));
|
||||
}
|
||||
let rects: Vec<Value> = d.ok("ui.elements", json!({"prefix": "scopes.view."})).as_array().unwrap().iter().map(|e| e["rect"].clone()).collect();
|
||||
assert_eq!(rects.len(), 5);
|
||||
d.snapshot("panels-scopes-five", Some("scopes."));
|
||||
d.click("scopes.wrench");
|
||||
d.ok("ui.click", json!({"id": "scopes.menu.presets"}));
|
||||
d.frames(3);
|
||||
d.wait_for("scopes.menu.preset.5");
|
||||
d.click("scopes.menu.preset.5");
|
||||
let s = d.ui_panels()["scopes"].clone();
|
||||
assert_eq!(s["preset"], json!("Four Scopes (YUV, float, no clamp)"), "{s}");
|
||||
assert_eq!((s["scale"].clone(), s["clamp"].clone(), s["paradeType"].clone()), (json!("float"), json!(false), json!("yuv")));
|
||||
assert_eq!(s["shown"].as_array().unwrap().len(), 4);
|
||||
// colour space: Rec. 601 shows in the footer
|
||||
d.ok("ui.set", json!({"panels": {"scopes": {"colorSpace": "rec601"}}}));
|
||||
d.frames(3);
|
||||
assert_eq!(d.label("scopes.space"), "Rec. 601");
|
||||
// the numbers behind it, for agents
|
||||
let r = d.exec("scopes.read", json!({"scopes": ["histogram"], "colorSpace": "601", "bins": false}));
|
||||
assert_eq!(r["matrix"], "Bt601");
|
||||
assert!(r["histogram"]["samples"].as_u64().unwrap() > 1000, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_panel_edits_a_field_with_undo() {
|
||||
let mut d = Driver::demo();
|
||||
let item = d.app().session.project.items.values().filter(|i| i.as_media().is_some()).map(|i| i.id).min().unwrap();
|
||||
d.app().session.state.project_selection = vec![item];
|
||||
d.app().ui.focused = filmcraft_ui_egui::dock::PanelKind::Project;
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.panel.Metadata"}));
|
||||
d.ok("ui.set", json!({"focused": "Project"}));
|
||||
d.frames(3);
|
||||
let name = d.app().session.project.item(item).unwrap().name.clone();
|
||||
assert_eq!(d.label("metadata.header"), format!("Clip: {name}"));
|
||||
for id in [
|
||||
"metadata.field.Name",
|
||||
"metadata.label",
|
||||
"metadata.field.MediaStart",
|
||||
"metadata.field.MediaDuration",
|
||||
"metadata.field.Scene",
|
||||
"metadata.field.LogNote",
|
||||
"metadata.field.FilePath",
|
||||
"metadata.section.File",
|
||||
] {
|
||||
assert!(d.has(id), "{id} missing: {:?}", d.ids("metadata."));
|
||||
}
|
||||
d.snapshot("panels-metadata", Some("metadata."));
|
||||
// type into Scene and commit with Enter: one undo step
|
||||
let undo0 = d.app().session.history.undo.len();
|
||||
d.click("metadata.field.Scene");
|
||||
d.ok("ui.type", json!({"text": "42A"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.key", json!({"key": "Enter"}));
|
||||
d.frames(3);
|
||||
assert_eq!(d.app().session.project.item(item).unwrap().metadata.get("Scene").map(String::as_str), Some("42A"));
|
||||
assert_eq!(d.app().session.history.undo.len(), undo0 + 1);
|
||||
assert_eq!(d.label("metadata.field.Scene"), "42A");
|
||||
d.exec("edit.undo", json!({}));
|
||||
d.frames(2);
|
||||
assert!(!d.app().session.project.item(item).unwrap().metadata.contains_key("Scene"));
|
||||
// the same edit from an agent
|
||||
d.exec("metadata.set", json!({"item": item.0, "field": "Tape Name", "value": "A001"}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.label("metadata.field.TapeName"), "A001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode_panel_rows_and_modes() {
|
||||
let mut d = Driver::demo();
|
||||
d.exec("playhead.set", json!({"timecode": "00:00:02:00"}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.panel.Timecode"}));
|
||||
d.frames(3);
|
||||
assert_eq!(d.label("timecode.row.0"), "00:00:02:00");
|
||||
d.click("timecode.addRow");
|
||||
assert_eq!(d.ui_panels()["timecode"]["rows"].as_array().unwrap().len(), 2);
|
||||
// the new row is a duration: the sequence's
|
||||
let (dur, df, rate) = {
|
||||
let q = d.app().session.active_sequence().unwrap();
|
||||
(q.duration(), q.settings.drop_frame, q.settings.frame_rate)
|
||||
};
|
||||
let want = filmcraft_time::format_time(dur, rate, df, filmcraft_time::TimeDisplay::Timecode, 48000);
|
||||
assert_eq!(d.label("timecode.row.1"), want);
|
||||
// frames display and Remaining through ui.set
|
||||
d.ok("ui.set", json!({"panels": {"timecode": {"rows": [{"mode": "current", "display": "Frames"}, {"mode": "remaining"}]}}}));
|
||||
d.frames(2);
|
||||
let frame = rate.frame_at(d.app().session.playhead());
|
||||
assert_eq!(d.label("timecode.row.0"), frame.to_string());
|
||||
let rem = filmcraft_time::format_time(dur - d.app().session.playhead(), rate, df, filmcraft_time::TimeDisplay::Timecode, 48000);
|
||||
assert_eq!(d.label("timecode.row.1"), rem);
|
||||
d.snapshot("panels-timecode", Some("timecode."));
|
||||
// right-click menu: Media Time
|
||||
d.ok("ui.click", json!({"id": "timecode.row.1", "button": "right"}));
|
||||
d.frames(2);
|
||||
if d.has("timecode.row.1.mode.media") {
|
||||
d.click("timecode.row.1.mode.media");
|
||||
assert_eq!(d.ui_panels()["timecode"]["rows"][1]["mode"], json!("media"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_and_progress_panels() {
|
||||
let mut d = Driver::demo();
|
||||
d.exec("events.clear", json!({}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.panel.Events"}));
|
||||
d.frames(2);
|
||||
// a failed command shows up as an error row
|
||||
let r = d.call("engine.execute", json!({"command": "clip.rename", "params": {}}));
|
||||
assert_eq!(r["ok"], json!(false));
|
||||
d.frames(2);
|
||||
let rows = d.ids("events.row.");
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert!(d.label(&rows[0]).contains("name"), "{}", d.label(&rows[0]));
|
||||
d.click(&rows[0]);
|
||||
assert!(d.has("events.details"));
|
||||
d.snapshot("panels-events", Some("events."));
|
||||
// filter: errors only still shows it; Clear All empties the list
|
||||
d.ok("ui.set", json!({"panels": {"events": {"level": "error"}}}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.ids("events.row.").len(), 1);
|
||||
d.click("events.clearAll");
|
||||
assert!(d.ids("events.row.").is_empty());
|
||||
assert!(d.app().session.log.entries.is_empty());
|
||||
|
||||
// Progress: a running job with a Cancel button
|
||||
let job = filmcraft_engine::Job {
|
||||
id: 4242,
|
||||
label: "Export demo.mp4".into(),
|
||||
progress: Arc::new(Default::default()),
|
||||
result: Arc::new(std::sync::Mutex::new(None)),
|
||||
};
|
||||
job.progress.total.store(10, std::sync::atomic::Ordering::Relaxed);
|
||||
job.progress.done.store(4, std::sync::atomic::Ordering::Relaxed);
|
||||
d.app().session.jobs.push(job.clone());
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.panel.Progress"}));
|
||||
d.frames(3);
|
||||
assert!(d.label("progress.job.4242").contains("40 %"), "{}", d.label("progress.job.4242"));
|
||||
d.snapshot("panels-progress", Some("progress."));
|
||||
d.click("progress.cancel.4242");
|
||||
assert!(job.progress.cancel.load(std::sync::atomic::Ordering::Relaxed));
|
||||
*job.result.lock().unwrap() = Some(Err("cancelled".into()));
|
||||
d.frames(3);
|
||||
assert!(!d.has("progress.cancel.4242"));
|
||||
assert!(d.label("progress.job.4242").contains("Cancelled"));
|
||||
// the Events panel logged the job
|
||||
let ev = d.exec("events.list", json!({}));
|
||||
let msgs: Vec<&str> = ev["entries"].as_array().unwrap().iter().filter_map(|e| e["message"].as_str()).collect();
|
||||
assert!(msgs.contains(&"Started: Export demo.mp4") && msgs.contains(&"Cancelled: Export demo.mp4"), "{msgs:?}");
|
||||
// hide finished jobs
|
||||
d.click("progress.showFinished");
|
||||
assert!(!d.has("progress.job.4242"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_monitor_parks_gangs_and_shows_scopes() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.panel.ReferenceMonitor"}));
|
||||
d.frames(3);
|
||||
assert_eq!(d.label("reference.timecode"), "00:00:00:00");
|
||||
d.exec("playhead.set", json!({"timecode": "00:00:03:00"}));
|
||||
d.frames(2);
|
||||
// parked: the playhead moved, the reference did not
|
||||
assert_eq!(d.label("reference.timecode"), "00:00:00:00");
|
||||
d.click("reference.matchPlayhead");
|
||||
assert_eq!(d.label("reference.timecode"), "00:00:03:00");
|
||||
d.click("reference.stepForward");
|
||||
assert_eq!(d.label("reference.timecode"), "00:00:03:01");
|
||||
// ganged: follows the playhead
|
||||
d.click("reference.gang");
|
||||
assert_eq!(d.ui_panels()["reference"]["ganged"], json!(true));
|
||||
d.exec("playhead.set", json!({"timecode": "00:00:01:00"}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.label("reference.timecode"), "00:00:01:00");
|
||||
d.wait_for("reference.picture");
|
||||
d.snapshot("panels-reference-composite", Some("reference."));
|
||||
// scopes of the reference frame
|
||||
d.ok("ui.set", json!({"panels": {"reference": {"display": "scopes"}}}));
|
||||
d.wait_for("reference.scopes.view.vectorscopeYuv");
|
||||
d.wait_for("reference.scopes.view.waveform");
|
||||
d.snapshot("panels-reference-scopes", Some("reference."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_of_colour_bars() {
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("file.newBarsAndTone", json!({"seconds": 2})).unwrap()["item"].as_u64().unwrap();
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [id]})).unwrap();
|
||||
let mut d = Driver::with(s);
|
||||
d.ok(
|
||||
"ui.set",
|
||||
json!({"workspace": "Color", "panels": {"scopes": {"shown": ["vectorscopeYuv", "histogram", "parade", "waveform"], "waveformType": "luma"}}}),
|
||||
);
|
||||
d.ok("ui.panel.show", json!({"panel": "LumetriScopes"}));
|
||||
for k in ["vectorscopeYuv", "histogram", "parade", "waveform"] {
|
||||
d.wait_for(&format!("scopes.view.{k}"));
|
||||
}
|
||||
d.snapshot("panels-scopes-bars", Some("scopes."));
|
||||
// the numbers: the six 75 % bars sit on their targets
|
||||
let r = d.exec("scopes.read", json!({"scopes": ["vectorscopeYuv", "parade"], "columns": 8}));
|
||||
let peaks = r["vectorscopeYuv"]["peaks"].as_array().unwrap();
|
||||
let angles: Vec<f64> = peaks.iter().take(7).map(|p| p["angleDeg"].as_f64().unwrap()).collect();
|
||||
for t in filmcraft_scopes::targets(filmcraft_scopes::Matrix::Bt709, 191.0 / 255.0) {
|
||||
let a = filmcraft_scopes::angle_deg(t.cb, t.cr) as f64;
|
||||
assert!(angles.iter().any(|x| (x - a).abs() < 1.5), "{} at {a:.1}° not in {angles:?}", t.name);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ and `filmcraft-cli`.
|
|||
| `render` | L3 | sequence evaluation, CPU compositor, video effects (`effects`, `vfx`; effects needing other frames or tracks read them through `vfx::FxEnv`), transitions, audio mix |
|
||||
| `gpu` | L3 | wgpu compositor (WGSL) |
|
||||
| `golden` | L3 | test-only: golden-image tests of the CPU renderer and GPU-vs-CPU parity; empty library, dev-dependencies only |
|
||||
| `scopes` | L3 | video scope maths: waveform, parade, histogram, YUV / HLS vectorscopes, numeric summaries ([crates/scopes/README.md](../crates/scopes/README.md)) |
|
||||
| `export` | L3 | render → encode → mux pipeline, progress/cancel |
|
||||
| `engine` | L4 | `Session`, command registry, undo history, media pool, jobs, interchange glue |
|
||||
| `ui-egui` | L5 | the egui frontend: docking, panels, timeline, monitors, playback, control-channel handlers |
|
||||
|
|
@ -176,6 +177,10 @@ pub struct CommandSpec {
|
|||
is serde, so agents can read it with `state.inspect`.
|
||||
- **Events** (`ProjectChanged`, `Toast`, `OpenSequence`, `OpenSource`) are drained by frontends
|
||||
each frame.
|
||||
- **Event log** (`Session::log`, `engine::panels`): every failed top-level command (an error; a
|
||||
disabled one is a warning), messages and error toasts, auto-save errors, and background jobs
|
||||
starting, finishing, failing or being cancelled. Repeats of the newest entry bump its count. The
|
||||
Events panel shows it; `events.list {level?, since?}` and `events.clear` reach it headless.
|
||||
- **UI-only commands** (tools, playback, zoom, panels, workspaces) live in
|
||||
`crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The menu bar is built from the engine registry plus
|
||||
this table, and `menus::invoke` is the single entry point for menus, shortcuts and the control
|
||||
|
|
@ -477,12 +482,18 @@ All of these dispatch the same command ids.
|
|||
settings) is in `crates/ui-egui/src/state.rs` as serde structs, so `ui.inspect` and `ui.set` can
|
||||
read and write it.
|
||||
|
||||
- **Panels with engine data** (M8.9 / M12.6): Lumetri Scopes (`scopes.read` returns the same
|
||||
scopes as numbers), Metadata (`metadata.get` / `metadata.set`, one undo step per edit, stored in
|
||||
`ProjectItem::metadata`), Events (`events.list` / `events.clear`), Progress (`jobs.list` /
|
||||
`jobs.cancel`). Timecode and Reference Monitor are frontend-only views. Their settings are
|
||||
`UiState::panels` (`ui.set {"panels": {...}}`).
|
||||
|
||||
Protocol reference: [control-protocol.md](control-protocol.md). Agent guide: [agents.md](agents.md).
|
||||
|
||||
## 8. Not built yet
|
||||
|
||||
The layer table reserves names for crates that don't exist yet: `riff`, `mjpeg`,
|
||||
`keyframe`, `effects`, `audio`, `scopes`, `playback`, `format` and `platform`.
|
||||
`keyframe`, `effects`, `audio`, `playback` and `platform`.
|
||||
Until they exist, that work lives elsewhere: keyframes and effect definitions in `project`, effects
|
||||
and the audio mix in `render`, scopes and playback in `ui-egui`, and OS integration (cpal, rfd,
|
||||
and the audio mix in `render`, playback in `ui-egui`, and OS integration (cpal, rfd,
|
||||
native menus) in `apps/filmcraft`. [ROADMAP.md](../ROADMAP.md) has the milestone status.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
|
|||
| `engine.commands` / `ui.menu.list` | – | command registry / menu tree |
|
||||
| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |
|
||||
| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect |
|
||||
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`) |
|
||||
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` |
|
||||
| `ui.panel.show` / `ui.panel.close` | `{panel}` | |
|
||||
| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input |
|
||||
| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release |
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails
|
|||
| Scripted UI | `crates/ui-egui/tests/scripted.rs` | headless app (egui_kittest) driven over the control channel: razor/undo, insert, apply effect, playback, click by automation id (§4) |
|
||||
| Monitor view / Graphics menu | `crates/ui-egui/tests/view_ui.rs`, `crates/engine/src/graphics_tests.rs` | View menu items and checkmarks, playback/paused resolution, channel / comparison / multi-camera / waveform display modes, magnification and Hand-tool panning, guides dragged from the rulers (move, lock, remove, Add Guide…, templates in the preferences), snapping a graphic to the frame centre and a guide; align to frame / as group / to selection, distribute (centres and gaps), arrange, select next/previous graphic and layer, reset parameters / duration, vertical text, New Layer from file (`FILMCRAFT_UI_SHOTS=<dir>` writes `view-*.png`) |
|
||||
| Settings | `crates/engine/src/settings_tests.rs`, `crates/ui-egui/tests/settings_ui.rs` | schema keys and defaults, persistence in the data directory, v1 → v2 migration and repair of bad values, validation, per-category reset; each wired setting (still / transition durations, step many, label defaults and colours, media scaling, timebase, recent projects, media cache policy, output mapping, smart quotes, auto-transcribe); the dialog: every category from its command and Cmd+,, edits by automation id, OK / Cancel / Escape / Reset…, theme, label names in Edit ▸ Label, tooltips, frame cache (`FILMCRAFT_UI_SHOTS=<dir>` writes `settings-*.png`) |
|
||||
| Scopes and panels (M8.9 / M12.6) | `crates/scopes/src/tests.rs`, `crates/engine/src/{scopes,panels}_tests.rs`, `crates/ui-egui/tests/panels_ui.rs` | scope maths on generated frames (flat colours → exact histogram bins, ramps → waveform row = code value, 75 % bars → parade levels and vectorscope target cells for BT.601/709/2020, HLS hue angles, YC chroma, Clamp Signal, decimation, NaN); `scopes.read` on a colour matte (exact bins, 78.43 %) and on Bars and Tone (the six targets); metadata edits (one undo step, read-only fields refused, saved in the project), the event log (failed / disabled commands, repeats, jobs started / finished / failed / cancelled); every panel from Window ▸, the scopes' wrench menu, presets, five-scope grid, typing into a Metadata field, Timecode rows and modes, Events filter / Clear All, Progress cancel, Reference Monitor park / gang / scopes (`FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` writes `panels-*.png`); `perf_scopes_at_1080p` (ignored) times each scope |
|
||||
| Golden images | `crates/golden/tests/golden.rs` | CPU renders vs committed PNGs; GPU vs CPU on the same scenes (§3) |
|
||||
| Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases |
|
||||
| Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity |
|
||||
|
|
|
|||
Loading…
Reference in New Issue