ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
lib/session/render.c
Go to the documentation of this file.
1
13#include <ascii-chat/session/render.h>
14#include <ascii-chat/session/capture.h>
15#include <ascii-chat/session/display.h>
16#include <ascii-chat/ui/help_screen.h>
17#include <ascii-chat/log/interactive_grep.h>
18#include <ascii-chat/common.h>
19#include <ascii-chat/log/logging.h>
20#include <ascii-chat/options/options.h>
21#include <ascii-chat/util/time.h>
22#include <ascii-chat/audio/audio.h>
23#include <ascii-chat/media/source.h>
24#include <ascii-chat/media/ffmpeg_decoder.h>
25#include <ascii-chat/platform/keyboard.h>
26#include <ascii-chat/asciichat_errno.h>
27
28#include <stddef.h>
29#include <stdbool.h>
30#include <math.h>
31#ifndef _WIN32
32#include <unistd.h>
33#endif
34
35/* ============================================================================
36 * Unified Render Loop Implementation
37 * ============================================================================ */
38
39asciichat_error_t session_render_loop(session_capture_ctx_t *capture, session_display_ctx_t *display,
40 session_should_exit_fn should_exit, session_capture_fn capture_cb,
41 session_sleep_for_frame_fn sleep_cb, session_keyboard_handler_fn keyboard_handler,
42 void *user_data) {
43 // Validate required parameters
44 if (!display) {
45 SET_ERRNO(ERROR_INVALID_PARAM, "session_render_loop: display context is NULL");
46 return ERROR_INVALID_PARAM;
47 }
48
49 if (!should_exit) {
50 SET_ERRNO(ERROR_INVALID_PARAM, "session_render_loop: should_exit callback is NULL");
51 return ERROR_INVALID_PARAM;
52 }
53
54 // Validate mode: either capture context OR custom callbacks, not both
55 if (!capture && !capture_cb) {
56 SET_ERRNO(ERROR_INVALID_PARAM, "session_render_loop: must provide either capture context or capture callback");
57 return ERROR_INVALID_PARAM;
58 }
59
60 if (capture && capture_cb) {
61 SET_ERRNO(ERROR_INVALID_PARAM, "session_render_loop: cannot provide both capture context and capture callback");
62 return ERROR_INVALID_PARAM;
63 }
64
65 // In event-driven mode, both capture_cb and sleep_cb must be provided together
66 if (capture_cb && !sleep_cb) {
67 SET_ERRNO(ERROR_INVALID_PARAM, "session_render_loop: capture_cb requires sleep_cb (must provide both or neither)");
68 return ERROR_INVALID_PARAM;
69 }
70
71 if (sleep_cb && !capture_cb) {
72 SET_ERRNO(ERROR_INVALID_PARAM, "session_render_loop: sleep_cb requires capture_cb (must provide both or neither)");
73 return ERROR_INVALID_PARAM;
74 }
75
76 // Snapshot mode state tracking
77 uint64_t snapshot_start_time_ns = 0; // Initialized when first frame is rendered
78 bool snapshot_done = false;
79 bool first_frame_rendered = false;
80 bool snapshot_mode = GET_OPTION(snapshot_mode);
81
82 // Pause mode state tracking
83 bool initial_paused_frame_rendered = false;
84 bool was_paused = false;
85 bool is_paused = false;
86
87 // Keyboard input initialization (if keyboard handler is provided)
88 // Disable keyboard in snapshot mode and non-interactive terminals
89 // Only enable keyboard if BOTH:
90 // 1. Terminal is interactive (stdin/stdout are TTYs)
91 // 2. NOT in snapshot mode
92 bool keyboard_enabled = false;
93 if (keyboard_handler && terminal_is_interactive() && !snapshot_mode) {
94 // Try to initialize keyboard if both stdin and stdout are TTYs in interactive mode
95 asciichat_error_t kb_result = keyboard_init();
96 if (kb_result == ASCIICHAT_OK) {
97 keyboard_enabled = true;
98 log_debug("Keyboard input enabled (TTY mode)");
99 } else {
100 log_debug("Failed to initialize keyboard input (%s) - will attempt fallback", asciichat_error_string(kb_result));
101 // Don't fail - continue with keyboard handler (will try to read anyway)
102 keyboard_enabled = true; // Allow trying to read keyboard even if init failed
103 }
104 }
105
106 // Determine mode: synchronous (capture provided) or event-driven (callbacks provided)
107 bool is_synchronous = (capture != NULL);
108
109 // Frame rate timing
110 uint64_t frame_count = 0;
111 uint64_t frame_start_ns = 0;
112 uint64_t frame_to_render_ns = 0;
113
114 // Disable console logging during rendering to prevent logs from corrupting frame display
115 // This must be done BEFORE the render loop to avoid repeated lock/unlock cycles that could cause deadlocks
117
118 // Main render loop - works for both synchronous and event-driven modes
119 while (!should_exit(user_data)) {
120 // Frame timing - measure total time to maintain target FPS
121 frame_start_ns = time_get_ns();
122
123 // Frame capture and timing - mode-dependent
124 image_t *image = NULL;
125 uint64_t capture_start_ns = 0;
126 uint64_t capture_end_ns = 0;
127 uint64_t pre_convert_ns = 0;
128 uint64_t post_convert_ns = 0;
129
130 if (is_synchronous) {
131 capture_start_ns = time_get_ns();
132 // SYNCHRONOUS MODE: Use session_capture context
133
134 // Check pause state and handle initial frame rendering
136 is_paused = source && media_source_is_paused(source);
137
138 // Detect pause transition - mark initial frame as rendered so polling starts
139 if (!was_paused && is_paused) {
140 initial_paused_frame_rendered = true;
141 log_debug("Media paused, enabling keyboard polling");
142 }
143
144 // Detect unpause transition to reset flag
145 if (was_paused && !is_paused) {
146 initial_paused_frame_rendered = false;
147 log_debug("Media unpaused, resuming frame capture");
148 }
149 was_paused = is_paused;
150
151 // If paused and already rendered initial frame, skip frame capture and poll for resume
152 if (is_paused && initial_paused_frame_rendered) {
153 // Sleep briefly to avoid busy-waiting while paused
154 uint64_t idle_sleep_ns = (uint64_t)(NS_PER_SEC_INT / GET_OPTION(fps)); // Frame period in nanoseconds
155 platform_sleep_ns(idle_sleep_ns);
156
157 // Keep polling keyboard to allow unpausing (even if keyboard wasn't formally initialized)
158 // keyboard_read_nonblocking() is safe to call even if keyboard_init() wasn't called - it just returns KEY_NONE
159 if (keyboard_handler) {
160 keyboard_key_t key = keyboard_read_nonblocking();
161 if (key != KEY_NONE) {
162 // Check if interactive grep should handle this key
165 continue; // Force immediate re-render
166 }
167
168 keyboard_handler(capture, key, user_data);
169 }
170 }
171 continue; // Skip frame capture and rendering, keep loop running
172 }
173
174 // Profile: frame capture with detailed retry tracking
175 static uint64_t max_retries = 0;
176 uint64_t loop_retry_count = 0;
177 uint64_t capture_elapsed_ns = 0;
178
179 do {
180 // Check for exit request before blocking on frame read
181 if (should_exit(user_data)) {
182 break;
183 }
184
185 log_debug_every(3 * US_PER_SEC_INT, "RENDER[%lu]: Starting frame read", frame_count);
186 image = session_capture_read_frame(capture);
187 log_debug_every(3 * US_PER_SEC_INT, "RENDER[%lu]: Frame read done, image=%p", frame_count, (void *)image);
188 capture_elapsed_ns = time_elapsed_ns(capture_start_ns, time_get_ns());
189
190 if (!image) {
191 // Check if we've reached end of file for media sources
192 if (session_capture_at_end(capture)) {
193 log_info("Media source reached end of file");
194 break; // Exit render loop - end of media
195 }
196
197 // Check for exit request during retry loop
198 if (should_exit(user_data)) {
199 break;
200 }
201
202 loop_retry_count++;
203
204 // Brief delay before retry on temporary frame unavailability
205 if (loop_retry_count <= 1 || frame_count % 100 == 0) {
206 if (loop_retry_count == 1 && frame_count > 0) {
207 log_debug_every(500 * US_PER_MS_INT, "FRAME_WAIT: retry at frame %lu (waited %.1f ms so far)",
208 frame_count, (double)capture_elapsed_ns / NS_PER_MS);
209 }
210 }
211
212 // Track max retry count for diagnostic logging
213 if (loop_retry_count > max_retries) {
214 max_retries = loop_retry_count;
215 }
216
217 platform_sleep_us(10 * US_PER_MS_INT); // 10ms
218 continue;
219 }
220
221 // Frame obtained successfully
222 if (loop_retry_count > 0) {
223 double wait_ms = (double)capture_elapsed_ns / NS_PER_MS;
224 log_debug_every(US_PER_SEC_INT, "FRAME_OBTAINED: after %lu retries, waited %.1f ms", loop_retry_count,
225 wait_ms);
226 }
227 break; // Exit retry loop
228 } while (true);
229
230 // If we still don't have an image after retries, skip this frame and continue
231 // (This happens during network latency or when prefetch thread is catching up)
232 if (!image) {
233 continue;
234 }
235
236 // Capture phase complete - record timestamp for phase breakdown
237 capture_end_ns = time_get_ns();
238
239 frame_count++;
240
241 // Log capture time every 30 frames
242 if (frame_count % 30 == 0) {
243 double capture_ms = (double)capture_elapsed_ns / NS_PER_MS;
244 log_dev_every(5 * US_PER_SEC_INT, "PROFILE[%lu]: CAPTURE=%.2f ms", frame_count, capture_ms);
245 }
246
247 // Pause after first frame if requested via --pause flag
248 // We read the frame first, then pause, so the initial frame is available for rendering
249 if (!is_paused && frame_count == 1 && GET_OPTION(pause) && source) {
250 media_source_pause(source);
251 is_paused = true;
252 // Note: initial_paused_frame_rendered will be set in next iteration when pause is detected above
253 log_debug("Paused media source after first frame");
254 }
255
256 } else {
257 // Both sleep_cb and capture_cb are guaranteed non-NULL by validation above
258 sleep_cb(user_data);
259 image = capture_cb(user_data);
260
261 if (!image) {
262 // No frame available - this is normal in async modes (network latency, etc.)
263 // Just continue to next iteration, don't exit
264 continue;
265 }
266 }
267
268 // Convert image to ASCII using display context
269 // Handles all palette, terminal caps, width, height, stretch settings
270 pre_convert_ns = time_get_ns();
271 char *ascii_frame = session_display_convert_to_ascii(display, image);
272 post_convert_ns = time_get_ns();
273 uint64_t conversion_elapsed_ns = post_convert_ns - pre_convert_ns;
274
275 if (ascii_frame) {
276 // Detect when we have a paused frame (first frame after pausing)
277 bool is_paused_frame = initial_paused_frame_rendered && is_paused;
278
279 // When paused with snapshot mode, output the initial frame immediately
280 bool output_paused_frame = snapshot_mode && is_paused_frame;
281
282 // Always attempt to render frames; the display context will handle filtering based on:
283 // - TTY mode: render all frames with cursor control (even in snapshot mode, for animation)
284 // - Piped mode: render all frames without cursor control (for continuous capture)
285 // - Snapshot mode on non-TTY: only the display context renders the final frame
286 bool should_write = true;
287 uint64_t pre_render_ns = 0, post_render_ns = 0; // Declare outside if block for timing
288 if (should_write) {
289 // is_final = true when: snapshot done, or paused frame (for both snapshot and pause modes)
290 // Profile: render frame
291 pre_render_ns = time_get_ns();
292 START_TIMER("render_frame");
293
294 // Check if help screen is active - if so, render help instead of frame
295 // Help screen is disabled in snapshot mode and non-interactive terminals (keyboard disabled)
296 if (display && session_display_is_help_active(display) && terminal_is_interactive() && !snapshot_mode) {
298 } else {
299 session_display_render_frame(display, ascii_frame);
300 }
301
302 uint64_t render_elapsed_ns = STOP_TIMER("render_frame");
303 post_render_ns = time_get_ns();
304
305 // Calculate total time from frame START (frame_start_ns) to render COMPLETE
306 frame_to_render_ns = time_elapsed_ns(frame_start_ns, post_render_ns);
307 if (frame_count % 30 == 0) {
308 double total_frame_time_ms = (double)frame_to_render_ns / NS_PER_MS;
309 log_dev("ACTUAL_TIME[%lu]: Total frame time from start to render complete: %.1f ms", frame_count,
310 total_frame_time_ms);
311 }
312
313 // Log render time every 30 frames
314 if (frame_count % 150 == 0) {
315 double conversion_ms = (double)conversion_elapsed_ns / NS_PER_MS;
316 double render_ms = (double)render_elapsed_ns / NS_PER_MS;
317 log_dev_every(5 * US_PER_SEC_INT, "PROFILE[%lu]: CONVERT=%.2f ms, RENDER=%.2f ms", frame_count, conversion_ms,
318 render_ms);
319 }
320 }
321
322 // Keyboard input polling (if enabled) - MUST come before snapshot exit check so help screen can be toggled
323 // Allow keyboard in snapshot mode too (for help screen toggle debugging)
324 // Only enable keyboard if BOTH stdin AND stdout are TTYs to avoid buffering issues
325 // when tcsetattr() modifies the tty line discipline
326 if (keyboard_enabled && keyboard_handler) {
327 START_TIMER("keyboard_read_%lu", (unsigned long)frame_count);
328 keyboard_key_t key = keyboard_read_nonblocking();
329 double keyboard_elapsed_ns = STOP_TIMER("keyboard_read_%lu", (unsigned long)frame_count);
330 if (keyboard_elapsed_ns >= 0.0) {
331 char _duration_str[32];
332 format_duration_ns(keyboard_elapsed_ns, _duration_str, sizeof(_duration_str));
333 log_dev("RENDER[%lu] Keyboard read complete (key=%d) in %s", (unsigned long)frame_count, key, _duration_str);
334 }
335 if (key != KEY_NONE) {
336 // Check if interactive grep should handle this key
339 continue; // Force immediate re-render
340 }
341
342 // Normal keyboard handler
343 keyboard_handler(capture, key, user_data);
344 }
345 }
346
347 // Snapshot mode timing: start timer right after rendering first frame
348 if (snapshot_mode && !first_frame_rendered) {
349 snapshot_start_time_ns = time_get_ns();
350 first_frame_rendered = true;
351 log_debug("Snapshot mode: first frame rendered, timer started");
352 }
353
354 // Snapshot mode: check if delay has elapsed after rendering a frame
355 if (snapshot_mode && !snapshot_done && first_frame_rendered) {
356 uint64_t current_time_ns = time_get_ns();
357 double elapsed_sec = time_ns_to_s(time_elapsed_ns(snapshot_start_time_ns, current_time_ns));
358 double snapshot_delay = GET_OPTION(snapshot_delay);
359
360 log_debug_every(US_PER_SEC_INT, "SNAPSHOT_DELAY_CHECK: elapsed=%.2f delay=%.2f", elapsed_sec, snapshot_delay);
361
362 // snapshot_delay=0 means exit immediately after rendering first frame
363 // snapshot_delay>0 means wait that many seconds after first frame
364 if (elapsed_sec >= snapshot_delay) {
365 // We don't end frames with newlines so the next log would print on the same line as the frame's
366 // last row without an \n here. We only need this \n in stdout in snapshot mode and when interactive,
367 // so piped snapshots don't have a weird newline in stdout that they don't need.
368 if (GET_OPTION(snapshot_mode) && terminal_is_interactive()) {
369 printf("\n");
370 }
371 log_info("Snapshot delay %.2f seconds elapsed, exiting", snapshot_delay);
372 snapshot_done = true;
373 }
374 }
375
376 // Exit conditions: snapshot mode exits after capturing the final frame or initial paused frame
377 if (snapshot_mode && (snapshot_done || output_paused_frame)) {
378 SAFE_FREE(ascii_frame);
379 break;
380 }
381
382 SAFE_FREE(ascii_frame);
383
384 // Measure frame completion right after rendering, BEFORE keyboard polling
385 // This gives us accurate timing for just the core frame operations
386 uint64_t frame_end_render_ns = time_get_ns();
387
388 // Calculate each phase duration
389 uint64_t prestart_ms =
390 (capture_start_ns > frame_start_ns) ? (capture_start_ns - frame_start_ns) / NS_PER_MS_INT : 0;
391 uint64_t capture_ms =
392 (capture_end_ns > capture_start_ns) ? (capture_end_ns - capture_start_ns) / NS_PER_MS_INT : 0;
393 uint64_t convert_ms = conversion_elapsed_ns / NS_PER_MS_INT;
394 uint64_t render_ms =
395 (post_render_ns > pre_render_ns && post_render_ns > 0) ? (post_render_ns - pre_render_ns) / NS_PER_MS_INT : 0;
396 uint64_t total_ms =
397 (frame_end_render_ns > frame_start_ns) ? (frame_end_render_ns - frame_start_ns) / NS_PER_MS_INT : 0;
398
399 // Log phase breakdown every 5 frames
400 if (frame_count % 5 == 0) {
401 log_dev(
402 "PHASE_BREAKDOWN[%lu]: prestart=%llu ms, capture=%llu ms, convert=%llu ms, render=%llu ms (total=%llu ms)",
403 frame_count, prestart_ms, capture_ms, convert_ms, render_ms, total_ms);
404 }
405 } else {
406 // Snapshot mode: even if frame conversion failed, check if we should exit
407 // This ensures snapshot_delay is honored even if display context isn't rendering
408 if (snapshot_mode && snapshot_done) {
409 break;
410 }
411 }
412
413 // Audio-Video Synchronization: Keep audio and video in sync by periodically adjusting audio to match video time
414 // DISABLED: Audio sync causes seeking every 1 second, which interrupts playback
415 // For file/media playback (mirror mode), audio and video decode independently
416 // and don't need synchronization - they play at their natural rates from the file.
417 // Audio sync is only needed for multi-client video conferencing (server mode).
418 //
419 // if (is_synchronous && capture && image && frame_count % 30 == 0) {
420 // media_source_sync_audio_to_video(source);
421 // }
422
423 // Frame rate limiting: Only sleep if we're ahead of schedule
424 // If decoder is slow and we're already behind, don't add extra sleep
425 if (is_synchronous && capture) {
426 uint32_t target_fps = session_capture_get_target_fps(capture);
427 if (target_fps > 0) {
428 uint64_t frame_end_ns = time_get_ns();
429 uint64_t frame_elapsed_ns = time_elapsed_ns(frame_start_ns, frame_end_ns);
430 uint64_t frame_target_ns = NS_PER_SEC_INT / target_fps;
431
432 log_dev("RENDER[%lu] TIMING_TOTAL: frame_time_ms=%.2f target_ms=%.2f", frame_count,
433 (double)frame_elapsed_ns / NS_PER_MS, (double)frame_target_ns / NS_PER_MS);
434
435 // Only sleep if we have time budget remaining
436 // If already behind, skip sleep to catch up
437 if (frame_elapsed_ns < frame_target_ns) {
438 uint64_t sleep_ns = frame_target_ns - frame_elapsed_ns;
439 // Sleep with 500us overhead reserved for recovery
440 if (sleep_ns > 500 * US_PER_MS_INT) {
441 platform_sleep_ns((sleep_ns - 500 * US_PER_MS_INT));
442 }
443 }
444 }
445 }
446
447 // Note: Images returned by media sources are cached/reused and should NOT be destroyed
448 // The image pointers are managed by the source and will be cleaned up on source shutdown
449 } // while (!should_exit(user_data)) {
450
451 // Re-enable console logging after rendering completes
453 if (!GET_OPTION(snapshot_mode) && terminal_is_interactive()) {
454 printf("\n");
455 }
456
457 // Keyboard input cleanup (if it was initialized)
458 if (keyboard_enabled) {
459 keyboard_destroy();
460 log_debug("Keyboard input disabled");
461 }
462
463 return ASCIICHAT_OK;
464}
bool should_exit(void)
Definition main.c:90
void session_display_render_help(session_display_ctx_t *ctx)
Render help screen centered on terminal.
asciichat_error_t interactive_grep_handle_key(keyboard_key_t key)
bool interactive_grep_should_handle(int key)
bool session_capture_at_end(session_capture_ctx_t *ctx)
image_t * session_capture_read_frame(session_capture_ctx_t *ctx)
uint32_t session_capture_get_target_fps(session_capture_ctx_t *ctx)
void * session_capture_get_media_source(session_capture_ctx_t *ctx)
void session_display_render_frame(session_display_ctx_t *ctx, const char *frame_data)
bool session_display_is_help_active(session_display_ctx_t *ctx)
Check if help screen is currently active (implemented in display.c for struct access)
char * session_display_convert_to_ascii(session_display_ctx_t *ctx, const image_t *image)
asciichat_error_t session_render_loop(session_capture_ctx_t *capture, session_display_ctx_t *display, session_should_exit_fn should_exit, session_capture_fn capture_cb, session_sleep_for_frame_fn sleep_cb, session_keyboard_handler_fn keyboard_handler, void *user_data)
void log_set_terminal_output(bool enabled)
bool terminal_is_interactive(void)
void platform_sleep_us(unsigned int us)
bool media_source_is_paused(media_source_t *source)
Definition source.c:926
void media_source_pause(media_source_t *source)
Definition source.c:908
Media source for video and audio capture.
Definition source.c:31
Internal session display context structure.
uint64_t time_get_ns(void)
Definition util/time.c:48
int format_duration_ns(double nanoseconds, char *buffer, size_t buffer_size)
Definition util/time.c:275
uint64_t time_elapsed_ns(uint64_t start_ns, uint64_t end_ns)
Definition util/time.c:90