Merge pull request #192 from cirocueto2013-wq/fix/editing-workflow-stability

Stabilize project lifecycle, timeline, audio and export workflows
This commit is contained in:
Brandon Thomas 2026-10-07 20:35:46 -04:00 committed by GitHub
commit 6d904c1b37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 91 additions and 56 deletions

View File

@ -423,11 +423,7 @@ fn can_replace_from_bin(s: &Session) -> std::result::Result<(), String> {
/// The media item behind `id` (subclips resolve to their parent) and the subclip range.
fn media_root(p: &Project, id: ItemId) -> Option<(ItemId, &MediaClip, Option<TimeRange>)> {
match &p.item(id)?.kind {
ItemKind::Media(m) => Some((id, m, None)),
ItemKind::Subclip { parent, range, .. } => media_root(p, *parent).map(|(i, m, _)| (i, m, Some(*range))),
_ => None,
}
p.resolve_media(id)
}
/// What the Source monitor shows for an item: the media-time span of its time ruler, its frame

View File

@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, RwLock};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaSource, Opener, SharedSource};
use filmcraft_project::{ItemId, ItemKind, MediaClip, MediaRef, Project};
use filmcraft_project::{ItemId, MediaClip, MediaRef, Project};
use filmcraft_render::offline::OfflineReason;
use crate::Services;
@ -217,12 +217,8 @@ impl MediaPool {
}
fn resolve(&self, p: &Project, item: ItemId, services: &dyn Services, proxies: bool) -> Option<SharedSource> {
let (item, m, _) = p.resolve_media(item)?;
let it = p.item(item)?;
let m = match &it.kind {
ItemKind::Media(m) => m,
ItemKind::Subclip { parent, .. } => return self.resolve(p, *parent, services, proxies),
_ => return None,
};
if proxies
&& !m.offline
&& let Some(MediaRef::File { path }) = &m.proxy
@ -406,6 +402,7 @@ impl MediaSource for ProxySource {
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_project::ItemKind;
/// A host whose whole-file read fails: opening must go through the reader.
struct ReaderOnly(Arc<[u8]>);
@ -421,6 +418,48 @@ mod tests {
}
}
#[test]
fn cyclic_subclips_do_not_recurse_in_media_or_rendering() {
let mut project = Project::new("damaged references");
let a = project.add_item(
"A",
filmcraft_project::Label::Iris,
ItemKind::Subclip {
parent: ItemId(999),
range: filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, filmcraft_time::Tick(1000)),
restrict_trims: false,
},
None,
);
let b = project.add_item(
"B",
filmcraft_project::Label::Iris,
ItemKind::Subclip {
parent: a,
range: filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, filmcraft_time::Tick(1000)),
restrict_trims: false,
},
None,
);
let pool = MediaPool::default();
for parent in [a, b] {
let ItemKind::Subclip { parent: reference, .. } = &mut project.item_mut(a).unwrap().kind else { panic!() };
*reference = parent;
assert!(pool.source_for(&project, a, &crate::FsServices).is_none());
assert!(pool.full_res_source(&project, a, &crate::FsServices).is_none());
assert!(crate::media_duration(&project, &pool, a).is_none());
assert!(filmcraft_render::source_size(&project, a).is_none());
assert!(project.resolve_media(a).is_none());
assert!(filmcraft_render::colorman::override_of(&project, a).is_none());
assert!(filmcraft_render::colorman::source_peak_nits(&project, a).is_none());
assert!(crate::sync::project_clip(&project, a).is_none());
let mut session = crate::Session { project: Arc::new(project.clone()), ..Default::default() };
session.state.project_selection = vec![a];
assert!(!session.is_enabled("file.mediaProperties"));
assert!(session.execute("transcript.generate", serde_json::json!({"items":[a.0]})).is_err());
}
}
#[test]
fn open_file_prefers_the_hosts_reader() {
let wav = filmcraft_media::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000);

View File

