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