diff --git a/system/netlify/functions/track-media.mjs b/system/netlify/functions/track-media.mjs
index af44d0ef49..8458ebaaf4 100644
--- a/system/netlify/functions/track-media.mjs
+++ b/system/netlify/functions/track-media.mjs
@@ -29,6 +29,11 @@ export async function handler(event, context) {
let body;
try {
body = JSON.parse(event.body);
+ // Keep a private draft retryable if preview conversion is not configured.
+ if (event.httpMethod === "POST" && body.ext === "zip" && !process.env.OVEN_CALLBACK_SECRET) {
+ return respond(503, { error: "Tape conversion is unavailable; please retry shortly" });
+ }
+
// Try to authorize user (but don't require it for guest uploads)
let user;
@@ -42,7 +47,6 @@ export async function handler(event, context) {
if (authHeader) {
console.log(`π Authorization header present, attempting to authorize...`);
- console.log(`π Token preview: ${authHeader.substring(0, 50)}...`);
user = await authorize(event.headers);
if (!user) {
// Token validation failed - could be expired or invalid
@@ -315,7 +319,7 @@ export async function handler(event, context) {
// Verify the ZIP is publicly accessible before sending to oven
try {
console.log(`π Verifying ZIP is publicly accessible: ${zipUrl}`);
- const testResponse = await fetch(zipUrl, { method: 'HEAD' });
+ const testResponse = await fetch(zipUrl, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
if (!testResponse.ok) {
console.error(`β οΈ ZIP not accessible: ${testResponse.status} ${testResponse.statusText}`);
console.error(` This likely means the ACL wasn't set properly`);
@@ -337,7 +341,6 @@ export async function handler(event, context) {
console.log(`π₯ Sending tape ${code} to oven for processing...`);
console.log(` Oven URL: ${ovenUrl}/bake`);
console.log(` Callback URL: ${callbackUrl}`);
- console.log(` Callback Secret: ${callbackSecret ? callbackSecret.substring(0, 10) + '...' : 'MISSING!'}`);
const payload = JSON.stringify({
mongoId: mediaId.toString(),
@@ -376,6 +379,7 @@ export async function handler(event, context) {
});
});
+ req.setTimeout(15000, () => req.destroy(new Error('Oven request timed out')));
req.on('error', reject);
req.write(payload);
req.end();
@@ -387,7 +391,8 @@ export async function handler(event, context) {
const ovenResponse = await fetch(`${ovenUrl}/bake`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: payload
+ body: payload,
+ signal: AbortSignal.timeout(15000)
});
if (!ovenResponse.ok) {
diff --git a/system/public/mime/index.html b/system/public/mime/index.html
index fc9e985754..d539e11cfe 100644
--- a/system/public/mime/index.html
+++ b/system/public/mime/index.html
@@ -146,6 +146,9 @@
font-size: 12px; background: var(--card); opacity: .94; }
.feed-media[data-interacting] { outline: 2px solid var(--link); outline-offset: -2px; }
.feed-media[data-interacting] > .media-shield { display: none; }
+ .tape-player { position: absolute; inset: 0 0 48px; width: 100%; height: calc(100% - 48px); border: 0; background: var(--media); z-index: 2; }
+ .feed-media[data-tape][data-interacting] { min-height: 300px; }
+ .feed-media[data-tape][data-interacting] > :first-child { visibility: hidden; }
.feed-media > [inert] { pointer-events: none; user-select: none; }
.save-ribbon { min-width: 44px; padding: 8px; color: var(--author); background: transparent; border: 0; box-shadow: none; }
.save-ribbon svg { width: 20px; height: 26px; fill: transparent; stroke: currentColor; stroke-width: 2; }
@@ -506,6 +509,7 @@ let interactingMedia = null;
function exitMediaInteraction() {
const frame = interactingMedia;
if (!frame) return;
+ frame.querySelector(".tape-player")?.remove();
delete frame.dataset.interacting;
frame.firstElementChild.inert = true;
if (frame.closest(".feed-post") && frame.firstElementChild.tagName === "VIDEO") frame.firstElementChild.muted = true;
@@ -534,6 +538,16 @@ function shieldMedia(frame, content, interactive) {
interactingMedia = frame;
frame.dataset.interacting = "true";
content.inert = false;
+ if (frame.dataset.tape) {
+ if (content.tagName === "VIDEO") content.pause();
+ content.inert = true;
+ const player = el("iframe", "tape-player");
+ player.title = `Tape !${frame.dataset.tape} β audible left/right scrub player`;
+ player.setAttribute("sandbox", "allow-scripts allow-same-origin");
+ player.allow = "autoplay";
+ player.src = "https://aesthetic.computer/video~!" + encodeURIComponent(frame.dataset.tape) + "?nogap=true&nolabel=true&noauth=true" + (window.acAutomation || new URLSearchParams(location.search).has("ac-automation") ? "&ac-automation=1" : "");
+ frame.append(player);
+ }
button.textContent = "Scroll feed";
button.setAttribute("aria-pressed", "true");
syncPieceFocus();
@@ -840,6 +854,7 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false)
const file = runnable ? renderRunnable(post) : renderFile(post.file);
const frame = el("div", "feed-media");
if (inFeed && file.tagName === "VIDEO") { file.muted = true; file.loop = true; }
+ if (post.media?.kind === "tape" && post.media.code) frame.dataset.tape = post.media.code;
frame.append(file);
if (["IMG", "VIDEO", "IFRAME", "PRE"].includes(file.tagName) || runnable) {
const label = file.tagName === "VIDEO" ? "video" : file.tagName === "IMG" ? "image" : runnable ? "program" : "preview";
@@ -872,7 +887,7 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false)
fit();
}
if (file.tagName === "AUDIO") frame.classList.add("media-audio");
- shieldMedia(frame, file, runnable || ["IFRAME", "VIDEO", "AUDIO", "PRE", "A"].includes(file.tagName));
+ shieldMedia(frame, file, Boolean(frame.dataset.tape) || runnable || ["IFRAME", "VIDEO", "AUDIO", "PRE", "A"].includes(file.tagName));
div.append(frame);
}
if (post.text) div.append(renderText(post.text));
@@ -1281,7 +1296,10 @@ window.addEventListener("resize", () => {
}
});
document.addEventListener("visibilitychange", () => {
- if (document.hidden) view.querySelectorAll("video, audio").forEach((media) => media.pause());
+ if (document.hidden) {
+ exitMediaInteraction();
+ view.querySelectorAll("video, audio").forEach((media) => media.pause());
+ }
});
window.addEventListener("keydown", (event) => {
if (!activeFeed?.nodes.length || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
diff --git a/system/tests/MIME-BROWSER.md b/system/tests/MIME-BROWSER.md
index 55cc731a1c..6ca2037aa9 100644
--- a/system/tests/MIME-BROWSER.md
+++ b/system/tests/MIME-BROWSER.md
@@ -18,7 +18,7 @@ Checks cover center wheel scrolling over a scroll-capturing program, a document
and long text; explicit interaction and returning to scrolling; resetting an
interaction after scrolling away; touch scrolling at mobile width; natural
image/video proportions; deferred offscreen text; a single dominant playing
-video; loading-state completion; and the three reviewed MIME action signals.
+video; on-demand tape player creation and destruction; loading-state completion; and the three reviewed MIME action signals.
The tiny WebM fixture was generated with ffmpeg from a one-second solid color
frame (160Γ90 at 10 fps); it contains no external media. Inspect the saved mobile and
desktop screenshots as well as the assertions. Live media loading still needs
diff --git a/system/tests/mime-scroll-browser.test.mjs b/system/tests/mime-scroll-browser.test.mjs
index 3b0b805499..fa03ecb464 100644
--- a/system/tests/mime-scroll-browser.test.mjs
+++ b/system/tests/mime-scroll-browser.test.mjs
@@ -16,14 +16,15 @@ test('MIME: center scrolling, explicit interaction, natural image sizing and def
const fixture = (code, type, media) => ({ code, when: new Date().toISOString(), name: '@test', replies: 0,
file: { name: code, type, url: `https://mime.ac/fixture/${code}` }, ...(media ? { media } : {}) });
const posts = [fixture('wide', 'image/svg+xml'), fixture('program', 'text/javascript', { kind: 'piece', code: 'test', url: 'https://aesthetic.computer/test' }),
- fixture('document', 'text/html'), fixture('longtext', 'text/plain'), fixture('below1', 'image/svg+xml'), fixture('below2', 'image/svg+xml'), fixture('deferred', 'text/plain'), fixture('video1', 'video/webm'), fixture('video2', 'video/webm')];
+ fixture('document', 'text/html'), fixture('longtext', 'text/plain'), fixture('below1', 'image/svg+xml'), fixture('below2', 'image/svg+xml'), fixture('deferred', 'text/plain'), fixture('video1', 'video/webm'), fixture('video2', 'video/webm'), fixture('tape', 'video/webm', { kind: 'tape', code: 'fixture-tape', url: 'https://aesthetic.computer/!fixture-tape' })];
await context.route('**/*', async route => {
const u = new URL(route.request().url()); requests.push(u.pathname);
if (u.hostname === 'mime.ac' && u.pathname === '/') return route.fulfill({ contentType: 'text/html', body: html });
if (u.pathname === '/api/mime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ recent: posts, hasMore: false }) });
if (u.pathname === '/fixture/longtext' || u.pathname === '/fixture/deferred') return route.fulfill({ body: 'A long line of text\n'.repeat(200) });
if (u.pathname === '/fixture/document' || u.pathname === '/test') return route.fulfill({ contentType: 'text/html', body: `` });
- if (/^\/fixture\/video[12]$/.test(u.pathname)) return route.fulfill({ contentType: 'video/webm', body: videoFixture });
+ if (u.pathname === '/video~!fixture-tape') return route.fulfill({ contentType: 'text/html', body: '' });
+ if (/^\/fixture\/(video[12]|tape)$/.test(u.pathname)) return route.fulfill({ contentType: 'video/webm', body: videoFixture });
if (u.pathname.startsWith('/fixture/')) return route.fulfill({ contentType: 'image/svg+xml', body: '' });
return route.fulfill({ status: 200, body: '' });
});
@@ -83,6 +84,20 @@ test('MIME: center scrolling, explicit interaction, natural image sizing and def
assert.equal(await page.locator(`#${id} video`).evaluate(video => video.paused), true, 'manual pause survives the autoplay scheduler');
await page.locator(`#${id} .media-toggle`).click();
}
+ assert.equal(await page.locator('.tape-player').count(), 0, 'full tape player is not loaded for browsing');
+ await page.locator('#tape .media-toggle').click();
+ const tapePlayer = page.locator('#tape .tape-player');
+ await tapePlayer.waitFor();
+ assert.match(await tapePlayer.getAttribute('src'), /video~!fixture-tape/);
+ assert.equal(await tapePlayer.getAttribute('allow'), 'autoplay');
+ assert.equal(await page.locator('#tape video').evaluate(v => v.paused && v.inert), true);
+ await page.frameLocator('#tape .tape-player').getByRole('button', { name: 'Tape scrubber' }).click();
+ await page.locator('#tape .media-toggle').click();
+ assert.equal(await tapePlayer.count(), 0, 'exit destroys the audible player');
+ await page.locator('#tape .media-toggle').click();
+ await tapePlayer.waitFor();
+ await page.locator('#wide').evaluate(el => el.scrollIntoView({ block: 'start' }));
+ await page.waitForFunction(() => !document.querySelector('.tape-player'));
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await program.evaluate(el => el.scrollIntoView({ block: 'center' }));
diff --git a/system/tests/oven-callback-auth.test.mjs b/system/tests/oven-callback-auth.test.mjs
index 1c952f8b29..27ab1ed100 100644
--- a/system/tests/oven-callback-auth.test.mjs
+++ b/system/tests/oven-callback-auth.test.mjs
@@ -10,3 +10,13 @@ test('oven callback requires configured authentication before any database or pu
assert.equal((await handler({httpMethod:'POST',body:'{"secret":"wrong"}'})).statusCode,401);
}finally{if(original===undefined)delete process.env.OVEN_CALLBACK_SECRET;else process.env.OVEN_CALLBACK_SECRET=original;}
});
+test('a new ZIP upload remains retryable before any record is created when conversion is unconfigured', async () => {
+ const {handler: trackMedia} = await import('../netlify/functions/track-media.mjs');
+ const original=process.env.OVEN_CALLBACK_SECRET;
+ try {
+ delete process.env.OVEN_CALLBACK_SECRET;
+ const result=await trackMedia({httpMethod:'POST',body:'{"ext":"zip"}',headers:{}});
+ assert.equal(result.statusCode,503);
+ assert.match(JSON.parse(result.body).error,/retry/);
+ } finally {if(original===undefined)delete process.env.OVEN_CALLBACK_SECRET;else process.env.OVEN_CALLBACK_SECRET=original;}
+});
diff --git a/toolchain/analytics/media-speed.mjs b/toolchain/analytics/media-speed.mjs
index fdfc45baea..29eae3b9ed 100644
--- a/toolchain/analytics/media-speed.mjs
+++ b/toolchain/analytics/media-speed.mjs
@@ -24,9 +24,15 @@ if(args.includes('--all-types')) for(const {board} of (feed.boards||[]).slice(0,
const r=await fetch(`${origin}/api/mime?board=${encodeURIComponent(board)}`,{signal:AbortSignal.timeout(20000)});
if(r.ok){const data=await r.json();if(data.threads?.[0]?.op)candidates.push(data.threads[0].op);}
}
+const forced=new Set();
+for(const code of option('--tapes','').split(',').filter(Boolean).slice(0,4)) {
+ const r=await fetch(`${origin}/api/mime?media=tape&code=${encodeURIComponent(code)}`,{signal:AbortSignal.timeout(20000)});
+ if(!r.ok)throw Error(`Tape lookup ${r.status}`);const {op}=await r.json();forced.add(op.code);
+ if(!candidates.some(p=>p.code===op.code))candidates.push(op);
+}
const counts=new Map(),rows=[];
for(const post of candidates){
- const type=post.file?.type;if(!type)continue;const max=type==='video/mp4'?2:1;if((counts.get(type)||0)>=max)continue;counts.set(type,(counts.get(type)||0)+1);
+ const type=post.file?.type;if(!type)continue;const max=type==='video/mp4'?2:1;if((counts.get(type)||0)>=max&&!forced.has(post.code))continue;counts.set(type,(counts.get(type)||0)+1);
const row={code:post.code,kind:post.media?.kind||'upload',mime:type};
try{const first=await probe(new URL(post.file.url,origin));delete first.finalUrl;row.first=first;
const warm=await probe(new URL(post.file.url,origin));delete warm.finalUrl;row.repeat=warm;
diff --git a/toolchain/analytics/reports/2026-09-23-media-loading.md b/toolchain/analytics/reports/2026-09-23-media-loading.md
new file mode 100644
index 0000000000..6234225aee
--- /dev/null
+++ b/toolchain/analytics/reports/2026-09-23-media-loading.md
@@ -0,0 +1,20 @@
+# MIME media loading, 23 September 2026
+
+Nine public ZIP tapes had stuck preview conversions. Restoring Lithβs verified oven credential and repairing them produced nine MP4 previews with posters. Originals total 379,582,044 bytes; previews total 49,264,685 bytes (87.0% smaller). Original tapes remain available for the audible scrub player. Repairs did not publish new federated posts.
+
+| Tape | Original | Preview | First presented frame |
+| --- | ---: | ---: | ---: |
+| !ujs | 62,162,214 bytes | 1,596,612 bytes | 242 ms |
+| !pjo | 179,755,593 bytes | 41,422,195 bytes | 311 ms |
+
+These two previews have their MP4 metadata before media data (`ftyp, moov, free, mdat`). Two older direct-video tapes (!2x3 and !tgt) contain WebM bytes despite their MP4 labels; that compatibility issue remains.
+
+The feed now skips the unused global MIME census. Three interleaved pairs on the same release returned the same 12 cards: full inventory 555/355/370 ms, feed-only 294/278/295 ms. Medians were 370 and 294 ms, respectively (21% lower).
+
+The browser probe covered PNG decode, video first frame, JavaScript/KidLisp/text source fetch, and ZIP prefixes before conversion. After deployment the sampled PNG decoded in 190 ms; JavaScript and KidLisp source prefixes arrived in 76 and 55 ms. Source fetch is not program runtime readiness. These are small samples from Poorslice, with uncontrolled connection/CDN warmth, not global percentiles or an exhaustive crawl of every studio property. Existing videos varied between runs; no universal speedup is claimed.
+
+Reproduce with `toolchain/analytics/media-speed.mjs --all-types --tapes ujs,pjo --out /tmp/media-speed.json` and `MIME_CDP_URL` pointing to a dedicated Chrome. The tape repair tool defaults to a read-only audit.
+
+MIME uses natural media proportions, deferred loading and a single dominant autoplay preview. Interact opens the actual AC tape player on demand; leaving interaction, scrolling offscreen, hiding the page or navigating destroys it. The frame-tape player provides the same audible left/right scrub interaction as `video` after `cap`. Direct-video tapes retain the existing `video` player's more limited playback controls.
+
+Validation: 13 focused backend/analytics tests passed, plus the Poorslice browser regression for center scrolling, mobile swipe, deferred loading, playback selection, player lifecycle and visit actions. A disposable production draft returned create 200, storage upload 200, unauthenticated read 403, and cleanup 200. This checks private draft transport, not a fresh public post or federated publication. Nine repaired originals exercised real preview conversion and authenticated completion.