Skip to main content

tellur_live/
server.rs

1use std::cmp::Reverse;
2use std::collections::HashMap;
3use std::error::Error;
4use std::io::{Read, Write};
5use std::net::{TcpListener, TcpStream};
6use std::path::PathBuf;
7use std::process::{Command, Stdio};
8use std::sync::{
9    atomic::{AtomicBool, AtomicU64, Ordering},
10    Arc, LazyLock, Mutex,
11};
12use std::thread;
13use std::time::{Duration, Instant};
14
15use lru::LruCache;
16use tellur_core::cache_budget::{cache_ram_capacity, try_reserve_cache_ram, BudgetReservation};
17use tellur_core::raster::{CpuRasterImage, PixelFormat, RasterResidency, Resolution};
18use tellur_core::render_context::{GpuPreference, RenderContext};
19use tellur_core::time::TimelineTime;
20use tellur_core::timeline_component::{Arrangement, AudioBuffer, NodeKind};
21use tellur_renderer::render_context::{CacheMetrics, TypeStats};
22use tellur_renderer::{CachingRenderContext, ColorRange};
23
24use crate::build_watch::{
25    describe_build, run_build_once, start_build_watcher, AutoBuildOptions, CompileSnapshot,
26    CompileState,
27};
28use crate::plugin::HotReloadPlugin;
29use crate::startup_info::{print_startup_banner, StartupBannerInputs};
30use tellur_plugin::TimelineInfo;
31
32/// Live preview re-encodes short MP4 segments repeatedly. Keeping the render
33/// cache below the renderer's export-oriented 1 GiB default avoids memory
34/// pressure and LRU churn making playback look stalled.
35const LIVE_PREVIEW_CACHE_BYTES: usize = 256 * 1024 * 1024;
36const VIDEO_SEGMENT_CACHE_BYTES: usize = 512 * 1024 * 1024;
37const VIDEO_SEGMENT_CACHE_ENTRIES: usize = 128;
38
39static VIDEO_SEGMENT_CACHE: LazyLock<Mutex<VideoSegmentCache>> =
40    LazyLock::new(|| Mutex::new(VideoSegmentCache::default()));
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43struct VideoSegmentCacheKey {
44    plugin_cache_key: String,
45    timeline_id: String,
46    start_seconds_bits: u64,
47    video_seconds_bits: u64,
48    width: u32,
49    height: u32,
50    fps: u32,
51    gop: u32,
52    crf: u8,
53    motion_blur: bool,
54    color_range: ColorRange,
55}
56
57struct VideoSegmentCache {
58    entries: LruCache<VideoSegmentCacheKey, CachedVideoSegment>,
59    bytes: usize,
60}
61
62struct CachedVideoSegment {
63    body: Arc<Vec<u8>>,
64    _reservation: BudgetReservation,
65}
66
67impl Default for VideoSegmentCache {
68    fn default() -> Self {
69        Self {
70            entries: LruCache::unbounded(),
71            bytes: 0,
72        }
73    }
74}
75
76impl VideoSegmentCache {
77    fn get(&mut self, key: &VideoSegmentCacheKey) -> Option<Arc<Vec<u8>>> {
78        self.entries.get(key).map(|entry| Arc::clone(&entry.body))
79    }
80
81    fn insert(&mut self, key: VideoSegmentCacheKey, body: Vec<u8>) {
82        let bytes = body.len();
83        let capacity = cache_ram_capacity(VIDEO_SEGMENT_CACHE_BYTES);
84        if bytes > capacity {
85            return;
86        }
87        while self.bytes + bytes > capacity || self.entries.len() >= VIDEO_SEGMENT_CACHE_ENTRIES {
88            let Some((_, old)) = self.entries.pop_lru() else {
89                break;
90            };
91            self.bytes = self.bytes.saturating_sub(old.body.len());
92        }
93        let Some(reservation) = try_reserve_cache_ram(bytes) else {
94            return;
95        };
96        let entry = CachedVideoSegment {
97            body: Arc::new(body),
98            _reservation: reservation,
99        };
100        if let Some(old) = self.entries.put(key, entry) {
101            self.bytes = self.bytes.saturating_sub(old.body.len());
102        }
103        self.bytes += bytes;
104    }
105
106    fn clear(&mut self) {
107        self.entries.clear();
108        self.bytes = 0;
109    }
110}
111
112fn cached_video_segment(key: &VideoSegmentCacheKey) -> Option<Arc<Vec<u8>>> {
113    VIDEO_SEGMENT_CACHE.lock().ok()?.get(key)
114}
115
116fn cache_video_segment(key: VideoSegmentCacheKey, body: Vec<u8>) {
117    if let Ok(mut cache) = VIDEO_SEGMENT_CACHE.lock() {
118        cache.insert(key, body);
119    }
120}
121
122fn clear_video_segment_cache() {
123    if let Ok(mut cache) = VIDEO_SEGMENT_CACHE.lock() {
124        cache.clear();
125    }
126}
127
128#[derive(Debug, Clone)]
129pub struct ServerOptions {
130    pub plugin_path: PathBuf,
131    pub project_name: String,
132    pub bind: String,
133    pub resolution: Resolution,
134    pub fps: u32,
135    pub color_range: ColorRange,
136    pub gpu_preference: GpuPreference,
137    pub verbose: bool,
138    pub auto_build: Option<AutoBuildOptions>,
139    pub started_at: Instant,
140}
141
142impl ServerOptions {
143    pub fn with_started_at(mut self, started_at: Instant) -> Self {
144        self.started_at = started_at;
145        self
146    }
147}
148
149pub fn serve(options: ServerOptions) -> Result<(), Box<dyn Error>> {
150    let listener = TcpListener::bind(&options.bind)?;
151    let local_addr = listener.local_addr()?;
152    if let Some(auto_build) = &options.auto_build {
153        eprintln!("auto build: {}", describe_build(auto_build));
154        eprintln!("running initial build");
155        run_build_once(auto_build).map_err(|e| -> Box<dyn Error> { e.into() })?;
156    }
157
158    let prewarm_gpu = options.gpu_preference.prefers_gpu();
159    let compile_state = options
160        .auto_build
161        .clone()
162        .map(start_build_watcher)
163        .unwrap_or_else(CompileState::compiled);
164
165    let plugin_path = options.plugin_path.clone();
166    let auto_build = options.auto_build.as_ref();
167
168    let app = Arc::new(Mutex::new(PreviewApp {
169        plugin: HotReloadPlugin::new(options.plugin_path),
170        project_name: options.project_name,
171        ctx: CachingRenderContext::with_capacity_bytes(LIVE_PREVIEW_CACHE_BYTES)
172            .with_gpu_preference(options.gpu_preference),
173        resolution: options.resolution,
174        fps: options.fps,
175        color_range: options.color_range,
176        verbose: options.verbose,
177        compile_state,
178    }));
179    {
180        let mut app = app
181            .lock()
182            .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
183        app.reload_plugin_if_changed()?;
184    }
185    print_startup_banner(StartupBannerInputs {
186        listen_addr: local_addr,
187        plugin_path: &plugin_path,
188        gpu_preference: options.gpu_preference,
189        auto_build,
190        started_at: options.started_at,
191    });
192    if prewarm_gpu {
193        start_preview_prewarm(Arc::clone(&app));
194    }
195
196    let video_epochs: Arc<Mutex<HashMap<String, Arc<AtomicU64>>>> =
197        Arc::new(Mutex::new(HashMap::new()));
198    for stream in listener.incoming() {
199        match stream {
200            Ok(stream) => {
201                let app = Arc::clone(&app);
202                let video_epochs = Arc::clone(&video_epochs);
203                thread::spawn(move || {
204                    if let Err(e) = handle_connection(app, video_epochs, stream) {
205                        if !is_client_disconnect(e.as_ref()) {
206                            eprintln!("request failed: {e}");
207                        }
208                    }
209                });
210            }
211            Err(e) => eprintln!("accept failed: {e}"),
212        }
213    }
214    Ok(())
215}
216
217fn start_preview_prewarm(app: Arc<Mutex<PreviewApp>>) {
218    thread::spawn(move || {
219        let prewarm_start = Instant::now();
220        match preview_prewarm(&app) {
221            Ok(Some((timeline_id, audio_time, render_time, build_time, readback_time, true))) => {
222                println!(
223                    "preview-prewarm timeline={} audio={:.2}ms render={:.2}ms build={:.2}ms readback={:.2}ms total={:.2}ms",
224                    timeline_id,
225                    ms(audio_time),
226                    ms(render_time),
227                    ms(build_time),
228                    ms(readback_time),
229                    ms(prewarm_start.elapsed()),
230                );
231            }
232            Ok(_) => {}
233            Err(e) => eprintln!("preview prewarm failed: {e}"),
234        }
235    });
236}
237
238type PreviewPrewarmStats = (String, Duration, Duration, Duration, Duration, bool);
239
240fn preview_prewarm(
241    app: &Arc<Mutex<PreviewApp>>,
242) -> Result<Option<PreviewPrewarmStats>, Box<dyn Error>> {
243    let mut app = app
244        .lock()
245        .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
246    app.reload_plugin_if_changed()?;
247    let Some(info) = app
248        .plugin
249        .collection()?
250        .timelines()
251        .into_iter()
252        .find(|info| info.error.is_none() && info.duration > 0.0)
253    else {
254        return Ok(None);
255    };
256    let verbose = app.verbose;
257    let resolution = app.resolution;
258    let audio_start = Instant::now();
259    let _ = app.plugin.collection()?.render_audio_window(
260        &info.id,
261        0.0,
262        info.duration.min(1.0),
263        AUDIO_RATE,
264        AUDIO_CHANNELS,
265    );
266    let audio_time = audio_start.elapsed();
267    let frame = app.render_video_rgba(&info.id, 0.0, resolution, false, false)?;
268    Ok(Some((
269        info.id,
270        audio_time,
271        frame.render_time,
272        frame.build_time,
273        frame.readback_time,
274        verbose,
275    )))
276}
277
278fn handle_connection(
279    app: Arc<Mutex<PreviewApp>>,
280    video_epochs: Arc<Mutex<HashMap<String, Arc<AtomicU64>>>>,
281    mut stream: TcpStream,
282) -> Result<(), Box<dyn Error>> {
283    let request = match read_request(&mut stream)? {
284        Some(request) => request,
285        None => return Ok(()),
286    };
287
288    if request.method != "GET" {
289        return write_response(
290            &mut stream,
291            405,
292            "Method Not Allowed",
293            "text/plain; charset=utf-8",
294            b"method not allowed",
295        );
296    }
297
298    let path = request.path.clone();
299    match path.as_str() {
300        "/api/video.mp4" | "/api/video" => {
301            handle_video_stream(app, video_epochs, stream, request.query)
302        }
303        "/api/events" => handle_event_stream(app, stream),
304        "/api/info" | "/api/frame" | "/api/stream" | "/api/arrangement" => {
305            let mut app = app
306                .lock()
307                .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
308            app.handle_api(stream, request)
309        }
310        other => serve_static(&mut stream, other),
311    }
312}
313
314fn handle_event_stream(
315    app: Arc<Mutex<PreviewApp>>,
316    mut stream: TcpStream,
317) -> Result<(), Box<dyn Error>> {
318    write!(
319        stream,
320        "HTTP/1.1 200 OK\r\n\
321         Content-Type: text/event-stream; charset=utf-8\r\n\
322         Cache-Control: no-store\r\n\
323         Connection: close\r\n\r\n"
324    )?;
325
326    let mut last_body = String::new();
327    loop {
328        let body = {
329            let mut app = app
330                .lock()
331                .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
332            app.info_body()?
333        };
334        if body != last_body {
335            write!(stream, "event: info\ndata: {body}\n\n")?;
336            stream.flush()?;
337            last_body = body;
338        }
339        thread::sleep(Duration::from_millis(250));
340    }
341}
342
343fn serve_static(stream: &mut TcpStream, path: &str) -> Result<(), Box<dyn Error>> {
344    let asset = match path {
345        "/" | "/index.html" => Some(StaticAsset {
346            body: WEB_INDEX_HTML,
347            mime: "text/html; charset=utf-8",
348        }),
349        "/assets/index.js" => Some(StaticAsset {
350            body: WEB_INDEX_JS,
351            mime: "application/javascript; charset=utf-8",
352        }),
353        "/assets/index.css" => Some(StaticAsset {
354            body: WEB_INDEX_CSS,
355            mime: "text/css; charset=utf-8",
356        }),
357        _ => None,
358    };
359    match asset {
360        Some(asset) => write_response(stream, 200, "OK", asset.mime, asset.body),
361        None => write_response(
362            stream,
363            404,
364            "Not Found",
365            "text/plain; charset=utf-8",
366            b"not found",
367        ),
368    }
369}
370
371struct StaticAsset {
372    body: &'static [u8],
373    mime: &'static str,
374}
375
376const WEB_INDEX_HTML: &[u8] = include_bytes!("../web/dist/index.html");
377const WEB_INDEX_JS: &[u8] = include_bytes!("../web/dist/assets/index.js");
378const WEB_INDEX_CSS: &[u8] = include_bytes!("../web/dist/assets/index.css");
379
380fn is_client_disconnect(error: &(dyn Error + 'static)) -> bool {
381    let mut current = Some(error);
382    while let Some(error) = current {
383        if let Some(io) = error.downcast_ref::<std::io::Error>() {
384            return matches!(
385                io.kind(),
386                std::io::ErrorKind::BrokenPipe
387                    | std::io::ErrorKind::ConnectionReset
388                    | std::io::ErrorKind::ConnectionAborted
389            );
390        }
391        current = error.source();
392    }
393    false
394}
395
396struct PreviewApp {
397    plugin: HotReloadPlugin,
398    project_name: String,
399    ctx: CachingRenderContext,
400    resolution: Resolution,
401    fps: u32,
402    color_range: ColorRange,
403    verbose: bool,
404    compile_state: CompileState,
405}
406
407impl PreviewApp {
408    fn reload_plugin_if_changed(&mut self) -> Result<bool, Box<dyn Error>> {
409        let changed = self.plugin.reload_if_changed()?;
410        if changed {
411            self.ctx.clear();
412            self.ctx.clear_metrics();
413            clear_video_segment_cache();
414        }
415        Ok(changed)
416    }
417
418    fn is_media_cacheable(&self, query: &HashMap<String, String>) -> bool {
419        matches!(
420            (query.get("v").map(String::as_str), self.plugin.cache_key()),
421            (Some(requested), Some(current)) if requested == current
422        )
423    }
424
425    fn media_cache_control(&self, query: &HashMap<String, String>) -> &'static str {
426        if self.is_media_cacheable(query) {
427            "public, max-age=31536000, immutable"
428        } else {
429            "no-store"
430        }
431    }
432
433    fn handle_api(&mut self, stream: TcpStream, request: Request) -> Result<(), Box<dyn Error>> {
434        match request.path.as_str() {
435            "/api/info" => self.handle_info(stream),
436            "/api/frame" => self.handle_frame(stream, &request.query),
437            "/api/stream" => self.handle_stream(stream, &request.query),
438            "/api/arrangement" => self.handle_arrangement(stream, &request.query),
439            _ => unreachable!("non-api routes are handled before acquiring the preview lock"),
440        }
441    }
442
443    fn handle_info(&mut self, mut stream: TcpStream) -> Result<(), Box<dyn Error>> {
444        let body = self.info_body()?;
445        write_response(
446            &mut stream,
447            200,
448            "OK",
449            "application/json; charset=utf-8",
450            body.as_bytes(),
451        )
452    }
453
454    fn info_body(&mut self) -> Result<String, Box<dyn Error>> {
455        self.reload_plugin_if_changed()?;
456        let timelines = self.plugin.collection()?.timelines();
457        let compile = self.compile_state.snapshot();
458        Ok(info_json(
459            &self.project_name,
460            self.resolution,
461            self.fps,
462            &timelines,
463            self.plugin.last_error(),
464            self.plugin.cache_key().unwrap_or(""),
465            &compile,
466        ))
467    }
468
469    fn handle_arrangement(
470        &mut self,
471        mut stream: TcpStream,
472        query: &HashMap<String, String>,
473    ) -> Result<(), Box<dyn Error>> {
474        self.reload_plugin_if_changed()?;
475        let collection = self.plugin.collection()?;
476        let timelines = collection.timelines();
477        let Some(info) = select_timeline(&timelines, query.get("timeline")) else {
478            return write_response(
479                &mut stream,
480                404,
481                "Not Found",
482                "application/json; charset=utf-8",
483                b"null",
484            );
485        };
486        let body = match collection.arrangement(&info.id) {
487            Some(arrangement) => arrangement_json(&arrangement),
488            // The collection has not resolved a tree for this id (a failed
489            // resolve, or a not-yet-migrated collection): emit `null` so the UI
490            // can fall back to its flat view.
491            None => "null".to_owned(),
492        };
493        write_response(
494            &mut stream,
495            200,
496            "OK",
497            "application/json; charset=utf-8",
498            body.as_bytes(),
499        )
500    }
501
502    fn handle_frame(
503        &mut self,
504        mut stream: TcpStream,
505        query: &HashMap<String, String>,
506    ) -> Result<(), Box<dyn Error>> {
507        match FrameFormat::from_query(query) {
508            FrameFormat::Png => {
509                let rendered = self.render_png(query)?;
510                if self.verbose {
511                    log_frame_stats(&rendered.stats);
512                }
513                let headers = rendered.stats.headers();
514                write_response_with_headers_and_cache_control(
515                    &mut stream,
516                    200,
517                    "OK",
518                    "image/png",
519                    &headers,
520                    &rendered.body,
521                    self.media_cache_control(query),
522                )
523            }
524            FrameFormat::Rgba => {
525                let rendered = self.render_rgba(query)?;
526                if self.verbose {
527                    log_frame_stats(&rendered.stats);
528                }
529                let headers = rendered.stats.headers();
530                write_response_with_headers_and_cache_control(
531                    &mut stream,
532                    200,
533                    "OK",
534                    "application/vnd.tellur.rgba",
535                    &headers,
536                    &rendered.body,
537                    self.media_cache_control(query),
538                )
539            }
540        }
541    }
542
543    fn handle_stream(
544        &mut self,
545        stream: TcpStream,
546        query: &HashMap<String, String>,
547    ) -> Result<(), Box<dyn Error>> {
548        match FrameFormat::from_query(query) {
549            FrameFormat::Png => self.handle_png_stream(stream, query),
550            FrameFormat::Rgba => self.handle_rgba_stream(stream, query),
551        }
552    }
553
554    fn handle_png_stream(
555        &mut self,
556        mut stream: TcpStream,
557        query: &HashMap<String, String>,
558    ) -> Result<(), Box<dyn Error>> {
559        let fps = request_fps(query, self.fps.max(1));
560        let resolution = request_resolution(query, self.resolution);
561        let timeline_id = query.get("timeline").cloned();
562        let mut seconds = query
563            .get("time")
564            .and_then(|v| v.parse::<f64>().ok())
565            .unwrap_or(0.0);
566
567        write!(
568            stream,
569            "HTTP/1.1 200 OK\r\n\
570             Content-Type: multipart/x-mixed-replace; boundary=tellur-frame\r\n\
571             Cache-Control: no-store\r\n\
572             Connection: close\r\n\r\n"
573        )?;
574
575        let frame_step = 1.0 / fps as f64;
576        let frame_duration = Duration::from_secs_f64(frame_step);
577        loop {
578            let frame_start = Instant::now();
579            let mut q = HashMap::new();
580            q.insert("time".to_owned(), seconds.to_string());
581            q.insert("width".to_owned(), resolution.width.to_string());
582            q.insert("height".to_owned(), resolution.height.to_string());
583            if let Some(motion_blur) = query.get("motion_blur") {
584                q.insert("motion_blur".to_owned(), motion_blur.clone());
585            }
586            if let Some(id) = &timeline_id {
587                q.insert("timeline".to_owned(), id.clone());
588            }
589            let rendered = self.render_png(&q)?;
590            if self.verbose {
591                log_frame_stats(&rendered.stats);
592            }
593            write!(
594                stream,
595                "--tellur-frame\r\n\
596                 Content-Type: image/png\r\n\
597                 Content-Length: {}\r\n\r\n",
598                rendered.body.len()
599            )?;
600            stream.write_all(&rendered.body)?;
601            stream.write_all(b"\r\n")?;
602            stream.flush()?;
603            seconds += frame_step;
604            sleep_remainder(frame_duration, frame_start.elapsed());
605        }
606    }
607
608    fn handle_rgba_stream(
609        &mut self,
610        mut stream: TcpStream,
611        query: &HashMap<String, String>,
612    ) -> Result<(), Box<dyn Error>> {
613        let fps = request_fps(query, self.fps.max(1));
614        let resolution = request_resolution(query, self.resolution);
615        let timeline_id = query.get("timeline").cloned();
616        let mut seconds = query
617            .get("time")
618            .and_then(|v| v.parse::<f64>().ok())
619            .unwrap_or(0.0);
620        let frame_bytes = (resolution.width as usize) * (resolution.height as usize) * 4;
621
622        write!(
623            stream,
624            "HTTP/1.1 200 OK\r\n\
625             Content-Type: application/vnd.tellur.rgba-stream\r\n\
626             X-Tellur-Width: {}\r\n\
627             X-Tellur-Height: {}\r\n\
628             X-Tellur-Fps: {}\r\n\
629             X-Tellur-Frame-Bytes: {}\r\n\
630             Cache-Control: no-store\r\n\
631             Connection: close\r\n\r\n",
632            resolution.width, resolution.height, fps, frame_bytes,
633        )?;
634
635        let frame_step = 1.0 / fps as f64;
636        let frame_duration = Duration::from_secs_f64(frame_step);
637        loop {
638            let frame_start = Instant::now();
639            let mut q = HashMap::new();
640            q.insert("time".to_owned(), seconds.to_string());
641            q.insert("format".to_owned(), "rgba".to_owned());
642            q.insert("width".to_owned(), resolution.width.to_string());
643            q.insert("height".to_owned(), resolution.height.to_string());
644            if let Some(motion_blur) = query.get("motion_blur") {
645                q.insert("motion_blur".to_owned(), motion_blur.clone());
646            }
647            if let Some(id) = &timeline_id {
648                q.insert("timeline".to_owned(), id.clone());
649            }
650            let rendered = self.render_rgba(&q)?;
651            if self.verbose {
652                log_frame_stats(&rendered.stats);
653            }
654            stream.write_all(&rendered.body)?;
655            stream.flush()?;
656            seconds += frame_step;
657            sleep_remainder(frame_duration, frame_start.elapsed());
658        }
659    }
660
661    fn render_video_rgba(
662        &mut self,
663        timeline_id: &str,
664        seconds: f64,
665        resolution: Resolution,
666        motion_blur: bool,
667        collect_stats: bool,
668    ) -> Result<VideoFrame, Box<dyn Error>> {
669        self.ctx.set_motion_blur_enabled(motion_blur);
670        let before = collect_stats.then(|| self.ctx.metrics());
671        let render_start = Instant::now();
672        let build_start = Instant::now();
673        // The encoder consumes CPU pixels. The explicit readback below also
674        // normalizes custom collections that do not honor that request.
675        let image = self
676            .plugin
677            .collection()?
678            .build(
679                timeline_id,
680                TimelineTime::new(seconds),
681                resolution,
682                RasterResidency::Cpu,
683                &mut self.ctx,
684            )
685            .ok_or("timeline did not produce a frame")?;
686        let build_time = build_start.elapsed();
687        let readback_start = Instant::now();
688        let image = self.ctx.readback(image);
689        let readback_time = readback_start.elapsed();
690        let render_time = render_start.elapsed();
691        if image.format != PixelFormat::Rgba8 {
692            return Err(format!("h264 stream requires Rgba8, got {:?}", image.format).into());
693        }
694        let stats = before.map(|before| {
695            let after = self.ctx.metrics();
696            let gpu_init_error = self.ctx.gpu_init_error().map(str::to_owned);
697            (
698                after.hits.saturating_sub(before.hits),
699                after.misses.saturating_sub(before.misses),
700                after.bytes_cached,
701                after.gpu_available,
702                after.gpu_init_attempted,
703                gpu_init_error,
704                format!("{:?}", after.gpu_preference),
705                after.gpu.total_ops().saturating_sub(before.gpu.total_ops()),
706                after.gpu.readbacks.saturating_sub(before.gpu.readbacks),
707                after
708                    .gpu
709                    .vram_reserve_failures
710                    .saturating_sub(before.gpu.vram_reserve_failures),
711                after.gpu_cache_bytes,
712                after.gpu_cache_cap_bytes,
713                after.vram_used_bytes,
714                after.vram_budget_bytes,
715            )
716        });
717        let (
718            cache_hits,
719            cache_misses,
720            bytes_cached,
721            gpu_available,
722            gpu_init_attempted,
723            gpu_init_error,
724            gpu_preference,
725            gpu_ops,
726            gpu_readbacks,
727            gpu_vram_failures,
728            gpu_cache_bytes,
729            gpu_cache_cap_bytes,
730            vram_used_bytes,
731            vram_budget_bytes,
732        ) = stats.unwrap_or_else(|| {
733            (
734                0,
735                0,
736                0,
737                false,
738                false,
739                None,
740                String::new(),
741                0,
742                0,
743                0,
744                0,
745                0,
746                0,
747                0,
748            )
749        });
750
751        Ok(VideoFrame {
752            image,
753            render_time,
754            build_time,
755            readback_time,
756            cache_hits,
757            cache_misses,
758            bytes_cached,
759            gpu_available,
760            gpu_init_attempted,
761            gpu_init_error,
762            gpu_preference,
763            gpu_ops,
764            gpu_readbacks,
765            gpu_vram_failures,
766            gpu_cache_bytes,
767            gpu_cache_cap_bytes,
768            vram_used_bytes,
769            vram_budget_bytes,
770        })
771    }
772
773    fn render_png(
774        &mut self,
775        query: &HashMap<String, String>,
776    ) -> Result<RenderedFrame, Box<dyn Error>> {
777        let mut rendered = self.render_image(query)?;
778        if request_video_color(query) {
779            rendered.image = video_color_preview_image(
780                &rendered.image,
781                request_color_range(query, self.color_range),
782            )?;
783        }
784
785        let encode_start = Instant::now();
786        let mut body = Vec::new();
787        export_preview_png(&rendered.image, &mut body)?;
788        let encode_time = encode_start.elapsed();
789
790        let mut stats = rendered.stats;
791        stats.output_format = FrameFormat::Png;
792        stats.encode_time = encode_time;
793        stats.total_time = rendered.total_start.elapsed();
794        stats.output_bytes = body.len();
795
796        Ok(RenderedFrame { body, stats })
797    }
798
799    fn render_rgba(
800        &mut self,
801        query: &HashMap<String, String>,
802    ) -> Result<RenderedFrame, Box<dyn Error>> {
803        let rendered = self.render_image(query)?;
804        if rendered.image.format != PixelFormat::Rgba8 {
805            return Err(format!(
806                "raw rgba output requires Rgba8, got {:?}",
807                rendered.image.format
808            )
809            .into());
810        }
811
812        let encode_start = Instant::now();
813        let body = rendered.image.pixels.as_ref().to_vec();
814        let encode_time = encode_start.elapsed();
815        let mut stats = rendered.stats;
816        stats.output_format = FrameFormat::Rgba;
817        stats.encode_time = encode_time;
818        stats.total_time = rendered.total_start.elapsed();
819        stats.output_bytes = body.len();
820
821        Ok(RenderedFrame { body, stats })
822    }
823
824    fn render_image(
825        &mut self,
826        query: &HashMap<String, String>,
827    ) -> Result<RenderedImage, Box<dyn Error>> {
828        let total_start = Instant::now();
829        self.reload_plugin_if_changed()?;
830        let timelines = self.plugin.collection()?.timelines();
831        let Some(info) = select_timeline(&timelines, query.get("timeline")) else {
832            return Err("timeline not found".into());
833        };
834        let fps = request_fps(query, self.fps.max(1));
835        let seconds = query
836            .get("frame")
837            .and_then(|v| v.parse::<u64>().ok())
838            .map(|frame| frame as f64 / fps as f64)
839            .or_else(|| query.get("time").and_then(|v| v.parse::<f64>().ok()))
840            .unwrap_or(0.0);
841        // Clamp into the half-open renderable range so a `time=<duration>`
842        // request (a frontend scrubbed to the very end) returns the last frame
843        // instead of erroring with `timeline did not produce a frame`.
844        let seconds = clamp_to_renderable(seconds, info.duration, fps);
845        let resolution = request_resolution(query, self.resolution);
846        self.ctx.set_motion_blur_enabled(request_motion_blur(query));
847
848        let before = self.ctx.metrics();
849        let render_start = Instant::now();
850        // PNG/raw responses are CPU consumers. The readback below remains a
851        // safety boundary for custom collections that do not honor the request.
852        let image = self
853            .plugin
854            .collection()?
855            .build(
856                &info.id,
857                TimelineTime::new(seconds),
858                resolution,
859                RasterResidency::Cpu,
860                &mut self.ctx,
861            )
862            .ok_or("timeline did not produce a frame")?;
863        let image = self.ctx.readback(image);
864        let render_time = render_start.elapsed();
865        let after = self.ctx.metrics();
866        let gpu_init_error = self.ctx.gpu_init_error().map(str::to_owned);
867
868        Ok(RenderedImage {
869            image,
870            stats: FrameRenderStats {
871                timeline_id: info.id.clone(),
872                seconds,
873                resolution,
874                render_time,
875                encode_time: Duration::ZERO,
876                total_time: render_time,
877                output_format: FrameFormat::Rgba,
878                output_bytes: 0,
879                cache_hits: after.hits.saturating_sub(before.hits),
880                cache_misses: after.misses.saturating_sub(before.misses),
881                bytes_cached: after.bytes_cached,
882                gpu_available: after.gpu_available,
883                gpu_init_attempted: after.gpu_init_attempted,
884                gpu_init_error,
885                gpu_preference: format!("{:?}", after.gpu_preference),
886                gpu_ops: after.gpu.total_ops().saturating_sub(before.gpu.total_ops()),
887                gpu_readbacks: after.gpu.readbacks.saturating_sub(before.gpu.readbacks),
888                gpu_vram_failures: after
889                    .gpu
890                    .vram_reserve_failures
891                    .saturating_sub(before.gpu.vram_reserve_failures),
892                gpu_cache_bytes: after.gpu_cache_bytes,
893                gpu_cache_cap_bytes: after.gpu_cache_cap_bytes,
894                vram_used_bytes: after.vram_used_bytes,
895                vram_budget_bytes: after.vram_budget_bytes,
896            },
897            total_start,
898        })
899    }
900}
901
902struct VideoStreamSetup {
903    timeline_id: String,
904    duration: f64,
905    /// The full timeline length, used to clamp each frame's requested time into
906    /// the half-open renderable range (`< total_duration`).
907    total_duration: f64,
908    fps: u32,
909    resolution: Resolution,
910    gop: u32,
911    crf: u8,
912    motion_blur: bool,
913    color_range: ColorRange,
914    start_seconds: f64,
915    cache_control: &'static str,
916    realtime: bool,
917    verbose: bool,
918}
919
920fn video_segment_cache_key(
921    setup: &VideoStreamSetup,
922    plugin_cache_key: &str,
923    video_seconds: f64,
924) -> VideoSegmentCacheKey {
925    VideoSegmentCacheKey {
926        plugin_cache_key: plugin_cache_key.to_owned(),
927        timeline_id: setup.timeline_id.clone(),
928        start_seconds_bits: setup.start_seconds.to_bits(),
929        video_seconds_bits: video_seconds.to_bits(),
930        width: setup.resolution.width,
931        height: setup.resolution.height,
932        fps: setup.fps,
933        gop: setup.gop,
934        crf: setup.crf,
935        motion_blur: setup.motion_blur,
936        color_range: setup.color_range,
937    }
938}
939
940fn write_video_stream_headers(
941    stream: &mut TcpStream,
942    setup: &VideoStreamSetup,
943) -> std::io::Result<()> {
944    write!(
945        stream,
946        "HTTP/1.1 200 OK\r\n\
947         Content-Type: video/mp4\r\n\
948         X-Tellur-Width: {}\r\n\
949         X-Tellur-Height: {}\r\n\
950         X-Tellur-Fps: {}\r\n\
951         X-Tellur-Gop: {}\r\n\
952         X-Tellur-Color-Range: {}\r\n\
953         Cache-Control: {}\r\n\
954         Connection: close\r\n\r\n",
955        setup.resolution.width,
956        setup.resolution.height,
957        setup.fps,
958        setup.gop,
959        setup.color_range.as_str(),
960        setup.cache_control,
961    )
962}
963
964struct VideoFrame {
965    image: CpuRasterImage,
966    render_time: Duration,
967    build_time: Duration,
968    readback_time: Duration,
969    cache_hits: u64,
970    cache_misses: u64,
971    bytes_cached: usize,
972    gpu_available: bool,
973    gpu_init_attempted: bool,
974    gpu_init_error: Option<String>,
975    gpu_preference: String,
976    gpu_ops: u64,
977    gpu_readbacks: u64,
978    gpu_vram_failures: u64,
979    gpu_cache_bytes: usize,
980    gpu_cache_cap_bytes: usize,
981    vram_used_bytes: usize,
982    vram_budget_bytes: usize,
983}
984
985/// The fixed audio layout the preview mux uses (matches the encoder boundary).
986const AUDIO_RATE: u32 = 48_000;
987const AUDIO_CHANNELS: u16 = 2;
988
989static AUDIO_TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
990
991/// A temp file removed on drop, so a stream's staged audio WAV is cleaned up
992/// whenever `handle_video_stream` returns (including early `?` returns).
993struct TempFile(PathBuf);
994
995impl Drop for TempFile {
996    fn drop(&mut self) {
997        let _ = std::fs::remove_file(&self.0);
998    }
999}
1000
1001/// Writes `buf` (interleaved f32) as a 32-bit IEEE float WAV to a unique temp
1002/// path, so ffmpeg can take it as a second input and mux it into the preview
1003/// stream.
1004fn write_temp_wav(buf: &AudioBuffer) -> std::io::Result<PathBuf> {
1005    let seq = AUDIO_TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1006    let mut path = std::env::temp_dir();
1007    path.push(format!(
1008        "tellur_live_audio_{}_{}.wav",
1009        std::process::id(),
1010        seq
1011    ));
1012
1013    let channels = buf.channels.max(1);
1014    let rate = buf.rate.max(1);
1015    let bits: u16 = 32;
1016    let bytes_per_sample = (bits as u32 / 8) as usize;
1017    let byte_rate = rate * channels as u32 * bytes_per_sample as u32;
1018    let block_align = channels * bytes_per_sample as u16;
1019    let data_bytes = (buf.samples.len() * bytes_per_sample) as u32;
1020
1021    let mut bytes = Vec::with_capacity(44 + buf.samples.len() * bytes_per_sample);
1022    bytes.extend_from_slice(b"RIFF");
1023    bytes.extend_from_slice(&(36 + data_bytes).to_le_bytes());
1024    bytes.extend_from_slice(b"WAVE");
1025    bytes.extend_from_slice(b"fmt ");
1026    bytes.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
1027    bytes.extend_from_slice(&3u16.to_le_bytes()); // audio format = IEEE float
1028    bytes.extend_from_slice(&channels.to_le_bytes());
1029    bytes.extend_from_slice(&rate.to_le_bytes());
1030    bytes.extend_from_slice(&byte_rate.to_le_bytes());
1031    bytes.extend_from_slice(&block_align.to_le_bytes());
1032    bytes.extend_from_slice(&bits.to_le_bytes());
1033    bytes.extend_from_slice(b"data");
1034    bytes.extend_from_slice(&data_bytes.to_le_bytes());
1035    for &s in &buf.samples {
1036        bytes.extend_from_slice(&s.to_le_bytes());
1037    }
1038    std::fs::write(&path, &bytes)?;
1039    Ok(path)
1040}
1041
1042fn handle_video_stream(
1043    app: Arc<Mutex<PreviewApp>>,
1044    video_epochs: Arc<Mutex<HashMap<String, Arc<AtomicU64>>>>,
1045    mut stream: TcpStream,
1046    query: HashMap<String, String>,
1047) -> Result<(), Box<dyn Error>> {
1048    let stream_start = Instant::now();
1049    let video_epoch = {
1050        let session = query
1051            .get("session")
1052            .cloned()
1053            .unwrap_or_else(|| "default".to_owned());
1054        let mut epochs = video_epochs
1055            .lock()
1056            .map_err(|_| -> Box<dyn Error> { "video epoch lock poisoned".into() })?;
1057        Arc::clone(
1058            epochs
1059                .entry(session)
1060                .or_insert_with(|| Arc::new(AtomicU64::new(0))),
1061        )
1062    };
1063    let stream_epoch = video_epoch.fetch_add(1, Ordering::AcqRel).wrapping_add(1);
1064    let setup_start = Instant::now();
1065    let setup = {
1066        let mut app = app
1067            .lock()
1068            .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
1069        app.reload_plugin_if_changed()?;
1070        let timelines = app.plugin.collection()?.timelines();
1071        let Some(info) = select_timeline(&timelines, query.get("timeline")) else {
1072            return Err("timeline not found".into());
1073        };
1074
1075        let fps = request_fps(&query, app.fps.max(1));
1076        let resolution = request_resolution(&query, app.resolution);
1077        let gop = query
1078            .get("gop")
1079            .and_then(|v| v.parse::<u32>().ok())
1080            .filter(|gop| *gop > 0)
1081            .unwrap_or((fps / 4).max(1));
1082        let crf = query
1083            .get("crf")
1084            .and_then(|v| v.parse::<u8>().ok())
1085            .unwrap_or(23);
1086        let start_seconds = query
1087            .get("time")
1088            .and_then(|v| v.parse::<f64>().ok())
1089            .unwrap_or(0.0)
1090            .clamp(0.0, info.duration.max(0.0));
1091        let remaining = (info.duration - start_seconds).max(0.0);
1092        let duration = query
1093            .get("duration")
1094            .and_then(|v| v.parse::<f64>().ok())
1095            .filter(|v| v.is_finite() && *v > 0.0)
1096            .map(|v| v.min(remaining))
1097            .unwrap_or(remaining);
1098
1099        let cacheable = app.is_media_cacheable(&query);
1100        VideoStreamSetup {
1101            timeline_id: info.id.clone(),
1102            duration,
1103            total_duration: info.duration.max(0.0),
1104            fps,
1105            resolution,
1106            gop,
1107            crf,
1108            motion_blur: request_motion_blur(&query),
1109            color_range: request_color_range(&query, app.color_range),
1110            start_seconds,
1111            cache_control: if cacheable {
1112                "public, max-age=31536000, immutable"
1113            } else {
1114                "no-store"
1115            },
1116            realtime: !cacheable,
1117            verbose: app.verbose,
1118        }
1119    };
1120    let setup_time = setup_start.elapsed();
1121    if video_epoch.load(Ordering::Acquire) != stream_epoch {
1122        return Ok(());
1123    }
1124
1125    // The frame-quantized video length, used to BOUND the output below with `-t`
1126    // (instead of `-shortest`; see that arg). This is also the loop's frame count.
1127    let total_frames = (setup.duration * setup.fps as f64).ceil().max(0.0) as u64;
1128    let video_seconds = total_frames as f64 / setup.fps as f64;
1129    let segment_cache_key = if setup.cache_control != "no-store" {
1130        query
1131            .get("v")
1132            .map(|cache_key| video_segment_cache_key(&setup, cache_key, video_seconds))
1133    } else {
1134        None
1135    };
1136    if let Some(key) = &segment_cache_key {
1137        if let Some(body) = cached_video_segment(key) {
1138            write_video_stream_headers(&mut stream, &setup)?;
1139            stream.write_all(&body)?;
1140            stream.flush()?;
1141            if setup.verbose {
1142                println!(
1143                    "video-stream-cache timeline={} start={:.3}s duration={:.3}s bytes={} total={:.2}ms",
1144                    setup.timeline_id,
1145                    setup.start_seconds,
1146                    video_seconds,
1147                    body.len(),
1148                    ms(stream_start.elapsed()),
1149                );
1150            }
1151            return Ok(());
1152        }
1153    }
1154
1155    // Render only this stream's audio window and stage it as a temp WAV. Full
1156    // timeline audio can be huge, and live preview requests many short cache
1157    // segments, so every segment must mix only `[start, start + video_seconds)`.
1158    // `None` only for custom collections that do not expose audio, where the
1159    // stream falls back to a generated silent track. The guard removes the file
1160    // when this function returns.
1161    let audio_start = Instant::now();
1162    let audio_wav = {
1163        let mut app = app
1164            .lock()
1165            .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
1166        app.reload_plugin_if_changed()?;
1167        app.plugin
1168            .collection()
1169            .ok()
1170            .and_then(|c| {
1171                c.render_audio_window(
1172                    &setup.timeline_id,
1173                    setup.start_seconds,
1174                    video_seconds,
1175                    AUDIO_RATE,
1176                    AUDIO_CHANNELS,
1177                )
1178            })
1179            .and_then(|buf| write_temp_wav(&buf).ok())
1180            .map(TempFile)
1181    };
1182    let audio_time = audio_start.elapsed();
1183    let audio_source = if audio_wav.is_some() {
1184        "window_wav"
1185    } else {
1186        "anullsrc"
1187    };
1188    if video_epoch.load(Ordering::Acquire) != stream_epoch {
1189        return Ok(());
1190    }
1191
1192    write_video_stream_headers(&mut stream, &setup)?;
1193
1194    // FLAC block size aligned to ONE video frame's worth of audio samples, applied
1195    // to the encoder below (only when the sample rate divides evenly by fps — the
1196    // common 24/25/30/48/50/60 case). `-t` quantizes every segment to an integer
1197    // number of video frames, so an aligned block size makes each segment an exact
1198    // integer number of FLAC blocks: no partial trailing block, so the muxer emits
1199    // NO per-segment trim (`discard_padding`) at the cut. Adjacent cached segments
1200    // then concatenate gaplessly in MSE. With the default (~1152-sample) block the
1201    // final block is partial and gets trimmed mid-block, and the browser — which
1202    // decodes each cached segment separately and stitches at the buffer level —
1203    // produces an audible click at every cache-segment boundary.
1204    let audio_frame_size = AUDIO_RATE
1205        .is_multiple_of(setup.fps)
1206        .then(|| AUDIO_RATE / setup.fps);
1207
1208    let video_duration_arg = video_seconds.to_string();
1209    let mut cmd = Command::new("ffmpeg");
1210    cmd.arg("-hide_banner")
1211        .args(["-loglevel", "error"])
1212        // Input 0: the raw video frames, fed live over stdin.
1213        .args(["-f", "rawvideo"])
1214        .args(["-pix_fmt", "rgba"])
1215        .args([
1216            "-s",
1217            &format!("{}x{}", setup.resolution.width, setup.resolution.height),
1218        ])
1219        .args(["-r", &setup.fps.to_string()])
1220        .args(["-i", "-"]);
1221    // Input 1: the audio track. A rendered WAV already starts at the stream's
1222    // timeline time; otherwise a generated silent track keeps every stream in
1223    // the same A/V structure the client's SourceBuffer expects.
1224    match &audio_wav {
1225        Some(wav) => {
1226            cmd.arg("-i").arg(&wav.0);
1227        }
1228        None => {
1229            cmd.args(["-f", "lavfi"]).arg("-i").arg(format!(
1230                "anullsrc=channel_layout=stereo:sample_rate={AUDIO_RATE}"
1231            ));
1232        }
1233    }
1234    let range = setup.color_range.ffmpeg_token();
1235    let color_vf = format!(
1236        "scale=out_range={range}:out_color_matrix=bt709,format=yuv420p,\
1237         setparams=range={range}:color_primaries=bt709:colorspace=bt709:color_trc=bt709"
1238    );
1239
1240    cmd.args(["-c:v", "libx264"])
1241        .args(["-preset", "ultrafast"])
1242        .args(["-tune", "zerolatency"])
1243        // Convert the rendered full-range RGB to BT.709 YUV and
1244        // TAG the stream with that exact matrix/range. Without this, swscale
1245        // converts with its BT.601/limited defaults and writes NO color metadata,
1246        // so the browser falls back to its own guess (BT.709 for 720p+) and
1247        // decodes with a different matrix than the encode used — shifting the
1248        // colors versus the paused PNG (which shows the raw full-range RGB with no
1249        // conversion). `scale` does the conversion, `setparams` stamps all four
1250        // color fields onto the frames (so primaries/transfer are written too,
1251        // without codec-specific params), and the `-color_*` options mirror the
1252        // same range at stream level. Full range is the default because paused
1253        // PNG/RGBA frames are full-range too; use `color_range=limited` only when
1254        // a downstream target requires TV range.
1255        .args(["-vf", &color_vf])
1256        .args(["-pix_fmt", "yuv420p"])
1257        .args(["-color_primaries", "bt709"])
1258        .args(["-color_trc", "bt709"])
1259        .args(["-colorspace", "bt709"])
1260        .args(["-color_range", range])
1261        .args(["-g", &setup.gop.to_string()])
1262        .args(["-keyint_min", &setup.gop.to_string()])
1263        .args(["-sc_threshold", "0"])
1264        .args(["-bf", "0"])
1265        .args(["-refs", "1"])
1266        .args(["-flags", "low_delay"])
1267        .args(["-crf", &setup.crf.to_string()])
1268        // Audio FLAC, NOT AAC; map exactly one video + one audio stream. Each
1269        // cache segment is a SEPARATE ffmpeg encode, and AAC adds ~2048 samples of
1270        // encoder delay (priming) at the start of every encode plus frame-grid
1271        // padding at the end. Concatenating adjacent segments in MSE then leaves a
1272        // brief silent gap / click at each cache boundary. FLAC is lossless with
1273        // ZERO encoder delay and stores the exact sample count, so a segment's
1274        // first sample lands exactly on its start time and the boundaries are
1275        // gapless. `-compression_level 0` keeps the encode fast for the realtime
1276        // path (it stays lossless regardless of level).
1277        .args(["-c:a", "flac"])
1278        .args(["-compression_level", "0"]);
1279    // Align FLAC blocks to the video-frame sample grid so cache-segment seams stay
1280    // gapless (see `audio_frame_size`). Stream-specified to `:a` so it can't be
1281    // misread as a (meaningless) video option.
1282    if let Some(frame_size) = audio_frame_size {
1283        cmd.args(["-frame_size:a", &frame_size.to_string()]);
1284    }
1285    cmd.args(["-map", "0:v:0"])
1286        .args(["-map", "1:a:0"])
1287        // Bound the output to the video's frame-quantized length with `-t`, NOT
1288        // `-shortest`. Video frames are produced live and paced to real time, but
1289        // the audio WAV is a complete file ffmpeg reads instantly; `-shortest`
1290        // then races the fully-available audio against the still-arriving video
1291        // and ends the output EARLY — dropping the tail frame(s) and opening a
1292        // startup gap (the offline export avoids this by pre-fitting audio to the
1293        // video length). `-t` cuts on output PTS, so every fed frame survives.
1294        .args(["-t", &video_duration_arg])
1295        .args(["-muxdelay", "0"])
1296        .args(["-muxpreload", "0"])
1297        .args(["-flush_packets", "1"])
1298        .args(["-f", "mp4"])
1299        // Fragment per GOP (`frag_keyframe`), NOT per video frame
1300        // (`frag_every_frame`). `frag_every_frame` cuts a fragment every video
1301        // frame (33.3 ms @ 30fps), but an AAC frame is 1024 samples (≈21.3 ms) /
1302        // 2048 (≈42.7 ms) and does not align to the video grid — so the audio
1303        // fragments end up with DUPLICATE / non-monotonic `tfdt`
1304        // (baseMediaDecodeTime), which MSE chokes on: the audio track stalls and,
1305        // since playback needs both tracks, the video freezes a frame or two in.
1306        // GOP fragments hold whole audio frames with strictly increasing `tfdt`.
1307        .args(["-movflags", "frag_keyframe+empty_moov+default_base_moof"])
1308        .arg("pipe:1")
1309        .stdin(Stdio::piped())
1310        .stdout(Stdio::piped())
1311        .stderr(Stdio::piped());
1312    let spawn_start = Instant::now();
1313    let mut child = cmd.spawn()?;
1314    let ffmpeg_spawn_time = spawn_start.elapsed();
1315
1316    let mut stdin = child.stdin.take().ok_or("ffmpeg stdin was not piped")?;
1317    let mut stdout = child.stdout.take().ok_or("ffmpeg stdout was not piped")?;
1318    let mut stderr = child.stderr.take().ok_or("ffmpeg stderr was not piped")?;
1319    // Disable Nagle so the final fragment is sent immediately rather than being
1320    // held waiting to coalesce — with `Connection: close` a delayed tail can
1321    // otherwise reach the client only at FIN, after playback has already ended.
1322    let _ = stream.set_nodelay(true);
1323    let mut stream_out = stream.try_clone()?;
1324    let client_alive = Arc::new(AtomicBool::new(true));
1325    let client_alive_for_stdout = Arc::clone(&client_alive);
1326    let collect_segment_body = segment_cache_key.is_some();
1327
1328    // Drain ffmpeg's stdout to the client until TRUE EOF. ffmpeg emits the tail
1329    // GOP/fragments only after stdin is closed (the EOF below `drop(stdin)`), so
1330    // this thread must keep reading past the last frame the main loop wrote — it
1331    // is what carries the final ~GOP of frames to the client. A transient
1332    // `Interrupted` (EINTR) read is RETRIED, not treated as EOF: aborting on it
1333    // would truncate exactly that tail under load (the intermittent dropped-tail
1334    // bug). Only a real read error or a client write failure ends the drain.
1335    let stdout_thread = thread::spawn(move || {
1336        let mut buf = [0u8; 64 * 1024];
1337        let mut segment_body = collect_segment_body.then(Vec::new);
1338        loop {
1339            let n = match stdout.read(&mut buf) {
1340                Ok(0) => break,
1341                Ok(n) => n,
1342                Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1343                Err(_) => {
1344                    client_alive_for_stdout.store(false, Ordering::Relaxed);
1345                    break;
1346                }
1347            };
1348            if stream_out.write_all(&buf[..n]).is_err() {
1349                client_alive_for_stdout.store(false, Ordering::Relaxed);
1350                break;
1351            }
1352            if let Some(body) = &mut segment_body {
1353                body.extend_from_slice(&buf[..n]);
1354            }
1355        }
1356        // Flush the final fragments to the kernel before the connection's FIN so
1357        // the tail is not stranded in a userspace/socket buffer at close.
1358        let _ = stream_out.flush();
1359        segment_body
1360    });
1361
1362    let stderr_thread = thread::spawn(move || {
1363        let mut text = String::new();
1364        let _ = stderr.read_to_string(&mut text);
1365        text
1366    });
1367
1368    let frame_step = 1.0 / setup.fps as f64;
1369    let frame_duration = Duration::from_secs_f64(frame_step);
1370    let mut frames_rendered = 0u64;
1371    let mut frames_written = 0u64;
1372    let mut render_total = Duration::ZERO;
1373    let mut stdin_write_total = Duration::ZERO;
1374    let mut end_reason = "complete";
1375    let cache_metrics_before = if setup.verbose {
1376        Some(
1377            app.lock()
1378                .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?
1379                .ctx
1380                .metrics(),
1381        )
1382    } else {
1383        None
1384    };
1385
1386    for frame in 0..total_frames {
1387        if !client_alive.load(Ordering::Relaxed) {
1388            end_reason = "client_closed";
1389            client_alive.store(false, Ordering::Relaxed);
1390            break;
1391        }
1392        if video_epoch.load(Ordering::Acquire) != stream_epoch {
1393            end_reason = "superseded";
1394            client_alive.store(false, Ordering::Relaxed);
1395            break;
1396        }
1397
1398        let frame_start = Instant::now();
1399        // Clamp into the half-open renderable range so the final frame of a
1400        // full-length stream (which can land on `total_duration`) renders the
1401        // last frame rather than erroring with `timeline did not produce a frame`.
1402        let seconds = clamp_to_renderable(
1403            setup.start_seconds + frame as f64 * frame_step,
1404            setup.total_duration,
1405            setup.fps,
1406        );
1407        let image = {
1408            let mut app = app
1409                .lock()
1410                .map_err(|_| -> Box<dyn Error> { "preview app lock poisoned".into() })?;
1411            let verbose = app.verbose;
1412            let frame = app.render_video_rgba(
1413                &setup.timeline_id,
1414                seconds,
1415                setup.resolution,
1416                setup.motion_blur,
1417                verbose,
1418            )?;
1419            if verbose {
1420                println!(
1421                    "video timeline={} t={:.3}s size={}x{} fps={} gop={} render={:.2}ms build={:.2}ms readback={:.2}ms bytes={} cache_delta={}h/{}m cache_size={} gpu_preference={} gpu_init_attempted={} gpu_init_error={} gpu_available={} gpu_ops={} gpu_readbacks={} gpu_vram_failures={} gpu_cache={}/{} vram={}/{}",
1422                    setup.timeline_id,
1423                    seconds,
1424                    setup.resolution.width,
1425                    setup.resolution.height,
1426                    setup.fps,
1427                    setup.gop,
1428                    ms(frame.render_time),
1429                    ms(frame.build_time),
1430                    ms(frame.readback_time),
1431                    frame.image.pixels.len(),
1432                    frame.cache_hits,
1433                    frame.cache_misses,
1434                    format_bytes(frame.bytes_cached as u64),
1435                    frame.gpu_preference,
1436                    frame.gpu_init_attempted,
1437                    frame.gpu_init_error.as_deref().unwrap_or("-"),
1438                    frame.gpu_available,
1439                    frame.gpu_ops,
1440                    frame.gpu_readbacks,
1441                    frame.gpu_vram_failures,
1442                    format_bytes(frame.gpu_cache_bytes as u64),
1443                    format_bytes(frame.gpu_cache_cap_bytes as u64),
1444                    format_bytes(frame.vram_used_bytes as u64),
1445                    format_bytes(frame.vram_budget_bytes as u64),
1446                );
1447            }
1448            render_total += frame.render_time;
1449            frames_rendered += 1;
1450            frame.image
1451        };
1452
1453        if video_epoch.load(Ordering::Acquire) != stream_epoch {
1454            end_reason = "superseded";
1455            client_alive.store(false, Ordering::Relaxed);
1456            break;
1457        }
1458        let write_start = Instant::now();
1459        let write_result = stdin.write_all(&image.pixels);
1460        stdin_write_total += write_start.elapsed();
1461        if write_result.is_err() {
1462            end_reason = "ffmpeg_stdin_closed";
1463            client_alive.store(false, Ordering::Relaxed);
1464            break;
1465        }
1466        frames_written += 1;
1467        if setup.realtime {
1468            sleep_remainder(frame_duration, frame_start.elapsed());
1469        }
1470    }
1471
1472    drop(stdin);
1473    if !client_alive.load(Ordering::Relaxed) {
1474        let _ = child.kill();
1475    }
1476    let segment_body = stdout_thread.join().unwrap_or(None);
1477    if !client_alive.load(Ordering::Relaxed) && end_reason == "complete" {
1478        end_reason = "client_closed";
1479    }
1480    let stderr_text = stderr_thread.join().unwrap_or_default();
1481    let status = child.wait()?;
1482    if status.success() && client_alive.load(Ordering::Relaxed) && end_reason == "complete" {
1483        if let (Some(key), Some(body)) = (segment_cache_key, segment_body) {
1484            cache_video_segment(key, body);
1485        }
1486    }
1487    if setup.verbose {
1488        println!(
1489            "video-stream timeline={} start={:.3}s duration={:.3}s frames={}/{} written={} reason={} setup={:.2}ms audio={} audio_setup={:.2}ms ffmpeg_spawn={:.2}ms render_total={:.2}ms stdin_write={:.2}ms total={:.2}ms status={} stderr_bytes={}",
1490            setup.timeline_id,
1491            setup.start_seconds,
1492            video_seconds,
1493            frames_rendered,
1494            total_frames,
1495            frames_written,
1496            end_reason,
1497            ms(setup_time),
1498            audio_source,
1499            ms(audio_time),
1500            ms(ffmpeg_spawn_time),
1501            ms(render_total),
1502            ms(stdin_write_total),
1503            ms(stream_start.elapsed()),
1504            status,
1505            stderr_text.len(),
1506        );
1507        if let Some(before) = cache_metrics_before {
1508            if let Ok(app) = app.lock() {
1509                log_cache_metrics_delta(&before, &app.ctx.metrics());
1510            }
1511        }
1512    }
1513    if !status.success() && client_alive.load(Ordering::Relaxed) {
1514        return Err(format!("ffmpeg exited with {status}: {stderr_text}").into());
1515    }
1516
1517    Ok(())
1518}
1519
1520struct RenderedFrame {
1521    body: Vec<u8>,
1522    stats: FrameRenderStats,
1523}
1524
1525struct RenderedImage {
1526    image: CpuRasterImage,
1527    stats: FrameRenderStats,
1528    total_start: Instant,
1529}
1530
1531struct FrameRenderStats {
1532    timeline_id: String,
1533    seconds: f64,
1534    resolution: Resolution,
1535    render_time: Duration,
1536    encode_time: Duration,
1537    total_time: Duration,
1538    output_format: FrameFormat,
1539    output_bytes: usize,
1540    cache_hits: u64,
1541    cache_misses: u64,
1542    bytes_cached: usize,
1543    gpu_preference: String,
1544    gpu_init_attempted: bool,
1545    gpu_init_error: Option<String>,
1546    gpu_available: bool,
1547    gpu_ops: u64,
1548    gpu_readbacks: u64,
1549    gpu_vram_failures: u64,
1550    gpu_cache_bytes: usize,
1551    gpu_cache_cap_bytes: usize,
1552    vram_used_bytes: usize,
1553    vram_budget_bytes: usize,
1554}
1555
1556impl FrameRenderStats {
1557    fn headers(&self) -> Vec<(&'static str, String)> {
1558        let mut headers = vec![
1559            ("X-Tellur-Render-Ms", format!("{:.2}", ms(self.render_time))),
1560            ("X-Tellur-Encode-Ms", format!("{:.2}", ms(self.encode_time))),
1561            ("X-Tellur-Total-Ms", format!("{:.2}", ms(self.total_time))),
1562            (
1563                "X-Tellur-Output-Format",
1564                self.output_format.as_str().to_owned(),
1565            ),
1566            ("X-Tellur-Output-Bytes", self.output_bytes.to_string()),
1567            ("X-Tellur-Width", self.resolution.width.to_string()),
1568            ("X-Tellur-Height", self.resolution.height.to_string()),
1569            ("X-Tellur-Cache-Hits", self.cache_hits.to_string()),
1570            ("X-Tellur-Cache-Misses", self.cache_misses.to_string()),
1571            ("X-Tellur-GPU-Available", self.gpu_available.to_string()),
1572            (
1573                "X-Tellur-GPU-Init-Attempted",
1574                self.gpu_init_attempted.to_string(),
1575            ),
1576            ("X-Tellur-GPU-Preference", self.gpu_preference.clone()),
1577            ("X-Tellur-GPU-Active", (self.gpu_ops > 0).to_string()),
1578            ("X-Tellur-GPU-Ops", self.gpu_ops.to_string()),
1579            ("X-Tellur-GPU-Readbacks", self.gpu_readbacks.to_string()),
1580            (
1581                "X-Tellur-GPU-VRAM-Failures",
1582                self.gpu_vram_failures.to_string(),
1583            ),
1584            ("X-Tellur-GPU-Cache-Bytes", self.gpu_cache_bytes.to_string()),
1585            (
1586                "X-Tellur-GPU-Cache-Cap-Bytes",
1587                self.gpu_cache_cap_bytes.to_string(),
1588            ),
1589            ("X-Tellur-VRAM-Used-Bytes", self.vram_used_bytes.to_string()),
1590            (
1591                "X-Tellur-VRAM-Budget-Bytes",
1592                self.vram_budget_bytes.to_string(),
1593            ),
1594        ];
1595        if let Some(error) = &self.gpu_init_error {
1596            headers.push(("X-Tellur-GPU-Init-Error", sanitize_header_value(error)));
1597        }
1598        headers
1599    }
1600}
1601
1602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1603enum FrameFormat {
1604    Png,
1605    Rgba,
1606}
1607
1608impl FrameFormat {
1609    fn from_query(query: &HashMap<String, String>) -> Self {
1610        match query.get("format").map(String::as_str) {
1611            Some("rgba") | Some("raw") => Self::Rgba,
1612            _ => Self::Png,
1613        }
1614    }
1615
1616    fn as_str(self) -> &'static str {
1617        match self {
1618            Self::Png => "png",
1619            Self::Rgba => "rgba",
1620        }
1621    }
1622}
1623
1624fn log_frame_stats(stats: &FrameRenderStats) {
1625    println!(
1626        "frame timeline={} t={:.3}s size={}x{} format={} render={:.2}ms encode={:.2}ms total={:.2}ms bytes={} cache_delta={}h/{}m cache_size={} gpu_preference={} gpu_init_attempted={} gpu_init_error={} gpu_available={} gpu_ops={} gpu_readbacks={} gpu_vram_failures={} gpu_cache={}/{} vram={}/{}",
1627        stats.timeline_id,
1628        stats.seconds,
1629        stats.resolution.width,
1630        stats.resolution.height,
1631        stats.output_format.as_str(),
1632        ms(stats.render_time),
1633        ms(stats.encode_time),
1634        ms(stats.total_time),
1635        stats.output_bytes,
1636        stats.cache_hits,
1637        stats.cache_misses,
1638        format_bytes(stats.bytes_cached as u64),
1639        stats.gpu_preference,
1640        stats.gpu_init_attempted,
1641        stats.gpu_init_error.as_deref().unwrap_or("-"),
1642        stats.gpu_available,
1643        stats.gpu_ops,
1644        stats.gpu_readbacks,
1645        stats.gpu_vram_failures,
1646        format_bytes(stats.gpu_cache_bytes as u64),
1647        format_bytes(stats.gpu_cache_cap_bytes as u64),
1648        format_bytes(stats.vram_used_bytes as u64),
1649        format_bytes(stats.vram_budget_bytes as u64),
1650    );
1651}
1652
1653#[derive(Clone, Copy)]
1654struct TypeStatsDelta {
1655    hits: u64,
1656    misses: u64,
1657    inclusive_time: Duration,
1658    self_time: Duration,
1659}
1660
1661impl TypeStatsDelta {
1662    fn total(self) -> u64 {
1663        self.hits + self.misses
1664    }
1665
1666    fn hit_rate(self) -> f64 {
1667        let total = self.total();
1668        if total == 0 {
1669            0.0
1670        } else {
1671            self.hits as f64 / total as f64
1672        }
1673    }
1674}
1675
1676fn log_cache_metrics_delta(before: &CacheMetrics, after: &CacheMetrics) {
1677    let hits = after.hits.saturating_sub(before.hits);
1678    let misses = after.misses.saturating_sub(before.misses);
1679    let total = hits + misses;
1680    let hit_rate = if total == 0 {
1681        0.0
1682    } else {
1683        hits as f64 / total as f64
1684    };
1685    let gpu_before = &before.gpu;
1686    let gpu_after = &after.gpu;
1687    println!(
1688        "video-stream-cache-delta hits={} misses={} hit_rate={:.1}% cache_size={} evicted_delta={} pressure_skips_delta={} oversize_skips_delta={} budget_skips_delta={} gpu_ops={} gpu_composites={} gpu_shadows={} gpu_outlines={} gpu_rasterizes={} gpu_fills={} gpu_temporal_avg={} gpu_readbacks={} gpu_vram_failures={} gpu_cache={}/{} vram={}/{}",
1689        hits,
1690        misses,
1691        hit_rate * 100.0,
1692        format_bytes(after.bytes_cached as u64),
1693        format_bytes(after.bytes_evicted.saturating_sub(before.bytes_evicted)),
1694        after.pressure_skips.saturating_sub(before.pressure_skips),
1695        after.oversize_skips.saturating_sub(before.oversize_skips),
1696        after.budget_skips.saturating_sub(before.budget_skips),
1697        gpu_after.total_ops().saturating_sub(gpu_before.total_ops()),
1698        gpu_after.composites.saturating_sub(gpu_before.composites),
1699        gpu_after.drop_shadows.saturating_sub(gpu_before.drop_shadows),
1700        gpu_after.outlines.saturating_sub(gpu_before.outlines),
1701        gpu_after.rasterizes.saturating_sub(gpu_before.rasterizes),
1702        gpu_after.fills.saturating_sub(gpu_before.fills),
1703        gpu_after
1704            .temporal_averages
1705            .saturating_sub(gpu_before.temporal_averages),
1706        gpu_after.readbacks.saturating_sub(gpu_before.readbacks),
1707        gpu_after
1708            .vram_reserve_failures
1709            .saturating_sub(gpu_before.vram_reserve_failures),
1710        format_bytes(after.gpu_cache_bytes as u64),
1711        format_bytes(after.gpu_cache_cap_bytes as u64),
1712        format_bytes(after.vram_used_bytes as u64),
1713        format_bytes(after.vram_budget_bytes as u64),
1714    );
1715
1716    let mut rows: Vec<(&'static str, TypeStatsDelta)> = after
1717        .per_type
1718        .iter()
1719        .map(|(name, stats)| {
1720            let before_stats = before.per_type.get(name);
1721            (*name, diff_type_stats(before_stats, stats))
1722        })
1723        .filter(|(_, stats)| stats.total() > 0 || !stats.self_time.is_zero())
1724        .collect();
1725    rows.sort_by_key(|(_, stats)| Reverse(stats.self_time));
1726    for (name, stats) in rows.into_iter().take(12) {
1727        println!(
1728            "video-stream-cache-type name={} hits={} misses={} hit_rate={:.1}% self={} incl={}",
1729            name,
1730            stats.hits,
1731            stats.misses,
1732            stats.hit_rate() * 100.0,
1733            format_duration(stats.self_time),
1734            format_duration(stats.inclusive_time),
1735        );
1736    }
1737}
1738
1739fn diff_type_stats(before: Option<&TypeStats>, after: &TypeStats) -> TypeStatsDelta {
1740    let before = before.copied().unwrap_or_default();
1741    TypeStatsDelta {
1742        hits: after.hits.saturating_sub(before.hits),
1743        misses: after.misses.saturating_sub(before.misses),
1744        inclusive_time: after.inclusive_time.saturating_sub(before.inclusive_time),
1745        self_time: after.self_time.saturating_sub(before.self_time),
1746    }
1747}
1748
1749fn format_duration(d: Duration) -> String {
1750    let micros = d.as_micros();
1751    if micros >= 1_000_000 {
1752        format!("{:.2}s", d.as_secs_f64())
1753    } else if micros >= 1_000 {
1754        format!("{:.2}ms", micros as f64 / 1_000.0)
1755    } else {
1756        format!("{micros}us")
1757    }
1758}
1759
1760fn ms(d: Duration) -> f64 {
1761    d.as_secs_f64() * 1000.0
1762}
1763
1764fn sleep_remainder(frame_duration: Duration, elapsed: Duration) {
1765    if let Some(remaining) = frame_duration.checked_sub(elapsed) {
1766        thread::sleep(remaining);
1767    }
1768}
1769
1770fn select_timeline<'a>(
1771    timelines: &'a [TimelineInfo],
1772    requested: Option<&String>,
1773) -> Option<&'a TimelineInfo> {
1774    requested
1775        .and_then(|id| timelines.iter().find(|info| &info.id == id))
1776        .or_else(|| timelines.first())
1777}
1778
1779fn request_fps(query: &HashMap<String, String>, default_fps: u32) -> u32 {
1780    query
1781        .get("fps")
1782        .and_then(|v| v.parse::<u32>().ok())
1783        .filter(|fps| *fps > 0)
1784        .unwrap_or(default_fps.max(1))
1785}
1786
1787/// The preview's motion-blur toggle; off unless the request explicitly opts in.
1788fn request_motion_blur(query: &HashMap<String, String>) -> bool {
1789    matches!(
1790        query.get("motion_blur").map(String::as_str),
1791        Some("1") | Some("true")
1792    )
1793}
1794
1795fn request_color_range(query: &HashMap<String, String>, default: ColorRange) -> ColorRange {
1796    query
1797        .get("color_range")
1798        .or_else(|| query.get("colorRange"))
1799        .and_then(|value| value.parse().ok())
1800        .unwrap_or(default)
1801}
1802
1803fn request_video_color(query: &HashMap<String, String>) -> bool {
1804    matches!(
1805        query
1806            .get("video_color")
1807            .or_else(|| query.get("videoColor"))
1808            .map(String::as_str),
1809        Some("1") | Some("true") | Some("mp4") | Some("video")
1810    )
1811}
1812
1813fn request_resolution(query: &HashMap<String, String>, default: Resolution) -> Resolution {
1814    if let (Some(width), Some(height)) = (
1815        query
1816            .get("width")
1817            .and_then(|v| v.parse::<u32>().ok())
1818            .filter(|v| *v > 0),
1819        query
1820            .get("height")
1821            .and_then(|v| v.parse::<u32>().ok())
1822            .filter(|v| *v > 0),
1823    ) {
1824        return Resolution::new(width, height);
1825    }
1826
1827    let Some(scale) = query
1828        .get("scale")
1829        .and_then(|v| v.parse::<f32>().ok())
1830        .filter(|v| v.is_finite() && *v > 0.0)
1831    else {
1832        return default;
1833    };
1834
1835    Resolution::new(
1836        scaled_dimension(default.width, scale),
1837        scaled_dimension(default.height, scale),
1838    )
1839}
1840
1841fn scaled_dimension(value: u32, scale: f32) -> u32 {
1842    ((value as f32) * scale).round().clamp(1.0, u32::MAX as f32) as u32
1843}
1844
1845fn video_color_preview_image(
1846    image: &CpuRasterImage,
1847    color_range: ColorRange,
1848) -> Result<CpuRasterImage, Box<dyn Error>> {
1849    if image.format != PixelFormat::Rgba8 {
1850        return Err(format!("video-color preview requires Rgba8, got {:?}", image.format).into());
1851    }
1852
1853    let width = image.width as usize;
1854    let height = image.height as usize;
1855    let expected = width * height * 4;
1856    if image.pixels.len() != expected {
1857        return Err(format!(
1858            "video-color frame size mismatch: expected {expected} bytes, got {}",
1859            image.pixels.len()
1860        )
1861        .into());
1862    }
1863
1864    let mut out = vec![0u8; expected];
1865    for y in (0..height).step_by(2) {
1866        for x in (0..width).step_by(2) {
1867            let mut chroma = [(0usize, 0.0_f32, 0.0_f32, 0.0_f32); 4];
1868            let mut count = 0usize;
1869            for dy in 0..2 {
1870                let py = y + dy;
1871                if py >= height {
1872                    continue;
1873                }
1874                for dx in 0..2 {
1875                    let px = x + dx;
1876                    if px >= width {
1877                        continue;
1878                    }
1879                    let idx = (py * width + px) * 4;
1880                    let rgb = [
1881                        image.pixels[idx] as f32,
1882                        image.pixels[idx + 1] as f32,
1883                        image.pixels[idx + 2] as f32,
1884                    ];
1885                    let (encoded_y, encoded_cb, encoded_cr) = bt709_rgb_to_ycbcr(rgb, color_range);
1886                    chroma[count] = (idx, encoded_y, encoded_cb, encoded_cr);
1887                    count += 1;
1888                }
1889            }
1890            if count == 0 {
1891                continue;
1892            }
1893
1894            let cb = quantize_u8(
1895                chroma[..count].iter().map(|(_, _, cb, _)| *cb).sum::<f32>() / count as f32,
1896            ) as f32;
1897            let cr = quantize_u8(
1898                chroma[..count].iter().map(|(_, _, _, cr)| *cr).sum::<f32>() / count as f32,
1899            ) as f32;
1900            for &(idx, encoded_y, _, _) in &chroma[..count] {
1901                let yy = quantize_u8(encoded_y) as f32;
1902                let [r, g, b] = bt709_ycbcr_to_rgb(yy, cb, cr, color_range);
1903                out[idx] = quantize_u8(r);
1904                out[idx + 1] = quantize_u8(g);
1905                out[idx + 2] = quantize_u8(b);
1906                out[idx + 3] = image.pixels[idx + 3];
1907            }
1908        }
1909    }
1910
1911    Ok(CpuRasterImage::new(
1912        image.width,
1913        image.height,
1914        PixelFormat::Rgba8,
1915        out,
1916    ))
1917}
1918
1919fn bt709_rgb_to_ycbcr(rgb: [f32; 3], color_range: ColorRange) -> (f32, f32, f32) {
1920    let [r, g, b] = rgb;
1921    let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1922    let cb = (b - y) / 1.8556;
1923    let cr = (r - y) / 1.5748;
1924    match color_range {
1925        ColorRange::Full => (y, 128.0 + cb, 128.0 + cr),
1926        ColorRange::Limited => (
1927            16.0 + y * (219.0 / 255.0),
1928            128.0 + cb * (224.0 / 255.0),
1929            128.0 + cr * (224.0 / 255.0),
1930        ),
1931    }
1932}
1933
1934fn bt709_ycbcr_to_rgb(y: f32, cb: f32, cr: f32, color_range: ColorRange) -> [f32; 3] {
1935    let (y, cb, cr) = match color_range {
1936        ColorRange::Full => (y, cb - 128.0, cr - 128.0),
1937        ColorRange::Limited => (
1938            (y - 16.0) * (255.0 / 219.0),
1939            (cb - 128.0) * (255.0 / 224.0),
1940            (cr - 128.0) * (255.0 / 224.0),
1941        ),
1942    };
1943    [
1944        y + 1.5748 * cr,
1945        y - 0.187_324 * cb - 0.468_124 * cr,
1946        y + 1.8556 * cb,
1947    ]
1948}
1949
1950fn quantize_u8(value: f32) -> u8 {
1951    value.round().clamp(0.0, 255.0) as u8
1952}
1953
1954fn export_preview_png<W: Write>(image: &CpuRasterImage, writer: W) -> Result<(), Box<dyn Error>> {
1955    if image.format != PixelFormat::Rgba8 {
1956        return Err(format!("png frame requires Rgba8, got {:?}", image.format).into());
1957    }
1958
1959    let expected = (image.width as usize) * (image.height as usize) * 4;
1960    if image.pixels.len() != expected {
1961        return Err(format!(
1962            "png frame size mismatch: expected {expected} bytes, got {}",
1963            image.pixels.len()
1964        )
1965        .into());
1966    }
1967
1968    let mut encoder = png::Encoder::new(writer, image.width, image.height);
1969    encoder.set_color(png::ColorType::Rgba);
1970    encoder.set_depth(png::BitDepth::Eight);
1971    encoder.set_compression(png::Compression::Fastest);
1972    let mut png_writer = encoder.write_header()?;
1973    png_writer.write_image_data(&image.pixels)?;
1974    Ok(())
1975}
1976
1977/// Clamps a requested frame time into the timeline's RENDERABLE range.
1978///
1979/// Clip time gates in the core are half-open `[start, end)` (`tellur-core`
1980/// `timeline_component.rs`'s `t < end`), so a request at exactly `duration`
1981/// leaves every clip inactive and the composite is `None` — surfacing as a
1982/// `timeline did not produce a frame` 500. A frontend scrubbing to the very end
1983/// (or any `time=<duration>` request) would otherwise break.
1984///
1985/// So the upper bound is the LAST renderable frame time, one frame step below
1986/// `duration` (`(duration - 1/fps).max(0.0)`), which satisfies `t < duration`
1987/// and lands in the final frame's interval. A zero/sub-frame timeline clamps to
1988/// `0.0`. This is the server-side root guard: an end-of-timeline request returns
1989/// the last frame instead of erroring.
1990fn clamp_to_renderable(seconds: f64, duration: f64, fps: u32) -> f64 {
1991    let frame_step = 1.0 / fps.max(1) as f64;
1992    let last_frame = (duration - frame_step).max(0.0);
1993    seconds.clamp(0.0, last_frame)
1994}
1995
1996struct Request {
1997    method: String,
1998    path: String,
1999    query: HashMap<String, String>,
2000}
2001
2002fn read_request(stream: &mut TcpStream) -> Result<Option<Request>, Box<dyn Error>> {
2003    let mut buf = Vec::with_capacity(8192);
2004    let mut chunk = [0u8; 1024];
2005    loop {
2006        let n = stream.read(&mut chunk)?;
2007        if n == 0 {
2008            if buf.is_empty() {
2009                return Ok(None);
2010            }
2011            break;
2012        }
2013        buf.extend_from_slice(&chunk[..n]);
2014        if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 64 * 1024 {
2015            break;
2016        }
2017    }
2018
2019    let request = String::from_utf8_lossy(&buf);
2020    let first_line = request.lines().next().ok_or("empty request")?;
2021    let mut parts = first_line.split_whitespace();
2022    let method = parts.next().ok_or("missing method")?.to_owned();
2023    let target = parts.next().ok_or("missing request target")?;
2024    let (path, query) = split_target(target);
2025    Ok(Some(Request {
2026        method,
2027        path,
2028        query,
2029    }))
2030}
2031
2032fn split_target(target: &str) -> (String, HashMap<String, String>) {
2033    let (path, query) = target.split_once('?').unwrap_or((target, ""));
2034    let mut params = HashMap::new();
2035    for pair in query.split('&').filter(|s| !s.is_empty()) {
2036        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
2037        params.insert(percent_decode(k), percent_decode(v));
2038    }
2039    (path.to_owned(), params)
2040}
2041
2042fn percent_decode(s: &str) -> String {
2043    let bytes = s.as_bytes();
2044    let mut out = Vec::with_capacity(bytes.len());
2045    let mut i = 0;
2046    while i < bytes.len() {
2047        match bytes[i] {
2048            b'+' => {
2049                out.push(b' ');
2050                i += 1;
2051            }
2052            b'%' if i + 2 < bytes.len() => {
2053                if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) {
2054                    if let Ok(v) = u8::from_str_radix(hex, 16) {
2055                        out.push(v);
2056                        i += 3;
2057                        continue;
2058                    }
2059                }
2060                out.push(bytes[i]);
2061                i += 1;
2062            }
2063            b => {
2064                out.push(b);
2065                i += 1;
2066            }
2067        }
2068    }
2069    String::from_utf8_lossy(&out).into_owned()
2070}
2071
2072fn write_response(
2073    stream: &mut TcpStream,
2074    status: u16,
2075    reason: &str,
2076    content_type: &str,
2077    body: &[u8],
2078) -> Result<(), Box<dyn Error>> {
2079    write_response_with_headers(stream, status, reason, content_type, &[], body)
2080}
2081
2082fn write_response_with_headers(
2083    stream: &mut TcpStream,
2084    status: u16,
2085    reason: &str,
2086    content_type: &str,
2087    extra_headers: &[(&str, String)],
2088    body: &[u8],
2089) -> Result<(), Box<dyn Error>> {
2090    write_response_with_headers_and_cache_control(
2091        stream,
2092        status,
2093        reason,
2094        content_type,
2095        extra_headers,
2096        body,
2097        "no-store",
2098    )
2099}
2100
2101fn write_response_with_headers_and_cache_control(
2102    stream: &mut TcpStream,
2103    status: u16,
2104    reason: &str,
2105    content_type: &str,
2106    extra_headers: &[(&str, String)],
2107    body: &[u8],
2108    cache_control: &str,
2109) -> Result<(), Box<dyn Error>> {
2110    write!(
2111        stream,
2112        "HTTP/1.1 {status} {reason}\r\n\
2113         Content-Type: {content_type}\r\n\
2114         Content-Length: {}\r\n\
2115         Cache-Control: {cache_control}\r\n\
2116         Connection: close\r\n",
2117        body.len()
2118    )?;
2119    for (name, value) in extra_headers {
2120        write!(stream, "{name}: {value}\r\n")?;
2121    }
2122    stream.write_all(b"\r\n")?;
2123    stream.write_all(body)?;
2124    Ok(())
2125}
2126
2127fn format_bytes(b: u64) -> String {
2128    const KIB: f64 = 1024.0;
2129    const MIB: f64 = KIB * 1024.0;
2130    const GIB: f64 = MIB * 1024.0;
2131    let bf = b as f64;
2132    if bf >= GIB {
2133        format!("{:.2} GiB", bf / GIB)
2134    } else if bf >= MIB {
2135        format!("{:.2} MiB", bf / MIB)
2136    } else if bf >= KIB {
2137        format!("{:.2} KiB", bf / KIB)
2138    } else {
2139        format!("{b} B")
2140    }
2141}
2142
2143fn sanitize_header_value(value: &str) -> String {
2144    value
2145        .chars()
2146        .map(|c| if c.is_control() { ' ' } else { c })
2147        .collect()
2148}
2149
2150fn info_json(
2151    project_name: &str,
2152    resolution: Resolution,
2153    fps: u32,
2154    timelines: &[TimelineInfo],
2155    last_error: Option<&str>,
2156    cache_key: &str,
2157    compile: &CompileSnapshot,
2158) -> String {
2159    let timelines_json = timelines
2160        .iter()
2161        .map(|info| {
2162            let error = match info.error.as_deref() {
2163                Some(e) => format!("\"{}\"", json_escape(e)),
2164                None => "null".to_owned(),
2165            };
2166            format!(
2167                "{{\"id\":\"{}\",\"title\":\"{}\",\"duration\":{},\"error\":{}}}",
2168                json_escape(&info.id),
2169                json_escape(&info.title),
2170                finite_json_number(info.duration),
2171                error,
2172            )
2173        })
2174        .collect::<Vec<_>>()
2175        .join(",");
2176    let last_error = match last_error {
2177        Some(e) => format!("\"{}\"", json_escape(e)),
2178        None => "null".to_owned(),
2179    };
2180    let compile_error = match compile.last_error.as_deref() {
2181        Some(e) => format!("\"{}\"", json_escape(e)),
2182        None => "null".to_owned(),
2183    };
2184    format!(
2185        "{{\"projectName\":\"{}\",\"width\":{},\"height\":{},\"fps\":{},\"lastError\":{},\"cacheKey\":\"{}\",\"compileStatus\":\"{}\",\"compileError\":{},\"timelines\":[{}]}}",
2186        json_escape(project_name),
2187        resolution.width,
2188        resolution.height,
2189        fps,
2190        last_error,
2191        json_escape(cache_key),
2192        compile.status.as_str(),
2193        compile_error,
2194        timelines_json
2195    )
2196}
2197
2198/// The lowercased `NodeKind` discriminant the live UI keys its rendering on.
2199/// Kept in lock-step with `web/src/types.ts`'s `NodeKind` union.
2200fn node_kind_str(kind: NodeKind) -> &'static str {
2201    match kind {
2202        NodeKind::Video => "video",
2203        NodeKind::Audio => "audio",
2204        NodeKind::Subtitle => "subtitle",
2205        NodeKind::Timeline => "timeline",
2206        NodeKind::Sequence => "sequence",
2207    }
2208}
2209
2210/// Serializes an [`Arrangement`] node and its children into the hand-built JSON
2211/// the live UI consumes (audit B.4). Recurses over `children`; every float goes
2212/// through [`finite_json_number`]; `trim` is `null` or `[a,b]`; each trigger is
2213/// an object `{"time":<num>,"name":<null|string>}`; `source` is `null` or
2214/// `{"file":<string>,"line":<num>}`. Shape mirrored in `web/src/types.ts`.
2215fn arrangement_json(node: &Arrangement) -> String {
2216    let trim = match node.trim {
2217        Some((a, b)) => format!("[{},{}]", finite_json_number(a), finite_json_number(b)),
2218        None => "null".to_owned(),
2219    };
2220    let triggers = node
2221        .triggers
2222        .iter()
2223        .map(|t| {
2224            let name = match &t.name {
2225                Some(n) => format!("\"{}\"", json_escape(n)),
2226                None => "null".to_owned(),
2227            };
2228            format!(
2229                "{{\"time\":{},\"name\":{}}}",
2230                finite_json_number(t.time),
2231                name,
2232            )
2233        })
2234        .collect::<Vec<_>>()
2235        .join(",");
2236    let children = node
2237        .children
2238        .iter()
2239        .map(arrangement_json)
2240        .collect::<Vec<_>>()
2241        .join(",");
2242    let name = match &node.name {
2243        Some(n) => format!("\"{}\"", json_escape(n)),
2244        None => "null".to_owned(),
2245    };
2246    let source = match &node.source {
2247        Some(s) => format!(
2248            "{{\"file\":\"{}\",\"line\":{}}}",
2249            json_escape(&s.file),
2250            s.line,
2251        ),
2252        None => "null".to_owned(),
2253    };
2254    format!(
2255        "{{\"kind\":\"{}\",\"label\":\"{}\",\"name\":{},\"source\":{},\"start\":{},\"end\":{},\"trim\":{},\"triggers\":[{}],\"children\":[{}]}}",
2256        node_kind_str(node.kind),
2257        json_escape(&node.label),
2258        name,
2259        source,
2260        finite_json_number(node.start),
2261        finite_json_number(node.end),
2262        trim,
2263        triggers,
2264        children,
2265    )
2266}
2267
2268fn finite_json_number(v: f64) -> String {
2269    if v.is_finite() {
2270        v.to_string()
2271    } else {
2272        "0".to_owned()
2273    }
2274}
2275
2276fn json_escape(s: &str) -> String {
2277    let mut out = String::with_capacity(s.len());
2278    for ch in s.chars() {
2279        match ch {
2280            '"' => out.push_str("\\\""),
2281            '\\' => out.push_str("\\\\"),
2282            '\n' => out.push_str("\\n"),
2283            '\r' => out.push_str("\\r"),
2284            '\t' => out.push_str("\\t"),
2285            ch if ch.is_control() => out.push_str(&format!("\\u{:04x}", ch as u32)),
2286            ch => out.push(ch),
2287        }
2288    }
2289    out
2290}
2291
2292#[cfg(test)]
2293mod tests {
2294    use super::*;
2295    use tellur_core::timeline_component::{SourceLoc, TriggerMark};
2296
2297    #[test]
2298    fn temp_wav_uses_f32le_and_preserves_headroom() {
2299        let buf = AudioBuffer {
2300            samples: vec![1.5, -2.0],
2301            rate: 48_000,
2302            channels: 1,
2303        };
2304        let path = write_temp_wav(&buf).expect("write temp float wav");
2305        let bytes = std::fs::read(&path).expect("read temp float wav");
2306
2307        assert_eq!(&bytes[20..22], &3u16.to_le_bytes());
2308        assert_eq!(&bytes[34..36], &32u16.to_le_bytes());
2309        assert_eq!(&bytes[40..44], &8u32.to_le_bytes());
2310        assert_eq!(&bytes[44..48], &1.5_f32.to_le_bytes());
2311        assert_eq!(&bytes[48..52], &(-2.0_f32).to_le_bytes());
2312
2313        let _ = std::fs::remove_file(path);
2314    }
2315
2316    // A request for exactly `duration` must clamp into the half-open renderable
2317    // range: `< duration` (so the core's `t < end` clip gate stays active) AND
2318    // inside the LAST frame's interval `[duration - 1/fps, duration)`. This is
2319    // the root guard for "scrub to the very end" — without it `t == duration`
2320    // leaves every clip inactive and the composite is `None` (a 500
2321    // `timeline did not produce a frame`).
2322    #[test]
2323    fn clamp_to_renderable_maps_exact_duration_to_last_frame() {
2324        let duration = 7.6_f64;
2325        let fps = 60;
2326        let frame_step = 1.0 / fps as f64;
2327
2328        let clamped = clamp_to_renderable(duration, duration, fps);
2329        // Strictly inside the timeline (the half-open endpoint is excluded).
2330        assert!(clamped < duration, "{clamped} must be < {duration}");
2331        // And within the final frame's interval, so it renders the last frame.
2332        assert!(
2333            clamped >= duration - frame_step,
2334            "{clamped} must be in the last frame interval [{}, {duration})",
2335            duration - frame_step
2336        );
2337        // Exactly the last frame time (one step below duration).
2338        assert!((clamped - (duration - frame_step)).abs() < 1e-6);
2339
2340        // A past-the-end request clamps to the same last frame.
2341        assert_eq!(clamp_to_renderable(duration + 5.0, duration, fps), clamped);
2342    }
2343
2344    #[test]
2345    fn video_segment_cache_key_preserves_f64_time_bits() {
2346        let mut setup = VideoStreamSetup {
2347            timeline_id: "main".to_owned(),
2348            duration: 1.0,
2349            total_duration: 1_000.0,
2350            fps: 60,
2351            resolution: Resolution::new(16, 9),
2352            gop: 15,
2353            crf: 23,
2354            motion_blur: false,
2355            color_range: ColorRange::Full,
2356            start_seconds: 512.0,
2357            cache_control: "no-store",
2358            realtime: false,
2359            verbose: false,
2360        };
2361        let first = video_segment_cache_key(&setup, "plugin", 1.0);
2362        setup.start_seconds += 1.0 / 48_000.0;
2363        let next_sample = video_segment_cache_key(&setup, "plugin", 1.0);
2364
2365        assert_ne!(first, next_sample);
2366    }
2367
2368    #[test]
2369    fn clamp_to_renderable_passes_through_interior_times() {
2370        // A time comfortably inside the range is unchanged.
2371        let t = clamp_to_renderable(3.0, 7.6, 60);
2372        assert_eq!(t, 3.0);
2373    }
2374
2375    #[test]
2376    fn clamp_to_renderable_handles_short_and_negative() {
2377        // A timeline shorter than one frame (or zero) clamps to 0.0 rather than
2378        // going negative.
2379        assert_eq!(clamp_to_renderable(1.0, 0.0, 60), 0.0);
2380        assert_eq!(clamp_to_renderable(0.005, 0.01, 60), 0.0);
2381        // A negative request floors at 0.0.
2382        assert_eq!(clamp_to_renderable(-2.0, 7.6, 60), 0.0);
2383    }
2384
2385    #[test]
2386    fn request_motion_blur_defaults_off() {
2387        assert!(!request_motion_blur(&HashMap::new()));
2388
2389        let mut query = HashMap::new();
2390        query.insert("motion_blur".to_owned(), "0".to_owned());
2391        assert!(!request_motion_blur(&query));
2392
2393        query.insert("motion_blur".to_owned(), "false".to_owned());
2394        assert!(!request_motion_blur(&query));
2395    }
2396
2397    #[test]
2398    fn request_motion_blur_is_explicitly_opt_in() {
2399        let mut query = HashMap::new();
2400        query.insert("motion_blur".to_owned(), "1".to_owned());
2401        assert!(request_motion_blur(&query));
2402
2403        query.insert("motion_blur".to_owned(), "true".to_owned());
2404        assert!(request_motion_blur(&query));
2405    }
2406
2407    #[test]
2408    fn request_color_range_defaults_to_server_value() {
2409        assert_eq!(
2410            request_color_range(&HashMap::new(), ColorRange::Limited),
2411            ColorRange::Limited
2412        );
2413
2414        let mut query = HashMap::new();
2415        query.insert("color_range".to_owned(), "bogus".to_owned());
2416        assert_eq!(
2417            request_color_range(&query, ColorRange::Full),
2418            ColorRange::Full
2419        );
2420    }
2421
2422    #[test]
2423    fn request_color_range_accepts_query_aliases() {
2424        let mut query = HashMap::new();
2425        query.insert("color_range".to_owned(), "limited".to_owned());
2426        assert_eq!(
2427            request_color_range(&query, ColorRange::Full),
2428            ColorRange::Limited
2429        );
2430
2431        query.clear();
2432        query.insert("colorRange".to_owned(), "pc".to_owned());
2433        assert_eq!(
2434            request_color_range(&query, ColorRange::Limited),
2435            ColorRange::Full
2436        );
2437    }
2438
2439    #[test]
2440    fn request_video_color_is_explicitly_opt_in() {
2441        assert!(!request_video_color(&HashMap::new()));
2442
2443        let mut query = HashMap::new();
2444        query.insert("video_color".to_owned(), "1".to_owned());
2445        assert!(request_video_color(&query));
2446
2447        query.clear();
2448        query.insert("videoColor".to_owned(), "mp4".to_owned());
2449        assert!(request_video_color(&query));
2450    }
2451
2452    #[test]
2453    fn video_color_preview_preserves_gray_pixels() {
2454        let image = CpuRasterImage::new(
2455            2,
2456            2,
2457            PixelFormat::Rgba8,
2458            vec![
2459                64, 64, 64, 255, 128, 128, 128, 200, 200, 200, 200, 180, 255, 255, 255, 128,
2460            ],
2461        );
2462
2463        let out = video_color_preview_image(&image, ColorRange::Full).expect("convert");
2464        assert_eq!(out.pixels, image.pixels);
2465    }
2466
2467    #[test]
2468    fn video_color_preview_shares_chroma_per_420_block() {
2469        let image =
2470            CpuRasterImage::new(2, 1, PixelFormat::Rgba8, vec![255, 0, 0, 77, 0, 0, 255, 88]);
2471
2472        let out = video_color_preview_image(&image, ColorRange::Full).expect("convert");
2473        assert_eq!(out.width, 2);
2474        assert_eq!(out.height, 1);
2475        assert_eq!(out.format, PixelFormat::Rgba8);
2476        assert_eq!(out.pixels[3], 77);
2477        assert_eq!(out.pixels[7], 88);
2478        assert_ne!(&out.pixels[..3], &image.pixels[..3]);
2479        assert_ne!(&out.pixels[4..7], &image.pixels[4..7]);
2480    }
2481
2482    #[test]
2483    fn info_json_includes_the_project_name() {
2484        let timelines = vec![TimelineInfo {
2485            id: "main".to_owned(),
2486            title: "Main".to_owned(),
2487            duration: 4.0,
2488            error: None,
2489        }];
2490        let json = info_json(
2491            "demo \"crate\"",
2492            Resolution::new(1280, 720),
2493            30,
2494            &timelines,
2495            None,
2496            "cache-key",
2497            &CompileSnapshot::compiled(),
2498        );
2499
2500        assert_eq!(
2501            json,
2502            "{\"projectName\":\"demo \\\"crate\\\"\",\"width\":1280,\"height\":720,\"fps\":30,\"lastError\":null,\"cacheKey\":\"cache-key\",\"compileStatus\":\"compiled\",\"compileError\":null,\"timelines\":[{\"id\":\"main\",\"title\":\"Main\",\"duration\":4,\"error\":null}]}"
2503        );
2504    }
2505
2506    // Round-trips the `.sketch/01` B.4 arrangement shape at a small scale: an
2507    // overlay timeline with a video child (a source crop) and a sequence of two
2508    // captions, one carrying a trigger. Asserts the hand-built emitter matches
2509    // the documented JSON exactly (lowercased kinds, `null`/`[a,b]` trim, every
2510    // float through `finite_json_number`, recursive children).
2511    #[test]
2512    fn arrangement_json_matches_the_b4_shape() {
2513        let arrangement = Arrangement {
2514            kind: NodeKind::Timeline,
2515            label: "root".to_owned(),
2516            // A non-null `name` exercises the escaped-string branch; the rest stay
2517            // `null` to cover the absent-name branch.
2518            name: Some("Dialogue · \"hi\"".to_owned()),
2519            source: None,
2520            start: 0.0,
2521            end: 6.0,
2522            trim: None,
2523            triggers: Vec::new(),
2524            children: vec![
2525                Arrangement {
2526                    kind: NodeKind::Video,
2527                    label: "establishing.mp4".to_owned(),
2528                    name: None,
2529                    // A non-null `source` exercises the object branch (note the
2530                    // backslash escaping in the file path); the rest stay `null`.
2531                    source: Some(SourceLoc {
2532                        file: "scenes\\intro.rs".to_owned(),
2533                        line: 42,
2534                    }),
2535                    start: 0.0,
2536                    end: 2.0,
2537                    trim: Some((1.0, 3.0)),
2538                    triggers: Vec::new(),
2539                    children: Vec::new(),
2540                },
2541                Arrangement {
2542                    kind: NodeKind::Sequence,
2543                    label: String::new(),
2544                    name: None,
2545                    source: None,
2546                    start: 0.0,
2547                    end: 6.0,
2548                    trim: None,
2549                    triggers: Vec::new(),
2550                    children: vec![
2551                        Arrangement {
2552                            kind: NodeKind::Video,
2553                            label: "one".to_owned(),
2554                            name: None,
2555                            source: None,
2556                            start: 0.0,
2557                            end: 3.0,
2558                            trim: None,
2559                            triggers: Vec::new(),
2560                            children: Vec::new(),
2561                        },
2562                        Arrangement {
2563                            kind: NodeKind::Video,
2564                            label: "two".to_owned(),
2565                            name: None,
2566                            source: None,
2567                            start: 3.0,
2568                            end: 6.0,
2569                            trim: None,
2570                            // A named trigger exercises the string branch; an
2571                            // anonymous one covers the `null` branch.
2572                            triggers: vec![
2573                                TriggerMark {
2574                                    time: 3.0,
2575                                    name: Some("reveal".to_owned()),
2576                                },
2577                                TriggerMark {
2578                                    time: 4.0,
2579                                    name: None,
2580                                },
2581                            ],
2582                            children: Vec::new(),
2583                        },
2584                    ],
2585                },
2586            ],
2587        };
2588
2589        let expected = concat!(
2590            "{\"kind\":\"timeline\",\"label\":\"root\",\"name\":\"Dialogue · \\\"hi\\\"\",\"source\":null,\"start\":0,\"end\":6,",
2591            "\"trim\":null,\"triggers\":[],\"children\":[",
2592            "{\"kind\":\"video\",\"label\":\"establishing.mp4\",\"name\":null,\"source\":{\"file\":\"scenes\\\\intro.rs\",\"line\":42},\"start\":0,\"end\":2,",
2593            "\"trim\":[1,3],\"triggers\":[],\"children\":[]},",
2594            "{\"kind\":\"sequence\",\"label\":\"\",\"name\":null,\"source\":null,\"start\":0,\"end\":6,",
2595            "\"trim\":null,\"triggers\":[],\"children\":[",
2596            "{\"kind\":\"video\",\"label\":\"one\",\"name\":null,\"source\":null,\"start\":0,\"end\":3,",
2597            "\"trim\":null,\"triggers\":[],\"children\":[]},",
2598            "{\"kind\":\"video\",\"label\":\"two\",\"name\":null,\"source\":null,\"start\":3,\"end\":6,",
2599            "\"trim\":null,\"triggers\":[",
2600            "{\"time\":3,\"name\":\"reveal\"},",
2601            "{\"time\":4,\"name\":null}",
2602            "],\"children\":[]}",
2603            "]}",
2604            "]}"
2605        );
2606
2607        assert_eq!(arrangement_json(&arrangement), expected);
2608    }
2609
2610    #[test]
2611    fn arrangement_json_non_finite_floats_become_zero() {
2612        // `finite_json_number` guards every float, so an unfired/absent length
2613        // (∞ / NaN) never leaks a non-JSON token.
2614        let arrangement = Arrangement {
2615            kind: NodeKind::Video,
2616            label: String::new(),
2617            name: None,
2618            source: None,
2619            start: f64::INFINITY,
2620            end: f64::NAN,
2621            trim: None,
2622            triggers: vec![TriggerMark {
2623                time: f64::INFINITY,
2624                name: None,
2625            }],
2626            children: Vec::new(),
2627        };
2628        let json = arrangement_json(&arrangement);
2629        assert!(json.contains("\"start\":0"));
2630        assert!(json.contains("\"end\":0"));
2631        assert!(json.contains("\"triggers\":[{\"time\":0,\"name\":null}]"));
2632        assert!(json.contains("\"source\":null"));
2633    }
2634}