@ -323,11 +323,7 @@ fn placed_item(p: &mut Project, item: ItemId, kind: TrackKind, exact: Tick, rang
}
fn sync_source_size(p: &Project, item: ItemId) -> Option<(u32, u32)> {
match &p.item(item)?.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Subclip { parent, .. } => sync_source_size(p, *parent),
_ => None,
}
p.resolve_media(item).and_then(|(_, media, _)| media.info.video.as_ref().map(|video| (video.width, video.height)))
}
// ---------------------------------------------------------------- Synchronize

View File

@ -370,11 +370,7 @@ fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
/// The media clip behind an item (subclips resolve to their parent).
fn media_of(p: &Project, id: ItemId) -> Option<(ItemId, &filmcraft_project::MediaClip)> {
match &p.item(id)?.kind {
ItemKind::Media(m) => Some((id, m)),
ItemKind::Subclip { parent, .. } => media_of(p, *parent),
_ => None,
}
p.resolve_media(id).map(|(root, media, _)| (root, media))
}
fn file_path(m: &filmcraft_project::MediaClip) -> Option<&str> {

View File

@ -81,11 +81,7 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
/// The media item a clip shows (a subclip resolves to its parent: subclip clips use the parent's
/// media time).
fn media_root(p: &filmcraft_project::Project, item: ItemId) -> Option<ItemId> {
match &p.item(item)?.kind {
ItemKind::Media(_) => Some(item),
ItemKind::Subclip { parent, .. } => media_root(p, *parent),
_ => None,
}
p.resolve_media(item).map(|(root, _, _)| root)
}
struct Work {

View File

@ -15,7 +15,7 @@
//! `offset` (frames) shifts every clip but the reference by that much (a known delay to correct).
use filmcraft_audio_dsp::sync::{SyncOptions, SyncResult, find_offset, mixdown};
use filmcraft_project::{ItemId, ItemKind, Project, TrackItem};
use filmcraft_project::{ItemId, Project, TrackItem};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use serde_json::{Value, json};
@ -76,12 +76,7 @@ pub struct SyncClip {
/// The media item behind an item (subclips resolve to their parent) and its subclip range.
fn media_of(p: &Project, item: ItemId) -> Option<(ItemId, &filmcraft_project::MediaClip, Option<TimeRange>)> {
let it = p.item(item)?;
match &it.kind {
ItemKind::Media(m) => Some((item, m, None)),
ItemKind::Subclip { parent, range, .. } => media_of(p, *parent).map(|(i, m, _)| (i, m, Some(*range))),
_ => None,
}
p.resolve_media(item)
}
fn start_timecode(m: &filmcraft_project::MediaClip) -> Tick {

View File

@ -77,11 +77,7 @@ fn can_download(_: &Session) -> std::result::Result<(), String> {
/// The media item behind a project item (subclips resolve to their parent).
fn media_item(s: &Session, item: ItemId) -> Option<ItemId> {
match &s.project.item(item)?.kind {
ItemKind::Media(_) => Some(item),
ItemKind::Subclip { parent, .. } => media_item(s, *parent),
_ => None,
}
s.project.resolve_media(item).map(|(root, _, _)| root)
}
fn ids_p(p: &Value, k: &str) -> Option<Vec<ItemId>> {

View File

@ -1491,6 +1491,24 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source:
}
impl Project {
/// Resolve media through at most sixteen items, preserving the outermost subclip range.
/// Missing parents, cycles and longer chains are unavailable media.
pub fn resolve_media(&self, item: ItemId) -> Option<(ItemId, &MediaClip, Option<TimeRange>)> {
let mut id = item;
let mut range = None;
for _ in 0..16 {
match &self.item(id)?.kind {
ItemKind::Media(media) => return Some((id, media, range)),
ItemKind::Subclip { parent, range: span, .. } => {
range.get_or_insert(*span);
id = *parent;
}
_ => return None,
}
}
None
}
/// Size in pixels of what an item shows: a media clip's picture, a sequence's frame, an
/// adjustment layer or graphic; a subclip has the size of its parent. `None` without
/// picture. This is the size the renderer centres a clip's "auto" anchor in.
@ -1630,6 +1648,22 @@ mod tests {
assert!(serde_json::from_value::<ParamValue>(serde_json::json!({ "Path": path })).is_err());
}
#[test]
fn media_resolution_bounds_depth_and_preserves_the_outer_range() {
let (mut p, media, _) = demo_project();
assert_eq!(p.resolve_media(media).map(|(id, _, range)| (id, range)), Some((media, None)));
let mut parent = media;
let mut last_range = TimeRange::default();
for n in 0..15 {
last_range = TimeRange::new(Tick(n * 1000), Tick(100));
parent = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None);
}
assert_eq!(p.resolve_media(parent).map(|(id, _, range)| (id, range)), Some((media, Some(last_range))));
let too_deep = p.add_item("Too deep", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None);
assert!(p.resolve_media(too_deep).is_none());
assert!(p.resolve_media(ItemId(u64::MAX)).is_none());
}
#[test]
fn placed_auto_points_resolve_once_the_source_size_is_final() {
let (mut p, clip, seq) = demo_project();

View File

@ -10,17 +10,13 @@ use std::sync::{Arc, Mutex, OnceLock};
use filmcraft_color::{ColorPipeline, ColorSpace, InputTransform, OutputTransform, Range};
use filmcraft_frame::VideoFrame;
use filmcraft_project::{ItemId, ItemKind, Project};
use filmcraft_project::{ItemId, Project};
use crate::Image;
/// The Interpret Footage colour-space override of an item (following subclips to their media).
pub fn override_of(project: &Project, item: ItemId) -> Option<ColorSpace> {
match &project.item(item)?.kind {
ItemKind::Media(m) => m.interpret.color_space,
ItemKind::Subclip { parent, .. } => override_of(project, *parent),
_ => None,
}
project.resolve_media(item).and_then(|(_, media, _)| media.interpret.color_space)
}
/// The colour space a frame of `item` is interpreted in.
@ -39,11 +35,7 @@ type Key = (ColorSpace, Range, ColorPipeline, Option<u32>);
/// The content peak (cd/m²) of an item's HDR metadata (MaxCLL, else the mastering display peak),
/// following subclips to their media. Used as the tone-mapping source peak of PQ media.
pub fn source_peak_nits(project: &Project, item: ItemId) -> Option<f32> {
match &project.item(item)?.kind {
ItemKind::Media(m) => m.info.video.as_ref()?.hdr.as_ref()?.peak_nits(),
ItemKind::Subclip { parent, .. } => source_peak_nits(project, *parent),
_ => None,
}
project.resolve_media(item).and_then(|(_, media, _)| media.info.video.as_ref()?.hdr.as_ref()?.peak_nits())
}
/// A cached input transform (`peak_nits`: the PQ content peak from the file's HDR metadata).
@ -115,7 +107,7 @@ mod tests {
fn mastering_metadata_sets_the_tone_mapping_peak() {
use filmcraft_color::HdrMetadata;
use filmcraft_media::MediaSource;
use filmcraft_project::{Label, MediaClip, MediaRef};
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef};
let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
// a PQ grey of ≈ 2000 cd/m² (code 0.83) and one of ≈ 4000 cd/m² (code 0.90)
let px = |nits: f32| (filmcraft_color::pq_inverse_eotf(nits / 10_000.0) * 255.0).round() as u8;

View File

@ -329,12 +329,7 @@ pub(crate) fn opacity_blend(item: &TrackItem, mt: Tick) -> (f32, Blend) {
/// Size of an item's source at full resolution.
pub fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> {
match &project.item(item)?.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Sequence(s) => Some((s.settings.width, s.settings.height)),
ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)),
ItemKind::Subclip { parent, .. } => source_size(project, *parent),
}
project.source_size(item)
}
/// The Motion transform of an item at media time `mt`, mapping full-res source pixels to