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.