Files
effectcraft/crates/engine/src/commands/panels_cmds.rs
T
Claude 40e87709d5 Import: numbered stills import as stills unless the Sequence option is on; dropped folders make sequences (#431)
Cause: SequenceOptions::from_params defaulted `sequence` to true for
file.import, so any numbered still (IMG_0001.jpg) brought its whole numbered
run from its folder, and several picked or dropped stills with a common
prefix became one sequence. Dropped files never asked, and the Import
dialog's "<format> Sequence" checkbox started ticked.

Fix, matching After Effects: file.import takes picked and dropped files as
stills unless `sequence: true` (the Import dialog's Sequence checkbox, which
now starts unticked; Force Alphabetical Order implies it), while the numbered
stills inside a picked or dropped folder still import as sequences
(SequenceOptions::folders), and `sequence: false` (Alt-drag of a folder)
imports a folder's files one by one. Replace Footage, reload, proxies and
render outputs keep detecting their sequence (SequenceOptions::default and
from_params are unchanged), and mediaBrowser.import keeps bringing a numbered
still's run unless `sequence: false`. docs/footage.md and docs/agents.md
describe the new defaults.

Tests: sequence import_takes_stills_unless_asked_for_a_sequence and engine
numbered_stills_import_as_stills_by_default (one dropped still and two picked
IMG_ stills stay stills, a dropped folder makes one sequence, the checkbox
brings the run); the existing sequence tests pass `sequence: true`; ui
import_dialog_offers_the_image_sequence_option (unticked by default: a still;
ticked: the run) and a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation
(two dropped frames: two stills; the folder: one sequence).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit aa3c31db9ab3a774ee2f8ba867e1bafc1fce7aba)
2026-10-10 19:48:25 +00:00

266 lines
11 KiB
Rust

