From 1309c710a1eb41aeb30ed0bc37fe82474e9c09b1 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 14:28:20 -0700 Subject: [PATCH] Speed MIME media loading and restore authenticated tape preview repairs --- system/backend/mime-media.mjs | 27 ++++--- system/netlify/functions/mime.mjs | 25 ++++-- system/netlify/functions/oven-complete.mjs | 27 +++++-- .../aesthetic.computer/lib/visit-model.mjs | 1 + system/public/mime/index.html | 73 ++++++++++++++++-- system/tests/MIME-BROWSER.md | 5 +- system/tests/fixtures/mime-video.webm | Bin 0 -> 815 bytes system/tests/mime-loading.test.mjs | 29 +++++++ system/tests/mime-scroll-browser.test.mjs | 24 +++++- system/tests/oven-callback-auth.test.mjs | 12 +++ system/tests/visit-tracking.test.mjs | 9 +++ toolchain/analytics/VISITS.md | 16 ++++ toolchain/analytics/media-speed.mjs | 51 ++++++++++++ 13 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 system/tests/fixtures/mime-video.webm create mode 100644 system/tests/mime-loading.test.mjs create mode 100644 system/tests/oven-callback-auth.test.mjs create mode 100644 toolchain/analytics/media-speed.mjs diff --git a/system/backend/mime-media.mjs b/system/backend/mime-media.mjs index 5c9baed7b4..aafed5ce2c 100644 --- a/system/backend/mime-media.mjs +++ b/system/backend/mime-media.mjs @@ -48,6 +48,7 @@ export function mediaPipeline(kind, match = {}, activity = true) { _media: { kind: literal(kind), id: { $toString: "$_id" }, code: "$code", user: "$user", slug: "$slug", size: "$size", + hasPoster: { $ne: [{ $ifNull: ["$thumbnailUrl", ""] }, ""] }, }, } }, ]; @@ -84,7 +85,8 @@ export async function resolveMedia(db, kind, ref) { export async function sourceRecord(db, code) { const ref = parseMediaThread(code); if (!ref) return null; - return db.collection(MEDIA_KINDS[ref.kind]).findOne({ ...visible, _id: ref.id }); + return db.collection(MEDIA_KINDS[ref.kind]).findOne({ ...visible, + ...(ref.kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }), _id: ref.id }); } export async function publicPosts(db, docs) { @@ -102,7 +104,8 @@ export async function publicPosts(db, docs) { }[doc.board]; const url = `/api/mime?file=${encodeURIComponent(doc.code)}`; const file = media - ? { name: `${media.code || media.id}.${extension}`, type: doc.board, size: media.size ?? null, url } + ? { name: `${media.code || media.id}.${extension}`, type: doc.board, size: media.size ?? null, url, + ...(media.kind === "tape" && media.hasPoster ? { poster: `/api/mime?poster=${encodeURIComponent(doc.code)}` } : {}) } : doc.file ? { name: doc.file.name, type: doc.file.type, size: doc.file.size, url } : null; let original; if (media?.code) { @@ -131,14 +134,7 @@ export function mediaFile(kind, record) { return { text: record.source }; } if (kind === "tape" && record.mp4Status === "complete" && record.mp4Url) { - const url = new URL(record.mp4Url); - if (url.protocol !== "https:" || url.username || url.password || ![ - "art-aesthetic-computer.sfo3.digitaloceanspaces.com", - "user-aesthetic-computer.sfo3.digitaloceanspaces.com", - "at-blobs-aesthetic-computer.sfo3.digitaloceanspaces.com", - "art.aesthetic.computer", "user.aesthetic.computer", "at-blobs.aesthetic.computer", - ].includes(url.hostname)) throw new Error("Unsupported media origin"); - return { url: url.href }; + return { url: publicMediaUrl(record.mp4Url) }; } const bucket = record.bucket || (record.user ? "user-aesthetic-computer" : "art-aesthetic-computer"); if (!["art-aesthetic-computer", "user-aesthetic-computer"].includes(bucket)) { @@ -151,3 +147,14 @@ export function mediaFile(kind, record) { if (key.split("/").some((part) => part === ".." || part === ".")) throw new Error("Invalid media path"); return { url: `https://${bucket}.sfo3.digitaloceanspaces.com/${key.split("/").map(encodeURIComponent).join("/")}.${encodeURIComponent(ext)}` }; } + +export function publicMediaUrl(value) { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || ![ + "art-aesthetic-computer.sfo3.digitaloceanspaces.com", + "user-aesthetic-computer.sfo3.digitaloceanspaces.com", + "at-blobs-aesthetic-computer.sfo3.digitaloceanspaces.com", + "art.aesthetic.computer", "user.aesthetic.computer", "at-blobs.aesthetic.computer", + ].includes(url.hostname)) throw new Error("Unsupported media origin"); + return url.href; +} diff --git a/system/netlify/functions/mime.mjs b/system/netlify/functions/mime.mjs index 433f6b53b2..a80edf07ad 100644 --- a/system/netlify/functions/mime.mjs +++ b/system/netlify/functions/mime.mjs @@ -5,7 +5,7 @@ import { respond as httpRespond } from "../../backend/http.mjs"; import { generateUniqueCode } from "../../backend/generate-short-code.mjs"; import { MEDIA_KINDS, MEDIA_THREADS, mediaPipeline, parseMediaThread, - mediaThread, resolveMedia, sourceRecord, publicPosts, mediaFile, + mediaThread, resolveMedia, sourceRecord, publicPosts, mediaFile, publicMediaUrl, } from "../../backend/mime-media.mjs"; const respond = (status, body, headers = {}) => httpRespond(status, body, { "Cache-Control": "no-store", ...headers }); @@ -66,6 +66,7 @@ export function createHandler(connectDb = connect, authorizeUser = authorize) { const account = user ? await db.collection("@handles").findOne({ _id: user.sub }) : null; return respond(200, { handle: account?.handle ? "@" + account.handle.replace(/^@/, "") : null }); } + if (q.poster) return await servePoster(db, q.poster); if (q.file) return await serveFile(db, posts, q.file); if (q.media) { const op = await resolveMedia(db, q.media, q.code); @@ -73,7 +74,7 @@ export function createHandler(connectDb = connect, authorizeUser = authorize) { } if (q.thread) return await getThread(db, posts, q.thread); const page = Math.max(0, Math.min(10000, parseInt(q.page) || 0)); - return q.board ? await getBoard(db, posts, q.board, page) : await getIndex(db, posts, page); + return q.board ? await getBoard(db, posts, q.board, page) : await getIndex(db, posts, page, q.feed === "1"); } let body; try { body = JSON.parse(event.body || "{}"); } @@ -98,14 +99,14 @@ async function findOp(db, posts, code) { async function serveFile(db, posts, code) { const ref = parseMediaThread(code); if (ref) { - const op = await findOp(db, posts, code); - if (!op) return respond(404, { error: "media not found" }); const record = await sourceRecord(db, code); if (!record) return respond(404, { error: "media not found" }); const file = mediaFile(ref.kind, record); const headers = { "Cache-Control": "no-store", "Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff" }; - return file.url ? respond(302, "", { ...headers, Location: file.url }) - : respond(200, file.text, { ...headers, "Content-Type": op.board + "; charset=utf-8" }); + if (file.url) return respond(302, "", { ...headers, Location: file.url }); + const op = await findOp(db, posts, code); + if (!op) return respond(404, { error: "media not found" }); + return respond(200, file.text, { ...headers, "Content-Type": op.board + "; charset=utf-8" }); } const doc = await posts.findOne({ code }); if (!doc?.file?.data) return respond(404, { error: "no file" }); @@ -124,6 +125,14 @@ async function serveFile(db, posts, code) { }; } +async function servePoster(db, code) { + const ref = parseMediaThread(code); + if (ref?.kind !== "tape") return respond(404, { error: "poster not found" }); + const record = await sourceRecord(db, code); + if (!record?.thumbnailUrl) return respond(404, { error: "poster not found" }); + return respond(302, "", { Location: publicMediaUrl(record.thumbnailUrl), "X-Content-Type-Options": "nosniff" }); +} + function allRoots(activity = true, board) { return [ { $match: { parent: null, ...(board ? { board } : {}) } }, @@ -136,11 +145,11 @@ function allRoots(activity = true, board) { ]; } -async function getIndex(db, posts, page) { +async function getIndex(db, posts, page, feedOnly = false) { const end = (page + 1) * PAGE_SIZE; const order = [{ $sort: { when: -1, code: 1 } }, { $limit: end + 1 }]; const [boards, streams] = await Promise.all([ - posts.aggregate([ + feedOnly ? [] : posts.aggregate([ ...allRoots(false), { $group: { _id: "$board", threads: { $sum: 1 }, bumped: { $max: "$bumped" } } }, { $sort: { bumped: -1, _id: 1 } }, diff --git a/system/netlify/functions/oven-complete.mjs b/system/netlify/functions/oven-complete.mjs index bbf4dfe301..0aa2789d57 100644 --- a/system/netlify/functions/oven-complete.mjs +++ b/system/netlify/functions/oven-complete.mjs @@ -28,17 +28,18 @@ export async function handler(event, context) { return respond(400, { message: "Invalid JSON body: " + error.message }); } - const { mongoId, code, mp4Url, thumbnailUrl, secret } = body; + const { mongoId, code, mp4Url, thumbnailUrl, secret, error: bakeError } = body; // Verify callback secret const expectedSecret = process.env.OVEN_CALLBACK_SECRET; - if (expectedSecret && secret !== expectedSecret) { + if (!expectedSecret) return respond(503, { message: "Oven callback is not configured" }); + if (secret !== expectedSecret) { console.error('❌ Invalid callback secret'); return respond(401, { message: "Unauthorized" }); } - if (!mongoId || !code || !mp4Url) { - console.error('❌ Missing required fields. Body:', JSON.stringify(body)); + if (!mongoId || !code || (!mp4Url && !bakeError)) { + console.error('❌ Missing required callback fields'); return respond(400, { message: "Missing required fields: mongoId, code, mp4Url" }); } @@ -67,6 +68,14 @@ export async function handler(event, context) { const { ObjectId } = await import("mongodb"); const tapes = database.db.collection("tapes"); + if (bakeError) { + await tapes.updateOne({ _id: new ObjectId(mongoId), code }, { $set: { + mp4Status: "oven-failed", mp4Error: String(bakeError).slice(0, 1000) + } }); + await notifyOven(`${ovenUrl}/bake-complete`, { code, success: false, error: String(bakeError).slice(0, 1000) }); + return respond(200, { ok: true, code, failed: true }); + } + await tapes.updateOne( { _id: new ObjectId(mongoId) }, { @@ -75,12 +84,19 @@ export async function handler(event, context) { thumbnailUrl, mp4Status: "complete", mp4CompletedAt: new Date() - } + }, + $unset: { mp4Error: "" } } ); console.log(`✅ Updated tape with MP4 URLs`); + // Repairing a public preview must not create a new federated publication. + if (event.queryStringParameters?.previewOnly === "1") { + await notifyOven(`${ovenUrl}/bake-complete`, { code, success: true, mp4Url, thumbnailUrl }); + return respond(200, { ok: true, code, previewOnly: true }); + } + // Download MP4 and thumbnail for ATProto upload await sendStatus('downloading', 'Downloading MP4 for ATProto'); console.log(`📥 Downloading MP4 from ${mp4Url}...`); @@ -210,6 +226,7 @@ function notifyOven(fullUrl, payload) { resolve(); }); + req.setTimeout(15000, () => req.destroy(new Error('Oven notification timed out'))); req.on('error', reject); req.write(body); req.end(); diff --git a/system/public/aesthetic.computer/lib/visit-model.mjs b/system/public/aesthetic.computer/lib/visit-model.mjs index d849acb282..a9ebc6c047 100644 --- a/system/public/aesthetic.computer/lib/visit-model.mjs +++ b/system/public/aesthetic.computer/lib/visit-model.mjs @@ -36,6 +36,7 @@ export const VISIT_PROPERTIES = Object.freeze({ export const VISIT_ACTIONS = Object.freeze([ "link_followed", "download_clicked", "canvas_interacted", "media_started", "round_started", "round_completed", "match_completed", + "mime_interact", "mime_scroll_feed", "mime_original_open", ]); export const ACTIVE_BUCKETS = Object.freeze([0, 10, 30, 60, 180, 600]); export const SURFACES = Object.freeze(["home", "play", "gallery", "read", "support", "other"]); diff --git a/system/public/mime/index.html b/system/public/mime/index.html index 643e3a093c..fc9e985754 100644 --- a/system/public/mime/index.html +++ b/system/public/mime/index.html @@ -137,6 +137,10 @@ .feed-media.media-audio { aspect-ratio: auto; min-height: 112px; } .feed-media.media-image, .feed-media.media-video { aspect-ratio: var(--media-ratio, 4 / 3); } .feed-media.media-image > img, .feed-media.media-video > video { object-fit: contain; } + .media-loading { position: absolute; z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%); + padding: 6px 10px; border-radius: 5px; background: var(--card); color: var(--dim); font-size: 12px; pointer-events: none; } + .feed-media[data-media-state="ready"] > .media-loading, + .feed-media[data-media-state="error"] > .media-loading { display: none; } .media-shield { position: absolute; inset: 0; z-index: 2; touch-action: pan-y pinch-zoom; } .media-toggle { position: absolute; right: 10px; bottom: 10px; z-index: 3; font-size: 12px; background: var(--card); opacity: .94; } @@ -376,6 +380,7 @@ function engagementRecord(code) { } function sampleEngagement() { syncPieceFocus(); + syncVideoPlayback(); const now = performance.now(); // A suspended browser must not turn a delayed timer into hours of attention. const elapsed = Math.max(0, Math.min(2000, Math.round(now - exposureAt))); @@ -458,6 +463,7 @@ view.addEventListener("click", (event) => { const key = link.matches(".discussion, .all-comments") ? "commentOpens" : link.matches(".source-button, .media-label") && link.origin !== location.origin ? "originalOpens" : null; if (!key) return; + if (key === "originalOpens") window.acVisits?.action("mime_original_open"); sampleEngagement(); const record = engagementRecord(post.id); record[key] = Math.min(10000, record[key] + 1); @@ -502,6 +508,7 @@ function exitMediaInteraction() { if (!frame) return; delete frame.dataset.interacting; frame.firstElementChild.inert = true; + if (frame.closest(".feed-post") && frame.firstElementChild.tagName === "VIDEO") frame.firstElementChild.muted = true; const button = frame.querySelector(".media-toggle"); button.textContent = "Interact"; button.setAttribute("aria-pressed", "false"); @@ -517,8 +524,12 @@ function shieldMedia(frame, content, interactive) { button.type = "button"; button.setAttribute("aria-pressed", "false"); button.setAttribute("aria-label", "Interact with this media; toggle to return to scrolling"); - button.onclick = () => { - if (interactingMedia === frame) { exitMediaInteraction(); return; } + button.onclick = (event) => { + if (interactingMedia === frame) { + if (event.isTrusted) window.acVisits?.action("mime_scroll_feed"); + exitMediaInteraction(); return; + } + if (event.isTrusted) window.acVisits?.action("mime_interact"); exitMediaInteraction(); interactingMedia = frame; frame.dataset.interacting = "true"; @@ -537,6 +548,27 @@ window.addEventListener("keydown", event => { } }); +// Only the dominant card auto-plays; nearby tapes warm metadata without competing playback. +function syncVideoPlayback() { + if (!activeFeed) return; + const videos = [...view.querySelectorAll(".feed-media > video")]; + const top = header.getBoundingClientRect().bottom; + let selected = null, largest = 0; + if (!document.hidden && document.hasFocus()) for (const frame of view.querySelectorAll(".feed-media")) { + const box = frame.getBoundingClientRect(); + const visible = Math.max(0, Math.min(innerHeight, box.bottom) - Math.max(top, box.top)); + const area = visible * box.width; + if (area > largest) { selected = frame; largest = area; } + } + if (!document.hidden && document.hasFocus() && interactingMedia?.isConnected) selected = interactingMedia; + for (const video of videos) { + if (selected?.contains(video)) { + if (interactingMedia === selected) continue; // Native pause/play belongs to the viewer in interaction mode. + if (video.paused && video.getAttribute("src")) video.play().catch(() => {}); + } else if (!video.paused) video.pause(); + } +} + let pieceFocused = null, piecePending = null, pieceFocusTimer = 0; function stopPiece() { clearTimeout(pieceFocusTimer); @@ -575,6 +607,7 @@ function syncPieceFocus() { iframe.title = focused.dataset.title; iframe.setAttribute("sandbox", "allow-scripts allow-same-origin"); iframe.setAttribute("allow", "autoplay 'none'; camera 'none'; microphone 'none'"); + iframe.addEventListener("load", () => { iframe.dataset.ready = "true"; }, { once: true }); iframe.src = focused.dataset.player; focused.append(iframe); pieceFocused = focused; @@ -613,6 +646,7 @@ function renderRunnable(post) { image.alt = preview.dataset.title; image.loading = "lazy"; image.decoding = "async"; + image.addEventListener("load", () => { image.dataset.ready = "true"; }, { once: true }); image.src = "https://oven.aesthetic.computer/grab/webp/512/512/" + encodeURIComponent(kind.prefix + code); image.addEventListener("error", () => { image.remove(); @@ -644,7 +678,12 @@ function renderFile(file) { media.controls = true; media.playsInline = true; media.preload = "none"; - nearViewport(media, () => { media.preload = "metadata"; media.src = file.url; }); + nearViewport(media, () => { + media.dataset.loadStarted = performance.now(); + media.preload = "metadata"; + if (file.poster) media.poster = file.poster; + media.src = file.url; + }); } else if (file.type === "application/pdf" || file.type === "text/html") { media = el("iframe", "doc"); media.setAttribute("sandbox", ""); // markup renders, scripts never run @@ -802,6 +841,27 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) const frame = el("div", "feed-media"); if (inFeed && file.tagName === "VIDEO") { file.muted = true; file.loop = true; } 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"; + const loading = el("span", "media-loading", `Loading ${label}…`); + frame.dataset.mediaState = "loading"; + frame.append(loading); + const ready = () => { + frame.dataset.mediaState = "ready"; + if (file.dataset.loadStarted) frame.dataset.mediaLoadMs = Math.round(performance.now() - Number(file.dataset.loadStarted)); + }; + file.addEventListener(file.tagName === "VIDEO" ? "loadeddata" : "load", ready, { once: true }); + file.addEventListener("error", () => { frame.dataset.mediaState = "error"; }, { once: true }); + if (file.tagName === "PRE" || runnable) { + const observer = new MutationObserver(() => { + if (file.tagName === "PRE" && file.textContent !== "…" || runnable && file.querySelector("iframe, img")?.dataset.ready) { + ready(); observer.disconnect(); + } + }); + observer.observe(file, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-ready"] }); + } + if (file.tagName === "IMG" && file.complete && file.naturalWidth) ready(); + } if (["IMG", "VIDEO"].includes(file.tagName)) { frame.classList.add(file.tagName === "IMG" ? "media-image" : "media-video"); const fit = () => { @@ -1032,7 +1092,7 @@ function pages(page, hasMore, path) { async function home(page = 0, signal) { let state = feedCache?.startPage === page ? feedCache : null; if (!state) { - const data = await read(API + "?page=" + page, signal); + const data = await read(API + "?feed=1&page=" + page, signal); state = { startPage: page, nextPage: page + 1, hasMore: data.hasMore, nodes: [], codes: new Set(), index: 0, offset: null }; for (const post of data.recent) { @@ -1068,9 +1128,10 @@ async function home(page = 0, signal) { for (const entry of entries) { for (const media of entry.target.querySelectorAll("video, audio")) { if (!entry.isIntersecting || entry.intersectionRatio < 0.6) media.pause(); - else if (media.tagName === "VIDEO" && !document.hidden) media.play().catch(() => {}); + } } + syncVideoPlayback(); }, { threshold: [0, 0.6] }); state.nodes.forEach((node) => playbackObserver.observe(node.querySelector(".feed-media") || node)); } @@ -1083,7 +1144,7 @@ async function home(page = 0, signal) { list.setAttribute("aria-busy", "true"); feedObserver?.disconnect(); try { - const data = await read(API + "?page=" + state.nextPage, signal); + const data = await read(API + "?feed=1&page=" + state.nextPage, signal); if (signal.aborted) return; for (const post of data.recent) { if (state.codes.has(post.code)) continue; diff --git a/system/tests/MIME-BROWSER.md b/system/tests/MIME-BROWSER.md index 28e5b7a276..55cc731a1c 100644 --- a/system/tests/MIME-BROWSER.md +++ b/system/tests/MIME-BROWSER.md @@ -17,6 +17,9 @@ account changes. Use an isolated Chrome profile and a loopback/SSH-only port. 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 proportions; and deferred offscreen text. Inspect the saved mobile and +image/video proportions; deferred offscreen text; a single dominant playing +video; 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 a separate marked browser check because fixtures do not measure network speed. diff --git a/system/tests/fixtures/mime-video.webm b/system/tests/fixtures/mime-video.webm new file mode 100644 index 0000000000000000000000000000000000000000..e37da4004b5d711e47ccf0821940d7b57c8f63b9 GIT binary patch literal 815 zcmb1gy}x+AQ(GgW)AvS3r%#P6PM;e&oSIt7Q5W*7+S(hEWrtyaF?w;V)EkYrO(}UMEF{(niGQqpEgV!`M zy?g^PhanPV8ci_BRhzV~Z*1mwa`0{F^+_yCGc(dNG}1FPFba1><-2rtIP^P!odI(G ziFHud*K=j`v|8syTBYJYL@wd4cc z$q1X$Pb@@MJ;f)nEZNLR&&UGgdWD?Kq_Tnvu$m5+&>+VUPk%p`_Q?hYRv=`kX8;2X z^3K~z9c`aCGAusd$iUE0z{)U}QELH16a#}hM{8?GYfA?c6I012hZj0oD^2e>uUc}w zkZ;xG*dtb}e9Hx1frOSZ{QGxJrRdDVxYPgN@!9`-@EAmu99TS~ks*aakcoj&K!8Dl zK_Wu`3j;?(BLf2iNah4C8ActPG81she8DBd6oFl4dLz>whL4OOw}~V$Bqr>?z@Ws$ n0(Kg+0(LDk8kt+L%7BvW#NGXy8yU8DeQ#v^*u=Q(LnAW)E!**Y literal 0 HcmV?d00001 diff --git a/system/tests/mime-loading.test.mjs b/system/tests/mime-loading.test.mjs new file mode 100644 index 0000000000..b39dfa1694 --- /dev/null +++ b/system/tests/mime-loading.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHandler} from '../netlify/functions/mime.mjs'; +import {publicPosts, publicMediaUrl} from '../backend/mime-media.mjs'; +const code='tape_6aa83d4ef78ff6472a0a64b2'; +test('tape file and poster reads each validate current visibility with one lookup',async()=>{ + let calls=0,visible=true; + const db={collection(name){assert.ok(['mimechan','tapes'].includes(name));return {async findOne(query){calls++;assert.deepEqual(query.slug,{$type:"string",$ne:""});assert.equal(query.private.$ne,true);assert.equal(query.deleted.$ne,true);assert.equal(query.hidden.$ne,true);assert.deepEqual(query.visibility.$in,[null,'public']);return visible?{slug:'tape',kind:'mp4',thumbnailUrl:'https://art.aesthetic.computer/tape.jpg'}:null;}}}}; + const handler=createHandler(async()=>({db})); + for(const key of ['file','poster']){ + calls=0;const r=await handler({httpMethod:'GET',queryStringParameters:{[key]:code}}); + assert.equal(r.statusCode,302);assert.equal(calls,1);assert.equal(r.headers['Cache-Control'],'no-store'); + } + visible=false; + for(const key of ['file','poster'])assert.equal((await handler({httpMethod:'GET',queryStringParameters:{[key]:code}})).statusCode,404); +}); +test('poster listing exposes a checked endpoint and rejects arbitrary storage hosts',async()=>{ + const [post]=await publicPosts({},[{code,parent:null,board:'video/mp4',_media:{kind:'tape',id:'id',code:'sample',hasPoster:true}}]); + assert.equal(post.file.poster,`/api/mime?poster=${code}`); + assert.ok(!JSON.stringify(post).includes('thumbnailUrl')); + for(const url of ['https://evil.example/a','http://art.aesthetic.computer/a','https://u:p@art.aesthetic.computer/a'])assert.throws(()=>publicMediaUrl(url)); +}); +test('feed-only reads skip the global MIME census, normal inventory retains it',async()=>{ + let census=0; + const db={collection(){return {aggregate(pipeline){if(pipeline.some(s=>s.$unionWith))census++;return {async toArray(){return []}}}}}}; + const handler=createHandler(async()=>({db})); + let r=await handler({httpMethod:'GET',queryStringParameters:{feed:'1'}});assert.equal(r.statusCode,200);assert.equal(census,0); + r=await handler({httpMethod:'GET',queryStringParameters:{}});assert.equal(r.statusCode,200);assert.equal(census,1); +}); diff --git a/system/tests/mime-scroll-browser.test.mjs b/system/tests/mime-scroll-browser.test.mjs index b06b59e804..3b0b805499 100644 --- a/system/tests/mime-scroll-browser.test.mjs +++ b/system/tests/mime-scroll-browser.test.mjs @@ -10,22 +10,25 @@ test('MIME: center scrolling, explicit interaction, natural image sizing and def ? await chromium.connectOverCDP(process.env.MIME_CDP_URL) : await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL, headless: true }); const html = await readFile(new URL('../public/mime/index.html', import.meta.url), 'utf8'); + const videoFixture = await readFile(new URL('./fixtures/mime-video.webm', import.meta.url)); const requests = []; const context = await browser.newContext({ viewport: { width: 900, height: 900 }, hasTouch: true }); 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('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')]; 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.startsWith('/fixture/')) return route.fulfill({ contentType: 'image/svg+xml', body: '' }); return route.fulfill({ status: 200, body: '' }); }); const page = await context.newPage(); + await page.addInitScript(() => { window.mimeActions = []; window.acVisits = { action(name) { window.mimeActions.push(name); } }; }); const errors = []; page.on('pageerror', e => errors.push(e.message)); try { await page.goto('https://mime.ac/?ac-automation=1'); @@ -55,14 +58,33 @@ test('MIME: center scrolling, explicit interaction, natural image sizing and def await page.frameLocator('#program iframe').getByRole('button', { name: 'clicked', exact: true }).waitFor(); await page.locator('#program .media-toggle').click(); assert.equal(await program.getAttribute('data-interacting'), null); + assert.deepEqual(await page.evaluate(() => window.mimeActions), ['mime_interact', 'mime_scroll_feed']); assert.equal(await page.locator('#program .piece-preview').evaluate(el => el.inert), true); + await page.locator('#program .source-button').evaluate(el => el.addEventListener('click', event => event.preventDefault())); + await page.locator('#program .source-button').click(); + assert.equal(await page.evaluate(() => window.mimeActions.at(-1)), 'mime_original_open'); await page.locator('#document .media-toggle').click(); await page.locator('#longtext').evaluate(el => el.scrollIntoView({ block: 'start' })); await page.waitForTimeout(1100); assert.equal(await page.locator('#document .feed-media').getAttribute('data-interacting'), null, 'scrolling away re-locks media'); await page.locator('#deferred').scrollIntoViewIfNeeded(); await page.waitForFunction(() => document.querySelector('#deferred pre')?.textContent.startsWith('A long')); + for (const id of ['video1', 'video2']) { + await page.locator(`#${id} .feed-media`).evaluate(el => el.scrollIntoView({ block: 'center' })); + await page.waitForFunction(id => document.querySelector(`#${id} video`)?.readyState >= 2, id); + await page.waitForFunction(id => !document.querySelector(`#${id} video`)?.paused, id); + assert.equal(await page.locator(`#${id} .feed-media`).getAttribute('data-media-state'), 'ready'); + assert.equal(await page.locator('video').evaluateAll(videos => videos.filter(v => !v.paused).length), 1, 'one dominant tape plays'); + const box = await page.locator(`#${id} .feed-media`).boundingBox(); + assert.ok(Math.abs(box.width / (box.height - 2) - 16 / 9) < .05); + await page.locator(`#${id} .media-toggle`).click(); + await page.locator(`#${id} video`).evaluate(video => video.pause()); + await page.waitForTimeout(1100); + 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(); + } await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await program.evaluate(el => el.scrollIntoView({ block: 'center' })); await page.waitForTimeout(500); const box = await program.boundingBox(); const x = box.x + box.width / 2, y = box.y + box.height / 2; diff --git a/system/tests/oven-callback-auth.test.mjs b/system/tests/oven-callback-auth.test.mjs new file mode 100644 index 0000000000..1c952f8b29 --- /dev/null +++ b/system/tests/oven-callback-auth.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {handler} from '../netlify/functions/oven-complete.mjs'; +test('oven callback requires configured authentication before any database or publication work',async()=>{ + const original=process.env.OVEN_CALLBACK_SECRET; + try{ + delete process.env.OVEN_CALLBACK_SECRET; + assert.equal((await handler({httpMethod:'POST',body:'{}'})).statusCode,503); + process.env.OVEN_CALLBACK_SECRET='test-only'; + 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;} +}); diff --git a/system/tests/visit-tracking.test.mjs b/system/tests/visit-tracking.test.mjs index 5e2fc25284..d958cec5c5 100644 --- a/system/tests/visit-tracking.test.mjs +++ b/system/tests/visit-tracking.test.mjs @@ -92,3 +92,12 @@ test("Shopify tracking follows analytics consent, including revocation during lo allowed = true; doc.dispatchEvent(new Event("visitorConsentCollected")); await tick(); assert.equal(starts, 2); }); + +test("MIME controls record only reviewed visit milestones", () => { + const visit = validateVisit({ ...snapshot(), actions: ["mime_interact", "mime_scroll_feed", "mime_original_open"] }, "https://mime.ac"); + assert.ok(visit); + const update = visitUpdate(visit); + assert.equal(update.$max["actions.mime_interact"], true); + assert.equal(update.$max["actions.mime_scroll_feed"], true); + assert.equal(update.$max["actions.mime_original_open"], true); +}); diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md index 1a3e2faa3b..f4f59238a6 100644 --- a/toolchain/analytics/VISITS.md +++ b/toolchain/analytics/VISITS.md @@ -155,3 +155,19 @@ checks should use marked automation and verify database milestones as well as HTTP responses. Do not label a 204 alone as verified measurement. `visits-clients-deployment.json` records the separate client rollout. + +MIME's explicit media controls add `mime_interact`, `mime_scroll_feed`, and +`mime_original_open`. These count **visits with the action**, not total clicks. +Automatic re-locking when a card leaves the viewport does not count as a click. +They use the existing anonymous visit collector, opt-outs, automation flag, +retention, and Studio scope; no post ID or destination URL is added. + +For bounded media loading probes, run `node toolchain/analytics/media-speed.mjs +--all-types --out report.json`. Set `MIME_CDP_URL` to a dedicated Chrome's +loopback DevTools URL to include video first-frame timing. The public MIME +catalog supplies samples of shared AC media, not an exhaustive crawl of every +studio domain. The probe reads at most a 128 KiB prefix per asset request and +checks declared MIME against MP4/WebM signatures. Video playback stops at the +first presented frame or a 20-second timeout. Source fetch timings for programs +are not runtime/render-ready timings. Do not compare a prefix download to a +full archive download or interpret one run as a stable network percentile. diff --git a/toolchain/analytics/media-speed.mjs b/toolchain/analytics/media-speed.mjs new file mode 100644 index 0000000000..fdfc45baea --- /dev/null +++ b/toolchain/analytics/media-speed.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Read-only, bounded probes of public MIME feed media. Run near the browser/network under test. +import { writeFile } from 'node:fs/promises'; +const args=process.argv.slice(2); +const option=(name,fallback)=>args.includes(name)?args[args.indexOf(name)+1]:fallback; +const origin=option('--origin','https://mime.ac'); +const out=option('--out',null); +const limit=128*1024; +async function probe(url){ + const started=performance.now(); + const response=await fetch(url,{headers:{Range:`bytes=0-${limit-1}`},signal:AbortSignal.timeout(20000)}); + const headersAt=performance.now();let bytes=0;const chunks=[];const reader=response.body?.getReader(); + if(reader){while(bytesBuffer.from(x))); + const container=buffer.subarray(0,4).toString('hex')==='1a45dfa3'?'webm':buffer.toString('ascii',4,8)==='ftyp'?'mp4':'other'; + const atoms=[];let pos=0;while(container==='mp4'&&pos+8<=buffer.length){const size=buffer.readUInt32BE(pos),type=buffer.toString('ascii',pos+4,pos+8);if(!/^[a-zA-Z0-9 ]{4}$/.test(type))break;atoms.push(type);if(size<8)break;pos+=size;} + return {status:response.status,redirected:response.redirected,ttfbMs:Math.round(headersAt-started),prefixMs:Math.round(performance.now()-started),sampleBytes:buffer.length,contentType:response.headers.get('content-type'),contentRange:response.headers.get('content-range'),acceptRanges:response.headers.get('accept-ranges'),cache:response.headers.get('cf-cache-status'),container,atoms,finalUrl:response.url}; +} +const t=performance.now();const response=await fetch(`${origin}/api/mime?page=0`,{signal:AbortSignal.timeout(20000)});if(!response.ok)throw Error(`Feed ${response.status}`); +const feed=await response.json();const feedMs=Math.round(performance.now()-t); +const candidates=[...(feed.recent||[])]; +if(args.includes('--all-types')) for(const {board} of (feed.boards||[]).slice(0,24)) { + if(candidates.some(p=>p.file?.type===board))continue; + 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 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 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; + if((type.startsWith('video/')||type.startsWith('image/'))&&process.env.MIME_CDP_URL){ + const {chromium}=await import('playwright');const browser=await chromium.connectOverCDP(process.env.MIME_CDP_URL);const context=await browser.newContext(); + try{const page=await context.newPage();await page.goto('about:blank');row.display=await page.evaluate(async ({src,type})=>{ + if(type.startsWith('image/')) { + const image=new Image(),t=performance.now();document.body.append(image); + return new Promise(resolve=>{const timer=setTimeout(()=>{image.remove();resolve({timeout:true})},20000);image.onerror=()=>{clearTimeout(timer);resolve({error:'image'})};image.onload=async()=>{await image.decode().catch(()=>{});clearTimeout(timer);resolve({decodedMs:Math.round(performance.now()-t),width:image.naturalWidth,height:image.naturalHeight})};image.src=src;}); + } + const video=document.createElement('video');video.muted=true;video.playsInline=true;video.preload='auto';document.body.append(video);const t=performance.now(),result={}; + return new Promise(resolve=>{const finish=()=>{video.pause();video.removeAttribute('src');video.load();resolve(result)};const timer=setTimeout(()=>{result.timeout=true;finish()},20000); + video.onloadedmetadata=()=>{result.metadataMs=Math.round(performance.now()-t);result.width=video.videoWidth;result.height=video.videoHeight}; + video.onerror=()=>{result.error=video.error?.code;clearTimeout(timer);finish()}; + video.requestVideoFrameCallback(()=>{result.firstFrameMs=Math.round(performance.now()-t);clearTimeout(timer);finish()});video.src=src;video.play().catch(()=>{}); + });},{src:new URL(post.file.url,origin).href,type});}finally{await context.close();await browser.close();}} + }catch(e){row.error=e.message;}rows.push(row);console.log(JSON.stringify(row)); +} +const fastStart=performance.now();const fastResponse=await fetch(`${origin}/api/mime?feed=1&page=0`,{signal:AbortSignal.timeout(20000)});await fastResponse.arrayBuffer(); +const feedOnlyMs=Math.round(performance.now()-fastStart); +const report={feedOnlyMs,availableTypes:(feed.boards||[]).map(b=>b.board),checkedAt:new Date(),origin,feedMs,sampleLimitBytes:limit,notes:'Small public feed sample; prefix timing is not full download time. Repeated probes follow initial probes; connection reuse and CDN state are not controlled.',rows}; +if(out)await writeFile(out,JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify({feedMs,feedOnlyMs,rows:rows.length,out})); -- 2.51.2