diff --git a/api/mcp.js b/api/mcp.js index b4e149b..4b605c5 100644 --- a/api/mcp.js +++ b/api/mcp.js @@ -23,7 +23,7 @@ const tools=[ {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. Rejects measurable text that cannot fit the canvas safe area, text that crosses the canvas, and line-height below 1. This is 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 an official HyperCodeMotion Studio gallery template. Use it whenever the user sends the link of a prompt-motion.com or oneshotted.io video together with a moderator code, with or without other words: that means publish it as a template. Takes that link and code (adminCode) 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 template, says so with its status instead; an unfinished proposal left idle for 20 minutes (another session) is discarded and the brief starts over (field restarted). Without a valid moderator code it answers needsCode.",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/."},adminCode:{type:"string",description:"The moderator code the user gave with the link. Required: only moderators can make gallery templates. Never repeat it back."},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","adminCode"],additionalProperties:false},outputSchema:{type:"object",properties:{ok:{type:"boolean"},needsPage:{type:"boolean"},needsCode:{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 gallery template",description:"Submit a recreated video as an official HyperCodeMotion Studio gallery template, with the moderator code: 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): send only patch (the changes, applied to the stored compositions; card may be left out) instead of the three full compositions, unless you are redesigning. Nothing is published until publish_template. A video that is already a template is not stored twice.",annotations:{title:"Propose a gallery 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."},adminCode:{type:"string",description:"The moderator code the user gave with the link. Required: only moderators can make gallery templates. Never repeat it back."},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. Required on a first proposal; a revision may send patch instead.",properties:{cuadrado:{anyOf:[{type:"object"},{type:"string"}]},vertical:{anyOf:[{type:"object"},{type:"string"}]},horizontal:{anyOf:[{type:"object"},{type:"string"}]}},required:["cuadrado","vertical","horizontal"]},patch:{type:"array",description:"Only with code, instead of compositions: the changes to the stored compositions. Each item {op: replace|add|remove|edit, path: JSON pointer such as /layers/3/props/fontSize or /docs/s1, value (replace, add), find and replace (edit: changes one exact piece of a text such as a doc's HTML or CSS; find must appear once), variants: which of cuadrado, vertical, horizontal (default all three)}.",items:{type:"object",properties:{op:{type:"string",enum:["replace","add","remove","edit"]},path:{type:"string"},value:{},find:{type:"string"},replace:{type:"string"},variants:{type:"array",items:{type:"string",enum:["cuadrado","vertical","horizontal"]}}},required:["op","path"]}},card:{type:"object",description:"Gallery card, in Spanish. Required on a first proposal; a revision keeps the previous one when left out.",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","adminCode"],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:"propose_template",title:"Propose a gallery template",description:"Submit a recreated video as an official HyperCodeMotion Studio gallery template, with the moderator code: 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 keeps it as a draft with a code, to be fixed with patch (estimated text overflows are only warnings). Do not call validate_json first: this runs the same checks. 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): send only patch (the changes, applied to the stored compositions; card may be left out) instead of the three full compositions, unless you are redesigning. Nothing is published until publish_template. A video that is already a template is not stored twice.",annotations:{title:"Propose a gallery 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."},adminCode:{type:"string",description:"The moderator code the user gave with the link. Required: only moderators can make gallery templates. Never repeat it back."},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. Required on a first proposal; a revision may send patch instead.",properties:{cuadrado:{anyOf:[{type:"object"},{type:"string"}]},vertical:{anyOf:[{type:"object"},{type:"string"}]},horizontal:{anyOf:[{type:"object"},{type:"string"}]}},required:["cuadrado","vertical","horizontal"]},patch:{type:"array",description:"Only with code, instead of compositions: the changes to the stored compositions. Each item {op: replace|add|remove|edit, path: JSON pointer such as /layers/3/props/fontSize or /docs/s1, value (replace, add), find and replace (edit: changes one exact piece of a text such as a doc's HTML or CSS; find must appear once), variants: which of cuadrado, vertical, horizontal (default all three)}.",items:{type:"object",properties:{op:{type:"string",enum:["replace","add","remove","edit"]},path:{type:"string"},value:{},find:{type:"string"},replace:{type:"string"},variants:{type:"array",items:{type:"string",enum:["cuadrado","vertical","horizontal"]}}},required:["op","path"]}},card:{type:"object",description:"Gallery card, in Spanish. Required on a first proposal; a revision keeps the previous one when left out.",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","adminCode"],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 template status",description:"Return the status of a gallery 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 (its media are rendered and it is being committed to the Studio code), published (with its template id and the link of its pull request, whose preview shows it in the gallery), failed (with the reason) or retired.",annotations:{title:"Get 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"},official:{type:"object"},message:{type:"string"},error:{type:"string"}},required:["found"]}}, {name:"publish_template",title:"Publish a gallery template",description:"Publish a compared template as an official HyperCodeMotion Studio gallery template once its comparison with the original looks faithful. Takes the tracking code, the moderator code and notes on what is consciously different from the original and why. Its gallery videos are rendered and the template is committed to the Studio code on its own branch, with a pull request whose preview shows it in the gallery: a moderator approves (merges) or discards it there. get_proposal_status gives the pull request link.",annotations:{title:"Publish a gallery template",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:true},inputSchema:{type:"object",properties:{code:{type:"string",description:"The tracking code."},adminCode:{type:"string",description:"The moderator code the user gave with the link. Required: only moderators can make gallery templates. Never repeat it back."},notes:{type:"string",description:"Up to 600 characters: what is consciously different from the original and why, or that nothing is."}},required:["code","adminCode","notes"],additionalProperties:false},outputSchema:{type:"object",properties:{accepted:{type:"boolean"},template:{type:"string"},code:{type:"string"},status:{type:"string"},official:{type:"object"},message:{type:"string"},error:{type:"string"}},required:["accepted"]}} ]; diff --git a/src/brief.js b/src/brief.js index 6484611..947a3e2 100644 --- a/src/brief.js +++ b/src/brief.js @@ -11,9 +11,9 @@ export const BRIEF = `Recreate the reference video as an official HyperCodeMotio 1. Analyse the original from its reference frames (the images sent with this brief, each labelled with its time) and the prompt that made it (included when the page publishes it). The frames are what to match; the prompt explains intent. List its moments with timings, exact on-screen texts, palette (hex), typefaces (pick close Google Fonts), sizes and positions, techniques and transitions. 2. Rebrand: keep the design, structure, rhythm and palette, but the product, app, signature or hero becomes HyperCodeMotion: every place the original shows its own product (name, logo, app screens, UI texts, closing card) shows HyperCodeMotion instead, (an AI-assisted motion editor: Claude generates the animation as data and you edit it directly instead of regenerating it). Slogan "Your ideas, in motion."; other lines: "Edit, don't regenerate.", "Motion as data.", "app.hypercodemotion.com". Write the word HyperCodeMotion in HTML; "Hyper" may use a gradient from #9b5cf6 to #ff7a45 when the background allows. Keep on-screen text in the original's language. Never invent figures about HyperCodeMotion, never keep a real third-party brand or logo. -3. Build three compositions with the same design, 30 fps, at most 45 s, with the same moments and timings as the original (if it is longer than 45 s, shorten every moment in proportion, since the comparison scales time): cuadrado 1080×1080 (responsive: place pieces relative to the centre so it also works at 1080×1440 and 1440×1080), vertical 1080×1920 and horizontal 1920×1080. The original's own ratio must look closest to the original; recompose the other two, do not just centre them. Nothing outside the margins, nothing overlapping, text readable. Type size follows get_capabilities.screen: keep a size taken from the original only when that text still fits this format's safe area; otherwise lower it or wrap. Do not copy one ratio's pixel size onto another. Vertical formats keep a larger top and bottom margin. validate_json errors when measurable text cannot fit, crosses the canvas, or uses line-height below 1. A warning that text sits in the margin is acceptable when the original places it there. +3. Build three compositions with the same design, 30 fps, at most 45 s, with the same moments and timings as the original (if it is longer than 45 s, shorten every moment in proportion, since the comparison scales time): cuadrado 1080×1080 (responsive: place pieces relative to the centre so it also works at 1080×1440 and 1440×1080), vertical 1080×1920 and horizontal 1920×1080. The original's own ratio must look closest to the original; recompose the other two, do not just centre them. Nothing outside the margins, nothing overlapping, text readable. Type size follows get_capabilities.screen: keep a size taken from the original only when that text still fits this format's safe area; otherwise lower it or wrap. Do not copy one ratio's pixel size onto another. Vertical formats keep a larger top and bottom margin. propose_template warns when measurable text may not fit, crosses the canvas, or uses line-height below 1; fix it unless the original does the same. 4. Use HTML scenes (docs + html layers with data-hcm pieces) by default and native layers where geometry must be edited; follow get_capabilities for layer properties and effects. Engine limits: backdrop-filter does not work; HTML pieces are rasterized at 2×, so avoid camera zoom above 2 on them; code layers do not load fonts; blend only works inside props; fixed layers ignore the camera; colors interpolate between keyframes, gradients do not. -5. Check each composition with validate_json and fix every error. +5. Do not call validate_json: propose_template runs the same checks. If it rejects the proposal it keeps it as a draft with a code: fix only what its errors list with propose_template, that code and patch. Its warnings (estimated text overflow, pieces past the edge) do not block; the comparison judges them. 6. Write the gallery card in Spanish: name (≤ 40 characters), description (one line, ≤ 160), 3 short chips, group (one of ${GROUPS.join(", ")}) and the moments as [name, start, end] in seconds, covering the whole duration. 7. Call propose_template with the source url, adminCode, the three compositions and the card. If it answers accepted: false, fix what its errors say and call it again. 8. Compare: once accepted, the composition in the original's format is rendered with the Studio engine. Call get_proposal_status with the code until its status is review. The job first measures it without AI (structure and color per moment, score 0–100): below the pass score you only get a text report of the weakest moments; fix those and propose again, without asking for images. To send fixes, call propose_template with the code and patch (only the changes: JSON pointer paths into your compositions, or edit with find/replace inside a doc's HTML/CSS) instead of resending the three compositions. At or above it you get a sheet with the original and your recreation side by side at the same moments. Check every pair (layout, sizes, positions, timing, colors, typefaces, texts). If something differs, fix it with a patch (see above) and call propose_template again with the same url and the code. A template that does not really look like the original must not be published. diff --git a/src/community.js b/src/community.js index e285a47..57041f8 100644 --- a/src/community.js +++ b/src/community.js @@ -52,7 +52,7 @@ export const MAX_ROUNDS = 3; 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 WAIT_MS = 52e3; // tools wait this long for a running job before answering (the function may run 60 s) 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; @@ -172,9 +172,9 @@ const jpg = async path => { const b = await (await store()).get(path); return b export const closestVariant = (w, h) => (w / h > 1.2 ? "horizontal" : w / h < 0.8 ? "vertical" : "cuadrado"); const publicRecord = r => r && { template: r.official?.slug ?? r.id, code: r.code, status: r.status, reason: r.reason ?? null, name: r.card?.name ?? null, source: r.source.url, published: r.status === "published", ...(r.official ? { official: { slug: r.official.slug, branch: r.official.branch, commit: r.official.commit ?? null, pr: r.official.pr ?? null } } : {}) }; -const live = (r, now) => ["comparing", "review"].includes(r.status) && now - Date.parse(r.updated || r.created) < STALE_MS; +const live = (r, now) => ["draft", "comparing", "review"].includes(r.status) && now - Date.parse(r.updated || r.created) < STALE_MS; /** A record that keeps its video from being proposed again by someone else. */ -const holds = (r, now) => r && r.status !== "failed" && (!["comparing", "review"].includes(r.status) || live(r, now)); +const holds = (r, now) => r && r.status !== "failed" && (!["draft", "comparing", "review"].includes(r.status) || live(r, now)); const refPath = (id, f = "meta.json") => `community/refs/${id}/${f}`; /** The source video's reference frames: extracted once per video by the render workflow. */ @@ -226,7 +226,7 @@ export async function getRecreationBrief(args, { fetcher = fetch, dispatch = dis // composiciones: un moderador que vuelve a mandar el enlace la descarta y empieza de nuevo let restarted = null; // (solo si lleva IDLE_RESTART_MS sin moverse: la misma sesión que vuelve a llamar no pierde su trabajo) - if (holds(existing, now) && ["comparing", "review"].includes(existing.status) && now - Date.parse(existing.updated || existing.created || 0) > IDLE_RESTART_MS) { + if (holds(existing, now) && ["draft", "comparing", "review"].includes(existing.status) && now - Date.parse(existing.updated || existing.created || 0) > IDLE_RESTART_MS) { restarted = existing.code; await putJson(`community/records/${src.id}.json`, { ...existing, status: "failed", reason: `Restarted by moderator ${moderatorOf(args.adminCode)}.`, updated: iso(now) }); } else if (holds(existing, now)) return { ok: true, alreadyExists: true, ...publicRecord(existing), message: "This video is already a gallery template (or one is being made from it). Do not recreate it: give the user this status." }; @@ -296,9 +296,17 @@ function checkCard(card, duration) { return errors; } +/** + * Checks of measured text and timing that are estimates: the official gallery templates themselves break them + * (texts that wrap in their box, pieces that bleed off the edge on purpose, layers ending after the video). + * In a proposal they are warnings; the comparison with the original is what judges the result. + */ +export const SOFT_CODES = new Set(["HTML_DOES_NOT_FIT", "HTML_OUTSIDE_CANVAS", "HTML_LINE_HEIGHT", "TEXT_OUTSIDE_CANVAS", "OUTSIDE_DURATION"]); +const MAX_ERRORS = 12; // errors and warnings returned at most (the rest are counted) +const MAX_DRAFT_ATTEMPTS = 6; // rejected sends of one proposal before it stops taking more /** The three compositions: valid, in their format, short enough and carrying the HyperCodeMotion signature. */ -function checkCompositions(compositions) { - const errors = []; +export function checkCompositions(compositions) { + const errors = [], warnings = []; if (!compositions || typeof compositions !== "object") return { errors: [{ path: "$.compositions", code: "MISSING", message: "compositions must hold cuadrado, vertical and horizontal." }] }; const out = {}; for (const f of FORMATS) { @@ -310,14 +318,16 @@ function checkCompositions(compositions) { if (raw.length > MAX_COMPOSITION_BYTES) { errors.push({ path: p, code: "TOO_LARGE", message: "Larger than 1.2 MB: simplify it." }); continue; } const report = validateJson(c); if (report.format !== "composition") { errors.push({ path: p, code: "NOT_A_COMPOSITION", message: "Needs a full composition (version, width, height, fps, duration, layers)." }); continue; } - errors.push(...[...report.errors, ...checkComposition(c)].map(e => ({ ...e, path: p + e.path.slice(1) }))); + for (const e of [...report.errors, ...checkComposition(c)]) (SOFT_CODES.has(e.code) ? warnings : errors).push({ ...e, path: p + e.path.slice(1) }); if (c.width !== f.width || c.height !== f.height) errors.push({ path: p, code: "WRONG_SIZE", message: `Must be ${f.width}×${f.height}.` }); if (c.duration > MAX_DURATION) errors.push({ path: p + ".duration", code: "TOO_LONG", message: `At most ${MAX_DURATION} s.` }); if (!/HyperCodeMotion/.test(raw)) errors.push({ path: p, code: "NO_SIGNATURE", message: "The hero or signature must be HyperCodeMotion (see the brief)." }); out[f.variant] = c; } - return { errors, compositions: out }; + return { errors, warnings, compositions: out }; } +/** At most MAX_ERRORS items, plus how many were left out. */ +const cap = list => list.length > MAX_ERRORS ? [...list.slice(0, MAX_ERRORS), { path: "$", code: "MORE", message: `${list.length - MAX_ERRORS} more of the same kinds: fix these first.` }] : list; const normCode = c => String(c ?? "").trim().toLowerCase(); const CODE = /^[0-9a-z]{6,12}[0-9a-f]{8}$/; @@ -331,11 +341,14 @@ export async function proposeTemplate(args, { now = Date.now(), dispatch = dispa if (!by) return fail("MODERATOR_CODE", NEEDS_CODE, "$.adminCode"); const existing = await getJson(`community/records/${src.id}.json`); const code = args.code == null ? "" : normCode(args.code); - const revision = !!code && existing?.code === code; + // un envío sin código sobre un borrador vivo lo continúa (Claude a veces reenvía todo sin el código) + const resumesDraft = !code && existing?.status === "draft" && holds(existing, now); + const revision = resumesDraft || (!!code && existing?.code === code); if (code && !revision) return fail("UNKNOWN_CODE", "No proposal of this video has that code. Leave code out for a first proposal.", "$.code"); if (revision) { + if (existing.status === "draft" && (existing.attempts || 0) >= MAX_DRAFT_ATTEMPTS) return fail("DRAFT_ATTEMPTS", `It was rejected ${MAX_DRAFT_ATTEMPTS} times: stop here and tell the user what keeps failing.`, "$.code"); if (existing.status === "comparing") return fail("COMPARING", "Its comparison is still being rendered: call get_proposal_status with the code first.", "$.code"); - if (existing.status !== "review") return { ...fail("NOT_REVISABLE", `This proposal is ${existing.status}; it can no longer be changed.`, "$.code"), ...publicRecord(existing) }; + if (!["review", "draft"].includes(existing.status)) return { ...fail("NOT_REVISABLE", `This proposal is ${existing.status}; it can no longer be changed.`, "$.code"), ...publicRecord(existing) }; if (existing.compare.done >= MAX_ROUNDS || existing.compare.round >= MAX_SUBMISSIONS) return fail("ROUNDS_USED", `It already used its ${MAX_ROUNDS} comparisons: publish it with publish_template if it is faithful, or stop here.`, "$.code"); } else if (holds(existing, now)) return { accepted: false, duplicate: true, ...publicRecord(existing), errors: [{ path: "$.url", code: "ALREADY_EXISTS", message: "This video is already a gallery template (or one is being made from it); give the user this status." }] }; const ref = await getJson(refPath(src.id)); @@ -357,11 +370,12 @@ export async function proposeTemplate(args, { now = Date.now(), dispatch = dispa if (p.errors.length) return { accepted: false, errors: p.errors }; given = p.docs; } - const cardIn = args.card ?? (revision ? (({ duration, ...c }) => c)(existing.card) : undefined); - const { errors, compositions } = checkCompositions(given); + const cardIn = args.card ?? (revision && existing.card ? (({ duration, ...c }) => c)(existing.card) : undefined); + const { errors, warnings: soft, compositions } = checkCompositions(given); const duration = compositions.horizontal?.duration ?? compositions.cuadrado?.duration ?? MAX_DURATION; - errors.push(...checkCard(cardIn, duration)); - if (errors.length) return { accepted: false, errors }; + const cardErrors = checkCard(cardIn, duration); + errors.push(...cardErrors); + const warnings = cap(soft); if (!revision) { const limitsPath = `community/limits/${day(now)}.json`; @@ -373,27 +387,49 @@ export async function proposeTemplate(args, { now = Date.now(), dispatch = dispa await putJson(limitsPath, limits); } - const card = { name: cardIn.name.trim(), description: cardIn.description.trim(), chips: cardIn.chips.map(c => c.trim()), group: cardIn.group, moments: cardIn.moments.map(([n, a, b]) => [n.trim(), a, b]), duration }; const page = revision ? null : await readSource(src, fetcher).catch(() => ({})); + const 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 }; + if (errors.length) { + // rechazada: se guarda como borrador (si las tres se leyeron) para corregirla con patch en vez de reenviarla entera + if (FORMATS.some(f => !compositions[f.variant])) return { accepted: false, errors: cap(errors), warnings }; + const draft = { + id: src.id, code: revision ? existing.code : `${now.toString(36)}${randomBytes(4).toString("hex")}`, status: revision && existing.status === "review" ? "review" : "draft", + created: revision ? existing.created : iso(now), updated: iso(now), source, card: cardErrors.length ? (revision ? existing.card : null) : { ...cardIn, duration }, + variant: closestVariant(ref.width, ref.height), reason: null, by: revision ? existing.by ?? by : by, attempts: (revision ? existing.attempts || 0 : 0) + 1, + compare: revision ? existing.compare : { round: 0, done: 0, ok: null, reason: null, times: null } + }; + // una revisión rechazada de algo ya comparado no pisa lo comparado + if (draft.status === "draft") { + for (const f of FORMATS) await putJson(`community/templates/${src.id}/${f.variant}.json`, { ...compositions[f.variant], community: { template: src.id, source: source.url } }); + if (!revision) await putJson(`community/codes/${draft.code}.json`, { id: src.id }); + await putJson(`community/records/${src.id}.json`, draft); + } + return { + accepted: false, code: draft.code, status: draft.status, attempts: draft.attempts, errors: cap(errors), warnings, + message: draft.status === "draft" + ? `Not accepted, but saved as a draft: fix only what errors list by calling propose_template again with this code and patch (the changes), not the full compositions (${MAX_DRAFT_ATTEMPTS - draft.attempts} tries left).` + : "Not accepted; the compared version is kept. Fix only what errors list and send it again with this code and patch." + }; + } + const card = { name: cardIn.name.trim(), description: cardIn.description.trim(), chips: cardIn.chips.map(c => c.trim()), group: cardIn.group, moments: cardIn.moments.map(([n, a, b]) => [n.trim(), a, b]), duration }; const variant = closestVariant(ref.width, ref.height); 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 ?? ref.info?.title ?? null, author: page.author ?? ref.info?.author ?? null, authorUrl: page.authorUrl ?? ref.info?.authorUrl ?? null }, - card, variant, reason: null, by: revision ? existing.by ?? by : by, - compare: { round: revision ? existing.compare.round + 1 : 1, done: revision ? existing.compare.done : 0, ok: null, reason: null, times: null } + source, card, variant, reason: null, by: revision ? existing.by ?? by : by, + attempts: 0, compare: { round: revision ? existing.compare.round + 1 : 1, done: revision ? existing.compare.done : 0, ok: null, reason: null, times: null } }; for (const f of FORMATS) { const c = { ...compositions[f.variant], name: `${card.name} (${f.variant})`, community: { template: src.id, source: record.source.url } }; await putJson(`community/templates/${src.id}/${f.variant}.json`, c); } - if (!revision) await putJson(`community/codes/${record.code}.json`, { id: src.id }); + if (!revision || !(await getJson(`community/codes/${record.code}.json`))) 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, ...(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 { - accepted: true, ...publicRecord(record), round: record.compare.round, errors: [], - message: sent ? `Accepted for comparison: the ${variant} composition (the original's format) is being rendered with the Studio engine and compared with the original, frame by frame. Call get_proposal_status with the code until its status is review (each call waits up to about 40 s).` : record.compare.reason + accepted: true, ...publicRecord(record), round: record.compare.round, errors: [], warnings, + message: sent ? `Accepted for comparison: the ${variant} composition (the original's format) is being rendered with the Studio engine and compared with the original, frame by frame. Call get_proposal_status with the code until its status is review (each call waits up to about 50 s).` : record.compare.reason }; } @@ -408,6 +444,7 @@ export async function getProposalStatus(args, { wait = WAIT_MS } = {}) { r = await waitFor(path, r, x => x.status !== "comparing", wait); const out = { found: true, ...publicRecord(r), studio: r.status === "published" ? (r.official ? (r.official.pr ? `Pull request ${r.official.pr}: its preview shows the template in the gallery (Explorar animaciones: «${r.card?.name}»); merging it adds it to ${r.official.branch}.` : `Committed to ${r.official.branch}.`) : `${STUDIO_URL} (Explorar animaciones, card marked «Community»)`) : null }; if (r.status === "comparing") return { ...out, message: "Its comparison is still being rendered: call get_proposal_status again." }; + if (r.status === "draft") return { ...out, attempts: r.attempts, message: "A draft that did not pass the checks: fix it with propose_template, this code and patch (the errors it returned say what)." }; if (r.status !== "review") return out; const left = Math.max(0, Math.min(MAX_ROUNDS - r.compare.done, MAX_SUBMISSIONS - r.compare.round)); const compare = { round: r.compare.round, ok: r.compare.ok, reason: r.compare.reason, variant: r.variant, times: r.compare.times, revisionsLeft: left }; diff --git a/tests/community.test.js b/tests/community.test.js index 704a2e4..d60d7a3 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, putUpload, finishUpload, readUpload, retire, readIndex, readTemplate, readRecord, setStore, moderators, moderatorOf, slugOf, MAX_PER_DAY, MAX_ROUNDS, PASS_SCORE, IDLE_RESTART_MS } from "../src/community.js"; +import { parseSource, readSource, getRecreationBrief, proposeTemplate, getProposalStatus, publishTemplate, finishRender, finishRef, finishCompare, putRef, putCompare, putMedia, putUpload, finishUpload, readUpload, retire, readIndex, readTemplate, readRecord, setStore, moderators, moderatorOf, slugOf, MAX_PER_DAY, MAX_ROUNDS, PASS_SCORE, IDLE_RESTART_MS, checkCompositions } from "../src/community.js"; import { getTemplate } from "../src/templates.js"; import handler from "../api/community.js"; @@ -244,6 +244,40 @@ test("a revision can send only a patch and keep the card", async () => { assert.equal((await readTemplate(id, "cuadrado")).layers.length, before); assert.equal((await readRecord(id)) , null, "not published yet"); }); +test("estimated overflows warn instead of rejecting", () => { + const c = compositions(); c.vertical.layers[0].props.x = 1070; c.vertical.layers[0].end = 12; + const r = checkCompositions(c); + assert.deepEqual(r.errors, []); + assert.ok(r.warnings.some(w => w.code === "TEXT_OUTSIDE_CANVAS")); assert.ok(r.warnings.some(w => w.code === "OUTSIDE_DURATION")); + c.vertical.layers.push({ ...c.vertical.layers[0] }); + assert.ok(checkCompositions(c).errors.some(e => e.code === "DUPLICATE_ID"), "real defects still reject"); +}); +test("a rejected proposal is kept as a draft and fixed with a patch", async () => { + setStore(mem()); jobs.length = 0; + const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe"; + await brief({ ...A, url }, opts); await referenceReady(id); + const bad = compositions(); bad.cuadrado.layers.push({ ...bad.cuadrado.layers[0] }); + const d = await proposeTemplate({ ...A, url, compositions: bad, card }, opts); + assert.equal(d.accepted, false); assert.equal(d.status, "draft"); assert.ok(d.code); assert.ok(d.errors.some(e => e.code === "DUPLICATE_ID")); + assert.equal(jobs.filter(j => j.kind === "compare").length, 0, "a draft is not compared"); + assert.equal((await getProposalStatus({ code: d.code }, { wait: 0 })).status, "draft"); + const again = await proposeTemplate({ ...A, url, compositions: bad, card }, opts); + assert.equal(again.code, d.code, "a full send without the code continues the draft"); assert.equal(again.attempts, 2); + const fixed = await proposeTemplate({ ...A, url, code: d.code, patch: [{ op: "remove", path: "/layers/1", variants: ["cuadrado"] }] }, opts); + assert.equal(fixed.accepted, true, JSON.stringify(fixed.errors)); assert.equal(fixed.round, 1); assert.equal(fixed.code, d.code); + assert.equal(jobs.filter(j => j.kind === "compare").length, 1); +}); +test("drafts stop taking sends after too many rejections, and errors are capped", async () => { + setStore(mem()); jobs.length = 0; + const url = "https://prompt-motion.com/parkerrex-1a54fe", id = "c-pm-parkerrex-1a54fe"; + await brief({ ...A, url }, opts); await referenceReady(id); + const bad = compositions(); + for (const v of ["cuadrado", "vertical", "horizontal"]) for (let i = 0; i < 10; i++) bad[v].layers.push({ ...bad[v].layers[0] }); + let r; + for (let i = 0; i < 6; i++) r = await proposeTemplate({ ...A, url, compositions: bad, card }, opts); + assert.ok(r.errors.length <= 13); assert.equal(r.errors.at(-1).code, "MORE"); + assert.equal((await proposeTemplate({ ...A, url, code: r.code, compositions: bad, card }, opts)).errors[0].code, "DRAFT_ATTEMPTS"); +}); test("daily limit per caller", async () => { setStore(mem()); const ids = Array.from({ length: MAX_PER_DAY + 1 }, (_, i) => `https://prompt-motion.com/autor-${i}abc`);