diff --git a/README.md b/README.md index c53cbfc..7eef45d 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Bigger options, not implemented (each adds always-loaded tokens or needs a decis | `get_motion_recipe` | On demand: real, validated JSON/HTML/CSS patterns for looks the basics do not give. Without `name` it returns the index; with one, a recipe: `riso` (print, overprint, grain), `choreography` (keyframes, springs, easings), `audio`, `camera`, `code-texture`, `ui-mockup` (the 11 `ui` kinds), `scene-bg` (a background per scene), `looks` (the 13 looks in one table) and `look:` (one look in full). Plain text result; it stores nothing. | read-only | | `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 | +| `fill_template` | Creates a .hcmotion project from a slot template (social-media trends such as `trend-portfolio-editor`): only the texts change, by layer id; the design, motion and music stay. Returns open and download links valid 24 hours. | creates a file | | `validate_json` | Checks a composition, design or improvement patch and returns errors and warnings. Rejects measurable text that cannot fit the canvas and line-height below 1. Warns (`RESERVED_NAME_DECLARED`) when a code layer or component declares a name the engine already provides (`S`, `H`, `ctx`, `t`, `w`, `h`, helpers such as `range`). 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 a moderator code (only moderators make gallery templates), plus the video file, prompt, title and author that Claude reads on that page (the source sites often refuse servers), and returns its source details, its published prompt when available, the brief to recreate it as a gallery template and reference frames of the original video (extracted once per video; the first call may answer that they are being prepared). prompt-motion.com refuses every server, so for its videos the brief returns a short script that Claude runs in its own code tool (or the same steps in a browser tool) to download the video and send it here in parts. | write (starts the frame extraction), not destructive | | `propose_template` | Submits the recreation (three compositions and the gallery card), with the moderator code. 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 | diff --git a/api/mcp.js b/api/mcp.js index 19925c4..2741b1b 100644 --- a/api/mcp.js +++ b/api/mcp.js @@ -1,12 +1,22 @@ import {getCapabilities,validateJson,CHECKLIST} from "../src/contract.js"; import {checkComposition,encodeHcmotion} from "../src/hcmotion.js"; import {saveFile} from "../src/files.js"; -import {getTemplate,TEMPLATE_FORMATS} from "../src/templates.js"; +import {getTemplate,fillTemplate,TEMPLATE_FORMATS} from "../src/templates.js"; import {getRecreationBrief,proposeTemplate,getProposalStatus,publishTemplate,MAX_ROUNDS} from "../src/community.js"; import {GROUPS} from "../src/brief.js"; import {motionRecipe} from "../src/recipes.js"; const MAX=4*1024*1024; /** create_hcmotion: Claude's composition JSON → validated .hcmotion file with a 24 h download link. */ +/** fill_template: a slot template (trends) with the user's texts → .hcmotion; photo and clips are replaced in the Studio. */ +async function fillTemplateFile(args){ + const r=await fillTemplate(args); + if(r.error)return {created:false,errors:[{path:"$",code:"FILL_FAILED",message:r.error}]}; + const errors=checkComposition(r.composition); + if(errors.length)return {created:false,errors}; + const bytes=await encodeHcmotion(r.composition); + const file=await saveFile(bytes); + return {created:true,download:file.download,open:file.open,expiresAt:file.expiresAt,bytes:bytes.length,slots:r.slots,errors:[],next:"Give the user the open link. In the Studio they replace the photo and the clips: select the «Hueco · …» layer and use «Elegir imagen…» or «Elegir video…»; the animation does not change."}; +} async function createHcmotion(args){ let json=args.json; if(typeof json==="string"){try{json=JSON.parse(json);}catch{return {created:false,errors:[{path:"$",code:"INVALID_JSON",message:"The JSON string does not parse. Send the composition object itself."}],warnings:[]};}} @@ -23,6 +33,7 @@ const tools=[ {name:"get_motion_recipe",title:"Get motion recipe",description:"Real, validated JSON/HTML/CSS patterns for looks the basics do not give: riso print, choreography, audio, camera, code textures, UI mockups, per-scene backgrounds, 13 style looks. Without name: the index.",annotations:{title:"Get motion recipe",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{name:{type:"string",description:"Recipe from the index, e.g. riso or look:cartel."}},additionalProperties:false}}, {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:"fill_template",title:"Fill a HyperCodeMotion slot template",description:"Create a .hcmotion project from a Studio gallery template that has slots (get_template lists them under slots, e.g. the social-media trends such as trend-portfolio-editor): only the texts change, by layer id; the design, animation, music and timings stay exactly as they are. Returns open and download links valid 24 hours. The user replaces the photo and clips in the Studio.",annotations:{title:"Fill a HyperCodeMotion slot template",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false},inputSchema:{type:"object",properties:{template:{type:"string",description:"Template id with slots, such as \"trend-portfolio-editor\"."},format:{type:"string",enum:TEMPLATE_FORMATS,description:"Aspect ratio of the video. Defaults to 16:9."},texts:{type:"object",additionalProperties:{type:"string"},description:"New text of each text slot, by layer id from get_template slots. Slots left out keep the template text."},name:{type:"string",description:"Optional project name shown in the Studio."}},required:["template","texts"],additionalProperties:false},outputSchema:{type:"object",properties:{created:{type:"boolean"},download:{type:"string"},open:{type:"string"},expiresAt:{type:"string"},bytes:{type:"number"},slots:{type:"array",items:{type:"object"}},errors:{type:"array",items:{type:"object"}},next:{type:"string"}},required:["created"]}}, {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. Warns when a code layer or component declares a name the engine already provides (RESERVED_NAME_DECLARED). 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 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"]}}, @@ -63,6 +74,7 @@ export default async function handler(req,res){ if(name==="get_proposal_status")return withImages(await getProposalStatus(args),o=>!o.found); if(name==="publish_template")return withImages(await publishTemplate(args),o=>!o.accepted); if(name==="get_motion_recipe"){const r=motionRecipe(args.name);return reply(res,200,ok(id,{content:[{type:"text",text:r.text}],isError:!r.found}));} + if(name==="fill_template"){const output=await fillTemplateFile(args);return reply(res,200,ok(id,{content:[{type:"text",text:JSON.stringify(output)}],structuredContent:output,isError:!output.created}));} if(name==="get_template"){const output=await getTemplate(args);return reply(res,200,ok(id,{content:[{type:"text",text:JSON.stringify(output)}],structuredContent:output,isError:!output.found}));} const output=name==="get_capabilities"?getCapabilities():validateJson(typeof args.json==="string"?JSON.parse(args.json):args.json,{composition:args.composition}); return reply(res,200,ok(id,{content:[{type:"text",text:JSON.stringify(output)}],structuredContent:output,isError:false})); diff --git a/skills/hypercodemotion/SKILL.md b/skills/hypercodemotion/SKILL.md index 5e3e064..0b7073a 100644 --- a/skills/hypercodemotion/SKILL.md +++ b/skills/hypercodemotion/SKILL.md @@ -26,6 +26,10 @@ Use this Skill when asked to create, improve, extend, combine, or troubleshoot e 7. **MCP validation** — The HyperCodeMotion MCP (`https://mcp.hypercodemotion.com/mcp`) is required. Call `get_capabilities` before selecting layer/effect fields; always run `validate_json` on the finished draft before `create_hcmotion`: fix every error and warning, then go through the checklist it returns and correct the composition (overlaps, hierarchy, motion, audio) until it holds. Quality matters more than saving tokens: a second pass is expected. **Do not claim validation if no call occurred.** 8. **Studio handoff** — Call `create_hcmotion` with the finished JSON. If `created` is false, fix every error and call it again. Give the user the two links it returns: `open` (opens the project in Studio) and `download` (the `.hcmotion` file, to keep or load later with «Cargar proyecto»), and say both expire in 24 hours. **Never paste the composition JSON for the user to copy:** Studio only opens `.hcmotion` files. If the MCP is not connected, stop and ask the user to connect it instead of returning JSON. Never claim a perfect render from MCP results alone. +## Social-media trends and other slot templates + +Some gallery templates are **slot templates**: a fixed social-media trend (for example `trend-portfolio-editor`, a video editor's portfolio with the person's photo in front of a giant word, their skills and stats, phones and a grid playing their clips, and a «DM» close; `trend-portfolio-editor-corto` is its 15-second cut). Their value is the exact trend: same design, motion, music and beat. When the user wants one, never redesign or re-animate it. Call `get_template` (its `slots` list each replaceable layer: texts, the photo and the clips), ask the user only for what the slot texts need (name, role, skills, stats, section titles, closing line, handle) in their language, then call `fill_template` with the template id, the format and `texts` by layer id. Give the user the `open` link and tell them to replace the photo (a PNG without background works best) and their clips in the Studio, layer by layer; the animation stays as it is. Keep each text about as long as the one it replaces so it fits the layout. + ## 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, 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. diff --git a/src/templates.js b/src/templates.js index 9faf3ba..5fe4419 100644 --- a/src/templates.js +++ b/src/templates.js @@ -6,18 +6,60 @@ const SIZES={"16:9":[1920,1080],"9:16":[1080,1920],"1:1":[1080,1080],"4:3":[1440 const variant=f=>f==="16:9"?"horizontal":f==="9:16"?"vertical":"cuadrado"; import {readTemplate} from "./community.js"; const cache=new Map(); -/** get_template: a gallery template's full design (HTML scenes, layers, brief) in the requested format. */ -export async function getTemplate(args,fetcher=fetch,community=readTemplate){ +/** A template's replaceable slots: layers named «Hueco · …» (the user's photo, clips and texts). */ +const SLOT=/^Hueco · /; +export function slotsOf(comp){ + return (comp.layers??[]).filter(l=>SLOT.test(String(l.name??""))).map(l=>({id:l.id,type:l.type,name:String(l.name).replace(SLOT,""),...(l.type==="text"?{text:String(l.props?.text??"")}:{}),...(l.type==="image"||l.type==="video"?{asset:l.props?.asset}:{})})); +} +// Embedded files above this size (the demo photo and clips of a slot template) never go to the model: they would cost +// hundreds of thousands of tokens and the user replaces them in the Studio anyway. +const HEAVY=8000; +async function load(args,fetcher,community){ const slug=String(args.template??"").trim().toLowerCase(),format=args.format??"16:9"; - if(!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug))return {found:false,error:"template must be a template id such as \"mix-promo-editorial\"."}; - if(!FORMATS.includes(format))return {found:false,error:`format must be one of ${FORMATS.join(", ")}.`}; + if(!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug))return {error:"template must be a template id such as \"mix-promo-editorial\"."}; + if(!FORMATS.includes(format))return {error:`format must be one of ${FORMATS.join(", ")}.`}; const url=`${BASE}/${slug}-${variant(format)}.json`; // «Community» templates (ids c-…) live in this server's own store, not in the Studio's files const src=slug.startsWith("c-")?await community(slug,variant(format)).catch(()=>null):await (cache.has(url)?cache:cache.set(url,fetcher(url).then(r=>r.ok?r.json():null).catch(()=>null))).get(url); - if(!src||typeof src!=="object"||!Array.isArray(src.layers)){cache.delete(url);return {found:false,error:`No template with id ${JSON.stringify(slug)}.`};} + if(!src||typeof src!=="object"||!Array.isArray(src.layers)){cache.delete(url);return {error:`No template with id ${JSON.stringify(slug)}.`};} + return {slug,format,src}; +} +/** get_template: a gallery template's full design (HTML scenes, layers, brief) in the requested format. */ +export async function getTemplate(args,fetcher=fetch,community=readTemplate){ + const t=await load(args,fetcher,community); + if(t.error)return {found:false,error:t.error}; + const {slug,format,src}=t; const {audio,community:_,...composition}=structuredClone(src); [composition.width,composition.height]=SIZES[format]; + const omitted=Object.keys(composition.assets??{}).filter(k=>typeof composition.assets[k]==="string"&&composition.assets[k].length>HEAVY); + for(const k of omitted)composition.assets[k]=composition.assets[k].slice(0,composition.assets[k].indexOf(",")+1)+"omitted"; const name=String(src.name||slug).replace(/\s*\((horizontal|vertical|cuadrado)\)$/,""); - return {found:true,template:slug,name,format,brief:src.brief??"",scenes:(src.scenes??[]).map(s=>({name:s.name,start:s.start,end:s.end,transition:s.transition?.type??null})),composition}; + const slots=slotsOf(src); + return {found:true,template:slug,name,format,brief:src.brief??"",scenes:(src.scenes??[]).map(s=>({name:s.name,start:s.start,end:s.end,transition:s.transition?.type??null})), + ...(slots.length?{slots,fill:"This is a slot template: do not redesign or re-animate it. Call fill_template with the template id, the format and the new text of each text slot (by layer id); the user replaces the photo and clips in the Studio, where the animation stays as it is."}:{}), + ...(omitted.length?{assetsOmitted:omitted}:{}),composition}; +} +/** fill_template: a slot template with new texts, kept whole (design, motion, music and demo files). */ +export async function fillTemplate(args,fetcher=fetch,community=readTemplate){ + const t=await load(args,fetcher,community); + if(t.error)return {error:t.error}; + const {slug,format,src}=t; + const slots=slotsOf(src); + if(!slots.length)return {error:`${slug} has no slots: follow it with get_template and create_hcmotion instead.`}; + const texts=args.texts??{}; + if(typeof texts!=="object"||Array.isArray(texts))return {error:"texts must be an object: {\"\": \"new text\"}."}; + const {community:_,...comp}=structuredClone(src); + [comp.width,comp.height]=SIZES[format]; + const errors=[]; + for(const [id,value] of Object.entries(texts)){ + const slot=slots.find(s=>s.id===id); + if(!slot){errors.push(`${id}: not a slot of ${slug}. Slots: ${slots.map(s=>s.id).join(", ")}.`);continue;} + if(slot.type!=="text"){errors.push(`${id} (${slot.name}) is a ${slot.type} slot: the user replaces it in the Studio.`);continue;} + if(typeof value!=="string"||!value.trim()||value.length>200){errors.push(`${id}: the text must be a non-empty string of at most 200 characters.`);continue;} + comp.layers.find(l=>l.id===id).props.text=value; + } + if(errors.length)return {error:errors.join(" ")}; + if(typeof args.name==="string"&&args.name.trim())comp.name=args.name.trim().slice(0,120); + return {composition:comp,slots:slotsOf(comp)}; } export const TEMPLATE_FORMATS=FORMATS; diff --git a/tests/contract.test.js b/tests/contract.test.js index b4c923f..ee27ec8 100644 --- a/tests/contract.test.js +++ b/tests/contract.test.js @@ -13,7 +13,7 @@ test("MCP initialize, tools/list and validation",async()=>{ return JSON.parse(body); }; assert.equal((await call("initialize")).result.serverInfo.name,"hypercodemotion-mcp"); - const listed=await call("tools/list");assert.equal(listed.result.tools.length,9); + const listed=await call("tools/list");assert.equal(listed.result.tools.length,10); const output=await call("tools/call",{name:"validate_json",arguments:{json}}); assert.equal(JSON.parse(output.result.content[0].text).valid,true); }); @@ -29,7 +29,7 @@ test("Smithery metadata includes descriptions, annotations and output schemas",a assert.ok(init.serverInfo.websiteUrl.startsWith("https://")); const listed=await call("tools/list"); for(const tool of listed.tools){ - assert.equal(tool.annotations.readOnlyHint,!["create_hcmotion","propose_template","publish_template","get_recreation_brief"].includes(tool.name)); + assert.equal(tool.annotations.readOnlyHint,!["create_hcmotion","fill_template","propose_template","publish_template","get_recreation_brief"].includes(tool.name)); assert.equal(tool.annotations.destructiveHint,false); if(tool.name!=="get_motion_recipe")assert.ok(tool.outputSchema); // plain-text result: no schema keeps the fixed load small assert.ok(tool.title); diff --git a/tests/mcp-integration.test.js b/tests/mcp-integration.test.js index 19fa413..16b0f91 100644 --- a/tests/mcp-integration.test.js +++ b/tests/mcp-integration.test.js @@ -53,7 +53,7 @@ test("MCP ping is available", async () => { }); test("tools/list includes catalog, file creator and validator", async () => { const r = await request("POST", rpc("tools/list")); - assert.deepEqual(r.body.result.tools.map(x=>x.name),["get_capabilities","get_motion_recipe","create_hcmotion","get_template","validate_json","get_recreation_brief","propose_template","get_proposal_status","publish_template"]); + assert.deepEqual(r.body.result.tools.map(x=>x.name),["get_capabilities","get_motion_recipe","create_hcmotion","get_template","fill_template","validate_json","get_recreation_brief","propose_template","get_proposal_status","publish_template"]); }); test("get_capabilities returns recognized layer types and fx", async () => { const r = await request("POST", tool("get_capabilities",{})); diff --git a/tests/templates.test.js b/tests/templates.test.js index f6cb58b..29ee4f8 100644 --- a/tests/templates.test.js +++ b/tests/templates.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { getTemplate } from "../src/templates.js"; +import { getTemplate, fillTemplate } from "../src/templates.js"; const tpl = { version: 1, name: "NODD (horizontal)", width: 1920, height: 1080, fps: 30, duration: 22, brief: "Promo editorial", docs: { a: "
" }, layers: [{ id: "a", type: "html", start: 0, end: 3 }], scenes: [{ name: "Gancho", start: 0, end: 3.2, transition: { type: "cut" } }], audio: { music: [] } }; @@ -31,3 +31,43 @@ test("get_template rejects unknown ids, bad ids and bad formats without fetching assert.equal((await getTemplate({ template: "nodd", format: "2:1" }, fake(seen))).found, false); assert.equal(seen.length, 1); }); + +// plantilla con huecos (trends): la foto y los clips de ejemplo no viajan al modelo; fill_template solo cambia textos +const big = "data:video/webm;base64," + "A".repeat(20000); +const trend = { version: 1, name: "Trend · Portfolio de editor", width: 1920, height: 1080, fps: 30, duration: 15, background: "#000", params: {}, + assets: { FOTO: "data:image/webp;base64," + "B".repeat(9000), V1: big, ICON: "data:image/png;base64,AAAA" }, + layers: [ + { id: "l1", type: "rect", name: "Fondo", start: 0, end: 15, props: {} }, + { id: "l2", type: "text", name: "Hueco · Nombre", start: 0, end: 6, props: { text: "LI WON" } }, + { id: "l3", type: "image", name: "Hueco · Tu foto (sin fondo)", start: 0, end: 6, props: { asset: "FOTO" } }, + { id: "l4", type: "video", name: "Hueco · Clip 1 (vertical)", start: 6, end: 11, props: { asset: "V1" } }, + ], + audio: { bpm: 100, tracks: [], sfx: [] } }; +const trendFetch = async (url) => (url.endsWith("/trend-portfolio-editor-vertical.json") ? { ok: true, json: async () => trend } : { ok: false }); + +test("get_template lists the slots and leaves the heavy demo files out", async () => { + const r = await getTemplate({ template: "trend-portfolio-editor", format: "9:16" }, trendFetch); + assert.equal(r.found, true); + assert.deepEqual(r.slots.map((s) => [s.id, s.type]), [["l2", "text"], ["l3", "image"], ["l4", "video"]]); + assert.equal(r.slots[0].text, "LI WON"); + assert.match(r.fill, /fill_template/); + assert.deepEqual(r.assetsOmitted, ["FOTO", "V1"]); + assert.equal(r.composition.assets.V1, "data:video/webm;base64,omitted"); + assert.equal(r.composition.assets.ICON, "data:image/png;base64,AAAA"); + assert.ok(JSON.stringify(r).length < 5000); +}); +test("fill_template changes only text slots and keeps the files", async () => { + const r = await fillTemplate({ template: "trend-portfolio-editor", format: "9:16", texts: { l2: "ANA PÉREZ" } }, trendFetch); + assert.equal(r.error, undefined); + assert.equal(r.composition.layers[1].props.text, "ANA PÉREZ"); + assert.equal(r.composition.assets.V1, big); + assert.deepEqual(r.composition.audio, trend.audio); + assert.deepEqual([r.composition.width, r.composition.height], [1080, 1920]); + assert.equal(trend.layers[1].props.text, "LI WON"); +}); +test("fill_template rejects unknown ids, media slots and templates without slots", async () => { + assert.match((await fillTemplate({ template: "trend-portfolio-editor", format: "9:16", texts: { l1: "x" } }, trendFetch)).error, /not a slot/); + assert.match((await fillTemplate({ template: "trend-portfolio-editor", format: "9:16", texts: { l4: "x" } }, trendFetch)).error, /replaces it in the Studio/); + assert.match((await fillTemplate({ template: "trend-portfolio-editor", format: "9:16", texts: { l2: "" } }, trendFetch)).error, /non-empty/); + assert.match((await fillTemplate({ template: "mix-promo-editorial", format: "9:16", texts: {} }, fake([]))).error, /no slots/); +});