optimize: video and audio render
CI / Build & test (Linux) (push) Canceled after 0s
CI / Build & test (Windows) (push) Canceled after 0s

This commit is contained in:
2026-08-29 05:01:55 +08:00
parent ca18105c8b
commit cbd4ba2442
16 changed files with 1776 additions and 442 deletions
+17 -3
View File
@@ -1198,7 +1198,18 @@ fn audio_layout(params: &crate::ticket::AudioTicketParams) -> Result<(i32, u64,
if seconds <= 0.0 || seconds > 3600.0 {
return Err(Error::Invalid);
}
let total_frames = (seconds * rate as f64).round() as usize;
// Anchor each chunk to the absolute sample grid (`round(out·rate) -
// round(in·rate)`) instead of rounding the duration: at fractional
// frame rates (29.97 fps → 1601.6 samples/frame) a duration-round
// would emit 1602 samples for every chunk and accumulate ~12 extra
// samples per second, slowly desyncing audio from video. Per-chunk
// anchoring keeps the total exact and matches the decode side
// (`FFmpegDecoder::retrieve_audio_to` fills `round(out·rate) -
// round(in·rate)` samples).
let total_frames = ((params.range.out().to_f64() * rate as f64).round()
- (params.range.in_().to_f64() * rate as f64).round())
.max(0.0) as usize;
Ok((rate, params.channel_layout, channels, total_frames))
}
@@ -1230,8 +1241,11 @@ fn mix_audio_montage(
if out_time <= in_time {
continue;
}
let start_frame = ((in_time - params.range.in_()).to_f64() * rate as f64) as usize;
let end_frame = ((out_time - params.range.in_()).to_f64() * rate as f64) as usize;
// Round to the absolute sample grid like the decode side (which
// anchors at `round(media_start·rate)`): truncation would shift a
// clip's mix by up to one sample per chunk.
let start_frame = ((in_time - params.range.in_()).to_f64() * rate as f64).round() as usize;
let end_frame = ((out_time - params.range.in_()).to_f64() * rate as f64).round() as usize;
if start_frame >= total_frames {
continue;
}
+27
View File
@@ -232,6 +232,33 @@ impl RenderManager {
Ok(())
}
/// M16 S1 graph mode: force the workers to re-load the current project
/// snapshot even when the undo-stack revision is unchanged. The
/// color-settings dialog writes project settings directly (no undo
/// command), so the revision-based dedup in [`set_graph_snapshot`] would
/// never re-send the snapshot — workers would keep rendering under the
/// colors they loaded at project-load time. The snapshot file is
/// rewritten (see [`GraphSnapshotStore::acquire_rewrite`]) and
/// `load_graph` re-broadcast to every live worker (the dispatcher's
/// re-send has no dedup).
pub fn resync_graph_snapshot(
&self,
project: &std::sync::Mutex<oak_node::project::Project>,
revision: u64,
) -> Result<()> {
if self.stopping.load(Ordering::Acquire) {
return Ok(()); // teardown: no re-arm after the drain
}
let path = self.snapshots.acquire_rewrite(project, revision)?;
if let Some(old) = lock(&self.current_snapshot).replace(path.clone()) {
self.snapshots.release(&old);
}
self.dispatch.set_graph_snapshot(Some(path));
let uuid = lock(project).uuid.clone();
*lock(&self.current_key) = Some((uuid, revision));
Ok(())
}
/// M16 S1 graph mode: drop the current snapshot (project closed). The
/// protocol has no clear message, so alive workers keep their loaded
/// graph; new/restarted workers no longer load it and the snapshot file
+83
View File
@@ -352,6 +352,51 @@ impl GraphSnapshotStore {
Ok(path_str)
}
/// Like [`acquire`], but always rewrites the snapshot file even when the
/// (uuid, revision) key is already present. Used to force the workers to
/// re-deserialize a project whose settings changed without an undo-stack
/// revision bump (color-settings resync): the dedup in [`acquire`] would
/// otherwise hand the workers the stale file and `load_graph` would never
/// re-broadcast. Atomic staging and reference counting are identical to
/// [`acquire`].
pub fn acquire_rewrite(
&self,
project: &std::sync::Mutex<oak_node::project::Project>,
revision: u64,
) -> Result<String> {
let (uuid, xml) = {
let g = lock(project);
let xml = oak_node::serializer::save(&g)
.map_err(|e| Error::Failed(format!("save graph snapshot: {e}")))?;
(g.uuid.clone(), xml)
};
let path = self.dir.join(format!("graph-{uuid}-{revision}.xml"));
let path_str = path.to_string_lossy().into_owned();
// Atomic staging: temp file + rename (see [`acquire`]).
let tmp = self
.dir
.join(format!("graph-{uuid}-{revision}.{}.tmp", std::process::id()));
std::fs::write(&tmp, &xml)
.map_err(|e| Error::Failed(format!("write snapshot temp: {e}")))?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Failed(format!("rename snapshot: {e}")));
}
let mut entries = lock(&self.entries);
if let Some(entry) = entries.get_mut(&path_str) {
entry.refs += 1;
} else {
entries.insert(
path_str.clone(),
SnapshotEntry {
refs: 1,
cached: false,
},
);
}
Ok(path_str)
}
/// Drop one reference. The FILE IS NOT UNLINKED: a worker may still
/// hold the path for a late `load_graph` (M16 S1), and per-project
/// filenames mean a stale snapshot is never confused with a live one.
@@ -608,4 +653,42 @@ mod tests {
"cleanup removes the snapshot directory"
);
}
#[test]
fn acquire_rewrite_forces_file_rewrite_on_same_key() {
let store = GraphSnapshotStore::new();
let project = oak_node::project::Project::new();
// First snapshot: the default project (working space ACEScg).
let p1 = store.acquire(&project, 1).unwrap();
let before = std::fs::read_to_string(&p1).unwrap();
assert!(
std::path::Path::new(&p1).exists(),
"snapshot file written"
);
assert_eq!(store.refs(&p1), 1);
// A settings change with no revision bump: plain acquire must NOT
// rewrite the file (the dedup the resync exists to bypass)...
{
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
guard.set_working_color_space(oak_common::colormath::WorkingColorSpace::SrgbLegacy);
}
let p2 = store.acquire(&project, 1).unwrap();
assert_eq!(p1, p2, "same (uuid, revision) key reuses the file");
assert_eq!(std::fs::read_to_string(&p1).unwrap(), before, "acquire never rewrites");
store.release(&p2);
// ...while acquire_rewrite rewrites it in place at the same key.
let p3 = store.acquire_rewrite(&project, 1).unwrap();
assert_eq!(p1, p3, "rewrite keeps the same path token");
let after = std::fs::read_to_string(&p1).unwrap();
assert_ne!(after, before, "rewrite replaces the snapshot content");
assert!(
after.contains("srgb_legacy"),
"rewritten snapshot carries the new setting: {after}"
);
store.release(&p3);
store.release(&p1);
store.cleanup();
}
}