diff --git a/api/mcp.js b/api/mcp.js index 6acc984..b4e149b 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). 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.",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","adminCode","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:"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:"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 da272c6..6484611 100644 --- a/src/brief.js +++ b/src/brief.js @@ -16,5 +16,5 @@ export const BRIEF = `Recreate the reference video as an official HyperCodeMotio 5. Check each composition with validate_json and fix every error. 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. 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 in all three compositions 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. +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. 9. When the comparison is faithful, call publish_template with the code, adminCode and short notes on what is consciously different and why. Its gallery videos are rendered and the template is committed to the Studio code as an official template in its own pull request; call get_proposal_status until it is published and give the user the template id, the tracking code and the pull request link (its preview shows the template; merging it approves it).`; diff --git a/src/community.js b/src/community.js index 424364f..e285a47 100644 --- a/src/community.js +++ b/src/community.js @@ -29,6 +29,7 @@ * Moderator codes: TEMPLATE_ADMIN_CODES (or COMMUNITY_ADMIN_SECRET) holds one code or a list * "name:code, name:code"; the name is recorded with each template. */ +import { applyPatch } from "./patch.js"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { validateJson } from "./contract.js"; import { checkComposition } from "./hcmotion.js"; @@ -340,9 +341,26 @@ export async function proposeTemplate(args, { now = Date.now(), dispatch = dispa const ref = await getJson(refPath(src.id)); if (ref?.status !== "ready") return fail("REFERENCE_NOT_READY", "Call get_recreation_brief with this url first and build from its reference frames.", "$.url"); - const { errors, compositions } = checkCompositions(args.compositions); + // una revisión puede mandar solo los cambios (patch) y omitir la tarjeta: se aplican a lo ya guardado + let given = args.compositions; + if (args.patch != null) { + if (!revision) return fail("PATCH_NEEDS_CODE", "patch only revises a proposal: send it with its code. A first proposal sends the full compositions.", "$.patch"); + if (given != null) return fail("PATCH_AND_COMPOSITIONS", "Send either compositions or patch, not both.", "$.patch"); + const stored = {}; + for (const f of FORMATS) { + const c = await getJson(`community/templates/${src.id}/${f.variant}.json`); + if (!c) return fail("PATCH_BASE_MISSING", "The stored compositions are missing: send the full compositions.", "$.patch"); + const { community, ...rest } = c; + stored[f.variant] = rest; + } + const p = applyPatch(stored, args.patch); + 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 duration = compositions.horizontal?.duration ?? compositions.cuadrado?.duration ?? MAX_DURATION; - errors.push(...checkCard(args.card, duration)); + errors.push(...checkCard(cardIn, duration)); if (errors.length) return { accepted: false, errors }; if (!revision) { @@ -355,7 +373,7 @@ export async function proposeTemplate(args, { now = Date.now(), dispatch = dispa await putJson(limitsPath, limits); } - const card = { name: args.card.name.trim(), description: args.card.description.trim(), chips: args.card.chips.map(c => c.trim()), group: args.card.group, moments: args.card.moments.map(([n, a, b]) => [n.trim(), a, b]), duration }; + 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 variant = closestVariant(ref.width, ref.height); const record = { @@ -399,12 +417,12 @@ export async function getProposalStatus(args, { wait = WAIT_MS } = {}) { // filtro sin LLM: por debajo de la nota mínima no hace falta mirar la lámina, el informe dice qué corregir if (mt && mt.score < PASS_SCORE && left > 0) return { ...out, compare: { ...compare, passScore: PASS_SCORE, report: mt.report }, - message: `Measured without images: score ${mt.score}/100, below ${PASS_SCORE}. ${mt.report} Fix those moments (layout, sizes, positions, colors, timing) in all three compositions and call propose_template again with this code (${left} left). The sheet is only shown once the score reaches ${PASS_SCORE}.` + message: `Measured without images: score ${mt.score}/100, below ${PASS_SCORE}. ${mt.report} Fix those moments (layout, sizes, positions, colors, timing) and call propose_template again with this code and a patch (only the changes) (${left} left). The sheet is only shown once the score reaches ${PASS_SCORE}.` }; const sheet = await jpg(`community/compare/${r.id}/sheet.jpg`); return { ...out, compare: { ...compare, ...(mt ? { passScore: PASS_SCORE, report: mt.report } : {}), how: "In the attached sheet each pair is one moment: left the original, right your recreation rendered by the Studio engine (times given as [original, recreation] in seconds; the recreation's time is scaled to its duration)." }, - message: `Compare every pair: layout, sizes, positions, timing, colors, typefaces and texts. If something differs, fix it in all three compositions and call propose_template again with this code (${left} left). When it truly looks like the original, call publish_template with this code and notes on what is consciously different and why.`, + message: `Compare every pair: layout, sizes, positions, timing, colors, typefaces and texts. If something differs, call propose_template again with this code and a patch (only the changes) (${left} left). When it truly looks like the original, call publish_template with this code and notes on what is consciously different and why.`, _images: sheet ? [sheet] : [] }; } diff --git a/src/patch.js b/src/patch.js new file mode 100644 index 0000000..3964855 --- /dev/null +++ b/src/patch.js @@ -0,0 +1,60 @@ +// Revisiones baratas: en vez de reenviar las 3 composiciones, Claude manda solo los cambios. +// Cada operación: { op: "replace" | "add" | "remove" | "edit", path: "/layers/3/props/fontSize", value?, find?, replace?, variants? } +// replace/add/remove: como JSON Patch (RFC 6902) sobre un puntero JSON (RFC 6901); "add" con "-" añade al final de un array +// edit: cambia un trozo de un texto (p. ej. el CSS o el HTML de un doc): `find` debe aparecer exactamente una vez +// variants: a qué composiciones se aplica (por defecto las tres) +export const VARIANTS = ["cuadrado", "vertical", "horizontal"]; +export const MAX_OPS = 200; + +const unescape = s => s.replace(/~1/g, "/").replace(/~0/g, "~"); +const parse = path => { + if (typeof path !== "string" || !path.startsWith("/")) return null; + return path.slice(1).split("/").map(unescape); +}; +const isIndex = (arr, k, allowEnd) => /^(0|[1-9]\d*)$/.test(k) && Number(k) < arr.length + (allowEnd ? 1 : 0); + +/** Aplica `ops` a una copia de `docs` ({cuadrado, vertical, horizontal}). Devuelve { docs, errors }. */ +export function applyPatch(docs, ops) { + const errors = []; + if (!Array.isArray(ops) || !ops.length) return { docs, errors: [{ path: "$.patch", code: "MISSING", message: "patch must be a non-empty array of operations." }] }; + if (ops.length > MAX_OPS) return { docs, errors: [{ path: "$.patch", code: "TOO_LARGE", message: `At most ${MAX_OPS} operations: send the full compositions instead.` }] }; + const out = structuredClone(docs); + ops.forEach((o, i) => { + const at = `$.patch[${i}]`; + const fail = (code, message) => errors.push({ path: at, code, message }); + if (!o || typeof o !== "object") return fail("INVALID_OP", "Each operation is an object."); + const variants = o.variants == null ? VARIANTS : Array.isArray(o.variants) ? o.variants : [o.variants]; + if (!variants.length || variants.some(v => !VARIANTS.includes(v))) return fail("INVALID_OP", `variants must be among ${VARIANTS.join(", ")}.`); + if (!["replace", "add", "remove", "edit"].includes(o.op)) return fail("INVALID_OP", "op must be replace, add, remove or edit."); + const keys = parse(o.path); + if (!keys || !keys.length) return fail("PATCH_PATH", "path must be a JSON pointer inside the composition, such as /layers/2/props/fontSize."); + if (o.op !== "remove" && o.op !== "edit" && !("value" in o)) return fail("INVALID_OP", `${o.op} needs a value.`); + if (o.op === "edit" && (typeof o.find !== "string" || !o.find || typeof o.replace !== "string")) return fail("INVALID_OP", "edit needs find (non-empty) and replace strings."); + for (const v of variants) { + let parent = out[v]; + for (const k of keys.slice(0, -1)) { + parent = Array.isArray(parent) ? (isIndex(parent, k) ? parent[Number(k)] : undefined) : parent && typeof parent === "object" ? parent[k] : undefined; + if (parent == null || typeof parent !== "object") return fail("PATCH_PATH", `${o.path} does not exist in ${v}.`); + } + const last = keys.at(-1); + const arr = Array.isArray(parent); + const has = arr ? isIndex(parent, last) : Object.hasOwn(parent, last); + if (o.op === "add") { + if (arr) { + if (last !== "-" && !isIndex(parent, last, true)) return fail("PATCH_PATH", `${o.path}: no such position in ${v}.`); + parent.splice(last === "-" ? parent.length : Number(last), 0, structuredClone(o.value)); + } else parent[last] = structuredClone(o.value); + continue; + } + if (!has) return fail("PATCH_PATH", `${o.path} does not exist in ${v}.`); + if (o.op === "remove") { arr ? parent.splice(Number(last), 1) : delete parent[last]; continue; } + if (o.op === "replace") { parent[arr ? Number(last) : last] = structuredClone(o.value); continue; } + const cur = parent[arr ? Number(last) : last]; + if (typeof cur !== "string") return fail("PATCH_PATH", `${o.path} in ${v} is not text: edit only changes strings.`); + const n = cur.split(o.find).length - 1; + if (n !== 1) return fail("EDIT_NOT_FOUND", `find appears ${n} times in ${o.path} of ${v}; it must appear exactly once (widen it).`); + parent[arr ? Number(last) : last] = cur.replace(o.find, () => o.replace); + } + }); + return { docs: out, errors }; +} diff --git a/tests/community.test.js b/tests/community.test.js index 55e0823..704a2e4 100644 --- a/tests/community.test.js +++ b/tests/community.test.js @@ -229,6 +229,21 @@ test("a moderator restarts a proposal left idle in another session", async () => assert.equal(fresh.accepted, true); assert.notEqual(fresh.code, r.code); assert.equal((await getProposalStatus({ code: r.code }, { wait: 0 })).found, false); }); +test("a revision can send only a patch and keep the card", 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); + assert.equal((await proposeTemplate({ ...A, url, patch: [{ op: "replace", path: "/fps", value: 30 }] }, opts)).errors[0].code, "PATCH_NEEDS_CODE"); + const r = await proposeTemplate({ ...A, url, compositions: compositions(), card }, opts); + await compared(id, 1); + assert.equal((await proposeTemplate({ ...A, url, code: r.code, patch: [{ op: "replace", path: "/layers/99/x", value: 1 }] }, opts)).errors[0].code, "PATCH_PATH"); + const before = (await readTemplate(id, "vertical")).layers.length; + const p = await proposeTemplate({ ...A, url, code: r.code, patch: [{ op: "add", path: "/layers/-", value: { id: "extra", type: "rect", start: 0, end: 1, props: { x: 10, y: 10, w: 20, h: 20, fill: "#ffffff" } }, variants: ["vertical"] }] }, opts); + assert.equal(p.accepted, true, JSON.stringify(p.errors)); assert.equal(p.round, 2); + assert.equal((await readTemplate(id, "vertical")).layers.length, before + 1); + assert.equal((await readTemplate(id, "cuadrado")).layers.length, before); + assert.equal((await readRecord(id)) , null, "not published yet"); +}); 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`); diff --git a/tests/patch.test.js b/tests/patch.test.js new file mode 100644 index 0000000..ad523ca --- /dev/null +++ b/tests/patch.test.js @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyPatch } from "../src/patch.js"; + +const base = () => ({ + cuadrado: { layers: [{ props: { fontSize: 100 } }], docs: { s1: "

Hola

" } }, + vertical: { layers: [{ props: { fontSize: 110 } }], docs: { s1: "

Hola

" } }, + horizontal: { layers: [{ props: { fontSize: 125 } }], docs: { s1: "

Hola

" } } +}); + +test("replace, add, remove and edit, per variant or on all three", () => { + const src = base(); + const { docs, errors } = applyPatch(src, [ + { op: "replace", path: "/layers/0/props/fontSize", value: 90, variants: ["vertical"] }, + { op: "add", path: "/layers/-", value: { props: { fontSize: 10 } } }, + { op: "remove", path: "/docs/s1", variants: ["horizontal"] }, + { op: "edit", path: "/docs/s1", find: ">Hola<", replace: ">Hey<", variants: ["cuadrado", "vertical"] } + ]); + assert.deepEqual(errors, []); + assert.equal(docs.vertical.layers[0].props.fontSize, 90); assert.equal(docs.cuadrado.layers[0].props.fontSize, 100); + assert.equal(docs.horizontal.layers.length, 2); assert.equal(docs.horizontal.docs.s1, undefined); + assert.match(docs.cuadrado.docs.s1, />Hey { + const e = p => applyPatch(base(), p).errors.map(x => x.code); + assert.deepEqual(e([{ op: "replace", path: "/layers/5/props/fontSize", value: 1 }]), ["PATCH_PATH"]); + assert.deepEqual(e([{ op: "edit", path: "/docs/s1", find: "zzz", replace: "" }]), ["EDIT_NOT_FOUND"]); + assert.deepEqual(e([{ op: "edit", path: "/layers/0/props/fontSize", find: "1", replace: "2" }]), ["PATCH_PATH"]); + assert.deepEqual(e([{ op: "move", path: "/a" }]), ["INVALID_OP"]); + assert.deepEqual(e([{ op: "replace", path: "/x", value: 1, variants: ["otro"] }]), ["INVALID_OP"]); + assert.deepEqual(e([]), ["MISSING"]); +});