From fd045fa1c3e4271e38be72632797c93ae0d5d96e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:01:33 +0000 Subject: [PATCH] feat(community): prompt-motion's video is sent by the caller's Claude in parts prompt-motion answers 403 to every server (Vercel and GitHub alike). get_recreation_brief now returns a short script for those videos: the caller's Claude downloads the video and posts it here in 3 MB parts with a one-time key; the reference and compare jobs read the parts back. Version 0.6.2. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TXFXQJroZAF74W4gg57vyH --- PRIVACY.md | 2 +- README.md | 2 +- api/community.js | 23 +++++++++++++- api/mcp.js | 4 +-- package.json | 2 +- server.json | 2 +- src/community.js | 61 ++++++++++++++++++++++++++++++++++--- tests/community.test.js | 67 ++++++++++++++++++++++++++++++++--------- 8 files changed, 137 insertions(+), 26 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index d6f82a4..991150b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -23,7 +23,7 @@ Like other web services, network and infrastructure providers may process techni `create_hcmotion` stores the generated `.hcmotion` file (the submitted composition, compressed) in a private Vercel Blob store so it can be downloaded. Anyone with the link can download it while it is valid. Links expire **24 hours** after creation, and a daily job deletes expired files, so a file may remain stored for up to about 48 hours before deletion. The service creates no user accounts. -`propose_template` (Community templates) stores the submitted compositions, the gallery card and the source link, and publishes them in the public HyperCodeMotion Studio gallery until they are retired. It keeps a salted hash of the caller's network address for one day, only to limit proposals to 5 a day; the daily job deletes it. `get_recreation_brief` reads the public source page the user links to, and has the public source video downloaded once to keep frames of it in the private store; those frames and the comparisons between the original and the recreation are used only to check the recreation and are never published. +`propose_template` (Community templates) stores the submitted compositions, the gallery card and the source link, and publishes them in the public HyperCodeMotion Studio gallery until they are retired. It keeps a salted hash of the caller's network address for one day, only to limit proposals to 5 a day; the daily job deletes it. `get_recreation_brief` reads the public source page the user links to, and has the public source video downloaded once (for prompt-motion.com, the user's Claude downloads it and sends it here) to keep it and frames of it in the private store; those frames and the comparisons between the original and the recreation are used only to check the recreation and are never published. The service runs on Vercel. Vercel may generate and retain infrastructure, security, deployment, and runtime logs under its own platform policies and the account configuration. We do **not** claim that Vercel stores no request metadata or that infrastructure logs have zero retention. Exact infrastructure retention can vary with plan, product, and configuration. Please consult [Vercel's Privacy Policy](https://vercel.com/legal/privacy-policy) and [Vercel's documentation](https://vercel.com/docs/observability). diff --git a/README.md b/README.md index 853f7c4..35dda1a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Both links expire **24 hours** after they are created. Save the project from Stu | `create_hcmotion` | Validates a finished composition and packages it as a `.hcmotion` file. Invalid input returns `created: false` with the errors to fix and stores nothing. Valid input returns `open` and `download` links (24 h). | write (creates a temporary file), not destructive | | `get_template` | Returns the full design of a Studio gallery template by its id and format (brief, scenes, HTML scene documents and layers), to use as a design reference. | read-only | | `validate_json` | Checks a composition, design or improvement patch and returns errors and warnings. Preliminary: it does not prove a correct render. | read-only | -| `get_recreation_brief` | Takes the link of a public motion video (prompt-motion.com or oneshotted.io), plus the video file, prompt, title and author that Claude reads on that page (the source sites often refuse servers), and returns its source details, its published prompt when available, the brief to recreate it as a gallery template and reference frames of the original video (extracted once per video; the first call may answer that they are being prepared). | write (starts the frame extraction), not destructive | +| `get_recreation_brief` | Takes the link of a public motion video (prompt-motion.com or oneshotted.io), plus the video file, prompt, title and author that Claude reads on that page (the source sites often refuse servers), and returns its source details, its published prompt when available, the brief to recreate it as a gallery template and reference frames of the original video (extracted once per video; the first call may answer that they are being prepared). prompt-motion.com refuses every server, so for its videos the brief returns a short script that Claude runs in its own code tool to download the video and send it here in parts. | write (starts the frame extraction), not destructive | | `propose_template` | Submits the recreation (three compositions and the gallery card). Checks the source, the compositions and the card; when accepted, renders the composition in the original's format with the Studio engine and compares it with the original frame by frame. Send fixes with the same code (up to 3 comparisons). Returns a tracking code. | write, not destructive | | `get_proposal_status` | Returns the status of a proposal by its tracking code: comparing, review (with the comparison sheet: original and recreation side by side), rendering, published, failed (with the reason) or retired. | read-only | | `publish_template` | Publishes a compared proposal in the Studio gallery marked «Community», with notes on what is consciously different from the original. | write, not destructive | diff --git a/api/community.js b/api/community.js index bda1d87..ea5c19d 100644 --- a/api/community.js +++ b/api/community.js @@ -1,4 +1,4 @@ -import { finishCompare, finishRef, finishRender, putCompare, putMedia, putRef, readCompare, readIndex, readMedia, readTemplateWithStatus, retire } from "../src/community.js"; +import { finishCompare, finishRef, finishRender, finishUpload, putCompare, putMedia, putRef, putUpload, readCompare, readIndex, readMedia, readTemplateWithStatus, readUpload, retire } from "../src/community.js"; const MAX = 4 * 1024 * 1024; const json = (res, status, payload, cache = "no-store") => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", cache); res.end(JSON.stringify(payload)); }; @@ -17,6 +17,9 @@ async function body(req) { * GET /c//.json a template's composition (cuadrado, vertical, horizontal) * GET /c//-. its preview media * GET /api/community?op=compare&id=&file= latest comparison, sheet.jpg or comparacion.mp4 (COMMUNITY_ADMIN_SECRET) + * GET /api/community?op=part&id=&part= a part of an original the caller sent (COMMUNITY_RENDER_SECRET) + * POST /api/community?op=upload&id=&key=&part= the caller's Claude sends one part of the original (key from the brief) + * POST /api/community?op=upload-done&id=&key=&parts= all parts sent: starts the reference job * POST /api/community?op=ref&id=&file= reference job uploads one frame sheet (COMMUNITY_RENDER_SECRET) * POST /api/community?op=ref-done {id, ok, reason, duration, width, height, sheets} (COMMUNITY_RENDER_SECRET) * POST /api/community?op=compare&id=&file= compare job uploads sheet.jpg or comparacion.mp4 (COMMUNITY_RENDER_SECRET) @@ -32,6 +35,15 @@ export default async function handler(req, res) { const op = q("op"), id = q("id"), file = q("file"); if (req.method === "OPTIONS") { res.statusCode = 204; return res.end(); } if (req.method === "GET" || req.method === "HEAD") { + if (op === "part") { + if (!bearer(req, process.env.COMMUNITY_RENDER_SECRET)) return json(res, 401, { error: "Unauthorized" }); + const bytes = await readUpload(String(id), q("part")); + if (!bytes) { res.statusCode = 404; return res.end("Not found."); } + res.statusCode = 200; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Cache-Control", "no-store"); + return res.end(Buffer.from(bytes)); + } if (op === "compare") { if (!bearer(req, process.env.COMMUNITY_ADMIN_SECRET)) return json(res, 401, { error: "Unauthorized" }); const bytes = await readCompare(String(id), String(file)); @@ -58,6 +70,15 @@ export default async function handler(req, res) { if (req.method !== "POST") return json(res, 405, { error: "Use GET or POST." }); let data; try { data = await body(req); } catch { return json(res, 413, { error: "Larger than 4 MB." }); } + if (op === "upload") { + const bytes = Buffer.isBuffer(data) ? data : null; + const r = await putUpload(String(id), q("key"), q("part"), bytes); + return json(res, r.ok ? 200 : 400, r); + } + if (op === "upload-done") { + const r = await finishUpload(String(id), q("key"), q("parts")); + return json(res, r.ok ? 200 : 400, r); + } const uploads = { media: putMedia, ref: putRef, compare: putCompare }; if (uploads[op]) { if (!bearer(req, process.env.COMMUNITY_RENDER_SECRET)) return json(res, 401, { error: "Unauthorized" }); diff --git a/api/mcp.js b/api/mcp.js index c4c5c7c..abecbe7 100644 --- a/api/mcp.js +++ b/api/mcp.js @@ -22,7 +22,7 @@ const tools=[ {name:"create_hcmotion",title:"Create HyperCodeMotion project file",description:"Validate a finished HyperCodeMotion composition and package it as a .hcmotion project file, the format HyperCodeMotion Studio opens. If the composition has problems, returns created: false with the errors to fix and stores nothing. On success, stores the file temporarily and returns an `open` link (opens the project in HyperCodeMotion Studio) and a `download` link; both expire 24 hours after creation. It does not render video or generate media.",annotations:{title:"Create HyperCodeMotion project file",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"The full composition (version, name, width, height, fps, duration, background, layers…), as an object or JSON-encoded string.",anyOf:[{type:"object"},{type:"string"}]},name:{type:"string",description:"Optional project name."}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{created:{type:"boolean"},download:{type:"string"},open:{type:"string"},expiresAt:{type:"string"},bytes:{type:"number"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["created","errors","warnings"]}}, {name:"get_template",title:"Get HyperCodeMotion template",description:"Return the full design of a template from the HyperCodeMotion Studio gallery, by its id and format: brief (palette, fonts, frame, rhythm), scenes with timings, and the composition with its HTML scene documents and layers. Use it as the design reference when asked to follow a template; it does not create or store anything.",annotations:{title:"Get HyperCodeMotion template",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{template:{type:"string",description:"Template id, such as \"mix-promo-editorial\"."},format:{type:"string",enum:TEMPLATE_FORMATS,description:"Aspect ratio of the video being made. Defaults to 16:9."}},required:["template"],additionalProperties:false},outputSchema:{type:"object",properties:{found:{type:"boolean"},template:{type:"string"},name:{type:"string"},format:{type:"string"},brief:{type:"string"},scenes:{type:"array",items:{type:"object"}},composition:{type:"object"},error:{type:"string"}},required:["found"]}}, {name:"validate_json",title:"Validate HyperCodeMotion JSON",description:"Check a composition (layers), design (elements), or improvement patch (add/change/remove/sfx) and return actionable errors and warnings. This is preliminary validation, not proof of a successful render.",annotations:{title:"Validate HyperCodeMotion JSON",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"HyperCodeMotion JSON object or JSON-encoded string to validate.",anyOf:[{type:"object"},{type:"string"}]},composition:{type:"object",description:"Optional original composition used to resolve patch references and timings."}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{valid:{type:"boolean"},format:{type:"string"},fullyValidated:{type:"boolean"},mode:{type:"string"},contractVersion:{type:"string"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["valid","format","fullyValidated","mode","errors","warnings","contractVersion"]}}, -{name:"get_recreation_brief",title:"Get template recreation brief",description:"Start turning a public motion video into a HyperCodeMotion Studio gallery template. Takes the link of the video's page (prompt-motion.com or oneshotted.io) and what that page shows (its video file, prompt, title and author, read by the caller, since the source sites often refuse this server) and returns the source details (title, author, and the published prompt when the page shows it), the brief (how to recreate it in three formats with the HyperCodeMotion signature, and the gallery card to fill in) and reference frames of the original video as images, labelled with their times. The first call starts extracting those frames and may answer reference.status: preparing; call it again until it is ready, and do not build before seeing them. If the video is already a Community template, says so with its status instead.",annotations:{title:"Get template recreation brief",readOnlyHint:false,destructiveHint:false,idempotentHint:true,openWorldHint:true},inputSchema:{type:"object",properties:{url:{type:"string",description:"Link of the video's page, such as https://prompt-motion.com/ or https://oneshotted.io/v/."},video:{type:"string",description:"Address of the page's video file (.mp4 on media.prompt-motion.com or r2.oneshotted.io), as read on the page."},prompt:{type:"string",description:"The prompt the page publishes with the video, as read on the page."},title:{type:"string",description:"The video's title on the page."},author:{type:"string",description:"The author's name, only when the page names one."},authorUrl:{type:"string",description:"The author's profile link, when the page gives one."}},required:["url"],additionalProperties:false},outputSchema:{type:"object",properties:{ok:{type:"boolean"},needsPage:{type:"boolean"},alreadyExists:{type:"boolean"},template:{type:"string"},status:{type:"string"},code:{type:"string"},source:{type:"object"},prompt:{type:["string","null"]},promptComplete:{type:"boolean"},note:{type:"string"},reference:{type:"object"},formats:{type:"array",items:{type:"object"}},groups:{type:"array",items:{type:"string"}},maxDuration:{type:"number"},brief:{type:"string"},error:{type:"string"},message:{type:"string"}},required:["ok"]}}, +{name:"get_recreation_brief",title:"Get template recreation brief",description:"Start turning a public motion video into a HyperCodeMotion Studio gallery template. Takes the link of the video's page (prompt-motion.com or oneshotted.io) and what that page shows (its video file, prompt, title and author, read by the caller, since the source sites often refuse this server) and returns the source details (title, author, and the published prompt when the page shows it), the brief (how to recreate it in three formats with the HyperCodeMotion signature, and the gallery card to fill in) and reference frames of the original video as images, labelled with their times. The first call starts extracting those frames and may answer reference.status: preparing; call it again until it is ready, and do not build before seeing them. When it answers reference.status: upload (prompt-motion.com refuses servers), run upload.script in a code or shell tool to send the video, then call it again. If the video is already a Community template, says so with its status instead.",annotations:{title:"Get template recreation brief",readOnlyHint:false,destructiveHint:false,idempotentHint:true,openWorldHint:true},inputSchema:{type:"object",properties:{url:{type:"string",description:"Link of the video's page, such as https://prompt-motion.com/ or https://oneshotted.io/v/."},video:{type:"string",description:"Address of the page's video file (.mp4 on media.prompt-motion.com or r2.oneshotted.io), as read on the page."},prompt:{type:"string",description:"The prompt the page publishes with the video, as read on the page."},title:{type:"string",description:"The video's title on the page."},author:{type:"string",description:"The author's name, only when the page names one."},authorUrl:{type:"string",description:"The author's profile link, when the page gives one."}},required:["url"],additionalProperties:false},outputSchema:{type:"object",properties:{ok:{type:"boolean"},needsPage:{type:"boolean"},alreadyExists:{type:"boolean"},template:{type:"string"},status:{type:"string"},code:{type:"string"},source:{type:"object"},prompt:{type:["string","null"]},promptComplete:{type:"boolean"},note:{type:"string"},reference:{type:"object"},upload:{type:"object"},formats:{type:"array",items:{type:"object"}},groups:{type:"array",items:{type:"string"}},maxDuration:{type:"number"},brief:{type:"string"},error:{type:"string"},message:{type:"string"}},required:["ok"]}}, {name:"propose_template",title:"Propose a Community template",description:"Submit a recreated video as a HyperCodeMotion Studio gallery template: the source link, its three compositions (cuadrado 1080×1080, vertical 1080×1920, horizontal 1920×1080) and the gallery card. It checks the source, the compositions and the card; if anything fails it returns accepted: false with the errors to fix and stores nothing. When accepted, the composition in the original's format is rendered with the Studio engine and compared with the original frame by frame; get_proposal_status returns that comparison. To send fixes after a comparison, call it again with the same code (up to "+MAX_ROUNDS+" comparisons). Nothing is published until publish_template. A video that is already a template is not stored twice.",annotations:{title:"Propose a Community template",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:true},inputSchema:{type:"object",properties:{url:{type:"string",description:"Link of the source video's page, the same one given to get_recreation_brief."},code:{type:"string",description:"Only to send a revision after a comparison: the tracking code of the first proposal."},compositions:{type:"object",description:"The three compositions, as objects or JSON-encoded strings.",properties:{cuadrado:{anyOf:[{type:"object"},{type:"string"}]},vertical:{anyOf:[{type:"object"},{type:"string"}]},horizontal:{anyOf:[{type:"object"},{type:"string"}]}},required:["cuadrado","vertical","horizontal"]},card:{type:"object",description:"Gallery card, in Spanish.",properties:{name:{type:"string"},description:{type:"string"},chips:{type:"array",items:{type:"string"}},group:{type:"string",enum:GROUPS},moments:{type:"array",items:{type:"array"}}},required:["name","description","chips","group","moments"]}},required:["url","compositions","card"],additionalProperties:false},outputSchema:{type:"object",properties:{accepted:{type:"boolean"},duplicate:{type:"boolean"},template:{type:"string"},code:{type:"string"},status:{type:"string"},round:{type:"number"},message:{type:"string"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["accepted","errors"]}}, {name:"get_proposal_status",title:"Get Community template status",description:"Return the status of a Community template proposal by the tracking code propose_template gave: comparing (it waits a little while the comparison renders), review (with the comparison sheet as an image: original and recreation side by side at the same moments), rendering, published, failed (with the reason) or retired, and its template id, which get_template accepts once it is published.",annotations:{title:"Get Community template status",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{code:{type:"string",description:"The tracking code."}},required:["code"],additionalProperties:false},outputSchema:{type:"object",properties:{found:{type:"boolean"},template:{type:"string"},code:{type:"string"},status:{type:"string"},reason:{type:["string","null"]},name:{type:["string","null"]},source:{type:"string"},published:{type:"boolean"},studio:{type:["string","null"]},compare:{type:"object"},message:{type:"string"},error:{type:"string"}},required:["found"]}}, {name:"publish_template",title:"Publish a Community template",description:"Publish a compared Community template in the HyperCodeMotion Studio gallery, marked «Community», once its comparison with the original looks faithful. Takes the tracking code and notes on what is consciously different from the original and why. Its gallery videos are rendered and it then appears without manual review; get_proposal_status tells when.",annotations:{title:"Publish a Community template",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:true},inputSchema:{type:"object",properties:{code:{type:"string",description:"The tracking code."},notes:{type:"string",description:"Up to 600 characters: what is consciously different from the original and why, or that nothing is."}},required:["code","notes"],additionalProperties:false},outputSchema:{type:"object",properties:{accepted:{type:"boolean"},template:{type:"string"},code:{type:"string"},status:{type:"string"},message:{type:"string"},error:{type:"string"}},required:["accepted"]}} @@ -44,7 +44,7 @@ export default async function handler(req,res){ const id=data?.id??null; if(data?.jsonrpc!=="2.0"||typeof data.method!=="string")return reply(res,400,err(id,-32600,"Expected a JSON-RPC 2.0 request with jsonrpc: \"2.0\" and a method.")); if(data.method.startsWith("notifications/")){res.statusCode=202;return res.end();} - if(data.method==="initialize")return reply(res,200,ok(id,{protocolVersion:"2025-03-26",capabilities:{tools:{}},serverInfo:{name:"hypercodemotion-mcp",title:"HyperCodeMotion MCP",version:"0.6.1",description:"Creates HyperCodeMotion .hcmotion project files from AI-generated composition JSON, with validation and layer, effect and format discovery.",websiteUrl:"https://github.com/jasp402/HyperCodeMotion-MCP"},instructions:"HyperCodeMotion Studio opens .hcmotion project files. get_capabilities lists the supported formats, layers and effects. get_template returns the full design of a gallery template by its id. create_hcmotion validates a finished composition and returns open and download links to its .hcmotion file (valid 24 hours); when created is false, its errors say what to fix. validate_json checks drafts; its check is preliminary, not a render guarantee. To turn a public video into a gallery template, get_recreation_brief takes its page link and returns the source, the brief and reference frames of the original; propose_template submits the recreation, which is rendered and compared with the original; get_proposal_status returns that comparison (send fixes with propose_template and the same code); publish_template publishes it once it is faithful."})); + if(data.method==="initialize")return reply(res,200,ok(id,{protocolVersion:"2025-03-26",capabilities:{tools:{}},serverInfo:{name:"hypercodemotion-mcp",title:"HyperCodeMotion MCP",version:"0.6.2",description:"Creates HyperCodeMotion .hcmotion project files from AI-generated composition JSON, with validation and layer, effect and format discovery.",websiteUrl:"https://github.com/jasp402/HyperCodeMotion-MCP"},instructions:"HyperCodeMotion Studio opens .hcmotion project files. get_capabilities lists the supported formats, layers and effects. get_template returns the full design of a gallery template by its id. create_hcmotion validates a finished composition and returns open and download links to its .hcmotion file (valid 24 hours); when created is false, its errors say what to fix. validate_json checks drafts; its check is preliminary, not a render guarantee. To turn a public video into a gallery template, get_recreation_brief takes its page link and returns the source, the brief and reference frames of the original; propose_template submits the recreation, which is rendered and compared with the original; get_proposal_status returns that comparison (send fixes with propose_template and the same code); publish_template publishes it once it is faithful."})); if(data.method==="ping")return reply(res,200,ok(id,{})); if(data.method==="tools/list")return reply(res,200,ok(id,{tools})); if(data.method==="tools/call"){ diff --git a/package.json b/package.json index 94a8cce..093dcc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hypercodemotion/mcp", - "version": "0.6.1", + "version": "0.6.2", "private": true, "type": "module", "scripts": { diff --git a/server.json b/server.json index c8fa22d..ca0fbbb 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.jasp402/hypercodemotion-mcp", "title": "HyperCodeMotion MCP", "description": "Validate AI-made motion graphics and package them as editable HyperCodeMotion projects.", - "version": "0.6.1", + "version": "0.6.2", "repository": { "url": "https://github.com/jasp402/HyperCodeMotion-MCP", "source": "github" diff --git a/src/community.js b/src/community.js index 3a2f9e8..25f45ce 100644 --- a/src/community.js +++ b/src/community.js @@ -13,6 +13,7 @@ * Blob layout (private store, served by api/community.js): * community/refs//meta.json reference of the source video (status, duration, size, frame times) * community/refs//.jpg reference frame sheets (general, inicio) + * community/refs//video/ the original in parts, when the caller's Claude had to send it * community/compare// latest comparison (sheet.jpg, comparacion.mp4) * community/records/.json state of a template (status, code, source, card, comparison) * community/codes/.json tracking code → template id @@ -21,14 +22,16 @@ * community/index.json published templates, read by the gallery * community/limits/.json proposals per caller per day */ -import { createHash, randomBytes } from "node:crypto"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { validateJson } from "./contract.js"; import { checkComposition } from "./hcmotion.js"; import { BRIEF, FORMATS, GROUPS } from "./brief.js"; import { PUBLIC_URL } from "./files.js"; export const SOURCES = { - "prompt-motion.com": { name: "Prompt Motion", path: /^\/([a-z0-9][a-z0-9_-]{2,80})\/?$/, prefix: "pm", videoHost: "media.prompt-motion.com", video: /^https:\/\/media\.prompt-motion\.com\/[^\s"']+\.mp4$/ }, + "prompt-motion.com": { name: "Prompt Motion", path: /^\/([a-z0-9][a-z0-9_-]{2,80})\/?$/, prefix: "pm", videoHost: "media.prompt-motion.com", video: /^https:\/\/media\.prompt-motion\.com\/[^\s"']+\.mp4$/, + // its videos answer 403 to every server (Vercel, GitHub), so the caller's Claude downloads it and sends it here + callerSends: true }, "oneshotted.io": { name: "Oneshotted", path: /^\/v\/([a-z0-9][a-z0-9-]{2,120})\/?$/, prefix: "os", videoHost: "r2.oneshotted.io", video: /^https:\/\/r2\.oneshotted\.io\/[^\s"']+\.mp4$/ } }; export const MAX_PER_DAY = 5; @@ -39,6 +42,9 @@ const MAX_SUBMISSIONS = MAX_ROUNDS * 2; const STALE_MS = 24 * 3600e3; // a proposal left unpublished this long frees its video for someone else const RETRY_MS = 15 * 60e3; // a reference job that did not answer in this time is started again const WAIT_MS = 40e3; // tools wait this long for a running job before answering +const UPLOAD_MS = 60 * 60e3; // an upload not finished in this time is started again with a new key +export const PART_BYTES = 3_000_000; // the original is sent in parts that fit a function's request body +export const MAX_PARTS = 20; export const REF_FILES = ["general.jpg", "inicio.jpg"]; export const COMPARE_FILES = ["sheet.jpg", "comparacion.mp4"]; const MAX_DURATION = 45; @@ -138,10 +144,15 @@ const refPath = (id, f = "meta.json") => `community/refs/${id}/${f}`; async function reference(src, info, { now, dispatch, wait }) { let meta = await getJson(refPath(src.id)); // a job that could not be started is tried again at once; one that started gets RETRY_MS to answer - const stuck = m => m && m.status !== "ready" && (m.notSent || now - Date.parse(m.requested) > RETRY_MS); + const stuck = m => m && m.status !== "ready" && (m.status === "upload" ? now - Date.parse(m.requested) > UPLOAD_MS : m.notSent || now - Date.parse(m.requested) > RETRY_MS); if (!meta || stuck(meta)) { if (!info.video) return { status: "missing" }; meta = { status: "preparing", video: info.video, requested: iso(now), info: { title: info.title, author: info.author, authorUrl: info.authorUrl } }; + if (SOURCES[src.host].callerSends) { + meta = { ...meta, status: "upload", key: randomBytes(16).toString("hex") }; + await putJson(refPath(src.id), meta); + return meta; + } await putJson(refPath(src.id), meta); if (!(await dispatch("reference", src.id, { video: info.video }).catch(() => false))) { meta = { ...meta, status: "failed", notSent: true, reason: "The video could not be sent to be read right now; try again later." }; @@ -182,6 +193,15 @@ export async function getRecreationBrief(args, { fetcher = fetch, dispatch = dis note: info.prompt ? (info.promptComplete ? "prompt is the full prompt published with the video." : "prompt may be only part of the original prompt: the reference frames are what to follow.") : "The page's prompt is not known here: read it on the page; the reference frames are what to follow.", formats: FORMATS, groups: GROUPS, maxDuration: MAX_DURATION, brief: BRIEF }; + if (ref.status === "upload") { + const send = `${PUBLIC_URL}/api/community?id=${src.id}&key=${ref.key}`; + return { + ...out, reference: { status: "upload" }, + upload: { video: ref.video, partBytes: PART_BYTES, maxParts: MAX_PARTS, part: `${send}&op=upload&part=`, done: `${send}&op=upload-done&parts=`, + script: `set -e; curl -sfL "${ref.video}" -o original.mp4; split -b ${PART_BYTES} -d -a 2 original.mp4 part.; n=0; for f in part.*; do curl -sf -H "content-type: application/octet-stream" --data-binary @"$f" "${send}&op=upload&part=$n" >/dev/null; n=$((n+1)); done; curl -sf -X POST "${send}&op=upload-done&parts=$n"` }, + message: `${src.site} does not let servers download its videos, so you send it: run upload.script in your code or shell tool (it downloads the video and sends it here in parts; it needs network access to ${SOURCES[src.host].videoHost} and ${new URL(PUBLIC_URL).host}). Then call get_recreation_brief again with the same url. If you have no tool that can run it, tell the user this video cannot be recreated from here.` + }; + } if (ref.status !== "ready") return { ...out, reference: { status: "preparing" }, message: "The original video is being downloaded and its reference frames extracted (usually one or two minutes). Read the prompt meanwhile, then call get_recreation_brief again with the same url. Do not build anything before you have seen the frames." }; const sheets = []; for (const f of REF_FILES) { const b = ref.sheets?.[f.slice(0, -4)] && await jpg(refPath(src.id, f)); if (b) sheets.push({ name: f.slice(0, -4), times: ref.sheets[f.slice(0, -4)], data: b }); } @@ -292,7 +312,7 @@ export async function proposeTemplate(args, { caller, now = Date.now(), dispatch await putJson(`community/templates/${src.id}/${f.variant}.json`, c); } if (!revision) await putJson(`community/codes/${record.code}.json`, { id: src.id }); - const sent = await dispatch("compare", src.id, { variant, round: record.compare.round, video: ref.video }).catch(() => false); + const sent = await dispatch("compare", src.id, { variant, round: record.compare.round, video: ref.video, ...(ref.uploaded ? { upload: ref.uploaded } : {}) }).catch(() => false); if (!sent) Object.assign(record, { status: "review", compare: { ...record.compare, ok: false, reason: "The comparison could not be started right now: send it again later with the same code." } }); await putJson(`community/records/${src.id}.json`, record); return { @@ -355,6 +375,39 @@ export async function dispatchJob(kind, id, extra = {}, fetcher = fetch) { const num = (v, min, max) => Number.isFinite(v) && v >= min && v <= max; const times = (a, max) => Array.isArray(a) && a.length <= 40 && a.every(t => num(t, 0, max)); +const sameKey = (a, b) => typeof a === "string" && typeof b === "string" && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b)); +/** The caller's Claude sends the original, one part at a time. */ +export async function putUpload(id, key, part, bytes) { + const m = await getJson(refPath(id)); + if (m?.status !== "upload" || !sameKey(m.key, key)) return { ok: false, error: "No upload is waiting with that key: call get_recreation_brief again." }; + const n = Number(part); + if (!Number.isInteger(n) || n < 0 || n >= MAX_PARTS) return { ok: false, error: `part must be 0 to ${MAX_PARTS - 1}.` }; + if (!bytes?.length || bytes.length > PART_BYTES) return { ok: false, error: `Each part must have 1 to ${PART_BYTES} bytes.` }; + if (n === 0 && Buffer.from(bytes.subarray(4, 8)).toString() !== "ftyp") return { ok: false, error: "That is not an .mp4 file." }; + await (await store()).put(refPath(id, `video/${n}`), bytes, "application/octet-stream"); + return { ok: true }; +} +/** All parts sent: the reference job reads them back from here. */ +export async function finishUpload(id, key, parts, { now = Date.now(), dispatch = dispatchJob } = {}) { + const m = await getJson(refPath(id)); + if (m?.status !== "upload" || !sameKey(m.key, key)) return { ok: false, error: "No upload is waiting with that key: call get_recreation_brief again." }; + const count = Number(parts); + if (!Number.isInteger(count) || count < 1 || count > MAX_PARTS) return { ok: false, error: `parts must be 1 to ${MAX_PARTS}.` }; + for (let i = 0; i < count; i++) if (!(await (await store()).get(refPath(id, `video/${i}`)))) return { ok: false, error: `Part ${i} is missing: send it again, then call this again.` }; + let next = { ...m, status: "preparing", key: null, uploaded: count, requested: iso(now) }; + await putJson(refPath(id), next); + if (!(await dispatch("reference", id, { video: m.video, upload: count }).catch(() => false))) { + next = { ...next, status: "failed", notSent: true, reason: "The video could not be sent to be read right now; try again later." }; + await putJson(refPath(id), next); + } + return { ok: next.status === "preparing", status: next.status, message: "Received. Call get_recreation_brief again with the same url to see the reference frames." }; +} +/** Reference and compare jobs: a part of an original the caller sent. */ +export async function readUpload(id, part) { + if (!/^c-[a-z0-9-]{3,62}$/.test(id) || !/^\d{1,2}$/.test(String(part))) return null; + return (await store()).get(refPath(id, `video/${Number(part)}`)); +} + /** Reference job: one frame sheet at a time. */ export async function putRef(id, file, bytes) { if (!REF_FILES.includes(file)) return { ok: false, error: `file must be one of ${REF_FILES.join(", ")}.` }; diff --git a/tests/community.test.js b/tests/community.test.js index 72a203c..43f042c 100644 --- a/tests/community.test.js +++ b/tests/community.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseSource, readSource, getRecreationBrief, proposeTemplate, getProposalStatus, publishTemplate, finishRender, finishRef, finishCompare, putRef, putCompare, putMedia, retire, readIndex, readTemplate, setStore, MAX_PER_DAY, MAX_ROUNDS } from "../src/community.js"; +import { parseSource, readSource, getRecreationBrief, proposeTemplate, getProposalStatus, publishTemplate, finishRender, finishRef, finishCompare, putRef, putCompare, putMedia, putUpload, finishUpload, readUpload, retire, readIndex, readTemplate, setStore, MAX_PER_DAY, MAX_ROUNDS } from "../src/community.js"; import { getTemplate } from "../src/templates.js"; import handler from "../api/community.js"; @@ -21,6 +21,16 @@ async function referenceReady(id) { assert.equal((await putRef(id, "../x.jpg", JPG)).ok, false); assert.equal((await finishRef(id, { ok: true, duration: 20, width: 1920, height: 1080, sheets: { general: [1, 5, 10, 15, 19] } })).ok, true); } +const MP4 = new Uint8Array([0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 1, 2, 3]); +/** What the caller's Claude does with upload.script: sends the original, then asks again. */ +async function brief(args, o) { + const b = await getRecreationBrief(args, o); + if (b.reference?.status !== "upload") return b; + const key = new URL(b.upload.done.replace("", "1")).searchParams.get("key"); + assert.equal((await putUpload(b.template, key, 0, MP4)).ok, true); + assert.equal((await finishUpload(b.template, key, 1, o)).ok, true); + return getRecreationBrief(args, o); +} async function compared(id, round, ok = true) { if (ok) assert.equal((await putCompare(id, "sheet.jpg", JPG)).ok, true); return finishCompare(id, { round, ok, reason: ok ? null : "pageerror: boom", times: ok ? [[1, 0.5], [10, 5]] : null }); @@ -42,15 +52,15 @@ test("the source page gives title, author, video and the full prompt", async () test("reference, comparison rounds, publication", async () => { const s = mem(); setStore(s); jobs.length = 0; const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe"; - const first = await getRecreationBrief({ url }, opts); + const first = await brief({ url }, opts); assert.equal(first.ok, true); assert.equal(first.reference.status, "preparing"); assert.match(first.brief, /publish_template/); assert.deepEqual(jobs.map(j => [j.kind, j.video]), [["reference", "https://media.prompt-motion.com/x/video.b1.mp4"]]); - await getRecreationBrief({ url }, opts); + await brief({ url }, opts); assert.equal(jobs.length, 1, "the reference is extracted once"); const early = await proposeTemplate({ url, compositions: compositions(), card }, { caller: "1.1.1.1", ...opts }); assert.equal(early.errors[0].code, "REFERENCE_NOT_READY"); await referenceReady(id); - const b = await getRecreationBrief({ url }, opts); + const b = await brief({ url }, opts); assert.equal(b.reference.status, "ready"); assert.equal(b.reference.mainFormat, "horizontal"); assert.equal(b._images.length, 1); const bad = await proposeTemplate({ url, compositions: { ...compositions(), vertical: comp(1080, 1080) }, card }, { caller: "1.1.1.1", ...opts }); @@ -59,9 +69,9 @@ test("reference, comparison rounds, publication", async () => { assert.ok((await proposeTemplate({ url, compositions: unsigned, card }, { caller: "1.1.1.1", ...opts })).errors.some(e => e.code === "NO_SIGNATURE")); const r = await proposeTemplate({ url, compositions: compositions(), card }, { caller: "1.1.1.1", ...opts }); assert.equal(r.accepted, true); assert.equal(r.status, "comparing"); assert.equal(r.round, 1); - assert.deepEqual(jobs.at(-1), { kind: "compare", id, variant: "horizontal", round: 1, video: "https://media.prompt-motion.com/x/video.b1.mp4" }); + assert.deepEqual(jobs.at(-1), { kind: "compare", id, variant: "horizontal", round: 1, video: "https://media.prompt-motion.com/x/video.b1.mp4", upload: 1 }); assert.equal((await proposeTemplate({ url, compositions: compositions(), card }, { caller: "9.9.9.9", ...opts })).duplicate, true); - assert.equal((await getRecreationBrief({ url }, opts)).alreadyExists, true); + assert.equal((await brief({ url }, opts)).alreadyExists, true); assert.equal((await proposeTemplate({ url, code: r.code, compositions: compositions(), card }, opts)).errors[0].code, "COMPARING"); assert.equal((await publishTemplate({ code: r.code, notes: "Nada" }, opts)).accepted, false, "nothing is published before its comparison"); assert.equal((await readTemplate(id, "horizontal")).width, 1920, "the compare job reads it"); @@ -100,7 +110,7 @@ test("reference, comparison rounds, publication", async () => { test("comparisons are limited and an abandoned proposal frees its video", async () => { setStore(mem()); jobs.length = 0; const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe", now = Date.now(); - await getRecreationBrief({ url }, opts); await referenceReady(id); + await brief({ url }, opts); await referenceReady(id); const r = await proposeTemplate({ url, compositions: compositions(), card }, { caller: "1.1.1.1", ...opts, now }); for (let round = 1; round <= MAX_ROUNDS; round++) { await compared(id, round); @@ -116,14 +126,14 @@ test("a page that refuses this server is read by the caller's Claude", async () const blocked = async () => ({ ok: false, status: 403, text: async () => "" }); const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe"; const o = { ...opts, fetcher: blocked }; - const first = await getRecreationBrief({ url }, o); + const first = await brief({ url }, o); assert.equal(first.ok, false); assert.equal(first.needsPage, true); assert.match(first.error, /403/); assert.equal(jobs.length, 0); - assert.match((await getRecreationBrief({ url, video: "https://evil.com/x.mp4" }, o)).error, /media\.prompt-motion\.com/); - const b = await getRecreationBrief({ url, video: "https://media.prompt-motion.com/x/v.mp4", prompt: "a ball and rings", title: "Rings", author: "Kent", authorUrl: "javascript:alert(1)" }, o); + assert.match((await brief({ url, video: "https://evil.com/x.mp4" }, o)).error, /media\.prompt-motion\.com/); + const b = await brief({ url, video: "https://media.prompt-motion.com/x/v.mp4", prompt: "a ball and rings", title: "Rings", author: "Kent", authorUrl: "javascript:alert(1)" }, o); assert.equal(b.ok, true); assert.equal(b.reference.status, "preparing"); assert.equal(b.prompt, "a ball and rings"); assert.equal(b.source.authorUrl, null); - assert.deepEqual(jobs.at(-1), { kind: "reference", id, video: "https://media.prompt-motion.com/x/v.mp4" }); + assert.deepEqual(jobs.at(-1), { kind: "reference", id, video: "https://media.prompt-motion.com/x/v.mp4", upload: 1 }); await referenceReady(id); - assert.equal((await getRecreationBrief({ url }, o)).reference.status, "ready", "once extracted, the video is not needed again"); + assert.equal((await brief({ url }, o)).reference.status, "ready", "once extracted, the video is not needed again"); const r = await proposeTemplate({ url, compositions: compositions(), card }, { caller: "4.4.4.4", ...o }); assert.equal(jobs.at(-1).video, "https://media.prompt-motion.com/x/v.mp4"); await compared(id, 1); @@ -134,9 +144,9 @@ test("a page that refuses this server is read by the caller's Claude", async () test("a job that cannot read the video fails the brief", async () => { setStore(mem()); const id = "c-pm-parkerrex-1a54fe"; - await getRecreationBrief({ url: "https://prompt-motion.com/parkerrex-1a54fe" }, opts); + await brief({ 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); + const b = await brief({ 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 () => { @@ -145,7 +155,7 @@ test("daily limit per caller", async () => { const out = []; for (const url of ids) { const id = parseSource(url).id; - await getRecreationBrief({ url }, opts); await referenceReady(id); + await brief({ url }, opts); await referenceReady(id); out.push(await proposeTemplate({ url, compositions: compositions(), card }, { caller: "2.2.2.2", ...opts })); } assert.equal(out.filter(o => o.accepted).length, MAX_PER_DAY); @@ -158,3 +168,30 @@ test("render callbacks need the render secret", async () => { assert.equal(await call({}, { op: "finish" }), 401); assert.equal(await call({ authorization: "Bearer s3cret" }, { op: "finish" }), 400); }); +test("prompt-motion's video is sent by the caller's Claude in parts", async () => { + setStore(mem()); jobs.length = 0; + const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe"; + const b = await getRecreationBrief({ url }, opts); + assert.equal(b.ok, true); assert.equal(b.reference.status, "upload"); assert.equal(jobs.length, 0); + assert.match(b.upload.script, /curl -sfL "https:\/\/media\.prompt-motion\.com\/x\/video\.b1\.mp4"/); + const key = new URL(b.upload.done).searchParams.get("key"); + assert.equal((await getRecreationBrief({ url }, opts)).upload.done, b.upload.done, "the same upload until it ends"); + assert.equal((await putUpload(id, "wrong".padEnd(32, "0"), 0, MP4)).ok, false); + assert.equal((await putUpload(id, key, 0, JPG)).ok, false, "only an .mp4"); + assert.equal((await putUpload(id, key, 1, new Uint8Array(3_000_001))).ok, false); + assert.equal((await putUpload(id, key, 0, MP4)).ok, true); + assert.equal((await finishUpload(id, key, 2, opts)).ok, false, "a missing part"); + assert.equal((await putUpload(id, key, 1, new Uint8Array([9, 9]))).ok, true); + const done = await finishUpload(id, key, 2, opts); + assert.equal(done.ok, true); assert.deepEqual(jobs.at(-1), { kind: "reference", id, video: "https://media.prompt-motion.com/x/video.b1.mp4", upload: 2 }); + assert.equal((await putUpload(id, key, 0, MP4)).ok, false, "the key ends with the upload"); + assert.deepEqual([...(await readUpload(id, 1))], [9, 9]); + assert.equal(await readUpload("../x", 1), null); + assert.equal((await getRecreationBrief({ url }, opts)).reference.status, "preparing"); + process.env.COMMUNITY_RENDER_SECRET = "s3cret"; + const get = async headers => { const res = { headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(v) { this.body = v; } }; await handler({ method: "GET", headers, query: { op: "part", id, part: "0" } }, res); return res.statusCode; }; + assert.equal(await get({}), 401); + assert.equal(await get({ authorization: "Bearer s3cret" }), 200); + const os = await getRecreationBrief({ url: "https://oneshotted.io/v/one-shape-loop", video: "https://r2.oneshotted.io/a/b.mp4" }, { ...opts, fetcher: async () => ({ ok: false, status: 403, text: async () => "" }) }); + assert.equal(os.reference.status, "preparing", "other sources are still downloaded by the job"); +});