//! Commands behind the Progress, Media Browser, Metadata and Lumetri Scopes panels.
use effectcraft_project::ItemId;
use effectcraft_raster::Image;
use effectcraft_raster::scopes::{self, ColorStandard, ScopeKind, ScopeOpts};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, f_p, str_p};
use crate::{EngineError, Result, Session, cmd, media_browser as mb, query};
// ---------------------------------------------------------------- Progress
fn jobs_list(s: &mut Session, _: &Value) -> Result<Value> {
s.poll_jobs();
Ok(json!({"running": s.jobs(), "finished": s.job_log}))
}
fn jobs_cancel(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_p(p, "job").unwrap_or("all");
Ok(json!({"cancelled": s.cancel_job(id)}))
}
fn jobs_wait(s: &mut Session, _: &Value) -> Result<Value> {
s.wait_jobs();
Ok(json!({"finished": s.job_log}))
}
// ---------------------------------------------------------------- Media Browser
fn browser(s: &Session) -> Result<std::sync::Arc<dyn mb::Browser>> {
s.media_browser().ok_or_else(|| EngineError::Other("the Media Browser is not available in this build".into()))
}
fn browser_dir(s: &Session, b: &dyn mb::Browser, p: &Value) -> String {
str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(|| b.home())
}
fn browser_list(s: &mut Session, p: &Value) -> Result<Value> {
let b = browser(s)?;
let dir = browser_dir(s, b.as_ref(), p);
let only = b_p(p, "importableOnly").unwrap_or(s.state.media_browser.importable_only);
let entries = b.list(&dir, only).map_err(EngineError::Other)?;
let places: Vec<Value> = b.places().into_iter().map(|(name, path)| json!({"name": name, "path": path})).collect();
let actions: Vec<Value> = b.actions().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect();
Ok(json!({
"path": dir,
"parent": b.parent(&dir),
"entries": entries,
"favorites": s.state.media_browser.favorites,
"places": places,
"actions": actions,
}))
}
fn browser_enabled(s: &Session) -> std::result::Result<(), String> {
if s.media_browser().is_some() { Ok(()) } else { Err("the Media Browser is not available in this build".into()) }
}
fn browser_go(s: &mut Session, p: &Value) -> Result<Value> {
let b = browser(s)?;
let dir = match str_p(p, "path") {
Some("..") => s.state.media_browser.folder.clone().and_then(|d| b.parent(&d)).unwrap_or_else(|| b.home()),
Some(d) => d.to_string(),
None => b.home(),
};
if let Some(v) = b_p(p, "importableOnly") {
s.state.media_browser.importable_only = v;
}
// Validate before switching.
b.list(&dir, false).map_err(EngineError::Other)?;
s.state.media_browser.folder = Some(dir.clone());
browser_list(s, &json!({"path": dir}))
}
/// A browser-specific toolbar action (web: Open Folder…, Add Files…).
fn browser_action(s: &mut Session, p: &Value) -> Result<Value> {
let b = browser(s)?;
let id = str_p(p, "action").ok_or_else(|| bad("mediaBrowser.action", "missing `action`"))?;
b.action(id).map_err(|e| bad("mediaBrowser.action", e))
}
fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result<Value> {
let b = browser(s)?;
let dir = browser_dir(s, b.as_ref(), p);
let favs = &mut s.state.media_browser.favorites;
favs.retain(|f| f != &dir);
if add {
favs.push(dir);
}
Ok(json!({"favorites": favs}))
}
fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = p.get("paths").or(p.get("path")).cloned().ok_or_else(|| bad("mediaBrowser.import", "missing `paths`"))?;
// Files the browser still has to read (the web app's opened folders): imported when they
// arrive.
let list: Vec<String> = match &paths {
Value::String(one) => vec![one.clone()],
Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
_ => vec![],
};
if !browser(s)?.fetch(&list, p) {
return Ok(json!({"pending": true, "paths": list}));
}
let target = s.active_comp_id();
// A numbered still picked in the browser still brings its image sequence (`sequence: false`
// for the picked stills alone); `file.import` alone imports stills.
let sequence = p.get("sequence").and_then(Value::as_bool).unwrap_or(true);
let mut r = s.execute("file.import", json!({"paths": paths, "sequence": sequence}))?;
// Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped.
let errors = super::file::add_to_comp(s, &r, target, p);
if !errors.is_empty()
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
{
e.extend(errors.into_iter().map(Value::from));
}
Ok(r)
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?;
let (created, modified, size) = mb::file_dates(path);
let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)});
if matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
&& let Ok(f) = s.probe_footage(path)
{
v["footage"] = mb::footage_metadata(&f);
}
Ok(v)
}
// ---------------------------------------------------------------- Metadata
fn item_ref(s: &Session, p: &Value) -> Option<ItemId> {
match p.get("item") {
Some(Value::Number(n)) => n.as_u64().map(ItemId),
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name).map(|i| i.id),
_ => s.state.project_selection.first().copied(),
}
}
fn metadata(s: &mut Session, p: &Value) -> Result<Value> {
let project = mb::project_metadata(&s.project, s.path.as_deref());
let item = match item_ref(s, p) {
Some(id) => Some(mb::item_metadata(&s.project, s.project.item(id).ok_or_else(|| bad("item.metadata", format!("no item {}", id.0)))?)),
None => None,
};
Ok(json!({"project": project, "item": item}))
}
fn project_comment(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_p(p, "comment").ok_or_else(|| bad("project.setProjectComment", "missing `comment`"))?.to_string();
s.edit("Project Comment", super::merge_p(p), |proj, _| {
proj.settings.comment = text;
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- Lumetri Scopes
/// The comp frame the scopes read (the viewer's image: over the comp background), at most
/// `max_side` pixels on the long side.
pub fn scope_frame(s: &Session, comp: ItemId, t: Tick, max_side: u32) -> Option<Image> {
let c = s.project.comp(comp)?;
let long = c.width.max(c.height).max(1) as f64;
let scale = (max_side as f64 / long).min(1.0);
let mut img = s.render(comp, t, RenderOpts { scale, ..Default::default() });
let bg = c.background;
for p in &mut img.data {
let k = 1.0 - p[3].clamp(0.0, 1.0);
*p = [p[0] + bg[0] * k, p[1] + bg[1] * k, p[2] + bg[2] * k, 1.0];
}
Some(img)
}
/// Scope options from parameters.
pub fn scope_opts(p: &Value) -> std::result::Result<(ScopeKind, ScopeOpts), String> {
let kind = match str_p(p, "scope") {
Some(k) => ScopeKind::from_name(k).ok_or_else(|| format!("unknown scope `{k}`; one of {}", ScopeKind::ALL.map(|k| k.name()).join(", ")))?,
None => ScopeKind::WaveformRgb,
};
let standard = match str_p(p, "standard") {
Some(x) => ColorStandard::from_name(x).ok_or_else(|| format!("standard: rec601|rec709|rec2020, not `{x}`"))?,
None => ColorStandard::Rec709,
};
let size = f_p(p, "size").unwrap_or(256.0).clamp(16.0, 1024.0) as u32;
Ok((kind, ScopeOpts { standard, float: b_p(p, "float").unwrap_or(false), clamp: b_p(p, "clamp").unwrap_or(true), width: size, height: size }))
}
fn scopes_analyze(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = super::time_p(s, p, Some(comp));
let (kind, o) = scope_opts(p).map_err(|e| bad("scopes.analyze", e))?;
let img = scope_frame(s, cid, t, 512).ok_or(EngineError::NoComp)?;
let sc = scopes::compute(&img, kind, &o);
// Signal statistics of the frame.
let n = img.data.len().max(1) as f64;
let mut mean = [0.0f64; 3];
let (mut ymin, mut ymax, mut ysum) = (f64::MAX, f64::MIN, 0.0);
for px in &img.data {
for c in 0..3 {
mean[c] += px[c] as f64 / n;
}
let y = o.standard.ycbcr([px[0], px[1], px[2]])[0] as f64;
ymin = ymin.min(y);
ymax = ymax.max(y);
ysum += y;
}
let mut v = json!({
"scope": kind.name(),
"width": sc.width,
"height": sc.height,
"peaks": [sc.peak(0), sc.peak(1), sc.peak(2)],
"totals": [sc.total(0), sc.total(1), sc.total(2)],
"mean": mean,
"luma": {"min": ymin, "max": ymax, "mean": ysum / n},
});
if kind == ScopeKind::Histogram {
v["histogram"] = json!(scopes::histogram_bars(&sc));
}
Ok(v)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("jobs.list", "Background Jobs", "{}", jobs_list),
cmd!("jobs.cancel", "Cancel Job", [], None, "{job?: render|track|maskTrack|warp|camera|roto|task:<n>|all}", always, jobs_cancel),
query!("jobs.wait", "Wait for Background Jobs", "{}", jobs_wait),
query!("mediaBrowser.list", "List Folder", "{path?, importableOnly?}", browser_list),
cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go),
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
cmd!(
"mediaBrowser.import",
"Import",
[],
None,
"{paths, sequence? (default true: a numbered still brings its image sequence), addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
browser_enabled,
browser_import
),
cmd!(
"mediaBrowser.action",
"Media Browser Action",
[],
None,
"{action}: an action from mediaBrowser.list's `actions` (web: openFolder, addFiles, uploadFolder)",
browser_enabled,
browser_action
),
query!("mediaBrowser.fileInfo", "File Info", "{path}", file_info),
query!("item.metadata", "Metadata", "{item?: id|name}", metadata),
cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment),
query!(
"scopes.analyze",
"Lumetri Scopes",
"{comp?, time?|frame?, scope?: waveformRgb|waveformLuma|waveformYc|vectorscopeYuv|vectorscopeHls|histogram|paradeRgb|paradeYuv, standard?: rec601|rec709|rec2020, float?, clamp?, size?}",
scopes_analyze
),
]
}