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
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: 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 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 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 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
1004const 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
1010struct TempFile(PathBuf);
1013
1014impl Drop for TempFile {
1015 fn drop(&mut self) {
1016 let _ = std::fs::remove_file(&self.0);
1017 }
1018}
1019
1020fn 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()); bytes.extend_from_slice(&3u16.to_le_bytes()); 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 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 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 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 .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 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 .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 .args(["-c:a", "flac"])
1296 .args(["-compression_level", "0"]);
1297 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 .args(["-t", &format!("{video_seconds:.6}")])
1313 .args(["-muxdelay", "0"])
1314 .args(["-muxpreload", "0"])
1315 .args(["-flush_packets", "1"])
1316 .args(["-f", "mp4"])
1317 .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 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 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 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 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
1832fn 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
2022fn 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
2243fn 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
2255fn 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 #[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 assert!(clamped < duration, "{clamped} must be < {duration}");
2376 assert!(
2378 clamped >= duration - frame_step,
2379 "{clamped} must be in the last frame interval [{}, {duration})",
2380 duration - frame_step
2381 );
2382 assert!((clamped - (duration - frame_step)).abs() < 1e-6);
2384
2385 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 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 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 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 #[test]
2533 fn arrangement_json_matches_the_b4_shape() {
2534 let arrangement = Arrangement {
2535 kind: NodeKind::Timeline,
2536 label: "root".to_owned(),
2537 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 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 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 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}