aboutsummaryrefslogtreecommitdiff
path: root/src/media.c
diff options
context:
space:
mode:
authorDavid Moc <personal@cdatgoose.org>2026-05-31 03:47:04 +0200
committerDavid Moc <personal@cdatgoose.org>2026-05-31 03:47:04 +0200
commit6aeaa171dc1ca43392f53cbd02097f76e1b1c5a0 (patch)
treeb16f559f5a701123ebe7b15ecebb9325263b4a3c /src/media.c
parente930cc6bdc7f62befac063d7d9d016ffb0a64f1a (diff)
Hardened API, tetris, MD-View
Diffstat (limited to 'src/media.c')
-rw-r--r--src/media.c378
1 files changed, 378 insertions, 0 deletions
diff --git a/src/media.c b/src/media.c
new file mode 100644
index 0000000..6c7a989
--- /dev/null
+++ b/src/media.c
@@ -0,0 +1,378 @@
+#include "media.h"
+
+#include "buffers.h"
+#include "common.h"
+#include "ecex.h"
+#include "path.h"
+#include "util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+extern FILE *popen(const char *command, const char *type);
+extern int pclose(FILE *stream);
+extern int mkstemp(char *template);
+
+static char *shell_quote(const char *path) {
+ if (!path) return NULL;
+ size_t len = 2; /* quotes */
+ for (const char *p = path; *p; p++) len += (*p == '\'') ? 4 : 1;
+ char *out = malloc(len + 1);
+ if (!out) return NULL;
+ char *w = out;
+ *w++ = '\'';
+ for (const char *p = path; *p; p++) {
+ if (*p == '\'') {
+ memcpy(w, "'\\''", 4);
+ w += 4;
+ } else {
+ *w++ = *p;
+ }
+ }
+ *w++ = '\'';
+ *w = '\0';
+ return out;
+}
+
+static int ppm_skip_ws_and_comments(FILE *f) {
+ int c = 0;
+ do {
+ c = fgetc(f);
+ if (c == '#') {
+ while (c != '\n' && c != EOF) c = fgetc(f);
+ }
+ } while (c != EOF && isspace((unsigned char)c));
+ if (c == EOF) return EOF;
+ ungetc(c, f);
+ return 0;
+}
+
+static int ppm_read_int(FILE *f, int *out) {
+ if (!f || !out) return ECEX_ERR;
+ if (ppm_skip_ws_and_comments(f) == EOF) return ECEX_ERR;
+ int c = fgetc(f);
+ if (c == EOF || !isdigit((unsigned char)c)) return ECEX_ERR;
+ int value = 0;
+ while (c != EOF && isdigit((unsigned char)c)) {
+ value = value * 10 + (c - '0');
+ c = fgetc(f);
+ }
+ if (c != EOF) ungetc(c, f);
+ *out = value;
+ return ECEX_OK;
+}
+
+static int read_ppm_frame(FILE *f, int *out_w, int *out_h, unsigned char **out_rgba) {
+ if (!f || !out_w || !out_h || !out_rgba) return ECEX_ERR;
+ int p = fgetc(f);
+ int six = fgetc(f);
+ if (p == EOF || six == EOF) return ECEX_ERR;
+ if (p != 'P' || six != '6') return ECEX_ERR;
+
+ int w = 0, h = 0, maxv = 0;
+ if (ppm_read_int(f, &w) != ECEX_OK || ppm_read_int(f, &h) != ECEX_OK || ppm_read_int(f, &maxv) != ECEX_OK) {
+ return ECEX_ERR;
+ }
+ if (w <= 0 || h <= 0 || maxv <= 0 || maxv > 255) return ECEX_ERR;
+
+ int c = fgetc(f);
+ if (c == EOF) return ECEX_ERR;
+ if (!isspace((unsigned char)c)) ungetc(c, f);
+
+ size_t rgb_len = (size_t)w * (size_t)h * 3u;
+ size_t rgba_len = (size_t)w * (size_t)h * 4u;
+ if (w > 8192 || h > 8192 || rgb_len / 3u != (size_t)w * (size_t)h) return ECEX_ERR;
+
+ unsigned char *rgb = malloc(rgb_len);
+ unsigned char *rgba = malloc(rgba_len);
+ if (!rgb || !rgba) {
+ free(rgb);
+ free(rgba);
+ return ECEX_ERR;
+ }
+
+ size_t got = fread(rgb, 1, rgb_len, f);
+ if (got != rgb_len) {
+ free(rgb);
+ free(rgba);
+ return ECEX_ERR;
+ }
+
+ for (size_t i = 0, j = 0; i < rgb_len; i += 3, j += 4) {
+ rgba[j + 0] = rgb[i + 0];
+ rgba[j + 1] = rgb[i + 1];
+ rgba[j + 2] = rgb[i + 2];
+ rgba[j + 3] = 255;
+ }
+
+ free(rgb);
+ *out_w = w;
+ *out_h = h;
+ *out_rgba = rgba;
+ return ECEX_OK;
+}
+
+static int set_buffer_pixels(buffer_t *buffer, int w, int h, unsigned char *rgba) {
+ if (!buffer || w <= 0 || h <= 0 || !rgba) {
+ free(rgba);
+ return ECEX_ERR;
+ }
+ free(buffer->media_pixels);
+ buffer->media_pixels = rgba;
+ buffer->media_width = w;
+ buffer->media_height = h;
+ buffer->media_dirty = 1;
+ return ECEX_OK;
+}
+
+static char *make_temp_log_path(void) {
+ char tmpl[] = "/tmp/ecex-ffmpeg-XXXXXX";
+ int fd = mkstemp(tmpl);
+ if (fd < 0) return NULL;
+ close(fd);
+ return ecex_strdup(tmpl);
+}
+
+static char *read_decode_log_status(const char *log_path, const char *fallback) {
+ FILE *f = log_path ? fopen(log_path, "rb") : NULL;
+ if (!f) return ecex_strdup(fallback ? fallback : "ffmpeg did not decode a preview frame.");
+
+ char body[1400];
+ size_t n = fread(body, 1, sizeof(body) - 1, f);
+ fclose(f);
+ body[n] = '\0';
+
+ if (n == 0) return ecex_strdup(fallback ? fallback : "ffmpeg did not decode a preview frame.");
+
+ char *out = malloc(n + 128);
+ if (!out) return ecex_strdup(fallback ? fallback : "ffmpeg did not decode a preview frame.");
+ snprintf(out, n + 128, "ffmpeg did not decode a preview frame.\n\nffmpeg stderr:\n%s", body);
+ return out;
+}
+
+static char *make_decode_command(const char *path, int video, char **out_log_path) {
+ char *q = shell_quote(path);
+ if (!q) return NULL;
+ char *log_path = make_temp_log_path();
+ char *qlog = log_path ? shell_quote(log_path) : NULL;
+ const char *ffmpeg = getenv("ECEX_FFMPEG");
+ if (!ffmpeg || !*ffmpeg) ffmpeg = "ffmpeg";
+ /*
+ * Important: ffmpeg's filtergraph parser treats ',' as a filter
+ * separator unless it is escaped, even when the expression is inside
+ * quotes. The previous command used min(%d,iw), which makes many
+ * ffmpeg builds fail before producing a single PPM frame. Keep the
+ * comma escaped in the command string that reaches ffmpeg.
+ *
+ * -nostdin also prevents ffmpeg from accidentally consuming editor
+ * terminal input when ecex is launched from a shell.
+ */
+ const char *fmt = video
+ ? "%s -nostdin -hide_banner -loglevel error -i %s -an -vf \"fps=60,scale='min(%d\\,iw)':-2\" -f image2pipe -vcodec ppm - %s%s"
+ : "%s -nostdin -hide_banner -loglevel error -i %s -frames:v 1 -vf \"scale='min(%d\\,iw)':-2\" -f image2pipe -vcodec ppm - %s%s";
+ int need = snprintf(NULL,
+ 0,
+ fmt,
+ ffmpeg,
+ q,
+ ECEX_MEDIA_MAX_DIMENSION,
+ qlog ? "2>" : "",
+ qlog ? qlog : "");
+ char *cmd = malloc((size_t)need + 1);
+ if (cmd) {
+ snprintf(cmd,
+ (size_t)need + 1,
+ fmt,
+ ffmpeg,
+ q,
+ ECEX_MEDIA_MAX_DIMENSION,
+ qlog ? "2>" : "",
+ qlog ? qlog : "");
+ }
+ if (out_log_path) {
+ *out_log_path = log_path;
+ log_path = NULL;
+ }
+ free(qlog);
+ free(log_path);
+ free(q);
+ return cmd;
+}
+
+static void buffer_set_media_text(buffer_t *buffer, const char *path, int video, const char *status) {
+ if (!buffer) return;
+ buffer->read_only = 0;
+ buffer_clear(buffer);
+ char text[2048];
+ snprintf(text,
+ sizeof(text),
+ "%s preview: %s\n\n%s\n\nRequirements:\n install ffmpeg/ffprobe in PATH for broad image and video decoding.\n\nControls:\n Space / p play-pause video\n q quit window\n",
+ video ? "Video" : "Image",
+ path ? path : "(unknown)",
+ status ? status : "No decoded frame available.");
+ buffer_append(buffer, text);
+ buffer->modified = 0;
+ buffer->read_only = 1;
+}
+
+void ecex_media_buffer_clear(buffer_t *buffer) {
+ if (!buffer) return;
+ if (buffer->media_pipe) {
+ pclose((FILE *)buffer->media_pipe);
+ buffer->media_pipe = NULL;
+ }
+ free(buffer->media_path);
+ buffer->media_path = NULL;
+ free(buffer->media_pixels);
+ buffer->media_pixels = NULL;
+ buffer->media_kind = ECEX_MEDIA_NONE;
+ buffer->media_width = 0;
+ buffer->media_height = 0;
+ buffer->media_dirty = 0;
+ buffer->media_texture_width = 0;
+ buffer->media_texture_height = 0;
+ buffer->media_last_frame_time = 0.0;
+ buffer->media_playing = 0;
+ buffer->media_status[0] = '\0';
+}
+
+int ecex_media_buffer_has_pixels(buffer_t *buffer) {
+ return buffer && buffer->media_pixels && buffer->media_width > 0 && buffer->media_height > 0;
+}
+
+static buffer_t *media_buffer_for_path(ecex_t *ed, const char *path, int video) {
+ if (!ed || !path) return NULL;
+ char *base = ecex_path_basename_dup(path);
+ if (!base) return NULL;
+ char name[1024];
+ snprintf(name, sizeof(name), "*%s-preview:%s*", video ? "video" : "image", base);
+ free(base);
+
+ buffer_t *buffer = ecex_find_buffer(ed, name);
+ if (!buffer) buffer = ecex_create_buffer(ed, name, path, 1);
+ if (!buffer) return NULL;
+ return buffer;
+}
+
+int ecex_media_load_into_buffer(ecex_t *ed, const char *path, buffer_t *buffer) {
+ if (!ed || !path || !buffer || !ecex_path_is_media(path)) return ECEX_ERR;
+ int video = ecex_path_is_video(path);
+
+ ecex_media_buffer_clear(buffer);
+ buffer->media_kind = video ? ECEX_MEDIA_VIDEO : ECEX_MEDIA_IMAGE;
+ ecex_buffer_set_major_mode_by_name(ed, buffer, "media-preview-mode");
+ buffer->media_path = ecex_path_normalize(path);
+ if (!buffer->media_path) buffer->media_path = ecex_strdup(path);
+ buffer->read_only = 0;
+ buffer_clear(buffer);
+ buffer_append(buffer, video ? "Loading video preview...\n" : "Loading image preview...\n");
+ buffer->modified = 0;
+ buffer->read_only = 1;
+
+ char *log_path = NULL;
+ char *cmd = make_decode_command(buffer->media_path ? buffer->media_path : path, video, &log_path);
+ if (!cmd) {
+ buffer_set_media_text(buffer, path, video, "Could not allocate ffmpeg command.");
+ free(log_path);
+ return ECEX_ERR;
+ }
+
+ FILE *pipe = popen(cmd, "r");
+ free(cmd);
+ if (!pipe) {
+ buffer_set_media_text(buffer, path, video, "Could not launch ffmpeg.");
+ if (log_path) unlink(log_path);
+ free(log_path);
+ return ECEX_ERR;
+ }
+
+ int w = 0, h = 0;
+ unsigned char *rgba = NULL;
+ if (read_ppm_frame(pipe, &w, &h, &rgba) != ECEX_OK) {
+ pclose(pipe);
+ char *status = read_decode_log_status(log_path, "ffmpeg did not decode a preview frame.");
+ buffer_set_media_text(buffer, path, video, status);
+ free(status);
+ if (log_path) unlink(log_path);
+ free(log_path);
+ return ECEX_ERR;
+ }
+
+ if (log_path) unlink(log_path);
+ free(log_path);
+
+ if (video) {
+ buffer->media_pipe = pipe;
+ buffer->media_playing = 1;
+ snprintf(buffer->media_status, sizeof(buffer->media_status), "Playing at decoded 60fps stream");
+ } else {
+ pclose(pipe);
+ snprintf(buffer->media_status, sizeof(buffer->media_status), "Image decoded via ffmpeg");
+ }
+
+ set_buffer_pixels(buffer, w, h, rgba);
+ buffer->read_only = 0;
+ buffer_clear(buffer);
+ char info[1024];
+ snprintf(info,
+ sizeof(info),
+ "%s preview: %s\n%d x %d\n%s\n\n%s\n",
+ video ? "Video" : "Image",
+ buffer->media_path ? buffer->media_path : path,
+ w,
+ h,
+ buffer->media_status,
+ video ? "Space/p toggles playback. q quits window." : "q quits window.");
+ buffer_append(buffer, info);
+ buffer->modified = 0;
+ buffer->read_only = 1;
+ return ECEX_OK;
+}
+
+int ecex_media_open(ecex_t *ed, const char *path) {
+ if (!ed || !path || !ecex_path_is_media(path)) return ECEX_ERR;
+ int video = ecex_path_is_video(path);
+ buffer_t *buffer = media_buffer_for_path(ed, path, video);
+ if (!buffer) return ECEX_ERR;
+ ecex_media_load_into_buffer(ed, path, buffer);
+ return ecex_switch_buffer(ed, buffer->name);
+}
+
+int ecex_media_toggle_playback(ecex_t *ed) {
+ buffer_t *buffer = ecex_current_buffer(ed);
+ if (!buffer || buffer->media_kind != ECEX_MEDIA_VIDEO) return ECEX_ERR;
+ buffer->media_playing = !buffer->media_playing;
+ snprintf(buffer->media_status,
+ sizeof(buffer->media_status),
+ "%s",
+ buffer->media_playing ? "Playing" : "Paused");
+ return ECEX_OK;
+}
+
+int ecex_media_tick(ecex_t *ed, double now_seconds) {
+ if (!ed) return 0;
+ int dirty = 0;
+ for (size_t i = 0; i < ed->buffer_count; i++) {
+ buffer_t *buffer = ed->buffers[i];
+ if (!buffer || buffer->media_kind != ECEX_MEDIA_VIDEO || !buffer->media_pipe || !buffer->media_playing) continue;
+ if (buffer->media_last_frame_time > 0.0 && now_seconds - buffer->media_last_frame_time < (1.0 / 60.0)) continue;
+
+ int w = 0, h = 0;
+ unsigned char *rgba = NULL;
+ if (read_ppm_frame((FILE *)buffer->media_pipe, &w, &h, &rgba) == ECEX_OK) {
+ set_buffer_pixels(buffer, w, h, rgba);
+ buffer->media_last_frame_time = now_seconds;
+ dirty = 1;
+ } else {
+ pclose((FILE *)buffer->media_pipe);
+ buffer->media_pipe = NULL;
+ buffer->media_playing = 0;
+ snprintf(buffer->media_status, sizeof(buffer->media_status), "Playback finished");
+ dirty = 1;
+ }
+ }
+ return dirty;
+}