From d6e758a7a30ed08fc2a1bb1a76a4b19907d48d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:07:07 +0000 Subject: [PATCH] feat(mcp): Claude reads the source page and passes its video and details The source sites refuse this server (prompt-motion answers 403 to Vercel), so get_recreation_brief accepts what the caller's Claude reads on the page: video, prompt, title, author. The video must be on the source's own host; the author link must be https. What the server can read still wins. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TXFXQJroZAF74W4gg57vyH --- README.md | 2 +- api/community.js | 2 +- api/mcp.js | 4 +-- package.json | 2 +- server.json | 2 +- skills/hypercodemotion/SKILL.md | 2 +- src/community.js | 47 ++++++++++++++++++++------------- tests/community.test.js | 24 +++++++++++------ 8 files changed, 51 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 873e7b4..853f7c4 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) 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). | 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 def2c96..bda1d87 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, video: data.video }) + ? 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 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/api/mcp.js b/api/mcp.js index 552718c..c4c5c7c 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 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/."}},required:["url"],additionalProperties:false},outputSchema:{type:"object",properties:{ok:{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. 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:"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.0",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.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==="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 9c9b9ae..94a8cce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hypercodemotion/mcp", - "version": "0.6.0", + "version": "0.6.1", "private": true, "type": "module", "scripts": { diff --git a/server.json b/server.json index 41bedbc..c8fa22d 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.0", + "version": "0.6.1", "repository": { "url": "https://github.com/jasp402/HyperCodeMotion-MCP", "source": "github" diff --git a/skills/hypercodemotion/SKILL.md b/skills/hypercodemotion/SKILL.md index 6af22e4..1c93083 100644 --- a/skills/hypercodemotion/SKILL.md +++ b/skills/hypercodemotion/SKILL.md @@ -19,7 +19,7 @@ Use this Skill when asked to create, improve, extend, combine, or troubleshoot e ## Turning a public video into a gallery template -When the user shares the link of a video from prompt-motion.com or oneshotted.io and wants it in the Studio gallery, call `get_recreation_brief` with that link (again if its reference frames are still being prepared) and follow its brief, working from the reference frames of the original: recreate it in the three formats in this conversation, check each with `validate_json`, then call `propose_template`. Look at the comparison `get_proposal_status` returns (original and recreation side by side), fix what differs and send it again with the same code; call `publish_template` only when it truly looks like the original. The user only gives the link; do not show them compositions. Give them the tracking code. If the brief says the video already exists, give that status instead of recreating it. +When the user shares the link of a video from prompt-motion.com or oneshotted.io and wants it in the Studio gallery, open that page yourself and call `get_recreation_brief` with the link and what the page shows (the address of its .mp4 video, its prompt, title and author); call it again while its reference frames are being prepared, and follow its brief, working from the reference frames of the original: recreate it in the three formats in this conversation, check each with `validate_json`, then call `propose_template`. Look at the comparison `get_proposal_status` returns (original and recreation side by side), fix what differs and send it again with the same code; call `publish_template` only when it truly looks like the original. The user only gives the link; do not show them compositions. Give them the tracking code. If the brief says the video already exists, give that status instead of recreating it. ## Always load relevant references diff --git a/src/community.js b/src/community.js index ab3f6c2..6a75790 100644 --- a/src/community.js +++ b/src/community.js @@ -28,8 +28,8 @@ 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", video: /^https:\/\/media\.prompt-motion\.com\/[^\s"']+\.mp4$/ }, - "oneshotted.io": { name: "Oneshotted", path: /^\/v\/([a-z0-9][a-z0-9-]{2,120})\/?$/, prefix: "os", video: /^https:\/\/r2\.oneshotted\.io\/[^\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$/ }, + "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; export const MAX_GLOBAL_PER_DAY = 100; @@ -135,15 +135,14 @@ const holds = (r, now) => r && r.status !== "failed" && (!["comparing", "review" const refPath = (id, f = "meta.json") => `community/refs/${id}/${f}`; /** The source video's reference frames: extracted once per video by the render workflow. */ -async function reference(src, video, { now, dispatch, wait }) { +async function reference(src, info, { 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)) { - // 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) }; + 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 } }; await putJson(refPath(src.id), meta); - if (!(await dispatch("reference", src.id, video ? { video } : { page: src.url }).catch(() => false))) { + if (!(await dispatch("reference", src.id, { video: info.video }).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); } @@ -151,20 +150,35 @@ async function reference(src, video, { now, dispatch, wait }) { return waitFor(refPath(src.id), meta, m => m.status !== "preparing", wait); } +const clip = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null); +/** What the caller's Claude read on the source page itself. */ +function fromCaller(args, src) { + const video = clip(args.video, 500), authorUrl = clip(args.authorUrl, 300); + return { + video: video && SOURCES[src.host].video.test(video) ? video : null, badVideo: !!video && !SOURCES[src.host].video.test(video), + prompt: clip(args.prompt, 20000), title: clip(args.title, 200), author: clip(args.author, 80), authorUrl: authorUrl && /^https:\/\/[^\s"'<>]+$/.test(authorUrl) ? authorUrl : null + }; +} + /** get_recreation_brief */ export async function getRecreationBrief(args, { fetcher = fetch, dispatch = dispatchJob, now = Date.now(), wait = WAIT_MS } = {}) { const src = parseSource(args.url); if (src.error) return { ok: false, error: src.error }; const existing = await getJson(`community/records/${src.id}.json`); 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 given = fromCaller(args, src); + if (given.badVideo) return { ok: false, template: src.id, error: `video must be the address of the page's own video file (an .mp4 on ${SOURCES[src.host].videoHost}).` }; + // the source sites often refuse this server (bot checks): what the caller read on the page fills the gaps 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."}${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 info = { video: page.video || given.video, prompt: page.prompt || given.prompt, promptComplete: page.prompt ? page.promptComplete : null, title: page.title || given.title, author: page.author || given.author, authorUrl: page.authorUrl || given.authorUrl }; + const ref = await reference(src, info, { now, dispatch, wait }); + if (ref.status === "missing") return { ok: false, template: src.id, needsPage: true, error: `This server could not read the page${page.error ? ` (${page.error})` : ""}. Open the page yourself, then call get_recreation_brief again with the url and what the page shows: video (the address of its .mp4 file, on ${SOURCES[src.host].videoHost}), prompt, title and author.` }; + 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.` }; 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 }, - prompt: page.prompt, promptComplete: page.promptComplete, - note: page.prompt ? (page.promptComplete ? "prompt is the full prompt published with the video." : "prompt is only the start of the original prompt: the reference frames are what to follow.") : "The page's prompt could not be read here: the reference frames are what to follow.", + source: { site: src.site, page: src.page, id: src.slug, url: src.url, title: info.title, author: info.author, authorUrl: info.authorUrl, video: info.video || ref.video }, + prompt: info.prompt, promptComplete: info.promptComplete, + 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 !== "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." }; @@ -268,7 +282,7 @@ export async function proposeTemplate(args, { caller, now = Date.now(), dispatch const record = { id: src.id, code: revision ? existing.code : `${now.toString(36)}${randomBytes(4).toString("hex")}`, status: "comparing", created: revision ? existing.created : iso(now), updated: iso(now), - source: revision ? existing.source : { site: src.site, page: src.page, id: src.slug, url: src.url, title: page.title ?? null, author: page.author ?? null, authorUrl: page.authorUrl ?? null }, + source: revision ? existing.source : { site: src.site, page: src.page, id: src.slug, url: src.url, title: page.title ?? ref.info?.title ?? null, author: page.author ?? ref.info?.author ?? null, authorUrl: page.authorUrl ?? ref.info?.authorUrl ?? null }, card, variant, reason: null, compare: { round: revision ? existing.compare.round + 1 : 1, done: revision ? existing.compare.done : 0, ok: null, reason: null, times: null } }; @@ -348,14 +362,9 @@ 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, video }) { +export async function finishRef(id, { ok, reason, duration, width, height, sheets }) { 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 4a1e021..72a203c 100644 --- a/tests/community.test.js +++ b/tests/community.test.js @@ -111,17 +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 that hides its video from the server is read by the reference job", async () => { +test("a page that refuses this server is read by the caller's Claude", async () => { setStore(mem()); jobs.length = 0; - const noVideo = async () => ({ ok: true, text: async () => "Just a moment..." }); + const blocked = async () => ({ ok: false, status: 403, text: async () => "" }); 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 }); + const o = { ...opts, fetcher: blocked }; + const first = await getRecreationBrief({ 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.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" }); + await referenceReady(id); + assert.equal((await getRecreationBrief({ 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); + await publishTemplate({ code: r.code, notes: "Nada" }, o); + await finishRender(id, { ok: true }); + assert.equal((await readIndex())[0].info.autor, "Kent"); }); test("a job that cannot read the video fails the brief", async () => { setStore(mem());