diff --git a/api/community.js b/api/community.js index bda1d87..def2c96 100644 --- a/api/community.js +++ b/api/community.js @@ -75,7 +75,7 @@ export default async function handler(req, res) { if (op === "ref-done" || op === "compared") { if (!bearer(req, process.env.COMMUNITY_RENDER_SECRET)) return json(res, 401, { error: "Unauthorized" }); const r = op === "ref-done" - ? await finishRef(String(data.id), { ok: data.ok === true, reason: data.reason, duration: data.duration, width: data.width, height: data.height, sheets: data.sheets }) + ? await finishRef(String(data.id), { ok: data.ok === true, reason: data.reason, duration: data.duration, width: data.width, height: data.height, sheets: data.sheets, video: data.video }) : await finishCompare(String(data.id), { round: data.round, ok: data.ok === true, reason: data.reason, times: data.times }); return json(res, r.ok ? 200 : 400, r); } diff --git a/src/community.js b/src/community.js index b026d99..ab3f6c2 100644 --- a/src/community.js +++ b/src/community.js @@ -113,6 +113,7 @@ export async function readSource(src, fetcher = fetch) { } catch { /* page layout changed: Claude reads the page itself */ } } if (out.title) out.title = text(out.title); + if (!out.video) out.error = `The page gave no video (${html.length} bytes${/cf-chl|challenge-platform|Just a moment/.test(html) ? ", a bot check" : ""}).`; return out; } @@ -138,10 +139,11 @@ async function reference(src, video, { now, dispatch, wait }) { let meta = await getJson(refPath(src.id)); const stuck = m => m && m.status !== "ready" && now - Date.parse(m.requested) > RETRY_MS; if (!meta || stuck(meta)) { - if (!video || !SOURCES[src.host].video.test(video)) return { status: "failed", reason: "The video of this page could not be found." }; + // a page that does not show its video to this server (a bot check, say) is read by the job itself + if (video && !SOURCES[src.host].video.test(video)) video = null; meta = { status: "preparing", video, requested: iso(now) }; await putJson(refPath(src.id), meta); - if (!(await dispatch("reference", src.id, { video }).catch(() => false))) { + if (!(await dispatch("reference", src.id, video ? { video } : { page: src.url }).catch(() => false))) { meta = { ...meta, status: "failed", reason: "The video could not be sent to be read right now; try again later." }; await putJson(refPath(src.id), meta); } @@ -157,7 +159,7 @@ export async function getRecreationBrief(args, { fetcher = fetch, dispatch = dis if (holds(existing, now)) return { ok: true, alreadyExists: true, ...publicRecord(existing), message: "This video is already a Community template (or one is being made from it). Do not recreate it: give the user this status." }; const page = await readSource(src, fetcher); const ref = await reference(src, page.video, { now, dispatch, wait }); - if (ref.status === "failed") return { ok: false, template: src.id, error: `${ref.reason || "The video could not be read."} A template is only made when it can be compared with its original video, so this one cannot be recreated now.` }; + if (ref.status === "failed") return { ok: false, template: src.id, error: `${ref.reason || "The video could not be read."}${page.error ? ` (${page.error})` : ""} A template is only made when it can be compared with its original video, so this one cannot be recreated now.` }; const out = { ok: true, alreadyExists: false, template: src.id, source: { site: src.site, page: src.page, id: src.slug, url: src.url, title: page.title, author: page.author, authorUrl: page.authorUrl, video: page.video }, @@ -346,9 +348,14 @@ export async function putRef(id, file, bytes) { return { ok: true }; } /** Reference job finished: the video's size, duration and the times of each sheet's frames. */ -export async function finishRef(id, { ok, reason, duration, width, height, sheets }) { +export async function finishRef(id, { ok, reason, duration, width, height, sheets, video }) { const m = await getJson(refPath(id)); if (m?.status !== "preparing") return { ok: false, error: "No reference is being prepared." }; + if (ok && !m.video) { + const host = Object.values(SOURCES).find(s => s.video.test(String(video))); + if (!host) return { ok: false, error: "video must be the source page's video." }; + m.video = video; + } if (ok && !(num(duration, 0.5, 600) && num(width, 16, 8192) && num(height, 16, 8192) && sheets && times(sheets.general, duration) && (sheets.inicio == null || times(sheets.inicio, duration)))) return { ok: false, error: "duration, width, height and sheets.general are required." }; const next = ok ? { ...m, status: "ready", duration, width, height, sheets: { general: sheets.general, ...(sheets.inicio ? { inicio: sheets.inicio } : {}) }, reason: null } : { ...m, status: "failed", reason: String(reason || "The video could not be read.").slice(0, 300) }; diff --git a/tests/community.test.js b/tests/community.test.js index 958b234..4a1e021 100644 --- a/tests/community.test.js +++ b/tests/community.test.js @@ -111,11 +111,25 @@ test("comparisons are limited and an abandoned proposal frees its video", async const other = await proposeTemplate({ url, compositions: compositions(), card }, { caller: "3.3.3.3", ...opts, now: later }); assert.equal(other.accepted, true); assert.notEqual(other.code, r.code); }); -test("a page without a readable video is not recreated", async () => { +test("a page that hides its video from the server is read by the reference job", async () => { + setStore(mem()); jobs.length = 0; + const noVideo = async () => ({ ok: true, text: async () => "Just a moment..." }); + const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe"; + const b = await getRecreationBrief({ url }, { ...opts, fetcher: noVideo }); + assert.equal(b.reference.status, "preparing"); + assert.deepEqual(jobs.at(-1), { kind: "reference", id, page: url }); + assert.equal((await finishRef(id, { ok: true, duration: 20, width: 1920, height: 1080, sheets: { general: [1] }, video: "https://evil.com/x.mp4" })).ok, false); + assert.equal((await finishRef(id, { ok: true, duration: 20, width: 1920, height: 1080, sheets: { general: [1] }, video: "https://media.prompt-motion.com/x/v.mp4" })).ok, true); + await proposeTemplate({ url, compositions: compositions(), card }, { caller: "4.4.4.4", ...opts, fetcher: noVideo }); + assert.equal(jobs.at(-1).video, "https://media.prompt-motion.com/x/v.mp4"); +}); +test("a job that cannot read the video fails the brief", async () => { setStore(mem()); - const noVideo = async () => ({ ok: true, text: async () => PM.replace(/"contentUrl":"[^"]+",/, "") }); - const b = await getRecreationBrief({ url: "https://prompt-motion.com/parkerrex-1a54fe" }, { ...opts, fetcher: noVideo }); - assert.equal(b.ok, false); assert.match(b.error, /compared/); + const id = "c-pm-parkerrex-1a54fe"; + await getRecreationBrief({ url: "https://prompt-motion.com/parkerrex-1a54fe" }, opts); + await finishRef(id, { ok: false, reason: "el video respondió 403" }); + const b = await getRecreationBrief({ url: "https://prompt-motion.com/parkerrex-1a54fe" }, opts); + assert.equal(b.ok, false); assert.match(b.error, /403.*compared/); }); test("daily limit per caller", async () => { setStore(mem());