Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 15:00:41 +07:00
47 changed files with 2471 additions and 182 deletions
+9 -1
View File
@@ -148,7 +148,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
cmd!(
"layer.slip",
"Slip Edit",
[],
None,
"{layers?, delta? (seconds) | frames?, merge?} → {requested, slipped: [{layer, delta, clamped}]}",
has_layers,
slip
),
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(-1);
+7 -3
View File
@@ -84,8 +84,9 @@ pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Val
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
/// layer markers are in layer time, so they travel with the source.
/// moves the source later. Clamped so the source still covers the in–out span: the result gives
/// the `requested` shift and, per layer, the `delta` applied and whether it was `clamped` (#2371).
/// Keyframes and layer markers are in layer time, so they travel with the source.
pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -130,7 +131,10 @@ pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
Ok(json!({
"requested": delta.seconds(),
"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds(), "clamped": *d != delta})).collect::<Vec<_>>(),
}))
}
pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
+5 -2
View File
@@ -205,7 +205,8 @@ const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
pub(crate) const MAX_DEPTH: usize = 64;
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
/// `lt` (what the toolbar shows for a selected shape layer).
/// `lt` (what the toolbar shows for a selected shape layer). Width comes from the first Stroke
/// or Gradient Stroke, independently of the first solid stroke's colour.
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
let (mut fill, mut stroke, mut width) = (None, None, None);
if let Some(c) = layer.props.sub("contents") {
@@ -216,10 +217,12 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
"fill" if fill.is_none() => fill = at("color").map(rgba),
"stroke" if stroke.is_none() => {
stroke = at("color").map(rgba);
width = at("width").map(|v| v.as_f64());
}
_ => {}
}
if width.is_none() && is_stroke(g) {
width = at("width").map(|v| v.as_f64());
}
});
}
(fill, stroke, width)
+81 -28
View File
@@ -5,14 +5,16 @@
//! The native `.prproj` format has no public specification and is not read or written.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_interchange::{ExportOptions, ImportOptions, PrecompMode, PrerenderMode, Prerendered, TimelineFormat};
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQueueItem, TimeSpan};
use effectcraft_project::{ItemId, LayerId, LayerSource, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::jobs::TaskCtl;
use crate::{EngineError, Exporter, Result, Session, cmd};
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
match str_p(p, "format") {
@@ -95,18 +97,34 @@ fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option<Project> {
Some(q)
}
/// Render one layer to ProRes 4444 with alpha over its in–out span.
fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result::Result<Prerendered, String> {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
/// What a pre-render needs from the session, so it can run on a background thread.
struct PrerenderCx {
exporter: Option<Arc<dyn Exporter>>,
footage: Arc<dyn effectcraft_render::FootageSource>,
expr: Option<Arc<dyn effectcraft_render::ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
nested_switches: bool,
}
/// Render one layer to ProRes 4444 with alpha over its in–out span; `progress` as the exporter's.
fn prerender(
cx: &PrerenderCx,
project: &Project,
cid: ItemId,
lid: LayerId,
path: &str,
progress: &mut dyn FnMut(u64, u64) -> bool,
) -> std::result::Result<Prerendered, String> {
let exporter = cx.exporter.as_ref().ok_or("export is not available in this build")?;
if !exporter.formats().contains(&OutputFormat::ProRes) {
return Err("ProRes export is not available in this build".into());
}
let comp = s.project.comp(cid).ok_or("no composition")?;
let comp = project.comp(cid).ok_or("no composition")?;
let l = comp.layer(lid).ok_or("no layer")?;
let fd = comp.frame_duration();
let start = comp.frame_rate.snap(l.in_point.max(effectcraft_time::Tick::ZERO));
let end = l.out_point.min(comp.duration).max(start + fd);
let q = isolate(&s.project, cid, lid).ok_or("no composition")?;
let q = isolate(project, cid, lid).ok_or("no composition")?;
let mut item = RenderQueueItem::new(0, cid);
item.settings.time_span = TimeSpan::Custom { start, end };
item.output = OutputModule::for_format(OutputFormat::ProRes);
@@ -116,16 +134,16 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result:
item.output.output = path.to_string();
let job = crate::ExportJob {
project: &q,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
accel: s.accel.as_deref(),
footage: cx.footage.as_ref(),
expr: cx.expr.as_deref(),
accel: cx.accel.as_deref(),
item: &item,
path,
storage: None,
label: format!("Pre-render {}", l.name),
nested_switches: s.prefs.general.switches_affect_nested_comps,
nested_switches: cx.nested_switches,
};
let r = exporter.export(&job, &mut |_, _| true)?;
let r = exporter.export(&job, progress)?;
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
}
@@ -157,33 +175,67 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into());
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
// Pre-render what the timeline can't represent.
let project = s.project.clone();
let services = s.services.clone();
let cx = PrerenderCx {
exporter: s.exporter.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
accel: s.accel.clone(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
// The pre-renders can take minutes: with `background` (the export dialog) they run as a job
// in Window ▸ Progress instead of with the window waiting (#2269).
let wait = !b_p(p, "background").unwrap_or(false);
s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| {
let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?;
services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?;
result["path"] = json!(path);
result["toast"] = json!(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(Box::new(move |_: &mut Session| Ok(result)))
})
}
/// Pre-render what the timeline can't represent, then build the timeline: its bytes and the
/// command's result (without `path`).
fn export_timeline_work(
cx: &PrerenderCx,
project: &Project,
cid: ItemId,
opts: &ExportOptions,
dir: &std::path::Path,
stem: &str,
ctl: &TaskCtl,
) -> std::result::Result<(Vec<u8>, Value), String> {
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
let mut rendered = vec![];
let mut errors = vec![];
for (c, l) in effectcraft_interchange::plan_prerender(&s.project, cid, &opts) {
let comp_name = s.project.item(c).map(|i| i.name.clone()).unwrap_or_default();
let layer_name = s.project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
let plan = effectcraft_interchange::plan_prerender(project, cid, opts);
let n = plan.len() as u64;
for (i, (c, l)) in (0u64..).zip(plan) {
let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default();
let layer_name = project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
ctl.message(format!("Pre-render {layer_name}"));
let file = dir.join(format!("{stem}_{}_{}_{}.mov", safe(&comp_name), safe(&layer_name), l.0)).to_string_lossy().to_string();
match prerender(s, c, l, &file) {
// Progress over all the layers, each one's frames a share of it.
let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000);
match prerender(cx, project, c, l, &file, &mut progress) {
Ok(r) => {
rendered.push(r.path.clone());
pre.insert((c, l), r);
}
Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()),
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
}
}
let out = effectcraft_interchange::export(&s.project, cid, &opts, &pre).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &out.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
let out = effectcraft_interchange::export(project, cid, opts, &pre).map_err(|e| e.to_string())?;
let mut warnings = out.warnings.clone();
warnings.extend(errors.iter().map(|e| format!("pre-render failed: {e}")));
s.toast(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(json!({
"path": path,
"format": format.id(),
let result = json!({
"format": opts.format.id(),
"bytes": out.bytes.len(),
"sequences": out.sequences,
"videoTracks": out.video_tracks,
@@ -191,7 +243,8 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
"clips": out.clips,
"prerendered": rendered,
"warnings": warnings,
}))
});
Ok((out.bytes, result))
}
/// The formats, for agents and the export dialog.
@@ -220,7 +273,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adobe Premiere Pro Project...",
["File", "Export"],
None,
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender}",
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender, background?: bool (pre-render and write as a job, jobs.list / jobs.wait; returns {job})}",
has_comp,
export_timeline
),