Loading…
a327ex.com

Anchor Website 3

Opus 4.8

Summary

Continued the in-engine reimplementation of a327ex.com (a327ex-site/renderer/, Anchor 2). This session took "Phase D — embeds" from a placeholder idea to a working in-engine video player with YouTube-embed-parity controls: pivoted the whole media strategy to fully-owned decode, built a libav video decoder into the shared C engine, wrote the yt-dlp acquisition pipeline, and rendered videos in the document with click-to-play, a full controls bar, human-authored subtitles, and in-app fullscreen.

Architecture pivot — fully-owned in-engine media (rejected the hole-punch idea):

  • Started Phase D as "native cards." User relayed an alternate "hole-punch" approach (engine leaves a transparent rect, the browser shows a real <iframe> there). Rejected it: web-only, the embed stays an inert cross-origin DOM rectangle the engine can't composite/collide with, and it undercuts the project's primary goal (prove Anchor 2 can render the site) plus the planned descent game (Downwell/Spelunky down the page; posts are walls; videos play as the player nears — every element must be an engine-owned texture/audio source).
  • Decided: download and own every external resource, decode it in-engine. For video specifically, the game requires the engine to own the pixels, which requires a decodable file (YouTube's sealed embed won't give the stream) → self-host + in-engine decode.
  • Web-delivery discussion: users will range-stream (HTTP Accept-Ranges); the eventual web path is a platform split — desktop libav, web a same-origin <video>gl.texImage2D engine-owned texture (NOT the inert cross-origin iframe). Hosting via Cloudflare R2 (zero egress). All deferred.

