ascii-chat
Binary v0.15.83-53-gb711d8720
Library lib/v0.3.2-701-gb711d8720
Video chat in your terminal
Loading...
Searching...
No Matches
ffmpeg_encoder.c
Go to the documentation of this file.
1
6
7/* Storage capacities in elements; text buffers include the terminating NUL. */
8#define MEDIA_FFMPEG_ENCODER_MESSAGE_BUFFER_SIZE 2048
9
10/* Existing buffer budgets in bytes, including NUL for text storage. */
11#define FFMPEG_STDOUT_AVIO_BUFFER_SIZE 32768
12
15#include <ascii-chat/log/log.h>
16#include <ascii-chat/log/io.h>
19#include <string.h>
22#include <libplatform/compiler.h>
23#include <libplatform/system.h>
24#include <libplatform/filesystem.h>
25#include <libplatform/terminal.h>
26
27#include <libavformat/avformat.h>
28#include <libavcodec/avcodec.h>
29#include <libavutil/opt.h>
30#include <libavutil/cpu.h>
31#include <libswscale/swscale.h>
32#include <libswresample/swresample.h>
33
40 // Video stream
41 AVCodecContext *codec_ctx;
42 AVStream *stream;
43 AVFrame *frame;
44 AVFrame *frame_encoded; // Frame in target pixel format for encoding
45 struct SwsContext *sws_ctx;
46
47 // Audio stream
48 AVStream *audio_stream;
49 AVCodecContext *audio_codec_ctx;
50 struct SwrContext *audio_swr_ctx;
51 AVFrame *audio_frame;
52 int64_t audio_pts;
53 int audio_frame_size; // Codec's required samples per frame
54 float *audio_partial_buf; // Accumulator for partial frames
56
57 // Shared
58 AVFormatContext *fmt_ctx;
59 AVPacket *pkt;
67 int64_t video_pts; // Cumulative PTS for proper frame timestamps
68 int fps; // Frames per second (for duration calculation)
69 int is_image; // Single-frame format (PNG, JPG)
70 enum AVPixelFormat target_pix_fmt; // RGB24 for images, YUV420P for video
71 int has_audio_stream; // Whether audio stream was created
72 int is_stdout_pipe; // Whether writing to stdout (non-seekable)
73 FILE *stdout_stream; // Retained before LOG_IO temporarily redirects stdio
74 uint64_t previous_captured_ns; // Previous frame's capture timestamp (for snapshot mode)
75 double snapshot_actual_duration; // Actual wall-clock duration in snapshot mode (seconds)
76 uint64_t snapshot_elapsed_ns; // Current elapsed time in snapshot mode for dynamic frame duration
77
78 // Frame timing from capture timestamps
79 uint64_t first_frame_captured_ns; // Wall-clock timestamp of first frame (0 = not set)
80
81 // Snapshot mode frame distribution
82 int estimated_frame_count; // Expected frame count for snapshot mode (fps * snapshot_delay)
83 int64_t estimated_frame_duration; // Duration each frame should have in stream time_base units
84};
85
86// Determine audio codec from file extension
87static void get_audio_codec_from_extension(const char *path, const char **audio_codec, enum AVSampleFormat *sample_fmt,
88 int *has_audio) {
89 const char *dot = strrchr(path, '.');
90 *has_audio = 0;
91 *audio_codec = NULL;
92 *sample_fmt = AV_SAMPLE_FMT_NONE;
93
94 if (!dot) {
95 // Use Opus for stdout (pipe/non-seekable output, part of WebM), otherwise default to AAC
96 if (path && strcmp(path, "-") == 0) {
97 *has_audio = 1;
98 *audio_codec = "libopus";
99 *sample_fmt = AV_SAMPLE_FMT_FLT;
100 } else {
101 *has_audio = 1;
102 *audio_codec = "aac";
103 *sample_fmt = AV_SAMPLE_FMT_FLTP;
104 }
105 return;
106 }
107
108 const char *ext = dot + 1;
109 // Video formats with audio support
110 // NOLINTNEXTLINE(bugprone-branch-clone) - Each format requires distinct codec configuration
111 if (strcmp(ext, "mp4") == 0 || strcmp(ext, "mov") == 0) {
112 *has_audio = 1;
113 *audio_codec = "aac";
114 *sample_fmt = AV_SAMPLE_FMT_FLTP;
115 } else if (strcmp(ext, "mkv") == 0 || strcmp(ext, "mka") == 0) {
116 *has_audio = 1;
117 *audio_codec = "aac"; // Matroska supports both AAC and Opus
118 *sample_fmt = AV_SAMPLE_FMT_FLTP;
119 } else if (strcmp(ext, "webm") == 0) {
120 *has_audio = 1;
121 *audio_codec = "libopus";
122 *sample_fmt = AV_SAMPLE_FMT_FLT;
123 } else if (strcmp(ext, "avi") == 0) {
124 *has_audio = 1;
125 *audio_codec = "pcm_s16le";
126 *sample_fmt = AV_SAMPLE_FMT_S16;
127 } else if (strcmp(ext, "flv") == 0) {
128 *has_audio = 1;
129 *audio_codec = "aac";
130 *sample_fmt = AV_SAMPLE_FMT_FLTP;
131 } else if (strcmp(ext, "ogv") == 0 || strcmp(ext, "ogg") == 0) {
132 *has_audio = 1;
133 *audio_codec = "libvorbis";
134 *sample_fmt = AV_SAMPLE_FMT_FLTP;
135 } else if (strcmp(ext, "3gp") == 0) {
136 *has_audio = 1;
137 *audio_codec = "aac";
138 *sample_fmt = AV_SAMPLE_FMT_FLTP;
139 } else if (strcmp(ext, "gif") == 0 || strcmp(ext, "png") == 0 || strcmp(ext, "jpg") == 0 ||
140 strcmp(ext, "jpeg") == 0 || strcmp(ext, "bmp") == 0 || strcmp(ext, "webp") == 0) {
141 *has_audio = 0;
142 *audio_codec = NULL;
143 *sample_fmt = AV_SAMPLE_FMT_NONE;
144 } else {
145 // Default for unknown formats
146 *has_audio = 1;
147 *audio_codec = "aac";
148 *sample_fmt = AV_SAMPLE_FMT_FLTP;
149 }
150}
151
152// Determine codec, format, and pixel format from file extension
153static void get_codec_from_extension(const char *path, const char **codec, const char **format, int *is_image,
154 enum AVPixelFormat *pix_fmt) {
155 const char *dot = strrchr(path, '.');
156 *is_image = 0;
157 *pix_fmt = AV_PIX_FMT_YUV420P; // Default
158
159 if (!dot) {
160 // Default to MP4 for any output without extension (including stdout piping)
161 *codec = "libx264";
162 *format = "mp4";
163 return;
164 }
165
166 const char *ext = dot + 1;
167 // Video formats
168 // NOLINTNEXTLINE(bugprone-branch-clone) - Each format requires distinct codec setup
169 if (strcmp(ext, "mp4") == 0 || strcmp(ext, "mov") == 0) {
170 *codec = "libx264";
171 *format = strcmp(ext, "mov") == 0 ? "mov" : "mp4";
172 *pix_fmt = AV_PIX_FMT_YUV420P;
173 } // NOLINTNEXTLINE(bugprone-branch-clone)
174 else if (strcmp(ext, "mkv") == 0) {
175 *codec = "libx264";
176 *format = "matroska";
177 *pix_fmt = AV_PIX_FMT_YUV420P;
178 } // NOLINTNEXTLINE(bugprone-branch-clone)
179 else if (strcmp(ext, "mka") == 0) {
180 // MKA is audio-only Matroska
181 *codec = "libx264";
182 *format = "matroska";
183 *pix_fmt = AV_PIX_FMT_YUV420P;
184 } else if (strcmp(ext, "webm") == 0) {
185 *codec = "libvpx-vp9";
186 *format = "webm";
187 *pix_fmt = AV_PIX_FMT_YUV420P;
188 } else if (strcmp(ext, "avi") == 0) {
189 *codec = "mpeg4";
190 *format = "avi";
191 *pix_fmt = AV_PIX_FMT_YUV420P;
192 } else if (strcmp(ext, "flv") == 0) {
193 *codec = "libx264";
194 *format = "flv";
195 *pix_fmt = AV_PIX_FMT_YUV420P;
196 } else if (strcmp(ext, "ogv") == 0 || strcmp(ext, "ogg") == 0) {
197 *codec = "libtheora";
198 *format = "ogg";
199 *pix_fmt = AV_PIX_FMT_YUV420P;
200 } else if (strcmp(ext, "3gp") == 0) {
201 *codec = "libx264";
202 *format = "3gp";
203 *pix_fmt = AV_PIX_FMT_YUV420P;
204 } else if (strcmp(ext, "gif") == 0) {
205 *codec = "gif";
206 *format = "gif";
207 *pix_fmt = AV_PIX_FMT_RGB8;
208 } else if (strcmp(ext, "png") == 0) {
209 *codec = "png";
210 *format = "image2";
211 *is_image = 1;
212 *pix_fmt = AV_PIX_FMT_RGB24; // PNG uses RGB24
213 } else if (strcmp(ext, "bmp") == 0 || strcmp(ext, "webp") == 0) {
214 *codec = strcmp(ext, "bmp") == 0 ? "bmp" : "libwebp";
215 *format = "image2";
216 *is_image = 1;
217 *pix_fmt = strcmp(ext, "bmp") == 0 ? AV_PIX_FMT_BGR24 : AV_PIX_FMT_YUV420P;
218 } else if (strcmp(ext, "jpg") == 0 || strcmp(ext, "jpeg") == 0) {
219 *codec = "mjpeg";
220 *format = "image2";
221 *is_image = 1;
222 *pix_fmt = AV_PIX_FMT_YUVJ420P; // MJPEG uses YUVJ420P
223 } else {
224 // Default for unknown formats
225 *codec = "libx264";
226 *format = "mp4";
227 *pix_fmt = AV_PIX_FMT_YUV420P;
228 }
229}
230
231// Initialize audio stream for encoder
232static asciichat_error_t encoder_init_audio_stream(ffmpeg_encoder_t *enc, const char *output_path) {
233 const char *audio_codec_name = NULL;
234 enum AVSampleFormat target_sample_fmt = AV_SAMPLE_FMT_NONE;
235 int has_audio = 0;
236
237 get_audio_codec_from_extension(output_path, &audio_codec_name, &target_sample_fmt, &has_audio);
238
239 if (!has_audio) {
240 log_debug("no audio for format");
241 enc->has_audio_stream = 0;
242 return ASCIICHAT_OK;
243 }
244
245 // Find audio encoder
246 const AVCodec *audio_codec = avcodec_find_encoder_by_name(audio_codec_name);
247 if (!audio_codec) {
248 log_warn("audio encoder '%s' not found, skipping audio", audio_codec_name);
249 enc->has_audio_stream = 0;
250 return ASCIICHAT_OK;
251 }
252
253 // Create audio stream
254 enc->audio_stream = avformat_new_stream(enc->fmt_ctx, audio_codec);
255 if (!enc->audio_stream) {
256 log_warn("avformat_new_stream for audio failed");
257 enc->has_audio_stream = 0;
258 return ASCIICHAT_OK;
259 }
260
261 // Allocate audio codec context
262 enc->audio_codec_ctx = avcodec_alloc_context3(audio_codec);
263 if (!enc->audio_codec_ctx) {
264 log_warn("avcodec_alloc_context3 for audio failed");
265 enc->has_audio_stream = 0;
266 return ASCIICHAT_OK;
267 }
268
269 // Configure audio codec context
270 enc->audio_codec_ctx->sample_rate = 48000; // 48kHz (from audio pipeline)
271 AVChannelLayout ch_layout = AV_CHANNEL_LAYOUT_STEREO;
272 av_channel_layout_copy(&enc->audio_codec_ctx->ch_layout, &ch_layout);
273 enc->audio_codec_ctx->sample_fmt = target_sample_fmt;
274 enc->audio_codec_ctx->time_base = (AVRational){1, 48000};
275 enc->audio_codec_ctx->bit_rate = 128000; // 128 kbps
276
277 // Open audio codec (capture FFmpeg logs)
278 int ret = 0;
279 LOG_IO("ffmpeg", { ret = avcodec_open2(enc->audio_codec_ctx, audio_codec, NULL); });
280 if (ret < 0) {
281 log_warn("avcodec_open2 for audio failed");
282 avcodec_free_context(&enc->audio_codec_ctx);
283 enc->has_audio_stream = 0;
284 return ASCIICHAT_OK;
285 }
286
287 // Copy codec parameters to stream
288 ret = avcodec_parameters_from_context(enc->audio_stream->codecpar, enc->audio_codec_ctx);
289 if (ret < 0) {
290 log_warn("avcodec_parameters_from_context for audio failed");
291 avcodec_free_context(&enc->audio_codec_ctx);
292 enc->has_audio_stream = 0;
293 return ASCIICHAT_OK;
294 }
295
296 // Create audio frame
297 enc->audio_frame = av_frame_alloc();
298 if (!enc->audio_frame) {
299 log_warn("av_frame_alloc for audio failed");
300 avcodec_free_context(&enc->audio_codec_ctx);
301 enc->has_audio_stream = 0;
302 return ASCIICHAT_OK;
303 }
304
305 enc->audio_frame->sample_rate = 48000;
306 AVChannelLayout frame_ch_layout = AV_CHANNEL_LAYOUT_STEREO;
307 av_channel_layout_copy(&enc->audio_frame->ch_layout, &frame_ch_layout);
308 enc->audio_frame->format = target_sample_fmt;
309
310 // Get frame size from codec (if specified)
311 enc->audio_frame_size = enc->audio_codec_ctx->frame_size;
312 if (enc->audio_frame_size == 0) {
313 // Default frame size for codecs that don't specify
314 enc->audio_frame_size = 1024;
315 }
316
317 // Allocate buffer for partial frames (accumulator)
318 enc->audio_partial_buf = SAFE_MALLOC(enc->audio_frame_size * 2 * sizeof(float), float *);
319 enc->audio_partial_len = 0;
320
321 // Create resampler from input format (AV_SAMPLE_FMT_FLT interleaved stereo 48kHz) to target format
322 enc->audio_swr_ctx = swr_alloc();
323 if (!enc->audio_swr_ctx) {
324 log_warn("swr_alloc failed");
326 av_frame_free(&enc->audio_frame);
327 avcodec_free_context(&enc->audio_codec_ctx);
328 enc->has_audio_stream = 0;
329 return ASCIICHAT_OK;
330 }
331
332 // Set input format (interleaved stereo 48kHz float32)
333 AVChannelLayout in_ch_layout = AV_CHANNEL_LAYOUT_STEREO;
334 av_opt_set_chlayout(enc->audio_swr_ctx, "in_chlayout", &in_ch_layout, 0);
335 av_opt_set_int(enc->audio_swr_ctx, "in_sample_rate", 48000, 0);
336 av_opt_set_sample_fmt(enc->audio_swr_ctx, "in_sample_fmt", AV_SAMPLE_FMT_FLT, 0);
337
338 // Set output format
339 AVChannelLayout out_ch_layout = AV_CHANNEL_LAYOUT_STEREO;
340 av_opt_set_chlayout(enc->audio_swr_ctx, "out_chlayout", &out_ch_layout, 0);
341 av_opt_set_int(enc->audio_swr_ctx, "out_sample_rate", 48000, 0);
342 av_opt_set_sample_fmt(enc->audio_swr_ctx, "out_sample_fmt", target_sample_fmt, 0);
343
344 // Initialize the resampler
345 ret = swr_init(enc->audio_swr_ctx);
346 if (ret < 0) {
347 log_warn("swr_init failed");
348 swr_free(&enc->audio_swr_ctx);
350 av_frame_free(&enc->audio_frame);
351 avcodec_free_context(&enc->audio_codec_ctx);
352 enc->has_audio_stream = 0;
353 return ASCIICHAT_OK;
354 }
355
356 enc->audio_pts = 0;
357 enc->has_audio_stream = 1;
358 log_debug("audio stream initialized with codec=%s, frame_size=%d", audio_codec_name, enc->audio_frame_size);
359
360 return ASCIICHAT_OK;
361}
362
363static void encoder_init_snapshot_timing(ffmpeg_encoder_t *enc, int fps) {
364 // For snapshot mode, calculate output FPS based on input FPS and snapshot duration
365 // If we capture for N seconds at input_fps, we'll get ~(N * input_fps) frames
366 // Output duration should be snapshot_delay seconds
367 // So output_fps = (input_fps * snapshot_delay) / snapshot_delay = input_fps
368 // EXCEPT: if the input was probed, fps parameter is the actual input fps
369 // Then output_fps should equal input_fps to make frame durations sum correctly
370 bool snapshot_mode = GET_OPTION(snapshot_mode);
371 double snapshot_delay = GET_OPTION(snapshot_delay);
372 log_debug("snapshot_mode=%d, snapshot_delay=%.1f, input_fps=%d", snapshot_mode, snapshot_delay, fps);
373
374 if (snapshot_mode && snapshot_delay > 0) {
375 // For snapshot mode: if we capture at input_fps for snapshot_delay seconds,
376 // we get approximately (input_fps * snapshot_delay) frames
377 // To make output span exactly snapshot_delay seconds:
378 // output_fps = frame_count / snapshot_delay = (input_fps * snapshot_delay) / snapshot_delay = input_fps
379 // So we should use input_fps as output_fps!
380 // Example: 30fps input for 3s → ~90 frames → output at 30fps → 90/30 = 3 seconds ✓
381
382 log_info("Snapshot mode snapshot_delay=%.1f, input_fps=%d", snapshot_delay, fps);
383 log_info(" → Will estimate ~%.0f frames, output FPS=%d makes durations sum to %.1f seconds", fps * snapshot_delay,
384 fps, snapshot_delay);
385 }
386
387 enc->fps = fps;
388
389 // Snapshot mode frame distribution setup
390 enc->estimated_frame_count = 0;
392 if (snapshot_mode && snapshot_delay > 0 && fps > 0) {
393 // Estimate how many frames we'll capture
394 enc->estimated_frame_count = (int)(fps * snapshot_delay + 0.5);
395 // Each frame should have uniform duration in stream time_base (1/90000 for MP4)
396 // frame_duration = total_duration / estimated_frame_count
397 // = snapshot_delay seconds * time_base.den / estimated_frame_count
398 // We'll set this to stream->time_base later after it's initialized
399 log_info("Snapshot mode ENABLED - estimated_frame_count=%d (fps=%d * snapshot_delay=%.2f)",
400 enc->estimated_frame_count, fps, snapshot_delay);
401 } else {
402 log_info("Snapshot mode disabled or invalid - snapshot_mode=%d, snapshot_delay=%.2f, fps=%d", snapshot_mode,
403 snapshot_delay, fps);
404 }
405}
406
407static const AVCodec *encoder_find_video_codec(const char *codec_name) {
408 // Find encoder (with fallback for FFmpeg builds without libx264)
409 const AVCodec *codec = avcodec_find_encoder_by_name(codec_name);
410 if (!codec && strcmp(codec_name, "libx264") == 0) {
411 // libx264 not available - try built-in H.264 encoders
412 static const char *h264_fallbacks[] = {"libopenh264", "h264_mf", NULL};
413 for (int i = 0; h264_fallbacks[i] && !codec; i++) {
414 codec = avcodec_find_encoder_by_name(h264_fallbacks[i]);
415 }
416 if (!codec) {
417 // Last resort: ask FFmpeg for any H.264 encoder
418 codec = avcodec_find_encoder(AV_CODEC_ID_H264);
419 }
420 if (codec) {
421 log_info("ffmpeg: using fallback H.264 encoder '%s' (libx264 not available)", codec->name);
422 }
423 }
424 if (!codec && strcmp(codec_name, "libvpx-vp9") == 0) {
425 // Homebrew and distribution FFmpeg builds do not consistently expose the
426 // libvpx VP9 encoder under the preferred name. Both VP9 and VP8 remain
427 // valid WebM video codecs.
428 static const char *webm_fallbacks[] = {"libvpx", "vp9", "libvpx-vp8", "vp8", NULL};
429 for (int i = 0; webm_fallbacks[i] && !codec; ++i)
430 codec = avcodec_find_encoder_by_name(webm_fallbacks[i]);
431 if (codec) {
432 log_info("ffmpeg: using fallback WebM encoder '%s' (libvpx-vp9 not available)", codec->name);
433 }
434 }
435 return codec;
436}
437
438// On failure, setup helpers release the partially initialized encoder.
439static asciichat_error_t encoder_init_video_stream(ffmpeg_encoder_t *enc, const char *codec_name) {
440 int ret;
441 int width_px = enc->width_px;
442 int height_px = enc->height_px;
443 int fps = enc->fps;
444 const AVCodec *codec = encoder_find_video_codec(codec_name);
445 if (!codec) {
446 avformat_free_context(enc->fmt_ctx);
447 SAFE_FREE(enc);
448 return SET_ERRNO(ERROR_INIT, "ffmpeg: encoder '%s' not found", codec_name);
449 }
450
451 // Create new stream
452 enc->stream = avformat_new_stream(enc->fmt_ctx, codec);
453 if (!enc->stream) {
454 avformat_free_context(enc->fmt_ctx);
455 SAFE_FREE(enc);
456 return SET_ERRNO(ERROR_INIT, "ffmpeg: avformat_new_stream failed");
457 }
458
459 // Allocate codec context
460 enc->codec_ctx = avcodec_alloc_context3(codec);
461 if (!enc->codec_ctx) {
462 avformat_free_context(enc->fmt_ctx);
463 SAFE_FREE(enc);
464 return SET_ERRNO(ERROR_INIT, "ffmpeg: avcodec_alloc_context3 failed");
465 }
466
467 // Set codec parameters
468 enc->codec_ctx->width = width_px;
469 enc->codec_ctx->height = height_px;
470 enc->codec_ctx->pix_fmt = enc->target_pix_fmt;
471 enc->codec_ctx->time_base = (AVRational){1, fps};
472 enc->codec_ctx->framerate = (AVRational){fps, 1};
473 enc->stream->time_base = enc->codec_ctx->time_base;
474 enc->stream->avg_frame_rate = enc->codec_ctx->framerate;
475 if (enc->fmt_ctx->oformat->flags & AVFMT_GLOBALHEADER)
476 enc->codec_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
477
478 // Set bitrate for high quality (music-level quality)
479 // Use ~5-10 Mbps per megapixel for visually lossless quality
480 int bitrate = (width_px * height_px * 10) / 1024; // higher quality estimate in kbps
481 if (bitrate < 4000)
482 bitrate = 4000; // Minimum 4000 kbps for quality
483 if (bitrate > 50000)
484 bitrate = 50000; // Cap at 50 Mbps max
485 enc->codec_ctx->bit_rate = bitrate * 1000;
486 log_debug("bitrate set to %d kbps for %dx%d", bitrate, width_px, height_px);
487
488 // Configure codec-specific options for quality
489 AVDictionary *codec_opts = NULL;
490 if (strcmp(codec->name, "libx264") == 0) {
491 av_dict_set(&codec_opts, "preset", "ultrafast", 0);
492 av_dict_set(&codec_opts, "crf", "28", 0);
493 log_debug("x264 preset=ultrafast crf=28");
494 } else if (strcmp(codec->name, "libx265") == 0) {
495 av_dict_set(&codec_opts, "preset", "ultrafast", 0);
496 av_dict_set(&codec_opts, "crf", "32", 0);
497 // x265 tag must be hvc1 for MP4 container compatibility (Apple/browser playback)
498 enc->stream->codecpar->codec_tag = MKTAG('h', 'v', 'c', '1');
499 log_debug("x265 preset=ultrafast crf=32");
500 }
501
502 // Open codec (capture encoder startup logs).
503 LOG_IO("ffmpeg", { ret = avcodec_open2(enc->codec_ctx, codec, &codec_opts); });
504 av_dict_free(&codec_opts);
505 if (ret < 0) {
506 char error_message[AV_ERROR_MAX_STRING_SIZE];
507 av_strerror(ret, error_message, sizeof(error_message));
508 avcodec_free_context(&enc->codec_ctx);
509 avformat_free_context(enc->fmt_ctx);
510 SAFE_FREE(enc);
511 return SET_ERRNO(ERROR_INIT, "ffmpeg: avcodec_open2 failed for %s: %s (%d)", codec->name, error_message, ret);
512 }
513
514 // Copy codec parameters to stream
515 ret = avcodec_parameters_from_context(enc->stream->codecpar, enc->codec_ctx);
516 if (ret < 0) {
517 avcodec_free_context(&enc->codec_ctx);
518 avformat_free_context(enc->fmt_ctx);
519 SAFE_FREE(enc);
520 return SET_ERRNO(ERROR_INIT, "ffmpeg: avcodec_parameters_from_context failed");
521 }
522
523 return ASCIICHAT_OK;
524}
525
526static AVDictionary *encoder_create_muxer_options(const ffmpeg_encoder_t *enc, const char *output_path) {
527 // Muxer options for proper MP4 container structure
528 AVDictionary *opts = NULL;
529
530 // Muxer flags configuration
531 // For stdout/non-seekable output, use fragmented MP4 (frag_keyframe)
532 // For regular files, use faststart (moov at front for streaming)
533 bool is_stdout = (output_path && strcmp(output_path, "-") == 0);
534 if (is_stdout) {
535 // Use fragmented MP4 for stdout - starts a new fragment at each video keyframe
536 // This allows streaming without requiring file seeking (FFmpeg docs recommended)
537 av_dict_set(&opts, "movflags", "+frag_keyframe", 0);
538 log_debug("Using fragmented MP4 (+frag_keyframe) for stdout output");
539 } else {
540 // Move moov atom to the front (faststart) - helps with streaming and player compatibility
541 av_dict_set(&opts, "movflags", "faststart", 0);
542 }
543
544 if (enc->is_image) {
545 // Use -update flag for single frame output
546 av_dict_set(&opts, "update", "1", 0);
547 }
548
549 return opts;
550}
551
552static asciichat_error_t encoder_init_video_frames(ffmpeg_encoder_t *enc) {
553 int ret;
554 int width_px = enc->width_px;
555 int height_px = enc->height_px;
556 // Allocate frames
557 enc->frame = av_frame_alloc();
558 enc->frame_encoded = av_frame_alloc();
559 enc->pkt = av_packet_alloc();
560 if (!enc->frame || !enc->frame_encoded || !enc->pkt) {
561 av_frame_free(&enc->frame);
562 av_frame_free(&enc->frame_encoded);
563 av_packet_free(&enc->pkt);
564 avcodec_free_context(&enc->codec_ctx);
565 avformat_free_context(enc->fmt_ctx);
566 SAFE_FREE(enc);
567 return SET_ERRNO(ERROR_INIT, "ffmpeg: frame allocation failed");
568 }
569
570 // Set frame properties for target pixel format
571 enc->frame_encoded->format = enc->target_pix_fmt;
572 enc->frame_encoded->width = width_px;
573 enc->frame_encoded->height = height_px;
574 ret = av_frame_get_buffer(enc->frame_encoded, 32);
575 if (ret < 0) {
576 av_frame_free(&enc->frame);
577 av_frame_free(&enc->frame_encoded);
578 av_packet_free(&enc->pkt);
579 avcodec_free_context(&enc->codec_ctx);
580 avformat_free_context(enc->fmt_ctx);
581 SAFE_FREE(enc);
582 return SET_ERRNO(ERROR_INIT, "ffmpeg: av_frame_get_buffer failed");
583 }
584
585 // Large recordings otherwise convert millions of pixels on one thread.
586 // swscale owns its workers; small outputs avoid the synchronization cost.
587 int conversion_threads = av_cpu_count();
588 if (conversion_threads > 4)
589 conversion_threads = 4;
590 LOG_IO("swscaler", {
591 if ((int64_t)width_px * height_px >= 1000000 && conversion_threads > 1) {
592 struct SwsContext *context = sws_alloc_context();
593 if (context) {
594 int configured = av_opt_set_int(context, "srcw", enc->source_width, 0) |
595 av_opt_set_int(context, "srch", enc->source_height, 0) |
596 av_opt_set_int(context, "dstw", width_px, 0) | av_opt_set_int(context, "dsth", height_px, 0) |
597 av_opt_set_int(context, "src_format", AV_PIX_FMT_RGBA, 0) |
598 av_opt_set_int(context, "dst_format", enc->target_pix_fmt, 0) |
599 av_opt_set_int(context, "sws_flags", SWS_BICUBIC, 0) |
600 av_opt_set_int(context, "threads", conversion_threads, 0);
601 if (configured >= 0 && sws_init_context(context, NULL, NULL) >= 0)
602 enc->sws_ctx = context;
603 else
604 sws_freeContext(context);
605 }
606 }
607 if (!enc->sws_ctx)
608 enc->sws_ctx = sws_getContext(enc->source_width, enc->source_height, AV_PIX_FMT_RGBA, width_px, height_px,
609 enc->target_pix_fmt, SWS_BICUBIC, NULL, NULL, NULL);
610 });
611 if (!enc->sws_ctx) {
612 av_frame_free(&enc->frame);
613 av_frame_free(&enc->frame_encoded);
614 av_packet_free(&enc->pkt);
615 avcodec_free_context(&enc->codec_ctx);
616 avformat_free_context(enc->fmt_ctx);
617 SAFE_FREE(enc);
618 return SET_ERRNO(ERROR_INIT, "ffmpeg: sws_getContext failed");
619 }
620
621 return ASCIICHAT_OK;
622}
623
624#if LIBAVFORMAT_VERSION_MAJOR >= 61
625static int encoder_stdout_write(void *opaque, const uint8_t *data, int size) {
626#else
627static int encoder_stdout_write(void *opaque, uint8_t *data, int size) {
628#endif
629 if (size < 0)
630 return AVERROR(EINVAL);
631 return stdout_writer_write(opaque, data, (size_t)size) == ASCIICHAT_OK ? size : AVERROR(EIO);
632}
633
634static void encoder_log_callback(void *context, int level, const char *format, va_list args) {
635 (void)context;
636 if (level > av_log_get_level())
637 return;
639 vsnprintf(message, sizeof(message), format, args);
640 if (level <= AV_LOG_ERROR)
641 log_error("ffmpeg: %s", message);
642 else if (level <= AV_LOG_WARNING)
643 log_warn("ffmpeg: %s", message);
644 else
645 log_debug("ffmpeg: %s", message);
646}
647
648static void encoder_close_io(ffmpeg_encoder_t *enc) {
649 if (!enc->fmt_ctx->pb)
650 return;
651 if (enc->is_stdout_pipe) {
652 av_freep(&enc->fmt_ctx->pb->buffer);
653 avio_context_free(&enc->fmt_ctx->pb);
654 if (enc->stdout_stream) {
655 fclose(enc->stdout_stream);
656 enc->stdout_stream = NULL;
657 }
658 } else {
659 avio_closep(&enc->fmt_ctx->pb);
660 }
661}
662
663static asciichat_error_t encoder_open_output(ffmpeg_encoder_t *enc, const char *output_path, AVDictionary *opts) {
664 int ret;
665 // Open output file or stdout
666 if (!(enc->fmt_ctx->oformat->flags & AVFMT_NOFILE)) {
667 const char *open_path = output_path;
668 if (enc->is_stdout_pipe) {
669 int fd = platform_dup(STDOUT_FILENO);
670 if (fd >= 0) {
671 enc->stdout_stream = platform_fdopen("render stdout", fd, "wb");
672 if (!enc->stdout_stream)
673 platform_close(fd);
674 }
675 uint8_t *buffer = av_malloc(FFMPEG_STDOUT_AVIO_BUFFER_SIZE);
676 if (buffer && enc->stdout_stream)
677 enc->fmt_ctx->pb = avio_alloc_context(buffer, FFMPEG_STDOUT_AVIO_BUFFER_SIZE, 1, enc->stdout_stream, NULL,
678 encoder_stdout_write, NULL);
679 if (!enc->fmt_ctx->pb) {
680 av_free(buffer);
681 if (enc->stdout_stream) {
682 fclose(enc->stdout_stream);
683 enc->stdout_stream = NULL;
684 }
685 }
686 ret = enc->fmt_ctx->pb ? 0 : AVERROR(ENOMEM);
687 enc->fmt_ctx->flags |= AVFMT_FLAG_CUSTOM_IO;
688 } else {
689 ret = avio_open(&enc->fmt_ctx->pb, open_path, AVIO_FLAG_WRITE);
690 }
691 if (ret < 0) {
692 sws_freeContext(enc->sws_ctx);
693 av_frame_free(&enc->frame);
694 av_frame_free(&enc->frame_encoded);
695 av_packet_free(&enc->pkt);
696 avcodec_free_context(&enc->codec_ctx);
697 avformat_free_context(enc->fmt_ctx);
698 SAFE_FREE(enc);
699 return SET_ERRNO(ERROR_INIT, "ffmpeg: avio_open failed for '%s'", open_path);
700 }
701 }
702
703 // Write header (capture FFmpeg format muxer logs)
704 LOG_IO("ffmpeg", { ret = avformat_write_header(enc->fmt_ctx, &opts); });
705 av_dict_free(&opts); // Free options after use
706 if (ret < 0) {
707 encoder_close_io(enc);
708 sws_freeContext(enc->sws_ctx);
709 av_frame_free(&enc->frame);
710 av_frame_free(&enc->frame_encoded);
711 av_packet_free(&enc->pkt);
712 avcodec_free_context(&enc->codec_ctx);
713 avformat_free_context(enc->fmt_ctx);
714 SAFE_FREE(enc);
715 return SET_ERRNO(ERROR_INIT, "ffmpeg: avformat_write_header failed");
716 }
717
718 return ASCIICHAT_OK;
719}
720
721static void encoder_set_snapshot_frame_duration(ffmpeg_encoder_t *enc) {
722 // Now that stream->time_base is set, calculate estimated frame duration for snapshot mode
723 bool snapshot_mode_after_header = GET_OPTION(snapshot_mode);
724 double snapshot_delay_after_header = GET_OPTION(snapshot_delay);
725 if (snapshot_mode_after_header && enc->estimated_frame_count > 0 && snapshot_delay_after_header > 0) {
726 // Each frame should span: snapshot_delay / estimated_frame_count seconds
727 // In stream time_base units: (snapshot_delay * time_base.den) / estimated_frame_count
729 (int64_t)((snapshot_delay_after_header * (double)enc->stream->time_base.den) / enc->estimated_frame_count);
730 log_debug("Calculated estimated_frame_duration=%lld time_base units "
731 "for snapshot mode (snapshot_delay=%.2f, estimated_frames=%d, time_base=%d/%d)",
732 (long long)enc->estimated_frame_duration, snapshot_delay_after_header, enc->estimated_frame_count,
733 enc->stream->time_base.num, enc->stream->time_base.den);
734 }
735}
736
737asciichat_error_t ffmpeg_encoder_create(const char *output_path, int width_px, int height_px, int fps,
739 return ffmpeg_encoder_create_scaled(output_path, width_px, height_px, width_px, height_px, fps, out);
740}
741
742asciichat_error_t ffmpeg_encoder_create_scaled(const char *output_path, int source_width, int source_height,
743 int width_px, int height_px, int fps, ffmpeg_encoder_t **out) {
744 log_info("output=%s, %dx%d @ %dfps", output_path, width_px, height_px, fps);
745
746 if (!output_path || !out || source_width <= 0 || source_height <= 0 || width_px <= 0 || height_px <= 0)
747 return SET_ERRNO(ERROR_INVALID_PARAM, "invalid parameters");
748 if (strcmp(output_path, "pipe:") == 0)
749 output_path = "-";
750 // Codec workers can log after LOG_IO restores the process descriptors.
751 // The session logger routes diagnostics to stderr when binary stdout is active.
752 if (strcmp(output_path, "-") == 0)
753 av_log_set_callback(encoder_log_callback);
754
755 // Use default FPS if not specified (0 from config means "use default")
756 if (fps <= 0) {
757 fps = 60;
758 log_debug("fps was 0, using default 60");
759 }
760
761 ffmpeg_encoder_t *enc = SAFE_CALLOC(1, sizeof(*enc), ffmpeg_encoder_t *);
762 enc->width_px = width_px;
763 enc->height_px = height_px;
764 enc->source_width = source_width;
765 enc->source_height = source_height;
766
767 encoder_init_snapshot_timing(enc, fps);
768
769 enc->is_stdout_pipe = (output_path && strcmp(output_path, "-") == 0) ? 1 : 0;
770
771 // For codec/format detection, use output_path unless it's "-" (stdout)
772 // For stdout, use "fake.mp4" to default to MP4 format
773 const char *detection_path = output_path;
774 if (output_path && strcmp(output_path, "-") == 0) {
775 detection_path = "fake.mp4"; // Fake path for format detection when piping to stdout
776 log_debug("Using MP4 format for stdout piping");
777 }
778
779 const char *codec_name = NULL, *format_name = NULL;
780 get_codec_from_extension(detection_path, &codec_name, &format_name, &enc->is_image, &enc->target_pix_fmt);
781
782 log_debug("%s (%s, %dx%d @ %dfps, %s)", output_path, codec_name, width_px, height_px, fps,
783 enc->is_image ? "image" : "video");
784
785 // Allocate output media context
786 int ret = avformat_alloc_output_context2(&enc->fmt_ctx, NULL, format_name, output_path);
787 if (ret < 0) {
788 SAFE_FREE(enc);
789 return SET_ERRNO(ERROR_INIT, "ffmpeg: avformat_alloc_output_context2 failed");
790 }
791
792 asciichat_error_t result = encoder_init_video_stream(enc, codec_name);
793 if (result != ASCIICHAT_OK)
794 return result;
795
796 AVDictionary *opts = encoder_create_muxer_options(enc, output_path);
797
798 result = encoder_init_video_frames(enc);
799 if (result != ASCIICHAT_OK)
800 return result;
801
802 // Initialize audio stream if supported
803 encoder_init_audio_stream(enc, output_path);
804
805 result = encoder_open_output(enc, output_path, opts);
806 if (result != ASCIICHAT_OK)
807 return result;
808
809 encoder_set_snapshot_frame_duration(enc);
810
811 enc->frame_count = 0;
812 enc->video_pts = 0;
813 enc->snapshot_actual_duration = 0.0;
814 enc->snapshot_elapsed_ns = 0;
815 enc->first_frame_captured_ns = 0; // Initialize first frame timestamp
816 *out = enc;
817
818 /* Register encoder with named registry */
819 NAMED_REGISTER_FFMPEG_ENCODER(enc, output_path, NULL);
820
821 log_info("SUCCESS: encoder initialized, frame_count=0, fps=%d, dims=%dx%d", enc->fps, enc->width_px, enc->height_px);
822 return ASCIICHAT_OK;
823}
824
826 uint64_t captured_ns) {
827 if (!enc || !rgb)
828 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL input");
829
830 // Initialize first frame capture time on first call
831 if (enc->first_frame_captured_ns == 0) {
832 enc->first_frame_captured_ns = captured_ns;
833 log_debug("first frame captured at %llu ns", (unsigned long long)captured_ns);
834 }
835
836 log_debug_every(5 * US_PER_SEC_INT, "Encoding frame %d, %dx%d", enc->frame_count, enc->width_px, enc->height_px);
837
838 // Set up RGBA frame for conversion
839 uint8_t *data[1] = {(uint8_t *)rgb};
840 int linesize[1] = {pitch};
841
842 // Encoders may retain references to earlier frames for motion or transparency detection.
843 if (av_frame_make_writable(enc->frame_encoded) < 0)
844 return SET_ERRNO(ERROR_MEMORY, "ffmpeg: cannot make video frame writable");
845
846 // Ensure frame format is set before each sws_scale call.
847 // Some FFmpeg versions reset the format field, so we set it explicitly.
848 enc->frame_encoded->format = enc->target_pix_fmt;
849
850 // Convert RGBA to target format
851 uint64_t conversion_started_ns = time_get_ns();
852 sws_scale(enc->sws_ctx, (const uint8_t *const *)data, linesize, 0, enc->source_height, enc->frame_encoded->data,
853 enc->frame_encoded->linesize);
854 uint64_t converted_ns = time_get_ns();
855
856 // Set frame duration based on mode
857 int64_t frame_duration;
858 bool snapshot_mode = GET_OPTION(snapshot_mode);
859
860 if (enc->frame_count == 0) {
861 log_info("frame 0, snapshot_mode=%d", snapshot_mode);
862 }
863
864 // Use FPS-based frame duration (snapshot mode adjusts total duration in destroy())
865 frame_duration = enc->codec_ctx->time_base.den / (enc->codec_ctx->time_base.num * enc->fps);
866
867 enc->frame_encoded->duration = frame_duration;
868
869 // Track this frame's capture timestamp for duration calculation of next frame
870 if (snapshot_mode) {
871 uint64_t frame_dur_ms = (enc->previous_captured_ns > 0) ? (captured_ns - enc->previous_captured_ns) / 1000000 : 0;
872 if (enc->frame_count <= 2) {
873 log_info("ffmpeg: frame %d SET duration=%lld units (%.3fms since last, codec_base=1/%d)", enc->frame_count,
874 (long long)frame_duration, (double)frame_dur_ms, enc->codec_ctx->time_base.den);
875 }
876 enc->previous_captured_ns = captured_ns;
877 }
878
879 // Calculate PTS from actual capture timestamp
880 // time_base is {1, fps}, so 1 PTS unit = 1/fps seconds
881 // Convert elapsed nanoseconds to PTS units: elapsed_ns * fps / 1_000_000_000
882 uint64_t elapsed_ns = captured_ns - enc->first_frame_captured_ns;
883 int64_t pts_from_timestamp = (int64_t)((elapsed_ns * (uint64_t)enc->fps) / 1000000000ULL);
884
885 // Normal file rendering is constant-frame-rate. Wall-clock capture timestamps
886 // include scheduler jitter and can create gaps or duplicate PTS values, so use
887 // the encoded frame index for exact N / FPS duration. File snapshots with
888 // audio use this same timeline, excluding capture initialization delays.
889 // Live sources preserve wall-clock gaps between received frames.
890 if ((!snapshot_mode || enc->has_audio_stream) && !enc->live_timing) {
891 pts_from_timestamp = enc->frame_count;
892 }
893
894 // For snapshot sources without an estimated frame count, distribute frames
895 // linearly across the actual duration once the capture duration is known.
896 if (snapshot_mode && !enc->has_audio_stream && !enc->live_timing && enc->estimated_frame_count == 0) {
900 // Distribute frames linearly based on their capture timestamp relative to total duration
901 // pts = (frame_elapsed_ns / total_elapsed_ns) * actual_duration_sec * fps
902 // This ensures frame 0 is at 0s and last frame is at actual_duration_sec
903 double actual_duration_sec = (double)g_snapshot_actual_duration_ms / 1000.0;
904 double total_elapsed_sec = (double)g_snapshot_last_capture_elapsed_ns / (double)NS_PER_SEC_INT;
905
906 if (total_elapsed_sec > 0.001) {
907 double frame_fraction = (double)elapsed_ns / (double)g_snapshot_last_capture_elapsed_ns;
908 double target_pts_sec = frame_fraction * actual_duration_sec;
909 int64_t pts_before = pts_from_timestamp;
910 // Convert target seconds to time_base units: pts = target_sec * time_base.den / time_base.num
911 pts_from_timestamp =
912 (int64_t)(target_pts_sec * (double)enc->codec_ctx->time_base.den / (double)enc->codec_ctx->time_base.num);
913
914 if (enc->frame_count < 3 || enc->frame_count % 10 == 0) {
915 log_debug("ffmpeg: snapshot frame %d: PTS distributed (before=%lld, after=%lld, sec=%lld->%lld, frac=%.3f)",
916 enc->frame_count, (long long)pts_before, (long long)pts_from_timestamp,
917 (long long)((double)pts_before * enc->codec_ctx->time_base.num / enc->codec_ctx->time_base.den),
918 (long long)target_pts_sec, frame_fraction);
919 }
920 }
921 }
922 }
923
924 // Use timestamp-based (or scaled) PTS
925 enc->frame_encoded->pts = pts_from_timestamp;
926 enc->last_video_pts = pts_from_timestamp;
927 enc->video_pts += frame_duration;
928
929 if (!snapshot_mode) {
930 log_debug_every(US_PER_SEC_INT, "PTS calculation - elapsed_ns=%llu, pts_from_timestamp=%lld, fps=%d",
931 (unsigned long long)elapsed_ns, (long long)pts_from_timestamp, enc->fps);
932 }
933
934 // Send frame to encoder
935 int ret = avcodec_send_frame(enc->codec_ctx, enc->frame_encoded);
936 uint64_t submitted_ns = time_get_ns();
937 if (ret < 0) {
938 return SET_ERRNO(ERROR_MEDIA_INIT, "Cannot encode recording frame: %d", ret);
939 }
940
941 // Receive packets from encoder and write to file
942 static int write_frame_pkt_count = 0;
943
944 while (ret >= 0) {
945 ret = avcodec_receive_packet(enc->codec_ctx, enc->pkt);
946 if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
947 break;
948 if (ret < 0) {
949 return SET_ERRNO(ERROR_MEDIA_INIT, "ffmpeg: avcodec_receive_packet failed: %d", ret);
950 }
951
952 write_frame_pkt_count++;
953
954 // Set packet duration in codec time base (duration of one frame at target FPS)
955 // This ensures proper stream duration calculation
956 if (enc->pkt->duration == 0) {
957 enc->pkt->duration = enc->codec_ctx->time_base.den / (enc->codec_ctx->time_base.num * enc->fps);
958 }
959
960 av_packet_rescale_ts(enc->pkt, enc->codec_ctx->time_base, enc->stream->time_base);
961 enc->pkt->stream_index = enc->stream->index;
962
963 // In snapshot mode, apply adjusted duration if snapshot_actual_duration is set
964 // This happens when we get the actual duration from the capture thread
965 bool is_snapshot_mode = GET_OPTION(snapshot_mode);
966 if (is_snapshot_mode && !enc->has_audio_stream && enc->snapshot_actual_duration > 0 && enc->frame_count > 0) {
967 int64_t adjusted_dur = (int64_t)((enc->snapshot_actual_duration * enc->stream->time_base.den) / enc->frame_count);
968 // Only apply if it's significantly different from the default
969 if (adjusted_dur > enc->pkt->duration * 2) {
970 enc->pkt->duration = adjusted_dur;
971 log_debug("ffmpeg: write_frame packet %d duration adjusted to %lld units based on actual duration",
972 write_frame_pkt_count, (long long)enc->pkt->duration);
973 }
974 } else if (!is_snapshot_mode && write_frame_pkt_count < 3) {
975 log_debug("ffmpeg: write_frame pkt %d, duration=%lld, snapshot_mode=%d, actual_dur=%.2f, frame_count=%d",
976 write_frame_pkt_count, (long long)enc->pkt->duration, is_snapshot_mode, enc->snapshot_actual_duration,
977 enc->frame_count);
978 }
979
980 if (write_frame_pkt_count <= 2 || write_frame_pkt_count >= 16) {
983 "ffmpeg: write_frame pkt %d with final duration=%lld (%.6f sec in stream time_base), total frames=%d",
984 write_frame_pkt_count, (long long)enc->pkt->duration, (double)enc->pkt->duration / enc->stream->time_base.den,
985 enc->frame_count);
986 }
987
988 ret = av_interleaved_write_frame(enc->fmt_ctx, enc->pkt);
989 av_packet_unref(enc->pkt);
990 if (ret < 0) {
991 return SET_ERRNO(ERROR_FILE_OPERATION, "ffmpeg: av_interleaved_write_frame failed: %d", ret);
992 }
993
994 // Flush output buffer for stdout to ensure frames are written immediately
995 if (enc->is_stdout_pipe) {
996 avio_flush(enc->fmt_ctx->pb);
997 }
998 }
999
1000 uint64_t muxed_ns = time_get_ns();
1001 log_debug_every(US_PER_SEC_INT, "ENCODER_STAGE: convert_ms=%.2f submit_ms=%.2f packets_ms=%.2f",
1002 (double)(converted_ns - conversion_started_ns) / NS_PER_MS_INT,
1003 (double)(submitted_ns - converted_ns) / NS_PER_MS_INT,
1004 (double)(muxed_ns - submitted_ns) / NS_PER_MS_INT);
1005 enc->frame_count++;
1006 return ASCIICHAT_OK;
1007}
1008
1009static asciichat_error_t encoder_prepare_audio_frame(ffmpeg_encoder_t *enc) {
1010 enc->audio_frame->nb_samples = enc->audio_frame_size;
1011 if (!enc->audio_frame->buf[0] && av_frame_get_buffer(enc->audio_frame, 0) < 0)
1012 return SET_ERRNO(ERROR_MEMORY, "Cannot allocate recording audio frame");
1013 if (av_frame_make_writable(enc->audio_frame) < 0)
1014 return SET_ERRNO(ERROR_MEMORY, "Cannot make recording audio frame writable");
1015 const uint8_t *input[] = {(const uint8_t *)enc->audio_partial_buf};
1016 int converted =
1017 swr_convert(enc->audio_swr_ctx, enc->audio_frame->data, enc->audio_frame_size, input, enc->audio_frame_size);
1018 if (converted < 0)
1019 return SET_ERRNO(ERROR_MEDIA_INIT, "Cannot convert recording audio samples: %d", converted);
1020 enc->audio_frame->nb_samples = converted;
1021 enc->audio_frame->pts = enc->audio_pts;
1022 return ASCIICHAT_OK;
1023}
1024
1025asciichat_error_t ffmpeg_encoder_write_audio(ffmpeg_encoder_t *enc, const float *samples, int num_samples) {
1026 if (!enc || !samples || num_samples <= 0)
1027 return ASCIICHAT_OK; // Silently ignore invalid calls
1028
1029 // No audio stream for this format
1030 if (!enc->has_audio_stream)
1031 return ASCIICHAT_OK;
1032
1033 // Accumulate samples into partial buffer
1034 int samples_to_process = num_samples;
1035 int samples_offset = 0;
1036
1037 while (samples_to_process > 0) {
1038 // How many samples can we add to the partial buffer?
1039 int space_in_buffer = enc->audio_frame_size * 2 - enc->audio_partial_len;
1040 int samples_to_copy = (samples_to_process < space_in_buffer) ? samples_to_process : space_in_buffer;
1041
1042 // Copy samples to partial buffer
1043 memcpy(&enc->audio_partial_buf[enc->audio_partial_len], &samples[samples_offset], samples_to_copy * sizeof(float));
1044 enc->audio_partial_len += samples_to_copy;
1045 samples_offset += samples_to_copy;
1046 samples_to_process -= samples_to_copy;
1047
1048 // If buffer is full, encode and write frame
1049 if (enc->audio_partial_len >= enc->audio_frame_size * 2) {
1050 // Set up audio frame with accumulated samples
1051 asciichat_error_t prepare_result = encoder_prepare_audio_frame(enc);
1052 if (prepare_result != ASCIICHAT_OK)
1053 return prepare_result;
1054
1055 // Send frame to encoder
1056 int ret = avcodec_send_frame(enc->audio_codec_ctx, enc->audio_frame);
1057 if (ret < 0) {
1058 return SET_ERRNO(ERROR_MEDIA_INIT, "Cannot encode recording audio: %d", ret);
1059 } else {
1060 // Receive and write packets
1061 while (ret >= 0) {
1062 ret = avcodec_receive_packet(enc->audio_codec_ctx, enc->pkt);
1063 if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
1064 break;
1065 if (ret < 0) {
1066 return SET_ERRNO(ERROR_MEDIA_INIT, "ffmpeg: avcodec_receive_packet for audio failed: %d", ret);
1067 }
1068
1069 // Rescale timestamp and write
1070 av_packet_rescale_ts(enc->pkt, enc->audio_codec_ctx->time_base, enc->audio_stream->time_base);
1071 enc->pkt->stream_index = enc->audio_stream->index;
1072 ret = av_interleaved_write_frame(enc->fmt_ctx, enc->pkt);
1073 av_packet_unref(enc->pkt);
1074 if (ret < 0) {
1075 return SET_ERRNO(ERROR_FILE_OPERATION, "ffmpeg: av_interleaved_write_frame for audio failed: %d", ret);
1076 }
1077 }
1078 }
1079
1080 // Update PTS for next frame
1081 enc->audio_pts += enc->audio_frame_size;
1082 enc->audio_partial_len = 0;
1083 }
1084 }
1085
1086 return ASCIICHAT_OK;
1087}
1088
1090 if (!enc)
1091 return;
1092 enc->snapshot_actual_duration = actual_duration_sec;
1093
1094 // If we already have frame count, we can calculate the adjusted packet duration now
1095 // This will be used for any remaining frames and during flush
1096 if (enc->frame_count > 0 && actual_duration_sec > 0) {
1097 // Calculate the packet duration that would make all frames fit in actual_duration_sec
1098 // packet_duration_units = (actual_duration_sec * time_base.den) / frame_count
1099 // This is only a starting point - the actual duration might be different if more frames are captured
1100 log_debug("%.3f sec with %d frames so far", actual_duration_sec, enc->frame_count);
1101 } else {
1102 log_info("Setting duration to %.3f seconds (already encoded %d frames)", actual_duration_sec, enc->frame_count);
1103 }
1104}
1105
1106// Drain output before retrying input rejected by the encoder's backpressure.
1107// A NULL frame enters draining mode only after the encoder accepts it.
1108static asciichat_error_t finish_audio_frame(ffmpeg_encoder_t *enc, const AVFrame *frame) {
1110 LOG_IO("ffmpeg", {
1111 int send_ret;
1112 do {
1113 send_ret = avcodec_send_frame(enc->audio_codec_ctx, frame);
1114 if (send_ret < 0 && send_ret != AVERROR(EAGAIN) && send_ret != AVERROR_EOF) {
1115 result = SET_ERRNO(ERROR_MEDIA_INIT, "Cannot finalize audio frame: %d", send_ret);
1116 break;
1117 }
1118
1119 bool received_packet = false;
1120 while (1) {
1121 int ret = avcodec_receive_packet(enc->audio_codec_ctx, enc->pkt);
1122 if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
1123 break;
1124 if (ret < 0) {
1125 result = SET_ERRNO(ERROR_MEDIA_INIT, "Cannot receive final audio packet: %d", ret);
1126 goto capture_done;
1127 }
1128
1129 received_packet = true;
1130 av_packet_rescale_ts(enc->pkt, enc->audio_codec_ctx->time_base, enc->audio_stream->time_base);
1131 enc->pkt->stream_index = enc->audio_stream->index;
1132 int write_ret = av_interleaved_write_frame(enc->fmt_ctx, enc->pkt);
1133 if (write_ret < 0)
1134 result = SET_ERRNO(ERROR_FILE_OPERATION, "Cannot write final audio packet: %d", write_ret);
1135 av_packet_unref(enc->pkt);
1136 }
1137
1138 // FFmpeg guarantees that a rejected send allows receiving output.
1139 if (send_ret == AVERROR(EAGAIN) && !received_packet) {
1140 result = SET_ERRNO(ERROR_MEDIA_INIT, "Audio encoder made no progress during finalization");
1141 break;
1142 }
1143 } while (send_ret == AVERROR(EAGAIN));
1144 capture_done:;
1145 });
1146 return result;
1147}
1148
1149static void encoder_log_snapshot_duration(const ffmpeg_encoder_t *enc) {
1150 // In snapshot mode, calculate the FPS needed to match the target duration
1151 bool snapshot_mode = GET_OPTION(snapshot_mode);
1152 int64_t adjusted_packet_duration = 0;
1153 double snapshot_delay = GET_OPTION(snapshot_delay);
1154
1155 if (snapshot_mode && !enc->has_audio_stream && !enc->live_timing && enc->frame_count > 0 && snapshot_delay > 0) {
1156 // Calculate output FPS that distributes frames across snapshot_delay seconds
1157 // output_fps = frame_count / snapshot_delay
1158 // packet_duration (in stream time_base) = stream_time_base.den / output_fps
1159 // = (stream_time_base.den * snapshot_delay) / frame_count
1160 adjusted_packet_duration = (int64_t)((snapshot_delay * enc->stream->time_base.den) / enc->frame_count);
1161 log_info("Snapshot mode - adjusting packet durations for %d frames over %.2f seconds", enc->frame_count,
1162 snapshot_delay);
1163 log_debug(" Adjusted packet duration: %lld units (%.6f seconds per frame)", (long long)adjusted_packet_duration,
1164 (double)adjusted_packet_duration / enc->stream->time_base.den);
1165 }
1166}
1167
1168static asciichat_error_t encoder_flush_video(ffmpeg_encoder_t *enc) {
1170 // Flush delayed video packets. Encoders such as VP9 may buffer every short
1171 // snapshot frame until this point.
1172 LOG_IO("ffmpeg", {
1173 int flush_ret = avcodec_send_frame(enc->codec_ctx, NULL);
1174 if (flush_ret < 0 && flush_ret != AVERROR_EOF)
1175 result = SET_ERRNO(ERROR_MEDIA_INIT, "Cannot flush video encoder: %d", flush_ret);
1176 while (1) {
1177 int ret = avcodec_receive_packet(enc->codec_ctx, enc->pkt);
1178 if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
1179 break;
1180 if (ret < 0) {
1181 result = SET_ERRNO(ERROR_MEDIA_INIT, "Cannot receive final video packet: %d", ret);
1182 break;
1183 }
1184
1185 // Set packet duration in codec time base if not already set
1186 if (enc->pkt->duration == 0) {
1187 enc->pkt->duration = enc->codec_ctx->time_base.den / (enc->codec_ctx->time_base.num * enc->fps);
1188 }
1189
1190 av_packet_rescale_ts(enc->pkt, enc->codec_ctx->time_base, enc->stream->time_base);
1191 enc->pkt->stream_index = enc->stream->index;
1192 int write_ret = av_interleaved_write_frame(enc->fmt_ctx, enc->pkt);
1193 if (write_ret < 0)
1194 result = SET_ERRNO(ERROR_FILE_OPERATION, "Cannot write final video packet: %d", write_ret);
1195 av_packet_unref(enc->pkt);
1196 }
1197 });
1198
1199 return result;
1200}
1201
1202static asciichat_error_t encoder_flush_audio(ffmpeg_encoder_t *enc) {
1204 // Flush audio encoder if present
1205 if (enc->has_audio_stream && enc->audio_codec_ctx) {
1206 // Write any remaining partial audio samples as padding
1207 if (enc->audio_partial_len > 0) {
1208 // Zero-pad the remaining samples
1209 memset(&enc->audio_partial_buf[enc->audio_partial_len], 0,
1210 (enc->audio_frame_size * 2 - enc->audio_partial_len) * sizeof(float));
1211 asciichat_error_t pad_result = encoder_prepare_audio_frame(enc);
1212 if (pad_result == ASCIICHAT_OK)
1213 pad_result = finish_audio_frame(enc, enc->audio_frame);
1214 if (pad_result != ASCIICHAT_OK)
1215 result = pad_result;
1216 }
1217
1218 // Flush any remaining packets (capture FFmpeg audio codec logs)
1219 asciichat_error_t flush_result = finish_audio_frame(enc, NULL);
1220 if (flush_result != ASCIICHAT_OK)
1221 result = flush_result;
1222 }
1223
1224 return result;
1225}
1226
1227static void encoder_set_stream_duration(ffmpeg_encoder_t *enc) {
1228 // Set stream duration for proper metadata (must be before trailer)
1229 if (enc->stream && enc->frame_count > 0) {
1230 int64_t duration;
1231 bool snapshot_mode = GET_OPTION(snapshot_mode);
1232 if (snapshot_mode && !enc->has_audio_stream && !enc->live_timing) {
1234 double snapshot_delay = GET_OPTION(snapshot_delay);
1235 // Use actual capture elapsed time if available, otherwise use snapshot_delay
1236 // This ensures frames are distributed across the correct time window
1237 double actual_capture_duration = g_snapshot_actual_duration_ms / 1000.0;
1238 double effective_duration = (actual_capture_duration > 0) ? actual_capture_duration : snapshot_delay;
1239
1240 // In snapshot mode with wall-clock timing, the video duration should equal the actual elapsed time
1241 // duration_seconds = effective_duration
1242 // duration_time_base_units = effective_duration * time_base.den
1243 duration = (int64_t)(effective_duration * enc->stream->time_base.den);
1244
1245 // Calculate what the frame durations should be to sum to effective_duration
1246 // This ensures FFmpeg plays the captured frames across the full effective_duration
1247 // effective_frame_duration = effective_duration / frame_count (seconds per frame)
1248 int64_t effective_frame_duration =
1249 (int64_t)((effective_duration / (double)enc->frame_count) * enc->stream->time_base.den);
1250
1251 log_info("Snapshot mode - snapshot_delay=%.2f, frames=%d, output_duration=%.2f sec", snapshot_delay,
1252 enc->frame_count, effective_duration);
1253 log_debug(" Using effective_duration=%.2f seconds for output", effective_duration);
1254 log_debug(" stream->time_base=%d/%d, effective frame duration=%lld time_base units", enc->stream->time_base.num,
1255 enc->stream->time_base.den, (long long)effective_frame_duration);
1256 log_debug(" (each frame should span %.3f seconds to total %.2f seconds)",
1257 effective_duration / (double)enc->frame_count, effective_duration);
1258
1259 log_info("Setting stream->duration=%lld (effective_duration=%.3f, time_base=%d/%d, "
1260 "equiv=%.3f sec)",
1261 (long long)duration, effective_duration, enc->stream->time_base.num, enc->stream->time_base.den,
1262 (double)duration * enc->stream->time_base.num / enc->stream->time_base.den);
1263 } else {
1264 // Normal mode: calculate duration from frame count and FPS
1265 // time_base = 1 / time_base.den seconds per unit
1266 // Each frame is 1/fps seconds, so duration = frame_count / fps seconds
1267 // In time base units: duration = (frame_count / fps) * time_base.den = frame_count * time_base.den / fps
1268 duration = (enc->live_timing ? enc->last_video_pts + 1 : (int64_t)enc->frame_count) * enc->stream->time_base.den /
1269 enc->fps;
1270 log_debug("Normal mode - Set stream duration=%lld (frames=%d, fps=%d, time_base=%d/%d)", (long long)duration,
1271 enc->frame_count, enc->fps, 1, enc->stream->time_base.den);
1272 }
1273 enc->stream->duration = duration;
1274
1275 // Each frame was encoded with FPS-based duration, but snapshot mode needs them distributed
1276 // across the actual capture duration. Since we can't change past frame durations, we extend
1277 // the last frame's duration to make the sum equal the desired total.
1278 if (snapshot_mode) {
1279 // Calculate sum of all frame durations (each frame currently has the same FPS-based duration)
1280 int64_t current_total_duration = enc->video_pts; // This is the sum of all frame durations so far
1281 int64_t desired_duration = duration;
1282
1283 // The last frame needs to be extended to: desired - (sum of all other frames)
1284 // This ensures the video plays for exactly the snapshot_delay duration
1285 int64_t last_frame_extension = desired_duration - current_total_duration;
1286
1287 if (last_frame_extension > 0) {
1288 // We need to extend the last frame. But we can't modify it directly after encoding.
1289 // Instead, the muxer will use stream->duration as the authoritative source.
1290 log_debug("Last frame would need extension of %lld units to reach total %lld "
1291 "(currently %lld)",
1292 (long long)last_frame_extension, (long long)desired_duration, (long long)current_total_duration);
1293 }
1294 }
1295 }
1296}
1297
1298static asciichat_error_t encoder_finalize_output(ffmpeg_encoder_t *enc) {
1300 // Write trailer (capture FFmpeg muxer logs and final frame statistics)
1301 LOG_IO("ffmpeg", {
1302 int trailer_ret = av_write_trailer(enc->fmt_ctx);
1303 if (trailer_ret < 0)
1304 result = SET_ERRNO(ERROR_FILE_OPERATION, "Cannot finalize recording: %d", trailer_ret);
1305 });
1306
1307 // Flush output buffer for stdout/pipes before closing
1308 if (enc->fmt_ctx && enc->fmt_ctx->pb) {
1309 avio_flush(enc->fmt_ctx->pb);
1310 if (enc->fmt_ctx->pb->error < 0)
1311 result = SET_ERRNO(ERROR_FILE_OPERATION, "Recording output failed: %d", enc->fmt_ctx->pb->error);
1312 log_debug("Flushed output buffer");
1313 }
1314
1315 // Close output file
1316 if (enc->fmt_ctx && !(enc->fmt_ctx->oformat->flags & AVFMT_NOFILE)) {
1317 if (enc->is_stdout_pipe) {
1318 encoder_close_io(enc);
1319 } else {
1320 int close_ret = avio_closep(&enc->fmt_ctx->pb);
1321 if (close_ret < 0)
1322 result = SET_ERRNO(ERROR_FILE_OPERATION, "Cannot close recording: %d", close_ret);
1323 }
1324 }
1325
1326 return result;
1327}
1328
1329static void encoder_free(ffmpeg_encoder_t *enc) {
1330 // Cleanup
1331 sws_freeContext(enc->sws_ctx);
1332 av_frame_free(&enc->frame);
1333 av_frame_free(&enc->frame_encoded);
1334
1335 // Cleanup audio resources (capture any remaining FFmpeg logs from codec cleanup)
1336 if (enc->has_audio_stream) {
1337 swr_free(&enc->audio_swr_ctx);
1338 av_frame_free(&enc->audio_frame);
1339 LOG_IO("ffmpeg", { avcodec_free_context(&enc->audio_codec_ctx); });
1341 }
1342
1343 av_packet_free(&enc->pkt);
1344 LOG_IO("ffmpeg", { avcodec_free_context(&enc->codec_ctx); });
1345 if (enc->fmt_ctx)
1346 avformat_free_context(enc->fmt_ctx);
1347
1348 log_debug("wrote %d frames", enc->frame_count);
1349 SAFE_FREE(enc);
1350}
1351
1353 if (!enc)
1354 return ASCIICHAT_OK;
1355
1357 NAMED_UNREGISTER(enc);
1358
1359 encoder_log_snapshot_duration(enc);
1360
1361 result = encoder_flush_video(enc);
1362
1363 asciichat_error_t audio_result = encoder_flush_audio(enc);
1364 if (audio_result != ASCIICHAT_OK)
1365 result = audio_result;
1366
1367 encoder_set_stream_duration(enc);
1368
1369 asciichat_error_t output_result = encoder_finalize_output(enc);
1370 if (output_result != ASCIICHAT_OK)
1371 result = output_result;
1372
1373 encoder_free(enc);
1374 return result;
1375}
1376
1378 if (enc)
1379 enc->live_timing = true;
1380}
action_args_t args
Definition actions.c:78
#define SET_ERRNO(code,...)
Named object registry for debugging — log identifiable resource names.
void ffmpeg_encoder_set_snapshot_actual_duration(ffmpeg_encoder_t *enc, double actual_duration_sec)
asciichat_error_t ffmpeg_encoder_create_scaled(const char *output_path, int source_width, int source_height, int width_px, int height_px, int fps, ffmpeg_encoder_t **out)
void ffmpeg_encoder_set_live_timing(ffmpeg_encoder_t *enc)
asciichat_error_t ffmpeg_encoder_write_frame(ffmpeg_encoder_t *enc, const uint8_t *rgb, int pitch, uint64_t captured_ns)
asciichat_error_t ffmpeg_encoder_destroy(ffmpeg_encoder_t *enc)
#define MEDIA_FFMPEG_ENCODER_MESSAGE_BUFFER_SIZE
asciichat_error_t ffmpeg_encoder_write_audio(ffmpeg_encoder_t *enc, const float *samples, int num_samples)
asciichat_error_t ffmpeg_encoder_create(const char *output_path, int width_px, int height_px, int fps, ffmpeg_encoder_t **out)
#define FFMPEG_STDOUT_AVIO_BUFFER_SIZE
FFmpeg video/image file encoder — codec selected from file extension.
struct ffmpeg_encoder_s ffmpeg_encoder_t
#define SAFE_FREE(ptr)
Definition common.h:377
#define SAFE_MALLOC(size, cast)
Definition common.h:265
#define SAFE_CALLOC(count, size, cast)
Definition common.h:275
unsigned long long uint64_t
Definition common.h:63
unsigned char uint8_t
Definition common.h:60
#define NAMED_REGISTER_FFMPEG_ENCODER(encoder, name, parent_ptr)
Register an FFmpeg encoder with automatic format specifier.
#define NAMED_UNREGISTER(ptr)
Unregister a pointer.
asciichat_error_t
Error and exit codes - unified status values (0-255).
Definition error_codes.h:44
@ ERROR_MEMORY
Definition error_codes.h:51
@ ASCIICHAT_OK
Definition error_codes.h:46
@ ERROR_INIT
Definition error_codes.h:56
@ ERROR_MEDIA_INIT
Definition error_codes.h:65
@ ERROR_FILE_OPERATION
@ ERROR_INVALID_PARAM
#define log_warn(...)
Log a WARN message.
Definition log/log.h:621
#define log_error(...)
Log an ERROR message.
Definition log/log.h:634
#define log_info(...)
Log an INFO message.
Definition log/log.h:608
#define log_debug(...)
Log a DEBUG message.
Definition log/log.h:595
#define NS_PER_SEC_INT
Definition time.h:169
#define NS_PER_MS_INT
Definition time.h:168
#define US_PER_SEC_INT
Definition time.h:173
#define GET_OPTION(field)
Safely get a specific option field (lock-free read).
Definition options.h:1336
#define EINVAL
int platform_dup(int fd)
#define EAGAIN
Capture stdout/stderr and redirect to logging system.
#define LOG_IO(prefix, block)
Definition io.h:104
📝 Logging API with multiple log levels and terminal output control
#define log_debug_every(interval_us, fmt,...)
Rate-limited DEBUG logging.
Definition log/log.h:749
⚙️ Unified options parsing system for ascii-chat with builder pattern and lock-free access
static bool size_t gotel_query_t * out
Definition queries.h:8
uint64_t g_snapshot_last_capture_elapsed_ns
uint64_t g_snapshot_actual_duration_ms
asciichat_error_t stdout_writer_write(FILE *stream, const void *data, size_t size)
🔤 String Manipulation and Shell Escaping Utilities
This is the handoff point for ffmpeg encoder. Callers fill codec_ctx, stream, frame,...
struct SwsContext * sws_ctx
AVFrame * frame_encoded
uint64_t first_frame_captured_ns
AVFrame * audio_frame
enum AVPixelFormat target_pix_fmt
float * audio_partial_buf
AVStream * audio_stream
AVCodecContext * audio_codec_ctx
struct SwrContext * audio_swr_ctx
uint64_t snapshot_elapsed_ns
AVFormatContext * fmt_ctx
double snapshot_actual_duration
AVCodecContext * codec_ctx
int64_t estimated_frame_duration
uint64_t previous_captured_ns
⏱️ High-precision timing utilities using sokol_time.h and uthash