mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 14:37:31 -06:00
- project: MixerStrip on every audio track, submix tracks and the Mix (automation mode Off/Read/Latch/Touch/Write, lanes for volume/pan/mute/ send levels, effect-parameter automation in the insert's own keyframes, sends pre/post fader, output routing, record arm, solo safe, input map); pre/post-fader flag on inserts; serde defaults (no schema migration). - render: mixer graph (tracks -> submixes -> Mix) in Premiere's order: pre-fader inserts, pre-fader sends, mute, fader, meter, post-fader inserts, post-fader sends, pan/balance. Per-sample automation, effect params on an absolute 64-sample grid (output independent of request cutting), route-level latency compensation, cached continuation state with pre-roll, solo/solo-safe logic, -3 dB constant-power pan law for mono tracks, balance for stereo. LiveMix carries held controls (Touch ramps back over the automatch time), meters and the newest project. - engine: mixer.* commands (setStrip, setValue, touch/release, recordStart/recordStop, submixes, inserts, sends, track keyframes, writeAutomation, inspect) and the automation recorder (gesture streams thinned into keyframes, one undo step per pass, Switch to Touch after Write); Preferences > Audio (automatch time, keyframe thinning). - tests: gain/pan law, block-boundary automation, solo/mute/solo-safe, sends, latency alignment, render-vs-playback identity, recorder modes, perf (24 tracks x EQ+Dynamics+Reverb + submix: 6.3x realtime, one core). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
670 lines
28 KiB
Rust
670 lines
28 KiB
Rust
//! Render previews (Sequence ▸ Render Effects In to Out, Render In to Out, Render Selection,
|
|
//! Delete Render Files…) and the timeline render bar.
|
|
//!
|
|
//! Segments, content hashes and the yellow/red cost estimate come from
|
|
//! [`filmcraft_render::preview`]. A rendered segment is a ProRes 422 QuickTime file named
|
|
//! `<hash>.mov` in the project's preview cache directory:
|
|
//!
|
|
//! * saved project `/path/Film.fcproj` → `/path/FilmCraft Previews/Film/`;
|
|
//! * unsaved project → a per-process folder in the system temp dir; its files move into the
|
|
//! project's folder on the first save.
|
|
//!
|
|
//! Because files are named by content, an edit simply changes the hash of the segments it touches
|
|
//! (their bar turns yellow/red again) while every other preview stays valid; undo brings the old
|
|
//! hash back and the segment is green again. Files are written as `<hash>.mov.part` and renamed
|
|
//! when complete, so an interrupted render never leaves a bad preview behind.
|
|
//!
|
|
//! Playback asks [`PreviewStore::frame`] first: when the frame lies in a segment with a preview, the
|
|
//! decoded preview frame replaces the live render.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, RwLock};
|
|
|
|
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
|
use filmcraft_media::{FrameRequest, SharedSource};
|
|
use filmcraft_project::{ItemId, Project};
|
|
use filmcraft_render::preview::{AudioSegment, Need, Segment, segment_at, video_segments};
|
|
use filmcraft_time::{Tick, TimeRange};
|
|
use serde::Serialize;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{EngineError, MediaPool, Result, Session};
|
|
|
|
/// Render-bar colour of one segment.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum BarState {
|
|
/// Plays natively, nothing drawn.
|
|
None,
|
|
Yellow,
|
|
Red,
|
|
/// A preview file exists.
|
|
Green,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
pub struct BarSpan {
|
|
pub start: Tick,
|
|
pub end: Tick,
|
|
pub state: BarState,
|
|
}
|
|
|
|
/// Which segments a render command picks.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum RenderMode {
|
|
/// Red and yellow segments in In/Out (Enter).
|
|
EffectsInToOut,
|
|
/// Every segment in In/Out, including no-bar ones.
|
|
InToOut,
|
|
/// Segments showing a selected clip.
|
|
Selection,
|
|
}
|
|
|
|
type Memo = (Arc<Project>, ItemId, Arc<Vec<Segment>>);
|
|
type AudioMemo = (Arc<Project>, ItemId, Arc<Vec<AudioSegment>>);
|
|
|
|
/// Preview files of one project, plus memoized segments for the latest project snapshots.
|
|
#[derive(Default)]
|
|
pub struct PreviewStore {
|
|
dir: RwLock<Option<PathBuf>>,
|
|
/// File names present: `<hash>.mov` (video) and `<hash>.wav` (audio).
|
|
files: RwLock<HashSet<String>>,
|
|
/// Opened preview files (most recent last).
|
|
sources: Mutex<Vec<(String, SharedSource)>>,
|
|
/// Loaded audio previews: interleaved stereo f32 (most recent last).
|
|
audio: Mutex<Vec<(String, Arc<Vec<f32>>)>>,
|
|
memo: Mutex<Vec<Memo>>,
|
|
audio_memo: Mutex<Vec<AudioMemo>>,
|
|
/// Bumped whenever the set of preview files changes.
|
|
pub generation: AtomicU64,
|
|
/// Live mixer state shared with playback: held controls, meters, newest project snapshot.
|
|
pub live: Arc<filmcraft_render::mixer::LiveMix>,
|
|
}
|
|
|
|
fn video_name(hash: &str) -> String {
|
|
format!("{hash}.mov")
|
|
}
|
|
fn audio_name(hash: &str) -> String {
|
|
format!("{hash}.wav")
|
|
}
|
|
|
|
impl PreviewStore {
|
|
/// A store using a fresh per-process temp folder (unsaved projects).
|
|
pub fn temp() -> Self {
|
|
let s = Self::default();
|
|
s.set_dir(default_temp_dir());
|
|
s
|
|
}
|
|
|
|
/// Switch to a fresh temp folder (new unsaved project).
|
|
pub fn reset_temp(&self) {
|
|
self.set_dir(default_temp_dir());
|
|
}
|
|
|
|
pub fn dir(&self) -> Option<PathBuf> {
|
|
self.dir.read().unwrap_or_else(|e| e.into_inner()).clone()
|
|
}
|
|
|
|
/// Point the store at `dir` and index the previews already there.
|
|
pub fn set_dir(&self, dir: Option<PathBuf>) {
|
|
let mut files = HashSet::new();
|
|
if let Some(d) = &dir
|
|
&& let Ok(rd) = std::fs::read_dir(d)
|
|
{
|
|
for e in rd.flatten() {
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
if let Some(h) = name.strip_suffix(".mov").or_else(|| name.strip_suffix(".wav"))
|
|
&& is_hash(h)
|
|
{
|
|
files.insert(name);
|
|
}
|
|
}
|
|
}
|
|
*self.dir.write().unwrap_or_else(|e| e.into_inner()) = dir;
|
|
*self.files.write().unwrap_or_else(|e| e.into_inner()) = files;
|
|
self.sources.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
|
self.audio.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
|
self.bump();
|
|
}
|
|
|
|
/// Move to `dir`, taking this store's preview files along (first save of a project).
|
|
pub fn move_to(&self, dir: PathBuf) {
|
|
let old = self.dir();
|
|
if old.as_deref() == Some(dir.as_path()) {
|
|
return;
|
|
}
|
|
if let Some(old) = &old
|
|
&& old.starts_with(std::env::temp_dir())
|
|
{
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
|
let (a, b) = (old.join(name), dir.join(name));
|
|
if !b.exists() && std::fs::rename(&a, &b).is_err() {
|
|
let _ = std::fs::copy(&a, &b).map(|_| std::fs::remove_file(&a));
|
|
}
|
|
}
|
|
}
|
|
self.set_dir(Some(dir));
|
|
}
|
|
|
|
/// Whether the video preview of segment `hash` exists.
|
|
pub fn has(&self, hash: &str) -> bool {
|
|
self.files.read().unwrap_or_else(|e| e.into_inner()).contains(&video_name(hash))
|
|
}
|
|
|
|
/// Whether the audio preview of audio segment `hash` exists.
|
|
pub fn has_audio(&self, hash: &str) -> bool {
|
|
self.files.read().unwrap_or_else(|e| e.into_inner()).contains(&audio_name(hash))
|
|
}
|
|
|
|
/// Number of preview files (video and audio).
|
|
pub fn count(&self) -> usize {
|
|
self.files.read().unwrap_or_else(|e| e.into_inner()).len()
|
|
}
|
|
|
|
pub fn path_for(&self, hash: &str) -> Option<PathBuf> {
|
|
self.dir().map(|d| d.join(video_name(hash)))
|
|
}
|
|
|
|
pub fn audio_path_for(&self, hash: &str) -> Option<PathBuf> {
|
|
self.dir().map(|d| d.join(audio_name(hash)))
|
|
}
|
|
|
|
/// Register a finished video preview file.
|
|
pub fn add(&self, hash: &str) {
|
|
self.files.write().unwrap_or_else(|e| e.into_inner()).insert(video_name(hash));
|
|
self.bump();
|
|
}
|
|
|
|
/// Register a finished audio preview file.
|
|
pub fn add_audio(&self, hash: &str) {
|
|
self.files.write().unwrap_or_else(|e| e.into_inner()).insert(audio_name(hash));
|
|
self.bump();
|
|
}
|
|
|
|
/// Delete the preview files (video and audio) of `hashes`, or every file when None. Returns
|
|
/// how many files were removed.
|
|
pub fn delete(&self, hashes: Option<&[String]>) -> usize {
|
|
let Some(dir) = self.dir() else { return 0 };
|
|
let victims: Vec<String> = {
|
|
let files = self.files.read().unwrap_or_else(|e| e.into_inner());
|
|
match hashes {
|
|
Some(h) => h.iter().flat_map(|h| [video_name(h), audio_name(h)]).filter(|n| files.contains(n)).collect(),
|
|
None => files.iter().cloned().collect(),
|
|
}
|
|
};
|
|
let gone = |h: &String| victims.contains(&video_name(h)) || victims.contains(&audio_name(h));
|
|
self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h));
|
|
self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h));
|
|
let mut files = self.files.write().unwrap_or_else(|e| e.into_inner());
|
|
for n in &victims {
|
|
let _ = std::fs::remove_file(dir.join(n));
|
|
files.remove(n);
|
|
}
|
|
drop(files);
|
|
self.bump();
|
|
victims.len()
|
|
}
|
|
|
|
/// Audio segments of `seq` in this project snapshot (memoized per snapshot).
|
|
pub fn audio_segments(&self, project: &Arc<Project>, seq: ItemId) -> Arc<Vec<AudioSegment>> {
|
|
let mut memo = self.audio_memo.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some((_, _, s)) = memo.iter().find(|(p, q, _)| Arc::ptr_eq(p, project) && *q == seq) {
|
|
return s.clone();
|
|
}
|
|
let segs = Arc::new(filmcraft_render::preview::audio_segments(project, seq));
|
|
memo.push((project.clone(), seq, segs.clone()));
|
|
if memo.len() > 4 {
|
|
memo.remove(0);
|
|
}
|
|
segs
|
|
}
|
|
|
|
fn load_audio(&self, hash: &str) -> Option<Arc<Vec<f32>>> {
|
|
{
|
|
let mut g = self.audio.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(i) = g.iter().position(|(h, _)| h == hash) {
|
|
let e = g.remove(i);
|
|
let s = e.1.clone();
|
|
g.push(e);
|
|
return Some(s);
|
|
}
|
|
}
|
|
let bytes = std::fs::read(self.audio_path_for(hash)?).ok()?;
|
|
let samples = Arc::new(read_wav_f32(&bytes)?);
|
|
let mut g = self.audio.lock().unwrap_or_else(|e| e.into_inner());
|
|
g.push((hash.to_string(), samples.clone()));
|
|
if g.len() > 32 {
|
|
g.remove(0);
|
|
}
|
|
Some(samples)
|
|
}
|
|
|
|
/// Sequence audio for playback and meters: rendered audio previews where they are valid, the
|
|
/// live mix ([`filmcraft_render::audio::mix_sequence`]) everywhere else.
|
|
pub fn mix(&self, project: &Arc<Project>, seq: ItemId, start: i64, frames: usize, sources: &dyn filmcraft_render::SourceProvider) -> AudioBuffer {
|
|
let Some(q) = project.sequence(seq) else { return AudioBuffer::silence(48_000, 2, frames) };
|
|
let live = Some(&*self.live);
|
|
let has_audio = self.files.read().unwrap_or_else(|e| e.into_inner()).iter().any(|n| n.ends_with(".wav"));
|
|
// held mixer controls are heard live (rendered previews don't know them)
|
|
if !has_audio || self.live.is_active() {
|
|
return filmcraft_render::mixer::mix_graph(project, q, start, frames, sources, live);
|
|
}
|
|
let segs = self.audio_segments(project, seq);
|
|
let mut out = AudioBuffer::silence(q.settings.sample_rate, 2, frames);
|
|
let end = start + frames as i64;
|
|
let mut pos = start;
|
|
while pos < end {
|
|
let i = segs.partition_point(|g| g.first_sample <= pos);
|
|
let seg = i.checked_sub(1).map(|i| &segs[i]).filter(|g| pos < g.first_sample + g.samples);
|
|
let preview = seg.filter(|g| self.has_audio(&g.hash)).and_then(|g| self.load_audio(&g.hash).map(|a| (g, a)));
|
|
let next = match seg {
|
|
Some(g) => (g.first_sample + g.samples).min(end),
|
|
None => segs.get(i).map(|g| g.first_sample).unwrap_or(end).min(end),
|
|
};
|
|
let n = (next - pos).max(1) as usize;
|
|
let off = (pos - start) as usize;
|
|
match preview {
|
|
Some((g, a)) if a.len() >= (g.samples as usize) * 2 => {
|
|
let k0 = (pos - g.first_sample) as usize;
|
|
for k in 0..n {
|
|
out.channels[0][off + k] = a[(k0 + k) * 2];
|
|
out.channels[1][off + k] = a[(k0 + k) * 2 + 1];
|
|
}
|
|
}
|
|
_ => {
|
|
let b = filmcraft_render::mixer::mix_graph(project, q, pos, n, sources, live);
|
|
for c in 0..2 {
|
|
out.channels[c][off..off + n].copy_from_slice(&b.channels[c.min(b.channels.len() - 1)][..n]);
|
|
}
|
|
}
|
|
}
|
|
pos += n as i64;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn bump(&self) {
|
|
self.generation.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Segments of `seq` in this project snapshot (memoized per snapshot).
|
|
pub fn segments(&self, project: &Arc<Project>, seq: ItemId) -> Arc<Vec<Segment>> {
|
|
let mut memo = self.memo.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some((_, _, s)) = memo.iter().find(|(p, q, _)| Arc::ptr_eq(p, project) && *q == seq) {
|
|
return s.clone();
|
|
}
|
|
let segs = Arc::new(video_segments(project, seq));
|
|
memo.push((project.clone(), seq, segs.clone()));
|
|
if memo.len() > 4 {
|
|
memo.remove(0);
|
|
}
|
|
segs
|
|
}
|
|
|
|
pub fn state_of(&self, seg: &Segment) -> BarState {
|
|
if self.has(&seg.hash) {
|
|
return BarState::Green;
|
|
}
|
|
match seg.need {
|
|
Need::None => BarState::None,
|
|
Need::Realtime => BarState::Yellow,
|
|
Need::Render => BarState::Red,
|
|
}
|
|
}
|
|
|
|
/// The render bar: one span per segment, adjacent spans of the same colour merged.
|
|
pub fn bar(&self, project: &Arc<Project>, seq: ItemId) -> Vec<BarSpan> {
|
|
let mut out: Vec<BarSpan> = Vec::new();
|
|
for s in self.segments(project, seq).iter() {
|
|
let state = self.state_of(s);
|
|
match out.last_mut() {
|
|
Some(l) if l.end == s.start && l.state == state => l.end = s.end,
|
|
_ => out.push(BarSpan { start: s.start, end: s.end, state }),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The preview frame for sequence frame `frame`, if its segment has been rendered.
|
|
pub fn frame(&self, pool: &MediaPool, project: &Arc<Project>, seq: ItemId, frame: i64, scale: f32) -> Option<Arc<VideoFrame>> {
|
|
if self.files.read().unwrap_or_else(|e| e.into_inner()).is_empty() {
|
|
return None;
|
|
}
|
|
let segs = self.segments(project, seq);
|
|
let seg = segment_at(&segs, frame)?;
|
|
if !self.has(&seg.hash) {
|
|
return None;
|
|
}
|
|
let src = self.open(pool, &seg.hash)?;
|
|
let rate = project.sequence(seq)?.settings.frame_rate;
|
|
src.video_frame(FrameRequest { time: rate.tick_of(frame - seg.first_frame), scale }).ok()
|
|
}
|
|
|
|
fn open(&self, pool: &MediaPool, hash: &str) -> Option<SharedSource> {
|
|
{
|
|
let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(i) = g.iter().position(|(h, _)| h == hash) {
|
|
let e = g.remove(i);
|
|
let s = e.1.clone();
|
|
g.push(e);
|
|
return Some(s);
|
|
}
|
|
}
|
|
let path = self.path_for(hash)?;
|
|
let bytes = std::fs::read(&path).ok()?;
|
|
let src = pool.open_bytes(&format!("{hash}.mov"), bytes.into()).ok()?;
|
|
let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner());
|
|
g.push((hash.to_string(), src.clone()));
|
|
if g.len() > 6 {
|
|
g.remove(0);
|
|
}
|
|
Some(src)
|
|
}
|
|
}
|
|
|
|
fn is_hash(s: &str) -> bool {
|
|
s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
|
}
|
|
|
|
fn default_temp_dir() -> Option<PathBuf> {
|
|
if cfg!(target_arch = "wasm32") {
|
|
return None;
|
|
}
|
|
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
|
Some(std::env::temp_dir().join("FilmCraft Previews").join(format!("untitled-{}-{nanos:x}", std::process::id())))
|
|
}
|
|
|
|
/// The preview folder of a project saved at `project_path`.
|
|
pub fn dir_for_project(project_path: &str) -> PathBuf {
|
|
let p = Path::new(project_path);
|
|
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
|
p.parent().unwrap_or(Path::new(".")).join("FilmCraft Previews").join(stem)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- commands
|
|
|
|
/// In/Out of the active sequence (whole sequence when neither is set).
|
|
fn in_out(s: &Session) -> Result<(ItemId, TimeRange)> {
|
|
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
let q = s.project.sequence(id).ok_or(EngineError::NoSequence)?;
|
|
let fd = q.settings.frame_rate.frame_duration();
|
|
let a = q.mark_in.unwrap_or(Tick::ZERO);
|
|
let b = q.mark_out.map(|o| o + fd).unwrap_or(q.duration());
|
|
Ok((id, TimeRange::from_bounds(a, b.max(a))))
|
|
}
|
|
|
|
fn overlaps(seg: &Segment, r: &TimeRange) -> bool {
|
|
seg.start < r.end() && r.start < seg.end
|
|
}
|
|
|
|
/// Start rendering previews as a background job. `{"wait": true}` renders synchronously.
|
|
pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
|
let (seq, range) = in_out(s)?;
|
|
let store = s.previews.clone();
|
|
let dir = store.dir().ok_or_else(|| EngineError::Other("render previews are not available here (no preview folder)".into()))?;
|
|
let segs = store.segments(&s.project, seq);
|
|
let selection: HashSet<_> = s.state.selection.iter().copied().collect();
|
|
let todo: Vec<Segment> = segs
|
|
.iter()
|
|
.filter(|g| !store.has(&g.hash))
|
|
.filter(|g| match mode {
|
|
RenderMode::EffectsInToOut => g.need != Need::None && overlaps(g, &range),
|
|
RenderMode::InToOut => overlaps(g, &range),
|
|
RenderMode::Selection => g.clips.iter().any(|c| selection.contains(c)),
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
if todo.is_empty() {
|
|
s.toast("Nothing to render: previews are up to date");
|
|
return Ok(json!({"job": null, "segments": 0}));
|
|
}
|
|
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("preview folder {}: {e}", dir.display())))?;
|
|
let frames: i64 = todo.iter().map(|g| g.frames).sum();
|
|
let id = s.jobs.len() as u64 + 1;
|
|
let job = crate::Job {
|
|
id,
|
|
label: format!("Rendering {} preview segment{}", todo.len(), if todo.len() == 1 { "" } else { "s" }),
|
|
progress: Default::default(),
|
|
result: Default::default(),
|
|
};
|
|
job.progress.total.store(frames.max(1) as u64, Ordering::Relaxed);
|
|
let project = s.project.clone();
|
|
let provider = s.media.provider(project.clone(), s.services.clone());
|
|
let (prog, res) = (job.progress.clone(), job.result.clone());
|
|
let nseg = todo.len();
|
|
let pool = s.media.clone();
|
|
let run = move || {
|
|
let t0 = std::time::Instant::now();
|
|
let mut bytes = 0u64;
|
|
let mut done_frames = 0u64;
|
|
let mut outcome: std::result::Result<(), String> = Ok(());
|
|
for (k, g) in todo.iter().enumerate() {
|
|
if prog.cancel.load(Ordering::Relaxed) {
|
|
outcome = Err("cancelled".into());
|
|
break;
|
|
}
|
|
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("Rendering segment {} of {nseg}", k + 1);
|
|
let part = dir.join(format!("{}.mov.part", g.hash));
|
|
let settings = filmcraft_export::ExportSettings {
|
|
format: filmcraft_export::Format::ProRes,
|
|
path: part.to_string_lossy().to_string(),
|
|
range: Some(TimeRange::from_bounds(g.start, g.end)),
|
|
scale: 1.0,
|
|
include_audio: false,
|
|
quality: 90,
|
|
bitrate_kbps: 0,
|
|
// Captions stay live over previews and are not part of the preview hash.
|
|
burn_captions: false,
|
|
part_of_batch: true,
|
|
};
|
|
match filmcraft_export::export(&project, seq, &settings, &provider, &prog) {
|
|
Ok(r) => {
|
|
bytes += r.bytes;
|
|
done_frames += r.frames;
|
|
let fin = dir.join(format!("{}.mov", g.hash));
|
|
if let Err(e) = std::fs::rename(&part, &fin) {
|
|
outcome = Err(e.to_string());
|
|
break;
|
|
}
|
|
store.add(&g.hash);
|
|
// open it now so the first playback doesn't wait for the file read
|
|
let _ = store.open(&pool, &g.hash);
|
|
}
|
|
Err(e) => {
|
|
let _ = std::fs::remove_file(&part);
|
|
outcome = Err(e.to_string());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let secs = t0.elapsed().as_secs_f64();
|
|
let r = outcome.map(|_| filmcraft_export::Report {
|
|
path: dir.to_string_lossy().to_string(),
|
|
frames: done_frames,
|
|
seconds: secs,
|
|
bytes,
|
|
render_fps: done_frames as f64 / secs.max(1e-6),
|
|
});
|
|
if let Err(e) = &r {
|
|
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
|
}
|
|
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) =
|
|
if r.is_ok() { format!("Rendered {done_frames} frames in {secs:.1}s") } else { "Render stopped".into() };
|
|
prog.finished.store(true, Ordering::Relaxed);
|
|
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
|
|
};
|
|
s.jobs.push(job);
|
|
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
|
|
if wait || cfg!(target_arch = "wasm32") {
|
|
run();
|
|
} else {
|
|
std::thread::Builder::new().name("filmcraft-render-previews".into()).spawn(run).map_err(|e| EngineError::Other(e.to_string()))?;
|
|
}
|
|
Ok(json!({"job": id, "segments": nseg, "frames": frames}))
|
|
}
|
|
|
|
/// Render Audio: mix the audio segments in In/Out (or the whole sequence) to float WAV previews.
|
|
pub fn render_audio(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (seq, range) = in_out(s)?;
|
|
let store = s.previews.clone();
|
|
let dir = store.dir().ok_or_else(|| EngineError::Other("render previews are not available here (no preview folder)".into()))?;
|
|
let sr = s.project.sequence(seq).map(|q| q.settings.sample_rate).unwrap_or(48_000) as i64;
|
|
let (a, b) = (range.start.to_units_floor(sr), range.end().to_units_floor(sr));
|
|
let todo: Vec<AudioSegment> = store
|
|
.audio_segments(&s.project, seq)
|
|
.iter()
|
|
.filter(|g| g.first_sample < b && a < g.first_sample + g.samples && !store.has_audio(&g.hash))
|
|
.cloned()
|
|
.collect();
|
|
if todo.is_empty() {
|
|
s.toast("Nothing to render: audio previews are up to date");
|
|
return Ok(json!({"job": null, "segments": 0}));
|
|
}
|
|
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("preview folder {}: {e}", dir.display())))?;
|
|
let total: i64 = todo.iter().map(|g| g.samples).sum();
|
|
let id = s.jobs.len() as u64 + 1;
|
|
let job = crate::Job { id, label: "Rendering audio previews".into(), progress: Default::default(), result: Default::default() };
|
|
job.progress.total.store(total.max(1) as u64, Ordering::Relaxed);
|
|
let project = s.project.clone();
|
|
let provider = s.media.provider(project.clone(), s.services.clone());
|
|
let (prog, res) = (job.progress.clone(), job.result.clone());
|
|
let nseg = todo.len();
|
|
let run = move || {
|
|
let t0 = std::time::Instant::now();
|
|
let mut bytes = 0u64;
|
|
let mut outcome: std::result::Result<(), String> = Ok(());
|
|
let Some(q) = project.sequence(seq) else { return };
|
|
'segs: for g in &todo {
|
|
let mut inter = Vec::with_capacity(g.samples as usize * 2);
|
|
let mut pos = g.first_sample;
|
|
while pos < g.first_sample + g.samples {
|
|
if prog.cancel.load(Ordering::Relaxed) {
|
|
outcome = Err("cancelled".into());
|
|
break 'segs;
|
|
}
|
|
let n = (g.first_sample + g.samples - pos).min(sr) as usize;
|
|
let buf = filmcraft_render::audio::mix_sequence(&project, q, pos, n, &provider);
|
|
for i in 0..n {
|
|
inter.push(buf.channels[0][i]);
|
|
inter.push(buf.channels[buf.channels.len().min(2) - 1][i]);
|
|
}
|
|
pos += n as i64;
|
|
prog.done.fetch_add(n as u64, Ordering::Relaxed);
|
|
}
|
|
let data = write_wav_f32(&inter, sr as u32);
|
|
let part = dir.join(format!("{}.wav.part", g.hash));
|
|
let fin = dir.join(audio_name(&g.hash));
|
|
if let Err(e) = std::fs::write(&part, &data).and_then(|_| std::fs::rename(&part, &fin)) {
|
|
let _ = std::fs::remove_file(&part);
|
|
outcome = Err(e.to_string());
|
|
break;
|
|
}
|
|
bytes += data.len() as u64;
|
|
store.add_audio(&g.hash);
|
|
}
|
|
let secs = t0.elapsed().as_secs_f64();
|
|
let r = outcome.map(|_| filmcraft_export::Report { path: dir.to_string_lossy().to_string(), frames: 0, seconds: secs, bytes, render_fps: 0.0 });
|
|
if let Err(e) = &r {
|
|
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
|
}
|
|
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) =
|
|
if r.is_ok() { format!("Rendered audio for {nseg} segment(s) in {secs:.1}s") } else { "Render stopped".into() };
|
|
prog.finished.store(true, Ordering::Relaxed);
|
|
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
|
|
};
|
|
s.jobs.push(job);
|
|
if p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32") {
|
|
run();
|
|
} else {
|
|
std::thread::Builder::new().name("filmcraft-render-audio".into()).spawn(run).map_err(|e| EngineError::Other(e.to_string()))?;
|
|
}
|
|
Ok(json!({"job": id, "segments": nseg, "samples": total}))
|
|
}
|
|
|
|
/// 32-bit float stereo WAV (WAVE_FORMAT_IEEE_FLOAT) from interleaved samples.
|
|
pub fn write_wav_f32(interleaved: &[f32], rate: u32) -> Vec<u8> {
|
|
let data_len = (interleaved.len() * 4) as u32;
|
|
let mut v = Vec::with_capacity(44 + data_len as usize);
|
|
v.extend_from_slice(b"RIFF");
|
|
v.extend_from_slice(&(36 + data_len).to_le_bytes());
|
|
v.extend_from_slice(b"WAVEfmt ");
|
|
v.extend_from_slice(&16u32.to_le_bytes());
|
|
v.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
|
|
v.extend_from_slice(&2u16.to_le_bytes());
|
|
v.extend_from_slice(&rate.to_le_bytes());
|
|
v.extend_from_slice(&(rate * 8).to_le_bytes());
|
|
v.extend_from_slice(&8u16.to_le_bytes());
|
|
v.extend_from_slice(&32u16.to_le_bytes());
|
|
v.extend_from_slice(b"data");
|
|
v.extend_from_slice(&data_len.to_le_bytes());
|
|
for s in interleaved {
|
|
v.extend_from_slice(&s.to_le_bytes());
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Read the interleaved stereo samples of a file written by [`write_wav_f32`].
|
|
pub fn read_wav_f32(b: &[u8]) -> Option<Vec<f32>> {
|
|
if b.len() < 12 || &b[0..4] != b"RIFF" || &b[8..12] != b"WAVE" {
|
|
return None;
|
|
}
|
|
let mut i = 12;
|
|
let mut float_stereo = false;
|
|
while i + 8 <= b.len() {
|
|
let id = &b[i..i + 4];
|
|
let len = u32::from_le_bytes(b[i + 4..i + 8].try_into().ok()?) as usize;
|
|
let body = b.get(i + 8..(i + 8 + len).min(b.len()))?;
|
|
if id == b"fmt " && body.len() >= 16 {
|
|
let fmt = u16::from_le_bytes([body[0], body[1]]);
|
|
let ch = u16::from_le_bytes([body[2], body[3]]);
|
|
let bits = u16::from_le_bytes([body[14], body[15]]);
|
|
float_stereo = fmt == 3 && ch == 2 && bits == 32;
|
|
} else if id == b"data" {
|
|
if !float_stereo {
|
|
return None;
|
|
}
|
|
return Some(body.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect());
|
|
}
|
|
i += 8 + len + (len & 1);
|
|
}
|
|
None
|
|
}
|
|
/// Delete Render Files (all of the project's previews) or Delete Render Files In to Out.
|
|
pub fn delete(s: &mut Session, in_to_out: bool) -> Result<Value> {
|
|
let n = if in_to_out {
|
|
let (seq, range) = in_out(s)?;
|
|
let hashes: Vec<String> = s.previews.segments(&s.project, seq).iter().filter(|g| overlaps(g, &range)).map(|g| g.hash.clone()).collect();
|
|
s.previews.delete(Some(&hashes))
|
|
} else {
|
|
s.previews.delete(None)
|
|
};
|
|
s.toast(format!("Deleted {n} render file{}", if n == 1 { "" } else { "s" }));
|
|
Ok(json!({"deleted": n}))
|
|
}
|
|
|
|
/// The render bar of the active sequence as JSON (for agents and tests).
|
|
pub fn bar_json(s: &Session) -> Result<Value> {
|
|
let seq = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
let rate = s.sequence_rate();
|
|
let segs = s.previews.segments(&s.project, seq);
|
|
Ok(json!({
|
|
"dir": s.previews.dir().map(|d| d.to_string_lossy().to_string()),
|
|
"files": s.previews.count(),
|
|
"segments": segs.iter().map(|g| json!({
|
|
"start": g.start.0,
|
|
"end": g.end.0,
|
|
"startFrame": g.first_frame,
|
|
"frames": g.frames,
|
|
"startSeconds": g.start.seconds(),
|
|
"endSeconds": g.end.seconds(),
|
|
"state": s.previews.state_of(g),
|
|
"costMs": (g.cost_ms * 10.0).round() / 10.0,
|
|
"budgetMs": (rate.frame_duration().seconds() * 1000.0 * filmcraft_render::preview::REALTIME_BUDGET * 10.0).round() / 10.0,
|
|
"hash": g.hash,
|
|
})).collect::<Vec<_>>(),
|
|
}))
|
|
}
|