From 351804819b78f29c6a11441e11dc93cea90a20c4 Mon Sep 17 00:00:00 2001 From: Sads Date: Sun, 14 Jun 2026 00:48:42 +0200 Subject: [PATCH] Video bitrate probe, loading throbbers, boot spinner, transparent logo - Video: probe the MP4 header (range request for moov) before downloading the whole clip; if the average bitrate overruns the declared H.264 level the Vita's decoder enforces, show the "too high" message up front instead of downloading then failing at playback. Verdict validated against the real demo videos (e.g. e75d495e: Level 3.1 @ 18.8 Mbps -> rejected). - Error screens: word-wrap title + detail so long messages no longer run off the edges; the bitrate message now spells out the server-side fix (Immich transcoding policy / bitrate cap). - Boot: one continuous spinner. A single boot worker thread does sign-in + first page + local scan while the main thread animates one logo with a never-resetting frame counter, instead of three separate static screens. - Throbbers: the cloud/server-info page and smart search now fetch on a worker thread and animate a throbber instead of freezing the UI. - Logo: swap the in-app logo for a clean 200x200 transparent PNG (the old one was matted from the white-background icon and had fringe); keep the launcher icon's white background. Linear-filter the logo + cloud badges so they scale/rotate smoothly. - Drop AgenticLoop.md (and its CLAUDE.md reference). Co-Authored-By: Claude Opus 4.8 --- AgenticLoop.md | 136 ----------------------- CLAUDE.md | 4 +- assets/immich_logo.png | Bin 11313 -> 8776 bytes src/app.c | 167 ++++++++++++++++++++++------ src/draw.c | 53 +++++++++ src/state.c | 11 +- src/ui.c | 13 +++ src/video.c | 243 +++++++++++++++++++++++++++++++++++++++-- 8 files changed, 442 insertions(+), 185 deletions(-) delete mode 100644 AgenticLoop.md diff --git a/AgenticLoop.md b/AgenticLoop.md deleted file mode 100644 index 9dc5838..0000000 --- a/AgenticLoop.md +++ /dev/null @@ -1,136 +0,0 @@ -# Agentic loop: build, run & drive vitaImmich in Vita3K - -This is the loop an agent uses to make a change, run it in the Vita3K emulator, -and verify it by sending input + reading screenshots — all headless, on KDE -Plasma (Wayland). - -## One-time setup - -- VitaSDK (community): native toolchain at `~/vitasdk` (`arm-vita-eabi-gcc`, - `vita-pack-vpk`, toolchain at `share/vita.toolchain.cmake`). -- Vita3K installed and on `PATH` (`/usr/bin/vita3k`). -- The MCP that drives the GUI: `uv tool install kwin-mcp` (configured in - `.mcp.json` as the `kwin-mcp` server). - -## 1. Build the VPK → `build/vitaImmich.vpk` - -Preferred (Docker, reproducible): - -```bash -./build.sh # writes build/vitaImmich.vpk (build/ ends up root-owned) -``` - -Fallback when the Docker runtime is broken (e.g. containerd shim error -`readlink /proc/self/exe: no such file or directory`), build natively with the -host SDK into `build-native/` (git-ignored): - -```bash -export VITASDK="$HOME/vitasdk" -export PATH=$VITASDK/bin:$PATH -cmake -B build-native -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_TOOLCHAIN_FILE=$VITASDK/share/vita.toolchain.cmake . -cmake --build build-native --parallel "$(nproc)" -``` - -## 2. Install the VPK into Vita3K - -Installing = launch Vita3K once with the `.vpk` as an argument; it extracts and -auto-boots. Gotchas: - -- Run it **from a script file**, backgrounded with a wait then killed. Inline - backgrounding from the agent's shell does not persist. -- Guard `pkill` with `|| true` — a no-match exit code aborts the chain. -- Vita3K extracts files preserving the **zip's internal mtime**, so to confirm a - fresh install compare the installed `eboot.bin` mtime against - `unzip -l | grep eboot`, not wall-clock time. - -```bash -#!/bin/bash -cd "$(git rev-parse --show-toplevel)" -pkill -9 -f vita3k 2>/dev/null || true -sleep 1 -vita3k ./build-native/vitaImmich.vpk >/tmp/vinst.log 2>&1 & # or ./build/... -sleep 8 -pkill -9 -f vita3k 2>/dev/null || true -sleep 1 -grep -i "successfully" /tmp/vinst.log | tail -1 -stat -c 'eboot: %y' ~/.local/share/Vita3K/Vita3K/ux0/app/VIMM00001/eboot.bin -``` - -App title id: **`VIMM00001`**. To wipe a stale install: `vita3k -d VIMM00001`. - -## 3. Launch + drive via kwin-mcp - -Launch the app **inside the MCP's own isolated KWin session** — its screenshot -tool works there. (Connecting to the live desktop with `session_connect` does -*not* work for capture: `spectacle` can't save non-interactively, and KWin's -`ScreenShot2` DBus API rejects unauthorized callers — so the isolated session is -the only reliable way to capture, and it is headless: the human cannot watch it -live on their monitor.) - -- **Before starting, wipe the per-session frame folder** so each run is clean: - `rm -rf agent-run/frames && mkdir -p agent-run/frames`. (`agent-run/` is - git-ignored; `frames/` is the throwaway capture folder — keepers live in - `agent-run/` proper and are never touched.) -- `session_start` with `app_command: "vita3k -r VIMM00001"`, - `keep_screenshots: true`, **and `env: {"SDL_VIDEODRIVER": "wayland", "DISPLAY": - ""}`** — starts an isolated Wayland session, launches the app, and keeps every - captured frame on disk after `session_stop`. Without that `env` the frames come - back blank white and input is dropped (host `DISPLAY=:1` sends SDL to X11). Give - it ~12s to boot. -- **`focus_window` with `app_name: "Vita3K"` BEFORE sending keys.** Without focus - the keypresses are dropped. Presses also drop intermittently — re-focus and - re-press. Send one key per call; rapid bursts get coalesced/dropped. -- `keyboard_key` with `screenshot_after_ms: [ms]` after **every** command, so the - frame folder is a complete visual record of the run for the human to scrub. -- `session_stop` when done (and `pkill -9 -f vita3k`). kwin-mcp always writes - frames to `/tmp/kwin-mcp-screenshots-*/` (that path is not configurable); don't - leave them there — **move them into the repo and clear the tmp folder**: - ```bash - cp /tmp/kwin-mcp-screenshots-*/*.png agent-run/frames/ 2>/dev/null - rm -rf /tmp/kwin-mcp-screenshots-* - ``` - Then tell the human the frames are in `agent-run/frames/`. - -### Zero-context-cost capture (the standing rule) - -Capturing a frame (writing the PNG) costs the agent **nothing** — files on disk -are not in context. The cost is only incurred when the agent **Reads** a PNG -back. So: - -- **Capture every command** (`screenshot_after_ms`) — free, and it gives the - human the full record. -- **Do not Read frames back** into context during the loop. Verify progress from - the app's own **text** log instead (cheap), not from images: - `~/.local/share/Vita3K/Vita3K/ux0/data/vitaimmich/log.txt`. -- Only Read a frame when a screenshot is genuinely the only way to resolve an - ambiguity (rare). Reading every frame is what bloats context and slows the - session — that is exactly what this rule avoids. - -### Key mapping (full list in `controls.MD`) - -Vita3K maps host keys → Vita buttons. The ones this app uses: - -| Vita button | host key (kwin-mcp) | in-app action | -|-------------|---------------------|--------------------------| -| D-pad | `Right` `Left` `Up` `Down` | move grid / browse | -| Cross (X) | `x` | view / play / replay | -| Circle (O) | `c` | back | -| Select | `Shift_R` | sync | -| Start | `Return` | exit | - -### Typical verify loop (capture everything, Read nothing) - -1. `rm -rf agent-run/frames && mkdir -p agent-run/frames`, then `session_start` - (`keep_screenshots: true`, `env` forcing Wayland) → wait ~12s. -2. `focus_window "Vita3K"`. -3. `keyboard_key "Right"` (with `screenshot_after_ms`) … stepping to the target - tile, one key per call. -4. `keyboard_key "x"` to open, `x` again to play. -5. Confirm what happened from the **text** log, not the frames: - - `~/.local/share/Vita3K/Vita3K/ux0/data/vitaimmich/log.txt` (the app's own log - — records page fetches, video open/play, scans, errors) - - MCP `read_app_log` with the launched PID, or `/tmp/vita3k-runtime.log`. -6. On `session_stop`, move the frames out of `/tmp` and clear it: - `cp /tmp/kwin-mcp-screenshots-*/*.png agent-run/frames/ 2>/dev/null && rm -rf /tmp/kwin-mcp-screenshots-*`. - Hand `agent-run/frames/` to the human as the visual record. diff --git a/CLAUDE.md b/CLAUDE.md index d4980bc..916b63a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,8 @@ just it); the parts are textual slices, so add new code to the relevant part, not to CMake. `jsmn.h` is vendored. Built with the VitaSDK toolchain into a `.vpk`, run in the **Vita3K** emulator, and driven headlessly through the **kwin-mcp** server. This file is the -end-to-end loop: **build → install → launch → drive → read logs.** For the -narrative version see `AgenticLoop.md`; full key map is in `controls.MD`. +end-to-end loop: **build → install → launch → drive → read logs.** Full key +map is in `controls.MD`. App title id: **`VIMM00001`**. diff --git a/assets/immich_logo.png b/assets/immich_logo.png index 9da6d01354eb0f6f5c9683f6503ebc2a58402438..18fa826a8ab3c44e4537f8afce9e97e45052a607 100644 GIT binary patch literal 8776 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 0) { - sceKernelStartThread(lt, 0, NULL); - unsigned int lf = 0; - while (!g_init_load_done) { - draw_loading("Loading your library...", lf++); + if (bt >= 0) { + sceKernelStartThread(bt, 0, NULL); + unsigned int bf = 0; + while (!g_boot_done) { + draw_loading(g_boot_status, bf++); sceDisplayWaitVblankStart(); } } else { - fetch_page(1); /* fallback: blocking, no spinner */ + /* fallback: run the same steps blocking, without the spinner */ + fetch_page(1); + scan_local_media(); + grow_locals(g_local_count + 512); + rebuild_display(); + } + /* a stored-credential sign-in failed: drop to interactive login (main + * thread), then load blocking */ + if (g_boot_login_failed) { + login_screen(); + fetch_page(1); + scan_local_media(); + grow_locals(g_local_count + 512); + rebuild_display(); } if (g_asset_count == 0) fatal_error(NULL, "Server returned no assets (see log.txt)"); @@ -197,15 +249,6 @@ int main(void) if (worker >= 0) sceKernelStartThread(worker, 0, NULL); - /* scan the Vita's own camera media (fast: paths + stat only), build - * the merged timeline, then let the sync thread hash + dup-check it */ - draw_loading("Scanning local media...", 0); - scan_local_media(); - /* reserve headroom so a resume-from-sleep rescan can append new camera - * files without reallocating while the worker/sync threads read */ - grow_locals(g_local_count + 512); - rebuild_display(); - SceUID syncw = sceKernelCreateThread("sync_worker", sync_thread, 0x10000100, 256 * 1024, 0, 0, NULL); if (syncw >= 0) @@ -628,8 +671,38 @@ int main(void) g_search_active ? g_search_query : "", q, sizeof(q), 0); if (ok && q[0]) { - show_status("Searching \"%s\"...", q); - int nres = run_smart_search(q); + /* run the search on a worker thread and + * spin a throbber while it's in flight */ + snprintf(g_search_pending, + sizeof(g_search_pending), "%s", q); + g_search_done = 0; + SceUID stid = sceKernelCreateThread("search", + search_thread, 0x10000100, + 128 * 1024, 0, 0, NULL); + int nres; + if (stid >= 0) { + sceKernelStartThread(stid, 0, NULL); + char cap[160]; + snprintf(cap, sizeof(cap), + "Searching \"%s\"...", q); + unsigned int sf = 0; + while (!g_search_done) { + vita2d_start_drawing(); + vita2d_clear_screen(); + draw_throbber(SCREEN_W / 2.0f, + SCREEN_H / 2.0f - 16.0f, + 22.0f, sf++); + draw_centered(SCREEN_H / 2 + 36, + RGBA8(200, 200, 200, 255), + cap); + vita2d_end_drawing(); + vita2d_swap_buffers(); + } + nres = g_search_result; + } else { + show_status("Searching \"%s\"...", q); + nres = run_smart_search(q); + } if (nres >= 0) { sel = 0; scroll = target = 0; @@ -1277,8 +1350,30 @@ pf_skip: /* server + backup overview, opened from the cloud * button on the search bar */ if (g_srv_state == 0) { - show_status("Loading server info..."); - fetch_server_info(); + /* fetch on a worker thread so the page animates a + * throbber instead of freezing on the request */ + if (!g_srv_fetching) { + g_srv_fetching = 1; + SceUID st = sceKernelCreateThread("srvinfo", + srvinfo_thread, 0x10000100, + 64 * 1024, 0, 0, NULL); + if (st >= 0) + sceKernelStartThread(st, 0, NULL); + else + fetch_server_info(); /* fallback */ + } + vita2d_start_drawing(); + vita2d_clear_screen(); + draw_throbber(SCREEN_W / 2.0f, + SCREEN_H / 2.0f - 16.0f, 22.0f, frame); + draw_centered(SCREEN_H / 2 + 36, + RGBA8(200, 200, 200, 255), + "Loading server info..."); + if (pressed & SCE_CTRL_CIRCLE) + mode = MODE_GRID; + vita2d_end_drawing(); + vita2d_swap_buffers(); + continue; } int total = g_local_count; diff --git a/src/draw.c b/src/draw.c index 0d1eb98..3752867 100644 --- a/src/draw.c +++ b/src/draw.c @@ -64,6 +64,59 @@ static void draw_centered(int y, uint32_t color, const char *text) draw_text((SCREEN_W - w) / 2, y, color, 1.0f, text); } +/* draw `text` centered and word-wrapped so no line exceeds maxw pixels, + * stacking lines line_h px apart. honours explicit '\n' as a hard break. + * returns the y just past the last line drawn. used by the error screens, + * whose messages used to be one centered line that ran off both edges. */ +static int draw_centered_wrapped(int y, uint32_t color, const char *text, + int maxw, int line_h) +{ + char line[256]; + line[0] = '\0'; + const char *p = text; + for (;;) { + while (*p == ' ') /* collapse runs of spaces between words */ + p++; + const char *w = p; + while (*p && *p != ' ' && *p != '\n') + p++; + int wlen = (int)(p - w); + int forced_nl = (*p == '\n'); + int end = (*p == '\0'); + + if (wlen > 0) { + char cand[256]; + if (line[0]) + snprintf(cand, sizeof(cand), "%s %.*s", line, wlen, w); + else + snprintf(cand, sizeof(cand), "%.*s", wlen, w); + /* place the word if it fits, or if the line is empty (a + * single over-long word goes on its own line rather than + * looping forever) */ + if (!line[0] || text_width(1.0f, cand) <= maxw) { + snprintf(line, sizeof(line), "%s", cand); + } else { + draw_centered(y, color, line); + y += line_h; + snprintf(line, sizeof(line), "%.*s", wlen, w); + } + } + if (forced_nl) { + draw_centered(y, color, line); + y += line_h; + line[0] = '\0'; + p++; /* consume the '\n' */ + } + if (end) { + if (line[0]) { + draw_centered(y, color, line); + y += line_h; + } + return y; + } + } +} + /* startup loading screen: the Immich logo spinning, with text below it */ static void draw_loading(const char *text, unsigned int frame) { diff --git a/src/state.c b/src/state.c index c2d3ecf..0ca8a02 100644 --- a/src/state.c +++ b/src/state.c @@ -74,10 +74,19 @@ static char g_search_query[128]; static int *g_search_idx; static int g_search_count; static int g_search_cap; +/* smart search runs on a worker thread so the grid can animate a throbber + * while the request is in flight; g_search_pending is the query it runs, + * g_search_done/g_search_result report completion + its return value */ +static char g_search_pending[128]; +static volatile int g_search_done; +static volatile int g_search_result; /* server info shown on the cloud/backup page; fetched lazily the first time - * the page is opened. g_srv_state: 0 not fetched, 1 ok, -1 failed. */ + * the page is opened. g_srv_state: 0 not fetched, 1 ok, -1 failed. + * g_srv_fetching: a worker thread is fetching it now (so the page animates a + * throbber instead of blocking the main thread on the request). */ static int g_srv_state; +static volatile int g_srv_fetching; static char g_srv_use[40]; /* disk used, human ("1.4 TiB") */ static char g_srv_total[40]; /* disk size, human ("1.8 TiB") */ static int g_srv_pct; /* disk usage percentage */ diff --git a/src/ui.c b/src/ui.c index 1af3677..819dc8c 100644 --- a/src/ui.c +++ b/src/ui.c @@ -871,6 +871,19 @@ static int run_smart_search(const char *query) return g_search_count; } +/* run the (blocking) smart search off the main thread so the grid can animate + * a throbber while the request is in flight. the main thread only draws the + * throbber meanwhile, so it never touches the display model this rebuilds. */ +static int search_thread(SceSize args, void *argp) +{ + (void)args; (void)argp; + int r = run_smart_search(g_search_pending); + __sync_synchronize(); + g_search_result = r; + g_search_done = 1; + return 0; +} + static void draw_hud(const char *text) { (void)text; /* bottom HUD bar removed */ diff --git a/src/video.c b/src/video.c index 0d4c6de..53edd6e 100644 --- a/src/video.c +++ b/src/video.c @@ -18,7 +18,16 @@ #define ALIGN_UP(x, a) (((x) + ((a) - 1)) & ~((a) - 1)) -/* draw an error screen until O is pressed */ +/* the server-side fix for a too-high-bitrate clip, appended to the relevant + * error messages so the user knows what to change on their Immich install */ +#define SERVER_FIX_STEPS \ + "To fix it on the server: in Immich open Administration -> Settings -> " \ + "Video Transcoding, set Transcode policy to \"All videos\" and Max " \ + "bitrate to 10000k (or Target resolution to 720p), save, then run the " \ + "\"Transcode video\" job under Administration -> Jobs." + +/* draw an error screen until O is pressed. title and detail are word-wrapped + * to the screen, so long messages no longer run off the edges. */ static void show_blocking_error(const char *title, const char *detail) { SceCtrlData pad; @@ -26,9 +35,11 @@ static void show_blocking_error(const char *title, const char *detail) sceCtrlPeekBufferPositive(0, &pad, 1); vita2d_start_drawing(); vita2d_clear_screen(); - draw_centered(230, RGBA8(255, 80, 80, 255), title); + int y = draw_centered_wrapped(150, RGBA8(255, 80, 80, 255), + title, SCREEN_W - 80, 34); if (detail && detail[0]) - draw_centered(280, RGBA8(200, 200, 200, 255), detail); + draw_centered_wrapped(y + 16, RGBA8(205, 205, 205, 255), + detail, SCREEN_W - 80, 30); draw_centered(510, RGBA8(160, 160, 160, 255), "O back"); vita2d_end_drawing(); vita2d_swap_buffers(); @@ -688,13 +699,12 @@ replay: * problem. Tell the user instead of holding on a black frame. */ log_line("video: active but 0 frames decoded (HW decoder " "rejected the stream, or running under Vita3K)"); - show_blocking_error("This video can't be decoded", - "The player started but produced no frames. " - "On a real Vita this usually means the clip's " - "bitrate exceeds the H.264 level it declares - " - "re-transcode it in Immich at a lower bitrate " - "or 720p. (The Vita3K emulator decodes no video " - "at all.)"); + show_blocking_error("This video's bitrate is too high to play", + "The player started but produced no frames. On a real " + "Vita this means the clip's bitrate exceeds the H.264 " + "level it declares, so the hardware decoder rejects it. " + SERVER_FIX_STEPS + "\n(The Vita3K emulator also decodes no video at all.)"); } /* reached the end of the video: hold on the last frame with a replay @@ -745,6 +755,193 @@ replay: __sync_synchronize(); } +/* ---- pre-download bitrate probe ------------------------------------------ + * Before pulling the whole stream we fetch just the MP4 header (the small + * `moov` box) and decide whether the Vita's hardware H.264 decoder will accept + * it. Everything we need is in `moov`: `avcC` carries the declared H.264 level + * (which the decoder sizes its buffers from and refuses to overrun), and the + * total file size (from the range response) over the `mvhd` duration gives the + * average bitrate. If that bitrate exceeds the level's ceiling we show the + * "too high" message immediately instead of downloading the whole clip only to + * fail at playback. Immich's transcodes are faststart (moov at the front), so + * a small head request usually has it; if not, we try the tail. The estimate + * is approximate (average vs the decoder's peak/buffer limit), so this only + * blocks clear cases and otherwise defers to the real playback attempt. */ + +enum { PROBE_OK = 0, PROBE_TOO_HIGH = 1, PROBE_UNKNOWN = -1 }; +#define PROBE_HEAD_BYTES (256 * 1024) + +static uint32_t rd_be32(const unsigned char *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | p[3]; +} +static uint64_t rd_be64(const unsigned char *p) +{ + return ((uint64_t)rd_be32(p) << 32) | rd_be32(p + 4); +} + +/* locate a 4CC box tag in a buffer; returns a pointer to the tag (payload + * follows immediately after the 4 bytes), or NULL. a plain scan is enough for + * the tags we want (avcC/mvhd/moov) — collisions in real files are negligible. */ +static const unsigned char *find_tag(const unsigned char *buf, size_t len, + const char *tag) +{ + if (len >= 4) + for (size_t i = 0; i + 4 <= len; i++) + if (buf[i] == (unsigned char)tag[0] && + !memcmp(buf + i, tag, 4)) + return buf + i; + return NULL; +} + +/* the H.264 level's bitrate ceiling in Mbps (High-profile MaxBR). the decoder + * keys its buffer sizing off this, so a stream above it is what gets rejected. */ +static double level_ceiling_mbps(int lvl) +{ + switch (lvl) { + case 30: return 12.5; + case 31: return 17.5; + case 32: return 25.0; + case 40: case 41: case 42: return 62.5; + case 50: return 168.75; + case 51: case 52: return 300.0; + default: return lvl >= 51 ? 300.0 : lvl >= 40 ? 62.5 : 17.5; + } +} + +/* capture the total file size from a 206's "Content-Range: bytes a-b/TOTAL" */ +static size_t probe_hdr_cb(char *b, size_t s, size_t n, void *ud) +{ + size_t len = s * n; + long long *total = ud; + if (len > 14 && !strncasecmp(b, "Content-Range:", 14)) { + char *slash = memchr(b, '/', len); + if (slash) + *total = strtoll(slash + 1, NULL, 10); + } + return len; +} + +/* range GET [start,end] of the asset's playback stream into `out`; sets + * *total to the full file size from Content-Range. returns the HTTP status, + * or -1 on transport error. */ +static int http_get_range(int idx, long start, long end, membuf *out, + long long *total) +{ + char url[700]; + snprintf(url, sizeof(url), "%s/api/assets/%s/video/playback", + g_server, g_asset_ids[idx]); + CURL *curl = curl_easy_init(); + if (!curl) + return -1; + char keyhdr[300]; + auth_header(keyhdr, sizeof(keyhdr)); + struct curl_slist *hdrs = curl_slist_append(NULL, keyhdr); + char range[64]; + snprintf(range, sizeof(range), "%ld-%ld", start, end); + out->data = NULL; + out->size = 0; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + curl_easy_setopt(curl, CURLOPT_RANGE, range); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, out); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, probe_hdr_cb); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, total); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "vitaImmich/0.1 (PS Vita)"); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + if (g_resolve_list) + curl_easy_setopt(curl, CURLOPT_RESOLVE, g_resolve_list); + net_lock(); + CURLcode res = curl_easy_perform(curl); + net_unlock(); + long code = 0; + if (res == CURLE_OK) + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_slist_free_all(hdrs); + curl_easy_cleanup(curl); + if (res != CURLE_OK) { + free(out->data); + out->data = NULL; + return -1; + } + return (int)code; +} + +/* decide from the header whether the clip is decodable. on TOO_HIGH, fills + * *level (level_idc, e.g. 31) and *mbps (estimated average bitrate). */ +static int probe_video_bitrate(int idx, int *level, double *mbps) +{ + membuf head; + long long total = -1; + int code = http_get_range(idx, 0, PROBE_HEAD_BYTES - 1, &head, &total); + /* need a real partial response (206) with a known total to estimate */ + if (code != 206 || total <= 0) { + log_line("video probe %s: no usable range (code=%d total=%lld)", + g_asset_ids[idx], code, total); + free(head.data); + return PROBE_UNKNOWN; + } + + const unsigned char *sbuf = (const unsigned char *)head.data; + size_t slen = head.size; + membuf tail; + tail.data = NULL; + /* faststart files have moov up front; otherwise grab the tail */ + if (!find_tag(sbuf, slen, "moov") && total > (long long)head.size) { + long ts = total > PROBE_HEAD_BYTES ? (long)(total - PROBE_HEAD_BYTES) : 0; + long long t2 = -1; + if (http_get_range(idx, ts, total - 1, &tail, &t2) == 206) { + sbuf = (const unsigned char *)tail.data; + slen = tail.size; + } + } + + const unsigned char *avcc = find_tag(sbuf, slen, "avcC"); + const unsigned char *mvhd = find_tag(sbuf, slen, "mvhd"); + int verdict = PROBE_UNKNOWN; + /* avcc payload[3] = level_idc; mvhd (v1) reads a 64-bit field at +24, + * so the payload must have 32 bytes available */ + if (avcc && avcc + 4 + 4 <= sbuf + slen && mvhd && mvhd + 4 + 32 <= sbuf + slen) { + int lvl = avcc[4 + 3]; + const unsigned char *m = mvhd + 4; + uint32_t timescale; + uint64_t duration; + if (m[0] == 1) { /* version 1: 64-bit times */ + timescale = rd_be32(m + 20); + duration = rd_be64(m + 24); + } else { /* version 0: 32-bit times */ + timescale = rd_be32(m + 12); + duration = rd_be32(m + 16); + } + double secs = timescale ? (double)duration / timescale : 0; + if (secs > 0.5 && lvl > 0) { + double est = (double)total * 8.0 / secs / 1e6; + double ceil_mbps = level_ceiling_mbps(lvl); + verdict = est > ceil_mbps ? PROBE_TOO_HIGH : PROBE_OK; + *level = lvl; + *mbps = est; + log_line("video probe %s: level %d.%d, ~%.1f Mbps " + "(%lld B / %.1fs), ceiling %.1f -> %s", + g_asset_ids[idx], lvl / 10, lvl % 10, est, + (long long)total, secs, ceil_mbps, + verdict == PROBE_TOO_HIGH ? "reject" : "ok"); + } + } + if (verdict == PROBE_UNKNOWN) + log_line("video probe %s: header incomplete (avcC=%p mvhd=%p) - " + "deferring to playback", g_asset_ids[idx], + (const void *)avcc, (const void *)mvhd); + free(head.data); + free(tail.data); + return verdict; +} + /* download + play asset idx, cleaning up the temp file afterwards */ static void view_video(int idx) { @@ -755,6 +952,32 @@ static void view_video(int idx) g_dl_bg = g_thumb[idx]; g_video_rot = ori_to_deg(g_asset_rot[idx]); + /* check the header before committing to the full download, so an + * unplayable (too-high-bitrate) clip is caught up front */ + vita2d_start_drawing(); + vita2d_clear_screen(); + if (g_dl_bg) { + draw_texture_fitted(g_dl_bg, 0, 0, SCREEN_W, SCREEN_H); + vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H, RGBA8(0, 0, 0, 130)); + } + draw_centered(SCREEN_H / 2, RGBA8(255, 255, 255, 255), "Checking video…"); + vita2d_end_drawing(); + vita2d_swap_buffers(); + + int level = 0; + double mbps = 0; + if (probe_video_bitrate(idx, &level, &mbps) == PROBE_TOO_HIGH) { + char det[512]; + snprintf(det, sizeof(det), + "This clip is about %.0f Mbps but declares H.264 Level " + "%d.%d, whose bitrate ceiling the Vita's hardware decoder " + "enforces. " SERVER_FIX_STEPS, + mbps, level / 10, level % 10); + show_blocking_error("This video's bitrate is too high to play", det); + g_dl_bg = NULL; + return; + } + char err[160]; int r = download_video(idx, err, sizeof(err)); if (r == 0) {