Decoder decision — pl_mpeg → libav (the M1 → M1b arc):

  • Engine has NO video decode; uses miniaudio (with an existing custom ma_data_source DSP at anchor.c:1536) and embeds single-header libs (stb_vorbis, miniaudio).
  • M1 (pl_mpeg/MPEG1) first — chosen for the engine's single-header/WASM identity. Built the Video subsystem in anchor.c: Video struct (decoder + Texture + RGBA scratch), a VideoAudio SPSC ring-buffer ma_data_source (audio = master clock), video_pump_all(dt) in the render loop (decode→upload the due frame before draw), and Lua bindings video_load/play/pause/stop/seek/set_volume/set_looping/texture/get_size/get_duration/get_time/get_state/unload. video_texture(h) returns a plain Texture* drawn by layer_texture (zero new draw code). Fixed a black-screen bug: pl_mpeg's plm_frame_to_rgba leaves the alpha byte untouched → pre-fill alpha=255.
  • Quality forced the switch to libav. MPEG1 looked poor; a 1080p HQ MPEG1 encode measured ~124 MB/min ≈ ~11× h264. User wants high quality everywhere (web + a future desktop app) → libav decodes the source h264/VP9/AV1 directly, no transcode. Cost accepted: ships 5 FFmpeg DLLs next to anchor.exe (engine loses single-exe property; static MSVC link impractical). No WASM problem (web uses browser <video>, libav is desktop-only). Installed BtbN n7.1 LGPL shared dev libs (headers→engine/include/libav*, import libs→engine/lib/, DLLs→build/+game dirs); MSVC compile/link/DLL-load smoke-tested OK.
  • M1b swapped the decode internals (everything else stayed): plm_decode_*avformat_open_input/avcodec_receive_frame/sws_scale, with a 32-frame YUV AVFrame queue (libav's single interleaved av_read_frame feeds both the video queue and the audio ring). Added video_set_display_size (decode/scale straight to card size via a lazily-built SwsContext) — fixes both the 1080p-source scaling and the downscale shimmer. Fixed seek A/V desync (post-seek drop of audio before the seek target). User confirmed the seek-hitch (decode-from-keyframe) is "fast enough" — left as-is.

M2 — acquisition pipeline (renderer/tools/fetch_media.py, yt-dlp):

  • Scans content for ::youtube/::short (106 unique) + 14 local ::video; downloads at a 1080p h264-preferred cap → media-cache/youtube/<id>.mp4 (gitignored, faststart); writes data/media.lua manifest (key→file/poster/subs/w/h/dur/title/ok); idempotent (--download-archive). Full ~106-video pull deferred to pre-deploy (--all).
  • Format-selector gotcha: the first run silently grabbed 360p (format 18) — bv* + an acodec:aac sort ranked the only-aac-containing combined 360p above the video-only DASH streams. Fix: bestvideo+bestaudio (not bv*) with sort vcodec:h264,res,fps,acodec:aac.
  • Posters = the official YouTube thumbnail, not an ffmpeg frame (user caught the mismatch): make_poster uses yt-dlp --write-thumbnail, cropped to 16:9 when only the 4:3 SD/HQ thumbnail exists. Local ::video still extracts a frame.

Console QuickEdit hang — misdiagnosed, reverted: an "after a while, unfocus → unrecoverable hang, force-kill" bug. Diagnosed (overconfidently) as Windows console QuickEdit suspending the unbuffered-stdout process on a console click; added SetConsoleMode to disable it. User reported it broke console copy AND didn't fix the hang → reverted. Hang deferred to its own session; left a lead: the renderer doesn't free textures on navigation (MAX_TEXTURES=256), a plausible leak.

M3a — videos in the document (poster cards + click-to-play):

  • M3a-1: convert.lua emits {type='video',kind,key}; canvas.lua loads data/media.lua, resolves keys, preloads posters; elements.lua layout/draw_video_element (poster + play-button overlay, or titled placeholder when ok=false). 334 pages reconverted (141 video elements). Architecture: posters are cheap JPGs, decoders are scarce.
  • M3a-2: click-to-play via a video manager (main.lua) holding ≤1 decoder in el._video; draw_video_element hit-tests its own rect with ui_mouse_pressed (works in both homepage feed and article pages); swaps poster↔video_texture. Decisions: scroll-off-screen does NOT pause (engine keeps pumping; audio continues like a real embed); ended→poster; click toggles. Autoplay-on-scroll demoted to a deferred opt-in, NOT the default.

M3c — YouTube-embed-parity controls:

  • M3c-1 controls bar: video_controls_geom + draw_video_controls (elements.lua) over the active video; interaction in video_controls_update (main.lua, update-time, against the el._rect the draw stashes). Seekbar (site-accent played portion, grows+knob on hover, seek-on-release), play/pause, volume (icon=mute, slider expands on hover, persists across videos), time readout, CC + fullscreen icons, dark gradient scrim, hover-to-show with movement-gated 2.5s auto-hide (always-on when paused). Fixes: clicking the bar no longer toggles play/pause (only the video body above the bar does); the bar hides promptly when the cursor leaves a playing video.
  • M3c-2 subtitles (human-only): fetch_media make_subs (yt-dlp --write-subs --sub-langs all, never auto) → media-cache/subs/<id>.<lang>.vtt + .done marker + manifest subs map; subs_parse (WEBVTT→cues, verified 26 cues on the test video) + draw_subtitles (active cue, white-on-dark-pill, bottom-center, rides above the bar); CC toggles video_subs_on, default en. CJK boxes (Arimo has no CJK) — en is the default so it's fine. Test video: Abject jtvxUU6qKpM (after "Attack System Generalization 1" in the feed) which has manual en/ja/zh-TW subs.
  • M3c-3 in-app fullscreen: fullscreen icon → video_enter_fullscreen (video_set_display_size to window-fit for crispness) → draw_fullscreen_video (black + letterboxed texture + controls + subs, document frozen) → Esc/icon exits (restores card size).

Homepage scrollbar (non-issue): user reported the homepage lacked the page scrollbar. The scrollbar_geom/max_scroll code already serves both views, and the homepage clearly scrolls (so max_scroll>0) — meaning it should already draw. Rather than guess, added a one-time HOME DEBUG print; user confirmed it works, print removed.

Still open (carried forward): M2b full download; M4 (tweet/steam/spotify cards — still [TYPE]); player polish (CJK sub font, fullscreen control scaling, sub-language picker); Phase E spoilers; G-tail (lightbox / old-posts skin); web delivery. The Anchor2 libav engine change is uncommitted pending the FFmpeg-vendoring decision (commit ~40 MB DLLs vs a setup script); the unfocus hang is its own session.

🔒 Only the summary of this log is public. Private because it contains too many website internal details.