feat(oakrender): render-process isolation S3 - audio over shm, per-ticket slot formats, tuning
- Audio tickets join the process backend: render_audio_batch wire message, workers mix straight into shm slots (SLOT_FORMAT_AUDIO_F32), ShmAudio payload with release semantics, crash isolation covers audio renders; playback audio uses an async 4-chunk prefetch drained on the UI tick (also fixes the sub-60fps chunk truncation bug); oversized ranges and dispatcher outages fall back to in-process inline. - Per-ticket slot formats: force_format is honored (exports request F32 slots, dropping the BGRA8 round-trip and its 8-bit quantization); segments grow on demand via worker-idle rebuild with generation handoff; the scheduler filters over-capacity tickets. - Adaptive defaults: 128-256MB/worker segment budgets drive slots per worker, batch size follows workers/slots; bench_process example measures throughput and adjacent-frame completion deltas (e.g. 4 workers: 841 fps, 4.6ms mean delta).
This commit is contained in:
@@ -553,6 +553,20 @@ pub fn render_footage_frame(
|
||||
pub fn render_audio_samples(
|
||||
params: &crate::ticket::AudioTicketParams,
|
||||
) -> Result<crate::ticket::TicketPayload> {
|
||||
let (rate, layout, channels, total_frames) = audio_layout(params)?;
|
||||
let mut acc = vec![0.0f32; total_frames.saturating_mul(channels as usize)];
|
||||
mix_audio_montage(params, rate, channels, total_frames, &mut acc)?;
|
||||
Ok(crate::ticket::TicketPayload::Audio(crate::ticket::AudioSamples {
|
||||
samples: acc,
|
||||
sample_rate: rate,
|
||||
channel_layout: layout,
|
||||
channel_count: channels,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The output layout an audio render produces: `(sample_rate,
|
||||
/// channel_layout, channel_count, total_sample_frames)`.
|
||||
fn audio_layout(params: &crate::ticket::AudioTicketParams) -> Result<(i32, u64, i32, usize)> {
|
||||
let rate = params.sample_rate.max(1);
|
||||
let channels = params.channel_layout.count_ones().max(1) as i32;
|
||||
let duration = params.range.out() - params.range.in_();
|
||||
@@ -565,8 +579,30 @@ pub fn render_audio_samples(
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let total_frames = (seconds * rate as f64).round() as usize;
|
||||
let mut acc = vec![0.0f32; total_frames.saturating_mul(channels as usize)];
|
||||
Ok((rate, params.channel_layout, channels, total_frames))
|
||||
}
|
||||
|
||||
/// The byte length (interleaved f32, little-endian) an audio render of
|
||||
/// `params` writes into a shm slot — the worker's slot-geometry check
|
||||
/// (M15 S3). Mirrors [`render_audio_samples_into`]'s layout math.
|
||||
pub fn audio_samples_byte_len(params: &crate::ticket::AudioTicketParams) -> Result<usize> {
|
||||
let (_rate, _layout, channels, total_frames) = audio_layout(params)?;
|
||||
Ok(total_frames
|
||||
.saturating_mul(channels as usize)
|
||||
.saturating_mul(4))
|
||||
}
|
||||
|
||||
/// Mix the audio montage into `acc` (`total_frames * channels` samples,
|
||||
/// zero-initialized by the caller). Shared by the heap
|
||||
/// [`render_audio_samples`] and the shm-slot [`render_audio_samples_into`]
|
||||
/// paths so the decode/mix logic exists once.
|
||||
fn mix_audio_montage(
|
||||
params: &crate::ticket::AudioTicketParams,
|
||||
rate: i32,
|
||||
channels: i32,
|
||||
total_frames: usize,
|
||||
acc: &mut [f32],
|
||||
) -> Result<()> {
|
||||
for clip in ¶ms.montage {
|
||||
// Overlap of the clip with the requested range.
|
||||
let in_time = params.range.in_().max(clip.in_time);
|
||||
@@ -603,13 +639,32 @@ pub fn render_audio_samples(
|
||||
acc[start_frame * channels as usize + i] += buf[i] * clip.gain;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(crate::ticket::TicketPayload::Audio(crate::ticket::AudioSamples {
|
||||
samples: acc,
|
||||
sample_rate: rate,
|
||||
channel_layout: params.channel_layout,
|
||||
channel_count: channels,
|
||||
}))
|
||||
/// Render the audio montage over `params.range` directly into `dst` as
|
||||
/// little-endian f32 bytes (M15 S3 worker seam): the render worker passes
|
||||
/// a shared-memory slot slice as `dst`, so the samples land in the slot
|
||||
/// with no staging allocation. `dst.len()` must hold
|
||||
/// `frame_count * channels * 4` bytes.
|
||||
pub fn render_audio_samples_into(
|
||||
params: &crate::ticket::AudioTicketParams,
|
||||
dst: &mut [u8],
|
||||
) -> Result<()> {
|
||||
let (rate, _layout, channels, total_frames) = audio_layout(params)?;
|
||||
let need = total_frames
|
||||
.saturating_mul(channels as usize)
|
||||
.saturating_mul(4);
|
||||
if dst.len() < need {
|
||||
return Err(Error::NoMem);
|
||||
}
|
||||
let mut acc = vec![0.0f32; total_frames.saturating_mul(channels as usize)];
|
||||
mix_audio_montage(params, rate, channels, total_frames, &mut acc)?;
|
||||
// Interleaved f32 -> little-endian bytes in the slot.
|
||||
for (out, sample) in dst[..need].chunks_exact_mut(4).zip(&acc) {
|
||||
out.copy_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bilinear scale an F32-RGBA image (row-major with per-row strides).
|
||||
@@ -1048,4 +1103,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
// ---- Audio (M12 P1 / M15 S3) -----------------------------------------
|
||||
|
||||
fn audio_params(range: TimeRange) -> crate::ticket::AudioTicketParams {
|
||||
crate::ticket::AudioTicketParams {
|
||||
viewer: 1,
|
||||
range,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
montage: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_audio_samples_produces_silence_for_empty_montage() {
|
||||
// M12 P1: an empty montage renders total silence at the requested
|
||||
// layout.
|
||||
let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 24)));
|
||||
match render_audio_samples(¶ms).unwrap() {
|
||||
crate::ticket::TicketPayload::Audio(samples) => {
|
||||
// 1/24 s at 48 kHz = 2000 sample frames, stereo.
|
||||
assert_eq!(samples.sample_rate, 48000);
|
||||
assert_eq!(samples.channel_count, 2);
|
||||
assert_eq!(samples.samples.len(), 2000 * 2);
|
||||
assert!(samples.samples.iter().all(|&v| v == 0.0), "silence");
|
||||
}
|
||||
other => panic!("expected Audio payload, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_audio_samples_into_matches_heap_path_byte_for_byte() {
|
||||
// M15 S3: the shm-slot writer must produce exactly the same
|
||||
// little-endian f32 bytes as the heap path, so a worker's slot and
|
||||
// the in-process fallback agree for the same montage.
|
||||
let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 48)));
|
||||
let heap = match render_audio_samples(¶ms).unwrap() {
|
||||
crate::ticket::TicketPayload::Audio(samples) => samples,
|
||||
other => panic!("expected Audio payload, got {other:?}"),
|
||||
};
|
||||
let mut dst = vec![0u8; heap.samples.len() * 4];
|
||||
render_audio_samples_into(¶ms, &mut dst).unwrap();
|
||||
let expected: Vec<u8> = heap
|
||||
.samples
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect();
|
||||
assert_eq!(dst, expected);
|
||||
// And the into-path output parses back into the same samples.
|
||||
let parsed: Vec<f32> = dst
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect();
|
||||
assert_eq!(parsed, heap.samples);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_audio_samples_into_rejects_small_buffer() {
|
||||
let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 24)));
|
||||
let mut dst = [0u8; 8]; // far too small for 2000x2 f32 samples
|
||||
assert!(render_audio_samples_into(¶ms, &mut dst).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+107
-1
@@ -116,6 +116,9 @@ pub const TYPE_RENDER_BATCH: &str = "render_batch";
|
||||
pub const TYPE_BATCH_ACCEPTED: &str = "batch_accepted";
|
||||
/// `"frame_failed"` (protocol v2).
|
||||
pub const TYPE_FRAME_FAILED: &str = "frame_failed";
|
||||
/// `"render_audio_batch"` (protocol v2, M15 S3): a batch of audio range
|
||||
/// pulls rendered into the same shm slot transport as video frames.
|
||||
pub const TYPE_RENDER_AUDIO_BATCH: &str = "render_audio_batch";
|
||||
|
||||
/// Wire-format slot format for 8-bit BGRA frames (M15 S1). The viewer
|
||||
/// preview path requests BGRA8 so the worker converts its F32 pipeline
|
||||
@@ -125,6 +128,15 @@ pub const TYPE_FRAME_FAILED: &str = "frame_failed";
|
||||
/// slot wire format, not a pipeline format.
|
||||
pub const SLOT_FORMAT_BGRA8: i32 = 100;
|
||||
|
||||
/// Wire-format slot format for interleaved f32 audio samples (M15 S3).
|
||||
/// An audio slot reuses [`FrameSlotMeta`]: `format` is this marker,
|
||||
/// `channel_count` is the channel count, `linesize` is
|
||||
/// `channels * 4` (bytes per sample frame), `data_size` is the total
|
||||
/// sample bytes and `width` carries the output sample rate (Hz). The
|
||||
/// value lives outside the `PixelFormat` enum range like
|
||||
/// [`SLOT_FORMAT_BGRA8`].
|
||||
pub const SLOT_FORMAT_AUDIO_F32: i32 = 101;
|
||||
|
||||
/// `handshake` — field-for-field equivalent of `oak_ipc_handshake`
|
||||
/// (ipc.h). Wire field names match the C++ serializer.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
@@ -323,6 +335,49 @@ pub struct BatchAcceptedMsg {
|
||||
pub tickets: Vec<i64>,
|
||||
}
|
||||
|
||||
/// One audio range pull inside a [`RenderAudioBatchMsg`] (M15 S3) — the
|
||||
/// audio counterpart of [`BatchTicketSpec`]: the main process assigns the
|
||||
/// destination `slot`, the worker mixes the montage over `[time,
|
||||
/// time + duration)` at `sample_rate`/`channel_layout` into interleaved
|
||||
/// f32 and writes it into the slot (wire format
|
||||
/// [`SLOT_FORMAT_AUDIO_F32`]).
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct AudioTicketSpec {
|
||||
/// Ticket id (correlates with frame_ready / frame_failed).
|
||||
pub ticket: i64,
|
||||
/// Destination slot index in the worker->main output pool.
|
||||
pub slot: i32,
|
||||
/// Range start numerator.
|
||||
pub time_num: i64,
|
||||
/// Range start denominator.
|
||||
pub time_den: i64,
|
||||
/// Range length numerator.
|
||||
pub duration_num: i64,
|
||||
/// Range length denominator.
|
||||
pub duration_den: i64,
|
||||
/// Output sample rate (Hz).
|
||||
pub sample_rate: i32,
|
||||
/// Output channel layout mask.
|
||||
pub channel_layout: u64,
|
||||
/// Channel count (derived from the layout; written into the slot meta).
|
||||
pub channels: i32,
|
||||
/// Sequence montage (ordered topmost-last; empty = silence).
|
||||
pub montage: Vec<WireMontageClip>,
|
||||
}
|
||||
|
||||
/// `render_audio_batch` (main->worker, M15 S3) — a batch of audio range
|
||||
/// pulls rendered in order, sharing the claim/credit/frame_ready flow of
|
||||
/// [`RenderBatchMsg`].
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct RenderAudioBatchMsg {
|
||||
/// Batch id (correlates with batch_accepted).
|
||||
pub batch_id: i64,
|
||||
/// The audio tickets, rendered in order.
|
||||
pub tickets: Vec<AudioTicketSpec>,
|
||||
}
|
||||
|
||||
/// `frame_failed` (worker->main) — one ticket failed to render; the main
|
||||
/// process falls back (purple frame) and owns the slot again.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
@@ -1338,15 +1393,66 @@ mod tests {
|
||||
assert_eq!(tagged, failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_audio_batch_wire_roundtrip() {
|
||||
// M15 S3: the audio batch message reuses the claim/credit flow of
|
||||
// render_batch with a dedicated ticket spec.
|
||||
let batch = RenderAudioBatchMsg {
|
||||
batch_id: 5,
|
||||
tickets: vec![AudioTicketSpec {
|
||||
ticket: 41,
|
||||
slot: 0,
|
||||
time_num: 0,
|
||||
time_den: 48000,
|
||||
duration_num: 1600,
|
||||
duration_den: 48000,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
channels: 2,
|
||||
montage: vec![WireMontageClip {
|
||||
filename: "a.mp4".into(),
|
||||
stream_index: 0,
|
||||
in_num: 0,
|
||||
in_den: 24,
|
||||
out_num: 48,
|
||||
out_den: 24,
|
||||
media_in_num: 10,
|
||||
media_in_den: 24,
|
||||
gain: 0.5,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let value = serde_json::to_value(&batch).unwrap();
|
||||
assert_eq!(value["batch_id"], 5);
|
||||
assert_eq!(value["tickets"][0]["ticket"], 41);
|
||||
assert_eq!(value["tickets"][0]["slot"], 0);
|
||||
assert_eq!(value["tickets"][0]["sample_rate"], 48000);
|
||||
assert_eq!(value["tickets"][0]["channel_layout"], 3);
|
||||
assert_eq!(value["tickets"][0]["channels"], 2);
|
||||
assert_eq!(value["tickets"][0]["duration_num"], 1600);
|
||||
assert_eq!(value["tickets"][0]["montage"][0]["filename"], "a.mp4");
|
||||
let round: RenderAudioBatchMsg = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(round, batch);
|
||||
// Defaults: a bare audio ticket parses (missing fields default).
|
||||
let bare: AudioTicketSpec = serde_json::from_str(r#"{"ticket":1,"slot":2}"#).unwrap();
|
||||
assert_eq!(bare.ticket, 1);
|
||||
assert_eq!(bare.slot, 2);
|
||||
assert_eq!(bare.sample_rate, 0);
|
||||
assert!(bare.montage.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_type_constants_are_stable_wire_names() {
|
||||
assert_eq!(TYPE_HELLO_CAPS, "hello_caps");
|
||||
assert_eq!(TYPE_RENDER_BATCH, "render_batch");
|
||||
assert_eq!(TYPE_BATCH_ACCEPTED, "batch_accepted");
|
||||
assert_eq!(TYPE_FRAME_FAILED, "frame_failed");
|
||||
assert_eq!(TYPE_RENDER_AUDIO_BATCH, "render_audio_batch");
|
||||
assert_eq!(TYPE_SHUTDOWN, "shutdown");
|
||||
// BGRA8 slot format stays outside the PixelFormat enum range.
|
||||
// BGRA8 slot format stays outside the PixelFormat enum range; the
|
||||
// audio slot format follows it (M15 S3).
|
||||
assert_eq!(SLOT_FORMAT_BGRA8, 100);
|
||||
assert_eq!(SLOT_FORMAT_AUDIO_F32, 101);
|
||||
}
|
||||
|
||||
// ---- Shared-memory transport -----------------------------------------
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::autocacher::PreviewAutoCacher;
|
||||
use crate::backend::BackendKind;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::eval;
|
||||
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmFrameRef};
|
||||
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef};
|
||||
use crate::ticket::{TicketArena, TicketId};
|
||||
use crate::worker::{InlineDispatcher, JobDispatch};
|
||||
|
||||
@@ -58,9 +58,9 @@ pub enum RenderBackendChoice {
|
||||
pub struct RenderManager {
|
||||
/// Video job dispatch (the process dispatcher, M15).
|
||||
pub dispatch: Arc<dyn JobDispatch>,
|
||||
/// Audio job dispatch — kept on main-process inline execution until S3
|
||||
/// (design §3.7: crash risk is dominated by video plugins, which live
|
||||
/// in oak-worker).
|
||||
/// Audio job dispatch (M15 S3: the process dispatcher, like video —
|
||||
/// audio renders in oak-worker so plugin crashes are isolated; the
|
||||
/// inline fallback lives in the arena, design §3.7).
|
||||
pub audio_dispatch: Arc<dyn JobDispatch>,
|
||||
/// Ticket arena.
|
||||
pub tickets: Arc<TicketArena>,
|
||||
@@ -96,27 +96,33 @@ impl RenderManager {
|
||||
eval::render_produced_frame(time, params)
|
||||
.map(crate::ticket::TicketPayload::Video)
|
||||
});
|
||||
let (dispatch, audio_dispatch): (Arc<dyn JobDispatch>, Arc<dyn JobDispatch>) =
|
||||
match choice {
|
||||
RenderBackendChoice::Threads => {
|
||||
// Test-only inline backend: synchronous execution on the
|
||||
// calling thread, shared by video and audio.
|
||||
let inline = InlineDispatcher::sync();
|
||||
(inline.clone(), inline)
|
||||
}
|
||||
RenderBackendChoice::Processes(config) => {
|
||||
let dispatcher = ProcessDispatcher::new(config)?;
|
||||
dispatcher.start()?;
|
||||
// Audio stays on main-process inline execution until S3
|
||||
// (design §3.7): render_audio_samples runs synchronously
|
||||
// on the posting thread.
|
||||
let audio = InlineDispatcher::sync();
|
||||
(dispatcher, audio)
|
||||
}
|
||||
};
|
||||
let tickets = Arc::new(TicketArena::new_with_audio(
|
||||
let (dispatch, audio_dispatch, audio_fallback): (
|
||||
Arc<dyn JobDispatch>,
|
||||
Arc<dyn JobDispatch>,
|
||||
Option<Arc<dyn JobDispatch>>,
|
||||
) = match choice {
|
||||
RenderBackendChoice::Threads => {
|
||||
// Test-only inline backend: synchronous execution on the
|
||||
// calling thread, shared by video and audio.
|
||||
let inline = InlineDispatcher::sync();
|
||||
(inline.clone(), inline, None)
|
||||
}
|
||||
RenderBackendChoice::Processes(config) => {
|
||||
let dispatcher = ProcessDispatcher::new(config)?;
|
||||
dispatcher.start()?;
|
||||
// M15 S3: audio rendering runs in the worker pool too (a
|
||||
// plugin crash during an audio render must not take down
|
||||
// the main process — design §3.7). The inline dispatcher
|
||||
// stays as the fallback when the process dispatcher is
|
||||
// unavailable (teardown).
|
||||
let fallback = InlineDispatcher::sync();
|
||||
(dispatcher.clone(), dispatcher, Some(fallback))
|
||||
}
|
||||
};
|
||||
let tickets = Arc::new(TicketArena::new_with_audio_fallback(
|
||||
dispatch.clone(),
|
||||
audio_dispatch.clone(),
|
||||
audio_fallback,
|
||||
producer,
|
||||
));
|
||||
*guard = Some(Arc::new(RenderManager {
|
||||
@@ -173,6 +179,12 @@ impl RenderManager {
|
||||
self.dispatch.release_frame(frame);
|
||||
}
|
||||
|
||||
/// Release a consumed shm audio frame's slot (M15 S3; see
|
||||
/// [`RenderManager::release_frame`]).
|
||||
pub fn release_audio_frame(&self, frame: &ShmAudioRef) {
|
||||
self.dispatch.release_audio_frame(frame);
|
||||
}
|
||||
|
||||
/// Cancel every pending AND claimed frame of `sequence` (M15 S2
|
||||
/// preview-window invalidation); their completions fire with
|
||||
/// `Error::State`. No-op on backends that schedule no window.
|
||||
|
||||
@@ -69,13 +69,16 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ipc::{
|
||||
write_message, BatchAcceptedMsg, FrameFailedMsg, FrameReadyMsg, FrameSlotMeta, FrameSlotPool,
|
||||
HandshakeMsg, HelloCapsMsg, RenderBatchMsg, BatchTicketSpec, SharedMemoryRegion, ShmMode,
|
||||
WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED, TYPE_ERROR, TYPE_FRAME_FAILED,
|
||||
TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS,
|
||||
write_message, AudioTicketSpec, BatchAcceptedMsg, BatchTicketSpec, FrameFailedMsg, FrameReadyMsg,
|
||||
FrameSlotMeta, FrameSlotPool, HandshakeMsg, HelloCapsMsg, RenderAudioBatchMsg, RenderBatchMsg,
|
||||
SharedMemoryRegion, ShmMode, WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED,
|
||||
TYPE_ERROR, TYPE_FRAME_FAILED, TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS,
|
||||
TYPE_RENDER_AUDIO_BATCH,
|
||||
};
|
||||
use crate::scheduler::{FrameKey, FrameRequest, PreviewScheduler, SubmitOutcome};
|
||||
use crate::ticket::{Completion, TicketPayload, TicketResult, VideoTicketParams};
|
||||
use crate::ticket::{
|
||||
AudioSamples, AudioTicketParams, Completion, TicketPayload, TicketResult, VideoTicketParams,
|
||||
};
|
||||
use crate::worker::{Job, JobDispatch};
|
||||
|
||||
/// Protocol version spoken by the dispatcher (v1 base; v2 messages are
|
||||
@@ -85,7 +88,17 @@ pub const DISPATCH_PROTOCOL_VERSION: i32 = 1;
|
||||
/// Restart attempts per worker before its tickets fail permanently.
|
||||
const MAX_RESTARTS: u32 = 5;
|
||||
|
||||
/// Default slots per worker segment (design §3.1: 8 slots starting).
|
||||
/// Maximum audio bytes a process-backend audio ticket may occupy in a shm
|
||||
/// slot (M15 S3). Larger ranges (long exports) are refused by `post` so
|
||||
/// the arena falls back to main-process inline rendering — a several-
|
||||
/// minute export audio buffer does not need (and should not force) a
|
||||
/// giant shared-memory segment. ~64 MB ≈ 2.9 min of 48 kHz stereo.
|
||||
const MAX_AUDIO_SLOT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Legacy fixed default slots per worker (design §3.1 "8 slots starting").
|
||||
/// The M15 S3 adaptive [`default_slots_per_worker`] policy supersedes it
|
||||
/// for auto-configured dispatchers; kept as the documented starting point
|
||||
/// and the cap for small frames.
|
||||
pub const DEFAULT_SLOTS_PER_WORKER: u32 = 8;
|
||||
|
||||
/// Frame bytes copied into main-process heap buffers. The playback path
|
||||
@@ -264,6 +277,81 @@ impl std::fmt::Debug for ShmFrameRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-copy handle to rendered audio in a worker segment (M15 S3): what
|
||||
/// an audio ticket completion carries on the process backend. The samples
|
||||
/// live in the shm slot as little-endian interleaved f32 (wire format
|
||||
/// [`crate::ipc::SLOT_FORMAT_AUDIO_F32`]); the consumer reads them with
|
||||
/// [`ShmAudioRef::samples`] and releases the slot through
|
||||
/// [`ProcessDispatcher::release_audio_frame`] when done. `sample_rate` /
|
||||
/// `channel_layout` are carried from the ticket params (they are not part
|
||||
/// of the slot meta POD).
|
||||
#[derive(Clone)]
|
||||
pub struct ShmAudioRef {
|
||||
/// Worker index owning the segment.
|
||||
pub worker: u32,
|
||||
/// Slot index in that segment.
|
||||
pub slot: u32,
|
||||
/// Slot metadata (format = `SLOT_FORMAT_AUDIO_F32`).
|
||||
pub meta: ShmFrameMeta,
|
||||
/// The segment view (keeps the mapping alive).
|
||||
pub shm: Arc<ShmRegionView>,
|
||||
/// Output sample rate (Hz; from the ticket params).
|
||||
pub sample_rate: i32,
|
||||
/// Output channel layout mask (from the ticket params).
|
||||
pub channel_layout: u64,
|
||||
/// Channel count (also in the slot meta).
|
||||
pub channel_count: i32,
|
||||
}
|
||||
|
||||
impl ShmAudioRef {
|
||||
/// View the same slot as a generic [`ShmFrameRef`] (slot release paths
|
||||
/// that are shared with video frames).
|
||||
pub fn frame_ref(&self) -> ShmFrameRef {
|
||||
ShmFrameRef {
|
||||
worker: self.worker,
|
||||
slot: self.slot,
|
||||
meta: self.meta.clone(),
|
||||
shm: self.shm.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the interleaved f32 samples out of the slot (the counted
|
||||
/// copy path — audio bytes must outlive the slot to reach the output
|
||||
/// device / encoder).
|
||||
pub fn samples(&self) -> Vec<f32> {
|
||||
let bytes = self.shm.slot_bytes(self.slot);
|
||||
let valid = bytes
|
||||
.get(..self.meta.data_size.max(0) as usize)
|
||||
.unwrap_or(&[]);
|
||||
valid
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The decoded [`AudioSamples`] (sample rate / layout from the ticket
|
||||
/// params, not the slot).
|
||||
pub fn to_audio_samples(&self) -> AudioSamples {
|
||||
AudioSamples {
|
||||
samples: self.samples(),
|
||||
sample_rate: self.sample_rate,
|
||||
channel_layout: self.channel_layout,
|
||||
channel_count: self.channel_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ShmAudioRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ShmAudioRef")
|
||||
.field("worker", &self.worker)
|
||||
.field("slot", &self.slot)
|
||||
.field("sample_rate", &self.sample_rate)
|
||||
.field("channel_count", &self.channel_count)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot pixel conversions (M15 S2)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -342,6 +430,41 @@ pub fn default_worker_count(slots_per_worker: u32, slot_bytes: usize) -> usize {
|
||||
by_cores.min(by_mem).max(1)
|
||||
}
|
||||
|
||||
/// Slot-count policy when a segment grows (M15 S3 grow-on-demand): cap
|
||||
/// the per-worker segment memory at `GROWN_SEGMENT_BUDGET`, never drop
|
||||
/// below 2 slots (enough to keep a worker flowing), never exceed the
|
||||
/// current count.
|
||||
pub fn default_slots_for_bytes(slot_bytes: usize, current_slots: u32) -> u32 {
|
||||
/// Per-worker segment budget for a grown segment (256 MiB).
|
||||
const GROWN_SEGMENT_BUDGET: usize = 256 * 1024 * 1024;
|
||||
let by_mem = (GROWN_SEGMENT_BUDGET / slot_bytes.max(1)).max(2) as u32;
|
||||
by_mem.min(current_slots).max(2)
|
||||
}
|
||||
|
||||
/// Default slots per worker segment (M15 S3 adaptive policy): the
|
||||
/// segment is sized so per-worker shared memory stays within
|
||||
/// `DEFAULT_SEGMENT_BUDGET` (128 MiB), bounded to `[2, 8]`. Small preview
|
||||
/// frames (BGRA8 1080p ≈ 8.3 MB) get the full 8 slots (~66 MB); F32 1080p
|
||||
/// (≈ 33 MB) drops to 3; F32 4K (≈ 132 MB) to 2. The worker-count policy
|
||||
/// then bounds the whole pool against RAM/4.
|
||||
pub fn default_slots_per_worker(slot_bytes: usize) -> u32 {
|
||||
const DEFAULT_SEGMENT_BUDGET: usize = 128 * 1024 * 1024;
|
||||
const MIN_SLOTS: u32 = 2;
|
||||
const MAX_SLOTS: u32 = 8;
|
||||
((DEFAULT_SEGMENT_BUDGET / slot_bytes.max(1)).max(MIN_SLOTS as usize) as u32)
|
||||
.clamp(MIN_SLOTS, MAX_SLOTS)
|
||||
}
|
||||
|
||||
/// Default batch size B (M15 S3 adaptive policy): the design figure
|
||||
/// `120 / workers` (a full playback pre-render window split across the
|
||||
/// pool), capped at the per-worker slot count — credit caps a batch at
|
||||
/// the free slots anyway, so a B larger than the slots just wastes a
|
||||
/// claim round trip.
|
||||
pub fn default_batch_size(workers: usize, slots: u32) -> usize {
|
||||
let design = (120 / workers.max(1)).max(1);
|
||||
design.min(slots.max(1) as usize)
|
||||
}
|
||||
|
||||
/// Physical memory in bytes (macOS `hw.memsize`, Linux `sysconf`).
|
||||
fn physical_memory_bytes() -> Option<u64> {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -390,7 +513,8 @@ pub struct DispatcherConfig {
|
||||
pub worker_bin: Option<PathBuf>,
|
||||
/// Worker process count. `0` = the [`default_worker_count`] policy.
|
||||
pub workers: usize,
|
||||
/// Output slots per worker segment. `0` = 8.
|
||||
/// Output slots per worker segment. `0` = the
|
||||
/// [`default_slots_per_worker`] policy (adaptive to the frame size).
|
||||
pub slots_per_worker: u32,
|
||||
/// Frame width of the segment geometry. `0` = 1920.
|
||||
pub width: i32,
|
||||
@@ -399,7 +523,8 @@ pub struct DispatcherConfig {
|
||||
/// Slot wire format: an `oakcore_rs::PixelFormat` int or
|
||||
/// [`SLOT_FORMAT_BGRA8`]. Default BGRA8 (the viewer preview path).
|
||||
pub slot_format: i32,
|
||||
/// Batch size `B`. `0` = `max(1, 120 / workers)`.
|
||||
/// Batch size `B`. `0` = the [`default_batch_size`] policy (adaptive
|
||||
/// to workers and slots).
|
||||
pub batch_size: usize,
|
||||
/// Graph snapshot path sent to every worker via `load_graph` after
|
||||
/// the handshake (`None` = no graph).
|
||||
@@ -426,12 +551,10 @@ impl Default for DispatcherConfig {
|
||||
|
||||
impl DispatcherConfig {
|
||||
fn normalize(&self) -> DispatcherConfig {
|
||||
// Only the geometry defaults are resolved here; the adaptive
|
||||
// policies (workers / slots / batch size) are resolved in
|
||||
// `ProcessDispatcher::new` where slot_bytes is known.
|
||||
let mut c = self.clone();
|
||||
c.slots_per_worker = if c.slots_per_worker == 0 {
|
||||
DEFAULT_SLOTS_PER_WORKER
|
||||
} else {
|
||||
c.slots_per_worker
|
||||
};
|
||||
c.width = if c.width == 0 { 1920 } else { c.width };
|
||||
c.height = if c.height == 0 { 1080 } else { c.height };
|
||||
c
|
||||
@@ -476,6 +599,9 @@ struct WorkerHandle {
|
||||
child: Option<Child>,
|
||||
stdin: Option<std::process::ChildStdin>,
|
||||
shm: Arc<ShmRegionView>,
|
||||
/// Current per-slot data capacity (grows on demand, M15 S3: a ticket
|
||||
/// requesting F32 or a larger frame rebuilds the segment first).
|
||||
slot_bytes: usize,
|
||||
/// FIFO mirror of the shm free ring's contents (the credit).
|
||||
free_slots: VecDeque<u32>,
|
||||
/// Dispatched ticket -> assigned slot (awaiting frame_ready).
|
||||
@@ -488,10 +614,20 @@ struct WorkerHandle {
|
||||
restarts: u32,
|
||||
spawned_at: Instant,
|
||||
accepted_batches: u64,
|
||||
/// True between a segment grow (M15 S3) and the worker's hello_caps
|
||||
/// re-attach: the dispatcher must not send new batches while the worker
|
||||
/// is still attached to the old pool.
|
||||
reconfiguring: bool,
|
||||
}
|
||||
|
||||
impl WorkerHandle {
|
||||
fn shell(index: usize, generation: u64, shm: Arc<ShmRegionView>, slots: u32) -> WorkerHandle {
|
||||
fn shell(
|
||||
index: usize,
|
||||
generation: u64,
|
||||
shm: Arc<ShmRegionView>,
|
||||
slots: u32,
|
||||
slot_bytes: usize,
|
||||
) -> WorkerHandle {
|
||||
WorkerHandle {
|
||||
index,
|
||||
generation,
|
||||
@@ -499,6 +635,7 @@ impl WorkerHandle {
|
||||
child: None,
|
||||
stdin: None,
|
||||
shm,
|
||||
slot_bytes,
|
||||
free_slots: (0..slots).collect(),
|
||||
outstanding: HashMap::new(),
|
||||
held: HashSet::new(),
|
||||
@@ -508,6 +645,7 @@ impl WorkerHandle {
|
||||
restarts: 0,
|
||||
spawned_at: Instant::now(),
|
||||
accepted_batches: 0,
|
||||
reconfiguring: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +657,9 @@ impl WorkerHandle {
|
||||
struct PendingTicket {
|
||||
key: FrameKey,
|
||||
params: Arc<VideoTicketParams>,
|
||||
/// Audio ticket params when this ticket is an audio range pull (M15
|
||||
/// S3); `None` for video tickets.
|
||||
audio: Option<Arc<AudioTicketParams>>,
|
||||
done: Option<Completion>,
|
||||
}
|
||||
|
||||
@@ -533,6 +674,10 @@ struct Inner {
|
||||
next_ticket: i64,
|
||||
events_rx: mpsc::Receiver<WorkerEvent>,
|
||||
events_tx: mpsc::Sender<WorkerEvent>,
|
||||
/// Segment rebuild generation (M15 S3 grow-on-demand geometry): bumped
|
||||
/// on every per-worker segment resize so re-created segments never
|
||||
/// reuse the name of a live mapping.
|
||||
seg_generation: u64,
|
||||
started: bool,
|
||||
shutting_down: bool,
|
||||
}
|
||||
@@ -550,18 +695,28 @@ pub struct ProcessDispatcher {
|
||||
|
||||
impl ProcessDispatcher {
|
||||
/// Build a dispatcher from `config` (does not spawn; call
|
||||
/// [`ProcessDispatcher::start`]).
|
||||
/// [`ProcessDispatcher::start`]). The adaptive policies resolve here
|
||||
/// (M15 S3): slots scale with the frame's slot bytes, workers with
|
||||
/// cores/RAM, batch size with workers and slots.
|
||||
pub fn new(config: DispatcherConfig) -> Result<Arc<ProcessDispatcher>> {
|
||||
let config = config.normalize();
|
||||
let slots = config.slots_per_worker;
|
||||
let slot_bytes = slot_bytes_for(config.width, config.height, config.slot_format);
|
||||
let slots = if config.slots_per_worker == 0 {
|
||||
default_slots_per_worker(slot_bytes)
|
||||
} else {
|
||||
config.slots_per_worker
|
||||
};
|
||||
let workers = if config.workers == 0 {
|
||||
default_worker_count(slots, slot_bytes)
|
||||
} else {
|
||||
config.workers
|
||||
};
|
||||
let batch_size = if config.batch_size == 0 {
|
||||
default_batch_size(workers, slots)
|
||||
} else {
|
||||
config.batch_size
|
||||
};
|
||||
let bin = resolve_worker_bin(&config)?;
|
||||
let batch_size = config.batch_size;
|
||||
let (events_tx, events_rx) = mpsc::channel();
|
||||
Ok(Arc::new(ProcessDispatcher {
|
||||
inner: Mutex::new(Inner {
|
||||
@@ -575,6 +730,7 @@ impl ProcessDispatcher {
|
||||
next_ticket: 1,
|
||||
events_rx,
|
||||
events_tx,
|
||||
seg_generation: 0,
|
||||
started: false,
|
||||
shutting_down: false,
|
||||
}),
|
||||
@@ -697,6 +853,12 @@ impl ProcessDispatcher {
|
||||
unsafe { handle.shm.pool().release(frame.slot) };
|
||||
}
|
||||
|
||||
/// Release a consumed audio frame's slot (M15 S3) — the audio
|
||||
/// counterpart of [`ProcessDispatcher::release_frame`].
|
||||
pub fn release_audio_frame(&self, frame: &ShmAudioRef) {
|
||||
self.release_frame(&frame.frame_ref());
|
||||
}
|
||||
|
||||
/// Cancel one frame request (pending or in flight). The completion
|
||||
/// fires with `Error::State` exactly once; a late frame_ready for an
|
||||
/// in-flight cancel recycles the slot silently.
|
||||
@@ -780,7 +942,9 @@ impl ProcessDispatcher {
|
||||
|
||||
// 3. Interleaved batch claims + dispatch (free slots = credit).
|
||||
for i in 0..inner.workers.len() {
|
||||
if matches!(inner.workers[i].state, WorkerState::Alive) {
|
||||
if matches!(inner.workers[i].state, WorkerState::Alive)
|
||||
&& !inner.workers[i].reconfiguring
|
||||
{
|
||||
self.dispatch_to(inner, i);
|
||||
}
|
||||
}
|
||||
@@ -805,18 +969,10 @@ impl ProcessDispatcher {
|
||||
match typ {
|
||||
TYPE_HANDSHAKE => {
|
||||
// The worker's startup handshake: answer with the shm
|
||||
// geometry (protocol v1 flow).
|
||||
// geometry (protocol v1 flow). A mid-session handshake is a
|
||||
// segment grow (M15 S3): the worker re-attaches the new pool.
|
||||
handle.startup_seen = true;
|
||||
let hs = HandshakeMsg {
|
||||
protocol_version: DISPATCH_PROTOCOL_VERSION,
|
||||
shm_key: handle.shm.key().to_string(),
|
||||
input_shm_key: String::new(),
|
||||
input_slots: 0,
|
||||
output_slots: handle.shm.slot_count() as i32,
|
||||
slot_data_bytes: handle.shm.slot_data_bytes() as i64,
|
||||
input_slot_data_bytes: 0,
|
||||
};
|
||||
if self.send_json(handle, &hs.to_json()).is_err() {
|
||||
if self.send_json(handle, &handshake_for(handle)).is_err() {
|
||||
handle.state = WorkerState::Dead;
|
||||
}
|
||||
}
|
||||
@@ -824,6 +980,9 @@ impl ProcessDispatcher {
|
||||
if let Ok(caps) = serde_json::from_value::<HelloCapsMsg>(msg) {
|
||||
handle.caps = Some(caps);
|
||||
handle.state = WorkerState::Alive;
|
||||
// A re-attach after a segment grow is complete: the
|
||||
// dispatcher may send batches again.
|
||||
handle.reconfiguring = false;
|
||||
// One load_graph right after the first handshake.
|
||||
if !handle.graph_sent {
|
||||
if let Some(path) = inner.config.graph_snapshot.clone() {
|
||||
@@ -913,15 +1072,34 @@ impl ProcessDispatcher {
|
||||
let key = pt.key;
|
||||
inner.scheduler.frame_done(&key);
|
||||
if let Some(done) = pt.done.take() {
|
||||
fired.push((
|
||||
done,
|
||||
Ok(TicketPayload::ShmFrame(ShmFrameRef {
|
||||
worker: worker as u32,
|
||||
slot: slot as u32,
|
||||
meta,
|
||||
shm,
|
||||
})),
|
||||
));
|
||||
// M15 S3: audio tickets complete with the shm audio
|
||||
// payload (the consumer reads the slot and releases it);
|
||||
// video tickets keep the ShmFrame payload.
|
||||
if let Some(audio) = &pt.audio {
|
||||
let params = audio.clone();
|
||||
fired.push((
|
||||
done,
|
||||
Ok(TicketPayload::ShmAudio(ShmAudioRef {
|
||||
worker: worker as u32,
|
||||
slot: slot as u32,
|
||||
meta,
|
||||
shm,
|
||||
sample_rate: params.sample_rate,
|
||||
channel_layout: params.channel_layout,
|
||||
channel_count: params.channel_layout.count_ones().max(1) as i32,
|
||||
})),
|
||||
));
|
||||
} else {
|
||||
fired.push((
|
||||
done,
|
||||
Ok(TicketPayload::ShmFrame(ShmFrameRef {
|
||||
worker: worker as u32,
|
||||
slot: slot as u32,
|
||||
meta,
|
||||
shm,
|
||||
})),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Cancelled while in flight: recycle the slot now.
|
||||
self.recycle_slot(inner, worker, slot as u32);
|
||||
@@ -977,10 +1155,38 @@ impl ProcessDispatcher {
|
||||
if credit == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(batch) = inner.scheduler.claim_batch(worker, credit) else {
|
||||
// Grow-on-demand (M15 S3): if a pending request for this worker
|
||||
// needs a bigger slot than the segment provides, and the worker
|
||||
// has no in-flight frames, rebuild its segment first (the worker
|
||||
// re-attaches on a fresh handshake). While the worker is busy the
|
||||
// oversized request simply stays pending — claim_batch filters it
|
||||
// by max_bytes, so it is served after the drain.
|
||||
let grow = {
|
||||
let handle = &inner.workers[worker];
|
||||
if handle.outstanding.is_empty() {
|
||||
inner
|
||||
.scheduler
|
||||
.max_pending_bytes_for_worker(worker, handle.slot_bytes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(need) = grow {
|
||||
if let Err(e) = self.rebuild_segment(inner, worker, need) {
|
||||
eprintln!("procpool: worker {worker} segment grow to {need} B failed: {e}");
|
||||
}
|
||||
// Stop here: the worker is re-attaching to the new segment
|
||||
// (hello_caps pending). Dispatch resumes on the next pump
|
||||
// once `reconfiguring` clears — sending a batch now would
|
||||
// race the pool swap.
|
||||
return;
|
||||
}
|
||||
let max_bytes = inner.workers[worker].slot_bytes;
|
||||
let Some(batch) = inner.scheduler.claim_batch(worker, credit, max_bytes) else {
|
||||
return;
|
||||
};
|
||||
let mut wire_tickets = Vec::with_capacity(batch.frames.len());
|
||||
let mut video_tickets = Vec::with_capacity(batch.frames.len());
|
||||
let mut audio_tickets: Vec<AudioTicketSpec> = Vec::new();
|
||||
for req in &batch.frames {
|
||||
let ticket = req.payload;
|
||||
let slot = match inner.workers[worker].free_slots.pop_front() {
|
||||
@@ -991,32 +1197,61 @@ impl ProcessDispatcher {
|
||||
let Some(pt) = inner.tickets.get(&ticket) else {
|
||||
continue;
|
||||
};
|
||||
wire_tickets.push(build_ticket_spec(
|
||||
ticket,
|
||||
slot,
|
||||
&pt.params,
|
||||
inner.config.slot_format,
|
||||
));
|
||||
if let Some(audio) = &pt.audio {
|
||||
audio_tickets.push(build_audio_ticket_spec(ticket, slot, audio));
|
||||
} else {
|
||||
video_tickets.push(build_ticket_spec(
|
||||
ticket,
|
||||
slot,
|
||||
&pt.params,
|
||||
inner.config.slot_format,
|
||||
));
|
||||
}
|
||||
}
|
||||
let msg = RenderBatchMsg {
|
||||
batch_id: batch.batch_id as i64,
|
||||
tickets: wire_tickets,
|
||||
};
|
||||
// The `type` tag is added by hand: [`RenderBatchMsg`] only
|
||||
// carries the payload fields (it is the parse-side struct).
|
||||
let mut value = match serde_json::to_value(&msg) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
Value::String(crate::ipc::TYPE_RENDER_BATCH.to_string()),
|
||||
);
|
||||
// A single claim may mix audio and video (different scheduler
|
||||
// keys in one batch); they are delivered as two messages under
|
||||
// the same batch id, claimed by the worker in order.
|
||||
if !video_tickets.is_empty() {
|
||||
let msg = RenderBatchMsg {
|
||||
batch_id: batch.batch_id as i64,
|
||||
tickets: video_tickets,
|
||||
};
|
||||
// The `type` tag is added by hand: the parse-side structs only
|
||||
// carry the payload fields.
|
||||
let mut value = match serde_json::to_value(&msg) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
Value::String(crate::ipc::TYPE_RENDER_BATCH.to_string()),
|
||||
);
|
||||
}
|
||||
if self.send_json(&mut inner.workers[worker], &value).is_err() {
|
||||
inner.workers[worker].state = WorkerState::Dead;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.send_json(&mut inner.workers[worker], &value).is_err() {
|
||||
inner.workers[worker].state = WorkerState::Dead;
|
||||
return;
|
||||
if !audio_tickets.is_empty() {
|
||||
let msg = RenderAudioBatchMsg {
|
||||
batch_id: batch.batch_id as i64,
|
||||
tickets: audio_tickets,
|
||||
};
|
||||
let mut value = match serde_json::to_value(&msg) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
Value::String(TYPE_RENDER_AUDIO_BATCH.to_string()),
|
||||
);
|
||||
}
|
||||
if self.send_json(&mut inner.workers[worker], &value).is_err() {
|
||||
inner.workers[worker].state = WorkerState::Dead;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1098,7 +1333,7 @@ impl ProcessDispatcher {
|
||||
})
|
||||
.map_err(|e| Error::Failed(format!("spawn reader thread: {e}")))?;
|
||||
|
||||
let mut handle = WorkerHandle::shell(index, generation, shm, inner.slots);
|
||||
let mut handle = WorkerHandle::shell(index, generation, shm, inner.slots, inner.slot_bytes);
|
||||
handle.child = Some(child);
|
||||
handle.stdin = stdin;
|
||||
handle.spawned_at = Instant::now();
|
||||
@@ -1166,6 +1401,59 @@ impl ProcessDispatcher {
|
||||
inner.workers[worker].state = WorkerState::Dead;
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a worker's segment to `need_bytes` per slot (M15 S3 grow-on-
|
||||
/// demand geometry, design §3.1 "段按需扩容或重建"): create a fresh
|
||||
/// segment under a new key (the old mapping stays alive for consumers
|
||||
/// still holding [`ShmFrameRef`]s into it — they release as stale
|
||||
/// refs), re-point the handle, reseed the free slots and have the
|
||||
/// worker re-attach through a fresh handshake. The caller guarantees
|
||||
/// `outstanding` is empty (no frame is mid-render in the old pool).
|
||||
/// The worker's hello_caps clears `reconfiguring`, unblocking dispatch.
|
||||
fn rebuild_segment(&self, inner: &mut Inner, worker: usize, need_bytes: usize) -> Result<()> {
|
||||
let current_slots = inner.workers[worker].shm.slot_count();
|
||||
let slots = default_slots_for_bytes(need_bytes, current_slots);
|
||||
let generation = inner.seg_generation;
|
||||
inner.seg_generation += 1;
|
||||
let base = SharedMemoryRegion::make_key(std::process::id() as i64, worker as i32);
|
||||
let key = format!(
|
||||
"{base}-g{}-s{generation}",
|
||||
inner.workers[worker].generation
|
||||
);
|
||||
let shm = ShmRegionView::create(&key, slots, need_bytes)?;
|
||||
{
|
||||
let handle = &mut inner.workers[worker];
|
||||
handle.shm = shm;
|
||||
handle.slot_bytes = need_bytes;
|
||||
handle.free_slots = (0..slots).collect();
|
||||
handle.held.clear();
|
||||
// No new batches until the worker re-attaches the new pool.
|
||||
handle.reconfiguring = true;
|
||||
}
|
||||
let hs = {
|
||||
let handle = &inner.workers[worker];
|
||||
handshake_for(handle)
|
||||
};
|
||||
if self.send_json(&mut inner.workers[worker], &hs).is_err() {
|
||||
inner.workers[worker].state = WorkerState::Dead;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The handshake reply the dispatcher sends a worker (startup and M15 S3
|
||||
/// segment-grow re-attach): the worker's current shm geometry.
|
||||
fn handshake_for(handle: &WorkerHandle) -> Value {
|
||||
HandshakeMsg {
|
||||
protocol_version: DISPATCH_PROTOCOL_VERSION,
|
||||
shm_key: handle.shm.key().to_string(),
|
||||
input_shm_key: String::new(),
|
||||
input_slots: 0,
|
||||
output_slots: handle.shm.slot_count() as i32,
|
||||
slot_data_bytes: handle.shm.slot_data_bytes() as i64,
|
||||
input_slot_data_bytes: 0,
|
||||
}
|
||||
.to_json()
|
||||
}
|
||||
|
||||
impl JobDispatch for ProcessDispatcher {
|
||||
@@ -1184,6 +1472,20 @@ impl JobDispatch for ProcessDispatcher {
|
||||
if inner.shutting_down {
|
||||
return false;
|
||||
}
|
||||
// M15 S3: audio ranges larger than a practical shm slot (export
|
||||
// of many minutes of audio) are refused here so the arena falls
|
||||
// back to main-process inline rendering (design §3.7) — the
|
||||
// process backend stays for the real-time chunks and short
|
||||
// ranges that fit a segment.
|
||||
if let Some(audio) = &job.audio {
|
||||
let too_large = match crate::eval::audio_samples_byte_len(audio) {
|
||||
Ok(bytes) => bytes > MAX_AUDIO_SLOT_BYTES,
|
||||
Err(_) => true, // invalid range: let the inline path report it
|
||||
};
|
||||
if too_large {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let id = inner.next_ticket;
|
||||
inner.next_ticket += 1;
|
||||
let frame = job.schedule.frame.unwrap_or(id);
|
||||
@@ -1192,11 +1494,22 @@ impl JobDispatch for ProcessDispatcher {
|
||||
frame,
|
||||
version: job.schedule.version,
|
||||
};
|
||||
// M15 S3: per-request slot geometry. Audio tickets need the
|
||||
// sample bytes of their range; video tickets need the frame
|
||||
// size x the ticket's wire format (force_format honored).
|
||||
let slot_bytes = match &job.audio {
|
||||
Some(audio) => crate::eval::audio_samples_byte_len(audio).unwrap_or(0),
|
||||
None => {
|
||||
let (w, h) = job.params.render_size();
|
||||
slot_bytes_for(w, h, ticket_wire_format(&job.params, inner.config.slot_format))
|
||||
}
|
||||
};
|
||||
inner.tickets.insert(
|
||||
id,
|
||||
PendingTicket {
|
||||
key,
|
||||
params: job.params,
|
||||
audio: job.audio,
|
||||
done: Some(job.done),
|
||||
},
|
||||
);
|
||||
@@ -1205,6 +1518,7 @@ impl JobDispatch for ProcessDispatcher {
|
||||
priority: job.schedule.priority,
|
||||
distance: job.schedule.distance,
|
||||
payload: id,
|
||||
slot_bytes,
|
||||
};
|
||||
match inner.scheduler.submit(request) {
|
||||
SubmitOutcome::Accepted => {}
|
||||
@@ -1267,6 +1581,12 @@ impl JobDispatch for ProcessDispatcher {
|
||||
self.release_frame(frame);
|
||||
}
|
||||
|
||||
/// Release a consumed audio frame's slot (M15 S3; delegates to the
|
||||
/// inherent release).
|
||||
fn release_audio_frame(&self, frame: &ShmAudioRef) {
|
||||
self.release_audio_frame(frame);
|
||||
}
|
||||
|
||||
/// Graceful shutdown: `shutdown` messages, a short drain pumping
|
||||
/// completions, then kill stragglers; every ticket still open
|
||||
/// completes with `Error::State`.
|
||||
@@ -1357,6 +1677,15 @@ fn resolve_worker_bin(config: &DispatcherConfig) -> Result<PathBuf> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// The wire slot format a video ticket requests: the ticket's forced
|
||||
/// PixelFormat when set (F32 for exports / full-resolution / scopes, M15
|
||||
/// S3 — the worker then writes F32 straight into the slot and the export
|
||||
/// reads it back with no BGRA8 round trip), else the dispatcher's default
|
||||
/// slot format (BGRA8 for the viewer preview path).
|
||||
fn ticket_wire_format(params: &VideoTicketParams, config_format: i32) -> i32 {
|
||||
params.force_format.map(|f| f as i32).unwrap_or(config_format)
|
||||
}
|
||||
|
||||
/// Map ticket params to the wire ticket spec (main assigns `slot`).
|
||||
fn build_ticket_spec(
|
||||
ticket: i64,
|
||||
@@ -1391,7 +1720,7 @@ fn build_ticket_spec(
|
||||
time_den: params.time.denominator(),
|
||||
width,
|
||||
height,
|
||||
format: slot_format,
|
||||
format: ticket_wire_format(params, slot_format),
|
||||
channels: 4,
|
||||
footage_file,
|
||||
footage_stream,
|
||||
@@ -1399,6 +1728,39 @@ fn build_ticket_spec(
|
||||
}
|
||||
}
|
||||
|
||||
/// Map audio ticket params to the wire audio ticket spec (M15 S3; main
|
||||
/// assigns `slot`).
|
||||
fn build_audio_ticket_spec(ticket: i64, slot: u32, params: &AudioTicketParams) -> AudioTicketSpec {
|
||||
let duration = params.range.out() - params.range.in_();
|
||||
let montage = params
|
||||
.montage
|
||||
.iter()
|
||||
.map(|c| WireMontageClip {
|
||||
filename: c.filename.clone(),
|
||||
stream_index: c.stream_index,
|
||||
in_num: c.in_time.numerator(),
|
||||
in_den: c.in_time.denominator(),
|
||||
out_num: c.out_time.numerator(),
|
||||
out_den: c.out_time.denominator(),
|
||||
media_in_num: c.media_in.numerator(),
|
||||
media_in_den: c.media_in.denominator(),
|
||||
gain: c.gain,
|
||||
})
|
||||
.collect();
|
||||
AudioTicketSpec {
|
||||
ticket,
|
||||
slot: slot as i32,
|
||||
time_num: params.range.in_().numerator(),
|
||||
time_den: params.range.in_().denominator(),
|
||||
duration_num: duration.numerator(),
|
||||
duration_den: duration.denominator(),
|
||||
sample_rate: params.sample_rate,
|
||||
channel_layout: params.channel_layout,
|
||||
channels: params.channel_layout.count_ones().max(1) as i32,
|
||||
montage,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1426,10 +1788,51 @@ mod tests {
|
||||
#[test]
|
||||
fn config_normalization_defaults() {
|
||||
let c = DispatcherConfig::default().normalize();
|
||||
assert_eq!(c.slots_per_worker, DEFAULT_SLOTS_PER_WORKER);
|
||||
// Geometry defaults resolve here; the adaptive counts stay 0 (auto)
|
||||
// and resolve in `ProcessDispatcher::new` where slot_bytes is known.
|
||||
assert_eq!(c.width, 1920);
|
||||
assert_eq!(c.height, 1080);
|
||||
assert_eq!(c.slot_format, SLOT_FORMAT_BGRA8);
|
||||
assert_eq!(c.slots_per_worker, 0);
|
||||
assert_eq!(c.workers, 0);
|
||||
assert_eq!(c.batch_size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_policy_adapts_to_slot_size() {
|
||||
// BGRA8 1080p: the full 8 slots (~66 MB per worker segment).
|
||||
let bgra8_1080p = slot_bytes_for(1920, 1080, SLOT_FORMAT_BGRA8);
|
||||
assert_eq!(default_slots_per_worker(bgra8_1080p), 8);
|
||||
// F32 1080p: drops to 4 (~133 MB per worker segment).
|
||||
let f32_1080p = slot_bytes_for(1920, 1080, 4);
|
||||
assert_eq!(default_slots_per_worker(f32_1080p), 4);
|
||||
// F32 4K: 2 slots (the floor).
|
||||
let f32_4k = slot_bytes_for(3840, 2160, 4);
|
||||
assert_eq!(default_slots_per_worker(f32_4k), 2);
|
||||
// Tiny slots: the cap at 8.
|
||||
assert_eq!(default_slots_per_worker(16), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_size_policy_scales_with_workers_and_slots() {
|
||||
// 4 workers x 8 slots: the design 120/4 = 30 caps at the 8 slots.
|
||||
assert_eq!(default_batch_size(4, 8), 8);
|
||||
// 1 worker x 8 slots: 120/1 = 120 caps at 8.
|
||||
assert_eq!(default_batch_size(1, 8), 8);
|
||||
// 2 workers x 4 slots: 120/2 = 60 caps at 4.
|
||||
assert_eq!(default_batch_size(2, 4), 4);
|
||||
// 8 workers x 8 slots: 120/8 = 15 caps at 8.
|
||||
assert_eq!(default_batch_size(8, 8), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grown_segment_slots_stay_bounded() {
|
||||
// Growing a segment keeps a sane slot count: 8.3 MB slots keep 8;
|
||||
// 33 MB slots keep 8 (still within the 256 MiB grown budget);
|
||||
// absurd sizes clamp at 2.
|
||||
assert_eq!(default_slots_for_bytes(8_300_000, 8), 8);
|
||||
assert_eq!(default_slots_for_bytes(33_000_000, 8), 8);
|
||||
assert_eq!(default_slots_for_bytes(1 << 30, 8), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -86,6 +86,11 @@ pub struct FrameRequest<P> {
|
||||
pub distance: i64,
|
||||
/// Caller payload.
|
||||
pub payload: P,
|
||||
/// Slot bytes this request needs (frame size x wire format; M15 S3).
|
||||
/// `claim_batch` skips requests whose bytes exceed the worker's current
|
||||
/// slot capacity, so the dispatcher can grow the segment first (grow-
|
||||
/// on-demand geometry, design §3.1).
|
||||
pub slot_bytes: usize,
|
||||
}
|
||||
|
||||
/// A batch of frames one worker claimed.
|
||||
@@ -193,12 +198,20 @@ impl<P: Clone> PreviewScheduler<P> {
|
||||
/// Claim the next batch for `worker`: the worker's interleaved shard
|
||||
/// (frame number `≡ worker (mod W)`, plus any crash-requeued frames),
|
||||
/// ordered by priority class / playhead distance / ascending frame,
|
||||
/// capped at `min(batch_size, credit)`. Returns `None` when nothing
|
||||
/// is claimable (`credit == 0`, unknown worker, empty shard).
|
||||
/// capped at `min(batch_size, credit)`. Requests needing more than
|
||||
/// `max_bytes` of slot space are skipped (they stay pending until the
|
||||
/// dispatcher grows the segment). Returns `None` when nothing
|
||||
/// claimable (`credit == 0`, unknown worker, empty shard, all
|
||||
/// oversized).
|
||||
///
|
||||
/// Claimed frames never go to another worker while in flight (no
|
||||
/// stealing).
|
||||
pub fn claim_batch(&mut self, worker: usize, credit: usize) -> Option<ClaimedBatch<P>> {
|
||||
pub fn claim_batch(
|
||||
&mut self,
|
||||
worker: usize,
|
||||
credit: usize,
|
||||
max_bytes: usize,
|
||||
) -> Option<ClaimedBatch<P>> {
|
||||
if worker >= self.workers || credit == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -208,7 +221,8 @@ impl<P: Clone> PreviewScheduler<P> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64
|
||||
e.request.slot_bytes <= max_bytes
|
||||
&& (e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
@@ -346,6 +360,25 @@ impl<P: Clone> PreviewScheduler<P> {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// The largest `slot_bytes` among pending requests claimable by
|
||||
/// `worker` (its shard plus crash-requeued frames) that exceeds
|
||||
/// `current`, if any. The dispatcher uses this to grow a worker's
|
||||
/// segment before the next claim (M15 S3 grow-on-demand geometry).
|
||||
pub fn max_pending_bytes_for_worker(&self, worker: usize, current: usize) -> Option<usize> {
|
||||
if worker >= self.workers {
|
||||
return None;
|
||||
}
|
||||
let workers = self.workers;
|
||||
self.pending
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64
|
||||
})
|
||||
.map(|e| e.request.slot_bytes)
|
||||
.max()
|
||||
.filter(|&m| m > current)
|
||||
}
|
||||
|
||||
/// Claimed (in-flight) request count.
|
||||
pub fn claimed_count(&self) -> usize {
|
||||
self.claimed.len()
|
||||
@@ -376,6 +409,7 @@ mod tests {
|
||||
priority: prio,
|
||||
distance: frame,
|
||||
payload: frame as u64,
|
||||
slot_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +420,7 @@ mod tests {
|
||||
loop {
|
||||
let mut progress = false;
|
||||
for w in 0..s.workers() {
|
||||
while let Some(batch) = s.claim_batch(w, 1024) {
|
||||
while let Some(batch) = s.claim_batch(w, 1024, usize::MAX) {
|
||||
for f in &batch.frames {
|
||||
out.push((w, f.key.frame));
|
||||
}
|
||||
@@ -448,9 +482,9 @@ mod tests {
|
||||
s.submit(req(1, f, FramePriority::Playback));
|
||||
}
|
||||
// Both workers claim their shards first.
|
||||
let batch0 = s.claim_batch(0, 8).unwrap();
|
||||
let batch0 = s.claim_batch(0, 8, 1024).unwrap();
|
||||
assert_eq!(batch0.frames.len(), 4); // frames 0,2,4,6
|
||||
let batch1 = s.claim_batch(1, 8).unwrap();
|
||||
let batch1 = s.claim_batch(1, 8, 1024).unwrap();
|
||||
assert_eq!(batch1.frames.len(), 4); // frames 1,3,5,7
|
||||
|
||||
// Worker 0 crashes: its frames come back...
|
||||
@@ -461,7 +495,7 @@ mod tests {
|
||||
assert_eq!(s.crash_requeued(), 4);
|
||||
|
||||
// ...and worker 1 (NOT their shard) can claim them all.
|
||||
let batch = s.claim_batch(1, 8).unwrap();
|
||||
let batch = s.claim_batch(1, 8, 1024).unwrap();
|
||||
assert_eq!(batch.frames.len(), 4);
|
||||
let mut frames: Vec<i64> = batch.frames.iter().map(|f| f.key.frame).collect();
|
||||
frames.sort_unstable();
|
||||
@@ -483,7 +517,7 @@ mod tests {
|
||||
}
|
||||
s.submit(req(1, 100, FramePriority::Seek));
|
||||
|
||||
let batch = s.claim_batch(0, 100).unwrap();
|
||||
let batch = s.claim_batch(0, 100, 1024).unwrap();
|
||||
let order: Vec<(FramePriority, i64)> = batch
|
||||
.frames
|
||||
.iter()
|
||||
@@ -516,9 +550,9 @@ mod tests {
|
||||
s.submit(req(1, f, FramePriority::Playback));
|
||||
}
|
||||
// Zero credit claims nothing.
|
||||
assert!(s.claim_batch(0, 0).is_none());
|
||||
assert!(s.claim_batch(0, 0, 1024).is_none());
|
||||
// Credit 3 claims exactly 3 (free slots are the credit).
|
||||
let batch = s.claim_batch(0, 3).unwrap();
|
||||
let batch = s.claim_batch(0, 3, 1024).unwrap();
|
||||
assert_eq!(batch.frames.len(), 3);
|
||||
assert_eq!(s.pending_count(), 7);
|
||||
}
|
||||
@@ -529,19 +563,47 @@ mod tests {
|
||||
for f in 0..10 {
|
||||
s.submit(req(1, f, FramePriority::Playback));
|
||||
}
|
||||
let batch = s.claim_batch(0, 100).unwrap();
|
||||
let batch = s.claim_batch(0, 100, 1024).unwrap();
|
||||
assert_eq!(batch.frames.len(), 4, "batch size B caps the claim");
|
||||
// Ascending frame order inside the batch.
|
||||
let frames: Vec<i64> = batch.frames.iter().map(|f| f.key.frame).collect();
|
||||
assert_eq!(frames, vec![0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_batch_skips_requests_exceeding_slot_capacity() {
|
||||
// M15 S3 grow-on-demand: a request needing more bytes than the
|
||||
// worker's current slot capacity stays pending (the dispatcher can
|
||||
// grow the segment and claim it later); smaller requests still flow.
|
||||
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(1, 100);
|
||||
for f in 0..4 {
|
||||
let mut r = req(1, f, FramePriority::Playback);
|
||||
r.slot_bytes = if f == 1 { 33_000_000 } else { 8_300_000 };
|
||||
s.submit(r);
|
||||
}
|
||||
// max_bytes 8_300_000: frame 1 (33 MB) is skipped.
|
||||
let batch = s.claim_batch(0, 100, 8_300_000).unwrap();
|
||||
let frames: Vec<i64> = batch.frames.iter().map(|f| f.key.frame).collect();
|
||||
assert_eq!(frames, vec![0, 2, 3]);
|
||||
assert_eq!(s.pending_count(), 1, "the oversized frame stays pending");
|
||||
assert_eq!(
|
||||
s.max_pending_bytes_for_worker(0, 8_300_000),
|
||||
Some(33_000_000),
|
||||
"the dispatcher sees the growth need"
|
||||
);
|
||||
// After the segment grows, the oversized frame is claimable.
|
||||
let batch = s.claim_batch(0, 100, 33_000_000).unwrap();
|
||||
assert_eq!(batch.frames.len(), 1);
|
||||
assert_eq!(batch.frames[0].key.frame, 1);
|
||||
assert_eq!(s.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_and_failed_drop_the_claim() {
|
||||
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(1, 4);
|
||||
s.submit(req(1, 0, FramePriority::Playback));
|
||||
s.submit(req(1, 1, FramePriority::Playback));
|
||||
let batch = s.claim_batch(0, 4).unwrap();
|
||||
let batch = s.claim_batch(0, 4, 1024).unwrap();
|
||||
assert_eq!(batch.frames.len(), 2);
|
||||
let k0 = batch.frames[0].key;
|
||||
let k1 = batch.frames[1].key;
|
||||
@@ -559,7 +621,7 @@ mod tests {
|
||||
let r = req(1, 5, FramePriority::Playback);
|
||||
let key = r.key;
|
||||
assert!(matches!(s.submit(r), SubmitOutcome::Accepted));
|
||||
let _ = s.claim_batch(0, 4).unwrap();
|
||||
let _ = s.claim_batch(0, 4, 1024).unwrap();
|
||||
// In flight: rejected.
|
||||
assert!(matches!(
|
||||
s.submit(req(1, 5, FramePriority::Seek)),
|
||||
@@ -592,7 +654,7 @@ mod tests {
|
||||
}
|
||||
other => panic!("expected Replaced, got {other:?}"),
|
||||
}
|
||||
let _ = s.claim_batch(0, 4).unwrap();
|
||||
let _ = s.claim_batch(0, 4, 1024).unwrap();
|
||||
assert_eq!(s.claimed_worker(&second_key), Some(0));
|
||||
}
|
||||
|
||||
@@ -602,7 +664,7 @@ mod tests {
|
||||
for f in 0..6 {
|
||||
s.submit(req(7, f, FramePriority::Playback));
|
||||
}
|
||||
let _ = s.claim_batch(0, 4).unwrap(); // claims 4 of sequence 7
|
||||
let _ = s.claim_batch(0, 4, 1024).unwrap(); // claims 4 of sequence 7
|
||||
s.submit(req(8, 0, FramePriority::Playback)); // other sequence
|
||||
let dropped = s.cancel_sequence(7);
|
||||
assert_eq!(dropped.len(), 6, "all 6 sequence-7 requests dropped");
|
||||
@@ -623,6 +685,6 @@ mod tests {
|
||||
fn unknown_worker_claims_nothing() {
|
||||
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(2, 4);
|
||||
s.submit(req(1, 0, FramePriority::Playback));
|
||||
assert!(s.claim_batch(2, 4).is_none());
|
||||
assert!(s.claim_batch(2, 4, 1024).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,11 @@ pub enum TicketPayload {
|
||||
/// process backend): zero copy — the consumer reads the pixels from
|
||||
/// the mapping and releases the slot through the dispatcher.
|
||||
ShmFrame(crate::procpool::ShmFrameRef),
|
||||
/// Rendered audio living in a worker's shared-memory slot (M15 S3):
|
||||
/// interleaved f32 in `SLOT_FORMAT_AUDIO_F32` slots, consumed with
|
||||
/// [`crate::procpool::ShmAudioRef::samples`] and released through the
|
||||
/// dispatcher — the audio counterpart of `ShmFrame`.
|
||||
ShmAudio(crate::procpool::ShmAudioRef),
|
||||
}
|
||||
|
||||
/// Completion payload: the rendered texture/samples or the failure
|
||||
@@ -209,6 +214,9 @@ impl TicketSlot {
|
||||
if let Ok(TicketPayload::ShmFrame(frame)) = &result {
|
||||
self.dispatch.release_frame(frame);
|
||||
}
|
||||
if let Ok(TicketPayload::ShmAudio(audio)) = &result {
|
||||
self.dispatch.release_audio_frame(audio);
|
||||
}
|
||||
result = Err(Error::State);
|
||||
}
|
||||
// Publish the result before flipping the state flag: `wait()` only
|
||||
@@ -255,6 +263,11 @@ pub struct TicketArena {
|
||||
next: AtomicU64,
|
||||
dispatch: Arc<dyn JobDispatch>,
|
||||
audio_dispatch: Arc<dyn JobDispatch>,
|
||||
/// M15 S3: main-process inline audio fallback, used when the process
|
||||
/// dispatcher is unavailable (shutting down) — design §3.7. Audio
|
||||
/// rendering normally runs in oak-worker; the inline fallback keeps
|
||||
/// playback alive during teardown.
|
||||
audio_fallback: Option<Arc<dyn JobDispatch>>,
|
||||
slots: Mutex<HashMap<TicketId, Arc<TicketSlot>>>,
|
||||
shutting_down: AtomicBool,
|
||||
producer: Producer,
|
||||
@@ -273,11 +286,26 @@ impl TicketArena {
|
||||
video: Arc<dyn JobDispatch>,
|
||||
audio: Arc<dyn JobDispatch>,
|
||||
producer: Producer,
|
||||
) -> Self {
|
||||
Self::new_with_audio_fallback(video, audio, None, producer)
|
||||
}
|
||||
|
||||
/// Arena with separate video/audio backends plus a main-process inline
|
||||
/// audio fallback (M15 S3): when `audio` (the process dispatcher)
|
||||
/// refuses a job, it is re-posted to `audio_fallback` (an
|
||||
/// [`crate::worker::InlineDispatcher::sync`]); both gone, the ticket
|
||||
/// cancels.
|
||||
pub fn new_with_audio_fallback(
|
||||
video: Arc<dyn JobDispatch>,
|
||||
audio: Arc<dyn JobDispatch>,
|
||||
audio_fallback: Option<Arc<dyn JobDispatch>>,
|
||||
producer: Producer,
|
||||
) -> Self {
|
||||
Self {
|
||||
next: AtomicU64::new(1),
|
||||
dispatch: video,
|
||||
audio_dispatch: audio,
|
||||
audio_fallback,
|
||||
slots: Mutex::new(HashMap::new()),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
producer,
|
||||
@@ -358,6 +386,7 @@ impl TicketArena {
|
||||
node_identity: params.viewer,
|
||||
time: params.time,
|
||||
params,
|
||||
audio: None,
|
||||
produce: producer,
|
||||
done: Box::new(move |result| slot_done.finish(result)),
|
||||
schedule,
|
||||
@@ -454,32 +483,49 @@ impl TicketArena {
|
||||
|
||||
// The audio producer captures the montage + output params; the
|
||||
// video-params field of the job is a dummy (unused by the audio
|
||||
// path).
|
||||
// path). M15 S3: `audio` carries the params to the process
|
||||
// dispatcher, which renders the range in oak-worker via
|
||||
// render_audio_batch; the producer is the inline-fallback path.
|
||||
let ap = Arc::new(params);
|
||||
let viewer = ap.viewer;
|
||||
let producer: Producer = Arc::new(move |_, _| eval::render_audio_samples(&ap));
|
||||
let slot_done = slot.clone();
|
||||
let job = crate::worker::Job {
|
||||
node_identity: viewer,
|
||||
time: range.in_(),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer,
|
||||
let make_job = |slot_done: Arc<TicketSlot>| {
|
||||
let ap_job = ap.clone();
|
||||
let ap_prod = ap.clone();
|
||||
let producer: Producer = Arc::new(move |_, _| eval::render_audio_samples(&ap_prod));
|
||||
crate::worker::Job {
|
||||
node_identity: viewer,
|
||||
time: range.in_(),
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
cache: None,
|
||||
cache_dir: None,
|
||||
cache_id: None,
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
}),
|
||||
produce: producer,
|
||||
done: Box::new(move |result| slot_done.finish(result)),
|
||||
schedule: JobSchedule::seek(),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer,
|
||||
time: range.in_(),
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
cache: None,
|
||||
cache_dir: None,
|
||||
cache_id: None,
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
}),
|
||||
audio: Some(ap_job),
|
||||
produce: producer,
|
||||
done: Box::new(move |result| slot_done.finish(result)),
|
||||
schedule: JobSchedule::seek(),
|
||||
}
|
||||
};
|
||||
let job = make_job(slot.clone());
|
||||
if !self.audio_dispatch.post(job) {
|
||||
slot.finish(Err(Error::State));
|
||||
// The process dispatcher is unavailable (shutting down): fall
|
||||
// back to main-process inline audio rendering (design §3.7) so
|
||||
// playback/export audio keeps flowing during teardown.
|
||||
if let Some(fallback) = &self.audio_fallback {
|
||||
let job = make_job(slot.clone());
|
||||
if !fallback.post(job) {
|
||||
slot.finish(Err(Error::State));
|
||||
}
|
||||
} else {
|
||||
slot.finish(Err(Error::State));
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ use oakcore_rs::Rational;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::procpool::ShmFrameRef;
|
||||
use crate::scheduler::FramePriority;
|
||||
use crate::ticket::{Completion, Producer, VideoTicketParams};
|
||||
use crate::ticket::{AudioTicketParams, Completion, Producer, VideoTicketParams};
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
@@ -57,6 +57,11 @@ pub struct Job {
|
||||
pub time: Rational,
|
||||
/// Ticket parameters (size/format overrides).
|
||||
pub params: Arc<VideoTicketParams>,
|
||||
/// Audio ticket parameters when this is an audio range pull (M15 S3):
|
||||
/// the process dispatcher renders it through the worker's
|
||||
/// `render_audio_batch` path into a shm slot; the in-process inline
|
||||
/// fallback executes the producer instead. `None` for video jobs.
|
||||
pub audio: Option<Arc<AudioTicketParams>>,
|
||||
/// Frame producer (arena-installed; the process backend never invokes
|
||||
/// it — workers render from the wire spec).
|
||||
pub produce: Producer,
|
||||
@@ -134,6 +139,10 @@ pub trait JobDispatch: Send + Sync {
|
||||
/// process backend holds slots.
|
||||
fn release_frame(&self, _frame: &ShmFrameRef) {}
|
||||
|
||||
/// Release a consumed shm audio frame's slot (M15 S3). Default no-op:
|
||||
/// only the process backend holds slots.
|
||||
fn release_audio_frame(&self, _frame: &crate::procpool::ShmAudioRef) {}
|
||||
|
||||
/// Cancel every pending AND claimed request of `sequence` (M15 S2
|
||||
/// preview-window invalidation); their completions fire with
|
||||
/// `Error::State`. Default no-op: only the process backend schedules.
|
||||
@@ -372,6 +381,7 @@ mod tests {
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce,
|
||||
done: Box::new(move |r| {
|
||||
assert!(r.is_ok(), "producer must succeed here");
|
||||
@@ -441,6 +451,7 @@ mod tests {
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce: p,
|
||||
done: Box::new(move |r| {
|
||||
let _ = tx.send(r.is_err());
|
||||
@@ -481,6 +492,7 @@ mod tests {
|
||||
node_identity: 0,
|
||||
time: Rational::new(0, 1),
|
||||
params: params.clone(),
|
||||
audio: None,
|
||||
produce: boom,
|
||||
done: Box::new(move |r| {
|
||||
assert!(r.is_err());
|
||||
@@ -492,6 +504,7 @@ mod tests {
|
||||
node_identity: 1,
|
||||
time: Rational::new(1, 1),
|
||||
params,
|
||||
audio: None,
|
||||
produce: ok,
|
||||
done: Box::new(move |r| {
|
||||
assert!(r.is_ok());
|
||||
|
||||
Reference in New Issue
Block a user