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