From 3f0638393356062562659172309f097e0564af87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:49:05 +0000 Subject: [PATCH] =?UTF-8?q?Medios=20dentro=20del=20.hcmotion=20(v2),=20tra?= =?UTF-8?q?nsici=C3=B3n=20glass,=20post-FX=20y=20texto=20girado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_hcmotion acepta media: [{id, url}] (enlace https público que el servidor descarga) o [{id, upload}] (archivos enviados con start_media_upload). Van en binario dentro del .hcmotion v2 (mismo formato que el Studio); máx. 150 MB por archivo y 200 MB por proyecto; el tipo se lee de los bytes. - start_media_upload: sesión de subida de 24 h con script curl por partes y una página donde el usuario suelta los archivos (/m//). El cron las borra. - /d/: lee el nombre de un v2 y envía los archivos grandes por trozos. - Transición glass y composition.post en get_capabilities, validador y skill. - textfit mide el texto girado con su caja girada (una palabra vertical a 90° ya no se rechaza como si fuera horizontal). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qqm6dfUskArmmLSYZhTNgR --- api/cleanup.js | 4 +- api/d.js | 10 +- api/mcp.js | 24 ++- api/media.js | 87 +++++++++ skills/hypercodemotion/SKILL.md | 4 +- .../references/motion-design.md | 4 +- src/contract.js | 6 +- src/files.js | 2 +- src/hcmotion.js | 86 ++++++++- src/media.js | 179 ++++++++++++++++++ src/textfit.js | 23 ++- src/transitions.js | 5 +- tests/contract.test.js | 4 +- tests/mcp-integration.test.js | 2 +- tests/media.test.js | 108 +++++++++++ vercel.json | 10 + 16 files changed, 529 insertions(+), 29 deletions(-) create mode 100644 api/media.js create mode 100644 src/media.js create mode 100644 tests/media.test.js diff --git a/api/cleanup.js b/api/cleanup.js index 83b0539..833e97c 100644 --- a/api/cleanup.js +++ b/api/cleanup.js @@ -1,5 +1,6 @@ import { sweep } from "../src/files.js"; import { sweepLimits } from "../src/community.js"; +import { sweepMedia } from "../src/media.js"; /** Daily cron: deletes expired .hcmotion files so the store does not fill up. */ export default async function handler(req, res) { @@ -7,7 +8,8 @@ export default async function handler(req, res) { if (!secret || req.headers?.authorization !== `Bearer ${secret}`) { res.statusCode = 401; return res.end("Unauthorized"); } const removed = await sweep(); const limits = await sweepLimits().catch(() => 0); + const media = await sweepMedia().catch(() => 0); res.statusCode = 200; res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ removed, limits })); + res.end(JSON.stringify({ removed, limits, media })); } diff --git a/api/d.js b/api/d.js index 955bf74..1718784 100644 --- a/api/d.js +++ b/api/d.js @@ -1,9 +1,10 @@ import { gunzipSync } from "node:zlib"; import { expired, pathOf, store, validId } from "../src/files.js"; +import { jsonPart } from "../src/hcmotion.js"; /** File name from the project's own name (links no longer carry it). */ function nameOf(bytes) { - try { return `${String(JSON.parse(gunzipSync(bytes.subarray(48))).name || "")}`.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "proyecto"; } + try { return `${String(JSON.parse(gunzipSync(jsonPart(bytes))).name || "")}`.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "proyecto"; } catch { return "proyecto"; } } @@ -25,5 +26,10 @@ export default async function handler(req, res) { res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Disposition", `attachment; filename="${name.endsWith(".hcmotion") ? name : name + ".hcmotion"}"`); res.setHeader("Content-Length", String(bytes.length)); - res.end(req.method === "HEAD" ? undefined : Buffer.from(bytes)); + if (req.method === "HEAD") return res.end(); + // projects with their media inside can weigh up to 200 MB: sent in pieces instead of one body + const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (buf.length <= 4 * 1024 * 1024 || typeof res.write !== "function") return res.end(buf); + for (let o = 0; o < buf.length; o += 1024 * 1024) if (!res.write(buf.subarray(o, o + 1024 * 1024))) await new Promise(r => res.once ? res.once("drain", r) : r()); + res.end(); } diff --git a/api/mcp.js b/api/mcp.js index 1803136..421e131 100644 --- a/api/mcp.js +++ b/api/mcp.js @@ -1,5 +1,6 @@ import {getCapabilities,validateJson,CHECKLIST} from "../src/contract.js"; -import {checkComposition,encodeHcmotion} from "../src/hcmotion.js"; +import {checkComposition,encodeHcmotion,TooLarge} from "../src/hcmotion.js"; +import {collectMedia,startUpload} from "../src/media.js"; import {saveFile} from "../src/files.js"; import {getTemplate,TEMPLATE_FORMATS} from "../src/templates.js"; import {getRecreationBrief,proposeTemplate,getProposalStatus,publishTemplate,MAX_ROUNDS} from "../src/community.js"; @@ -14,14 +15,26 @@ async function createHcmotion(args){ const errors=report.format!=="composition"?[{path:"$",code:"NOT_A_COMPOSITION",message:"create_hcmotion needs a full composition (version, width, height, fps, duration, layers)."}]:[...report.errors,...checkComposition(json)]; if(errors.length)return {created:false,errors,warnings:report.warnings}; if(typeof args.name==="string"&&args.name.trim())json={...json,name:args.name.trim().slice(0,120)}; - const bytes=await encodeHcmotion(json); + // the user's images, videos and audio travel inside the file (binary, max 200 MB) + const media=await collectMedia(args.media); + if(media.errors.length)return {created:false,errors:media.errors,warnings:report.warnings}; + const warnings=[...report.warnings,...unusedMedia(json,media.files)]; + let bytes; + try{bytes=await encodeHcmotion(json,media.files);} + catch(e){if(e instanceof TooLarge)return {created:false,errors:[{path:"$media",code:e.code,message:e.message}],warnings};throw e;} const file=await saveFile(bytes); - return {created:true,download:file.download,open:file.open,expiresAt:file.expiresAt,bytes:bytes.length,errors:[],warnings:report.warnings,checklist:CHECKLIST}; + return {created:true,download:file.download,open:file.open,expiresAt:file.expiresAt,bytes:bytes.length,media:media.files.map(f=>({id:f.id,type:f.mime,bytes:f.bytes.length})),errors:[],warnings,checklist:CHECKLIST}; +} +/** Media sent but no layer or HTML scene uses it (props.asset, asset: or the runtime's asset). */ +function unusedMedia(comp,files){ + const text=JSON.stringify(comp); + return files.filter(f=>!text.includes(`"${f.id}"`)&&!text.includes(`asset:${f.id}`)).map(f=>({path:"$media",code:"MEDIA_UNUSED",message:`${f.id} is packed but no layer uses it (props.asset = "${f.id}").`})); } const tools=[ {name:"get_capabilities",title:"HyperCodeMotion Capabilities",description:"List the HyperCodeMotion JSON formats, layer types and properties, and effect presets that HyperCodeMotion Studio supports, how to reference files the user keeps in Studio by id, the screen rule that decides type size (field screen: the canvas wins over a requested size), a runtime layer that keeps an existing Lottie, glTF or GSAP file and seeks it, scene transitions (field transitions), a short start guide (field start), the names code layers and components must not redeclare (field reservedCodeNames), and the Studio contract revision they come from. Call it before designing.",annotations:{title:"HyperCodeMotion Capabilities",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{},additionalProperties:false},outputSchema:{type:"object",properties:{contractVersion:{type:"string"},source:{type:"string"},sourceSchemaSha:{type:"string"},sourcePresetsSha:{type:"string"},formats:{type:"array",items:{type:"string"}},commonProperties:{type:"array",items:{type:"string"}},layerProperties:{type:"object",additionalProperties:{type:"array",items:{type:"string"}}},layerPropertiesNote:{type:"string"},start:{type:"string"},transitions:{type:"object"},presetSlots:{type:"object",additionalProperties:{type:"array",items:{type:"string"}}},userMaterials:{type:"string"},reservedCodeNames:{type:"object",properties:{rule:{type:"string"},codeLayer:{type:"array",items:{type:"string"}},component:{type:"array",items:{type:"string"}}}},screen:{type:"string"},fullyValidated:{type:"boolean"}},required:["contractVersion","formats","layerProperties","presetSlots","screen","fullyValidated"]}}, {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, professional glitch. 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:"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. Pass the user's images, videos and audio in `media` (public https links, or files sent with start_media_upload) and they travel inside the file, so the project opens complete. 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."},media:{type:"array",maxItems:40,description:"The user's images, videos, audio or fonts to pack inside the file (binary, max 200 MB in total). Each item: {id, url} with a public https link the server downloads, or {id, upload} with the upload code from start_media_upload once the file was received. Layers use it as props.asset = id (image, video, audio) or in an HTML scene.",items:{type:"object",properties:{id:{type:"string"},url:{type:"string"},upload:{type:"string"}},required:["id"],additionalProperties:false}}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{created:{type:"boolean"},download:{type:"string"},media:{type:"array",items:{type:"object",properties:{id:{type:"string"},type:{type:"string"},bytes:{type:"number"}}}},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:"start_media_upload",title:"Send files for a project",description:"Open an upload for the user's own images, videos, audio or fonts that are not at a public https link (attachments, files in your sandbox), so create_hcmotion can pack them inside the .hcmotion (binary, max 150 MB per file, 200 MB per project). Takes the files' ids (the ones layers will use as props.asset) and returns: script, a shell function to send each file in parts with curl (needs network access to mcp.hypercodemotion.com); part and done, the endpoints it uses (POST raw bytes of up to partBytes per part, then done); page, a link where the user drops the files in a browser; and upload, the code to put in create_hcmotion media as {id, upload}. Try the script first; if your shell cannot reach the host, give the user page and wait until they say the files are in. The upload lasts 24 hours.",annotations:{title:"Send files for a project",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false},inputSchema:{type:"object",properties:{files:{type:"array",minItems:1,maxItems:40,description:"One entry per file: id (letters, digits, . - _; what layers use as props.asset) and optional name (what the user calls it, shown on the drop page).",items:{type:"object",properties:{id:{type:"string"},name:{type:"string"}},required:["id"],additionalProperties:false}}},required:["files"],additionalProperties:false},outputSchema:{type:"object",properties:{upload:{type:"string"},page:{type:"string"},script:{type:"string"},part:{type:"string"},done:{type:"string"},status:{type:"string"},partBytes:{type:"number"},maxFileBytes:{type:"number"},expiresAt:{type:"string"},error:{type:"string"}}}}, {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. 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"]}}, @@ -46,7 +59,7 @@ export default async function handler(req,res){ const id=data?.id??null; if(data?.jsonrpc!=="2.0"||typeof data.method!=="string")return reply(res,400,err(id,-32600,"Expected a JSON-RPC 2.0 request with jsonrpc: \"2.0\" and a method.")); if(data.method.startsWith("notifications/")){res.statusCode=202;return res.end();} - if(data.method==="initialize")return reply(res,200,ok(id,{protocolVersion:"2025-03-26",capabilities:{tools:{}},serverInfo:{name:"hypercodemotion-mcp",title:"HyperCodeMotion MCP",version:"0.9.0",description:"Creates HyperCodeMotion .hcmotion project files from AI-generated composition JSON, with validation and layer, effect and format discovery.",websiteUrl:"https://github.com/jasp402/HyperCodeMotion-MCP"},instructions:"HyperCodeMotion Studio opens .hcmotion project files. get_capabilities lists the supported formats, layers and effects. get_template returns the full design of a gallery template by its id. create_hcmotion validates a finished composition and returns open and download links to its .hcmotion file (valid 24 hours); when created is false, its errors say what to fix. validate_json checks drafts, including whether measurable text fits the canvas (get_capabilities field screen). That check is not a render guarantee. A runtime layer (lottie, three or gsap) keeps the original file in assets and is only a seek contract; validate_json does not render it. HTML text that changes on every frame should be a text layer. When the user sends the link of a prompt-motion.com or oneshotted.io video together with a moderator code, with or without more words, they want it published as an official gallery template: call get_recreation_brief with the url and adminCode and follow its brief to the end without asking anything else. propose_template submits the recreation, which is rendered and compared with the original; get_proposal_status returns that comparison (send fixes with propose_template and the same code); publish_template commits it to the Studio code once it is faithful. Never repeat the moderator code back."})); + if(data.method==="initialize")return reply(res,200,ok(id,{protocolVersion:"2025-03-26",capabilities:{tools:{}},serverInfo:{name:"hypercodemotion-mcp",title:"HyperCodeMotion MCP",version:"0.9.0",description:"Creates HyperCodeMotion .hcmotion project files from AI-generated composition JSON, with validation and layer, effect and format discovery.",websiteUrl:"https://github.com/jasp402/HyperCodeMotion-MCP"},instructions:"HyperCodeMotion Studio opens .hcmotion project files. get_capabilities lists the supported formats, layers and effects. get_template returns the full design of a gallery template by its id. create_hcmotion validates a finished composition and returns open and download links to its .hcmotion file (valid 24 hours); when created is false, its errors say what to fix. The user\'s images, videos and audio go inside the file through create_hcmotion media: public https links directly, other files first through start_media_upload. validate_json checks drafts, including whether measurable text fits the canvas (get_capabilities field screen). That check is not a render guarantee. A runtime layer (lottie, three or gsap) keeps the original file in assets and is only a seek contract; validate_json does not render it. HTML text that changes on every frame should be a text layer. When the user sends the link of a prompt-motion.com or oneshotted.io video together with a moderator code, with or without more words, they want it published as an official gallery template: call get_recreation_brief with the url and adminCode and follow its brief to the end without asking anything else. propose_template submits the recreation, which is rendered and compared with the original; get_proposal_status returns that comparison (send fixes with propose_template and the same code); publish_template commits it to the Studio code once it is faithful. Never repeat the moderator code back."})); if(data.method==="ping")return reply(res,200,ok(id,{})); if(data.method==="tools/list")return reply(res,200,ok(id,{tools})); if(data.method==="tools/call"){ @@ -55,6 +68,7 @@ export default async function handler(req,res){ try{ if(name==="validate_json"&&!Object.hasOwn(args,"json"))return reply(res,200,err(id,-32602,"Missing required argument json: the HyperCodeMotion JSON to validate.")); if(name==="create_hcmotion"&&!Object.hasOwn(args,"json"))return reply(res,200,err(id,-32602,"Missing required argument json: the full composition to package.")); + if(name==="start_media_upload"){const output=await startUpload(args.files);return reply(res,200,ok(id,{content:[{type:"text",text:JSON.stringify(output)}],structuredContent:output,isError:!!output.error}));} if(name==="create_hcmotion"){const output=await createHcmotion(args);return reply(res,200,ok(id,{content:[{type:"text",text:JSON.stringify(output)}],structuredContent:output,isError:!output.created}));} // the reference frames and the comparison sheet travel as images, so Claude sees them const withImages=(output,failed)=>{const {_images=[],...rest}=output;return reply(res,200,ok(id,{content:[{type:"text",text:JSON.stringify(rest)},..._images.map(data=>({type:"image",data,mimeType:"image/jpeg"}))],structuredContent:rest,isError:failed(rest)}));}; diff --git a/api/media.js b/api/media.js new file mode 100644 index 0000000..f89dc42 --- /dev/null +++ b/api/media.js @@ -0,0 +1,87 @@ +import { putPart, finishFile, uploadStatus, PART_BYTES, MAX_ONE } from "../src/media.js"; + +const json = (res, status, payload) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", "no-store"); res.end(JSON.stringify(payload)); }; +const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +async function body(req) { + if (Buffer.isBuffer(req.body)) return req.body; + if (req.body instanceof Uint8Array) return Buffer.from(req.body); + const chunks = []; let len = 0; + for await (const part of req) { len += part.length; if (len > PART_BYTES) throw new Error("too large"); chunks.push(part); } + return Buffer.concat(chunks); +} + +/** + * The user's files for a project (see src/media.js). + * GET /m// drop page for the user + * GET /api/media?upload=&key=&op=status which files arrived + * POST /api/media?upload=&key=&op=part&file=&part= one part (≤ 3 MB, raw bytes) + * POST /api/media?upload=&key=&op=done&file=&parts= all parts of that file sent + */ +export default async function handler(req, res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + const url = new URL(req.url || "/", "http://x"); + const q = k => String(req.query?.[k] ?? url.searchParams.get(k) ?? ""); + const upload = q("upload"), key = q("key"), op = q("op"); + if (req.method === "OPTIONS") { res.statusCode = 204; return res.end(); } + if (req.method === "GET" && op === "status") { + const s = await uploadStatus(upload, key); + return s ? json(res, 200, s) : json(res, 404, { error: "No upload session with that key (they last 24 h)." }); + } + if (req.method === "GET") return page(res, upload, key); + if (req.method !== "POST") { res.statusCode = 405; return res.end(); } + if (op === "part") { + let bytes; + try { bytes = await body(req); } catch { return json(res, 413, { ok: false, error: `Each part must have at most ${PART_BYTES} bytes.` }); } + const r = await putPart(upload, key, q("file"), q("part"), new Uint8Array(bytes)); + return json(res, r.ok ? 200 : 400, r); + } + if (op === "done") { + const r = await finishFile(upload, key, q("file"), q("parts")); + return json(res, r.ok ? 200 : 400, r); + } + return json(res, 400, { ok: false, error: "op must be part, done or status." }); +} + +async function page(res, upload, key) { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + const s = await uploadStatus(upload, key); + if (!s) { res.statusCode = 404; return res.end("

Enlace de subida no válido o caducado (duran 24 h).

"); } + const base = `/api/media?upload=${encodeURIComponent(upload)}&key=${encodeURIComponent(key)}`; + res.statusCode = 200; + res.end(`Sube tus archivos · HyperCodeMotion +
+

Sube los archivos del proyecto

+

Suelta cada archivo en su casilla (o todos juntos abajo: se reparten en orden). Van dentro del .hcmotion; máx. ${Math.round(MAX_ONE / 1048576)} MB por archivo y 200 MB en total. Cuando estén todos en verde, vuelve al chat y escribe «listo».

+
    ${s.files.map(f => `
  • ${esc(f.id)}${esc(f.name || "")}${f.received ? "recibido" : "soltar aquí"}
  • `).join("")}
+ +

+
`); +} diff --git a/skills/hypercodemotion/SKILL.md b/skills/hypercodemotion/SKILL.md index 9153a02..4c50d65 100644 --- a/skills/hypercodemotion/SKILL.md +++ b/skills/hypercodemotion/SKILL.md @@ -20,7 +20,7 @@ Use this Skill when asked to create, improve, extend, combine, or troubleshoot e Accept a platform name as the answer («para Reels» means 9:16). Ask this together with any other blocking question, never as a second round. Use the chosen `width` and `height` everywhere: type sizes, margins and the safe area depend on it (see [composition.md](references/composition.md)). Never switch to another format later without asking. Do not ask when the size is already known: an existing project keeps its own `width` and `height`, a prompt copied from Studio states its format, and a gallery recreation follows its brief. 2. **Brief and inputs** — Identify purpose, audience, distribution format, duration, fps, language, brand, CTA (only when requested), narration and exact data. Analyze user-provided reference images/video, scripts, logos, visual assets and any existing JSON. Distinguish factual content from creative decisions. Ask only for information that blocks progress. 3. **Art direction** — Define visual concept, typography hierarchy, spacing grid, palette, contrast, material, rhythm, transition vocabulary and continuity. Reuse the actual reference's visual intent; do not invent a brand or factual measurements. When the request names a Studio gallery template (an id such as `mix-promo-editorial`), call `get_template` with that id and the video's format, and follow its design: palette, fonts, frame, scene system and motion; replace all its texts. -4. **Storyboard and scenes** — Create sequential beats with one communicative objective each. Decide which objects persist and transform between beats versus which appear only within one scene. Allocate time to reading and transitions; storyboard timing must fit total duration. Join the beats with Studio scene transitions (`scenes[i].transition`: `fade`, `slide`, `push`, `wipe`, `clockWipe`, `iris`, `zoom`, `flip`, `blur`; exact shape in [motion-design](references/motion-design.md#scene-transitions)). +4. **Storyboard and scenes** — Create sequential beats with one communicative objective each. Decide which objects persist and transform between beats versus which appear only within one scene. Allocate time to reading and transitions; storyboard timing must fit total duration. Join the beats with Studio scene transitions (`scenes[i].transition`: `fade`, `slide`, `push`, `wipe`, `clockWipe`, `iris`, `zoom`, `flip`, `blur`, `glass`; exact shape in [motion-design](references/motion-design.md#scene-transitions)). 5. **Composition plan** — Default to **HTML scenes**: one HTML document per scene in `docs`, each visible piece marked `data-hcm` and animated by its own `html` layer (see [html-scenes](references/html-scenes.md) and [the example](examples/html-scene-launch.json)). Use native layers (`text`, `rect`, `particles`…) when a piece must be edited as geometry or needs per-property keyframes. Plan layer IDs, order, scene boundaries, assets, effects and audio. For a distinctive look (riso print, springs, camera, audio, UI mockups, per-scene backgrounds, the 13 style looks) or the finish of a talking-head video (`autoedit`: captions with emphasis, zooms, motion screens, sound effects) or any **glitch** effect (call `get_motion_recipe("glitch")` before drawing one: bursts, RGB split, slices, stepped frames, clean resolve; never a plain flicker) call `get_motion_recipe` first: no name gives the index, a name gives validated JSON to adapt. 6. **JSON generation** — Produce **one complete, standalone composition** with `layers` at the root. To change an existing project, return the whole updated composition (keep unchanged layers, IDs and assets as they were), not a patch: a `.hcmotion` file can only carry a full composition. In `code` layers and `components[x].draw`, never declare a name the engine already provides (`ctx`, `S`, `H`, `t`, `lt`, `dur`, `w`, `h`, `p`, `color`, `amount`, `amp`, `beat`, `hit`, `audio`, `c` and the helpers such as `range`, `lerp`, `rand`; full list in [composition.md](references/composition.md#code-layers-and-components)): use `SEG`, `ranges`, `pts`. 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.** @@ -54,7 +54,7 @@ Load only the applicable reference files instead of pasting all reference conten ## Non-negotiable truthfulness -`validate_json` performs **structural preflight**. Unknown layer types, properties and effects come back only as warnings (`UNKNOWN_…`), which you must read and fix; easing and spring names are checked (`UNKNOWN_EASE`) but effect values are not (`CONTRACT_NOT_CHECKED`). It rejects measurable text that cannot fit the canvas (`get_capabilities` field `screen`), text that crosses the canvas, and `line-height` below 1. It returns `fullyValidated: false` and **cannot verify appearance or guarantee Studio import/rendering**. Images in a prompt are references; they are not automatically embedded in JSON. The exception is files the user lists by id (photos, cut-outs, a logo, videos kept in their Studio): reference those ids in `image` and `video` layers as [the contract](references/composition.md) describes. Never invent other asset IDs, live external image URLs, render results, tool calls, or successful tests. +`validate_json` performs **structural preflight**. Unknown layer types, properties and effects come back only as warnings (`UNKNOWN_…`), which you must read and fix; easing and spring names are checked (`UNKNOWN_EASE`) but effect values are not (`CONTRACT_NOT_CHECKED`). It rejects measurable text that cannot fit the canvas (`get_capabilities` field `screen`), text that crosses the canvas, and `line-height` below 1. It returns `fullyValidated: false` and **cannot verify appearance or guarantee Studio import/rendering**. Images in a prompt are references; they are not automatically embedded in JSON. The exception is files the user lists by id (photos, cut-outs, a logo, videos kept in their Studio): reference those ids in `image` and `video` layers as [the contract](references/composition.md) describes. Files the user gives **you** (attachments, public links) can travel inside the `.hcmotion`: choose an id, use it as `props.asset`, and pass the file to `create_hcmotion` `media` as `{id, url}` (public https) or `{id, upload}` after `start_media_upload` (its script sends files from your shell; if the shell cannot reach the host, give the user its drop page). Never paste base64 into `assets`. For a finished look over the whole frame (glow of lightning and sparks, chromatic aberration, vignette, grain) use `composition.post` (`get_capabilities` field `post`). Never invent other asset IDs, live external image URLs, render results, tool calls, or successful tests. ## Output contract diff --git a/skills/hypercodemotion/references/motion-design.md b/skills/hypercodemotion/references/motion-design.md index 291278e..7692ee0 100644 --- a/skills/hypercodemotion/references/motion-design.md +++ b/skills/hypercodemotion/references/motion-design.md @@ -48,9 +48,9 @@ Studio plays a transition when one scene hands over to the next. Declare `scenes Full working file: [scene-transitions.json](../examples/scene-transitions.json). From Studio `types.ts` and `render.ts`: -- `type`: `fade` (cross-dissolve), `slide` (new scene slides over the old one), `push` (new scene pushes the old one out), `wipe` (reveal edge), `clockWipe` (clockwise sweep from 12 o'clock), `iris` (circle opens from the centre), `zoom` (old scene grows and fades, new one settles in from 85%), `flip` (card flip: old scene squashes to a line, the new one opens), `blur` (cross-dissolve through a blur). An unknown `type` is a hard cut. +- `type`: `fade` (cross-dissolve), `slide` (new scene slides over the old one), `push` (new scene pushes the old one out), `wipe` (reveal edge), `clockWipe` (clockwise sweep from 12 o'clock), `iris` (circle opens from the centre), `zoom` (old scene grows and fades, new one settles in from 85%), `flip` (card flip: old scene squashes to a line, the new one opens), `blur` (cross-dissolve through a blur), `glass` (the old scene cracks from the impact, shatters into glass shards that refract it and fly off over the new one; best at 0.35–0.6 s on a hit). An unknown `type` is a hard cut. - `dur`: seconds, above 0 (typically 0.4–0.8, never longer than the scene). Studio ignores the transition when `dur` is 0 or missing. -- `dir`: `left`, `right`, `up` or `down` = the side the new scene enters **from**; used by `slide`, `push`, `wipe` and `flip` (flip uses only the axis). Default `right`. Leave it out for `fade`, `clockWipe`, `iris`, `zoom` and `blur`. An invalid value does not fall back to the default: `slide` stops moving and `wipe` runs top-down. +- `dir`: `left`, `right`, `up` or `down` = the side the new scene enters **from**; used by `slide`, `push`, `wipe` and `flip` (flip uses only the axis); for `glass` it is the edge the impact starts from (no `dir` = the centre). Default `right`. Leave it out for `fade`, `clockWipe`, `iris`, `zoom` and `blur`. An invalid value does not fall back to the default: `slide` stops moving and `wipe` runs top-down. - `ease`: any name from the list above; default `easeInOutCubic`. - It is an **entry** transition played at the new scene's `start`, so it belongs to the scene that arrives. It is ignored on the first scene. The outgoing scene stays frozen on its last frame during the transition: finish its motion before the cut. - Layers that belong to no scene (fixed layers under or over everything) are drawn outside the transition. Without them the scenes are composited opaque over `background`, so give each scene its own background layer or palette change. diff --git a/src/contract.js b/src/contract.js index 157cc23..b26dfee 100644 --- a/src/contract.js +++ b/src/contract.js @@ -6,7 +6,10 @@ import { checkEases } from "./easing.js"; import { checkReservedNames, RESERVED_RULE, CODE_RESERVED, COMPONENT_RESERVED } from "./reserved.js"; /** The user's own files («Tus materiales» in Studio): referenced by id, never uploaded here. */ -const USER_MATERIALS = "Files the user listed by id in their request (photos, cut-outs, a logo, videos) stay in Studio and are filled in by id when the project opens. Reference them with props.asset = : an image as an \"image\" layer (or \"> in an HTML scene), a video as a \"video\" layer whose props.from is the second of the file where the shown clip starts; the clip lasts end - start and plays without sound. Do not put them in \"assets\" or as URLs, and use only ids the user gave."; +const USER_MATERIALS = "Files the user listed by id in their request (photos, cut-outs, a logo, videos) stay in Studio and are filled in by id when the project opens. Reference them with props.asset = : an image as an \"image\" layer (or \"> in an HTML scene), a video as a \"video\" layer whose props.from is the second of the file where the shown clip starts; the clip lasts end - start and plays without sound. Files the user gave YOU (attachments, a public link) go inside the .hcmotion instead: pick an id, use it the same way in props.asset, and pass the file in create_hcmotion media as {id, url} (public https) or {id, upload} (after start_media_upload). Never paste base64 into assets: it costs the whole conversation; media sends the bytes."; + +/** composition.post (Studio: types.ts PostFx, render.ts applyPost). */ +const POST_FX = { where: "composition.post", shape: { bloom: "0-3 glow of the bright parts (lightning, sparks, glowing text)", bloomThreshold: "0-1, default 0.6", bloomRadius: "px, default 2.5% of the short side", aberration: "px the red and blue split at the edges", vignette: "0-1 darker corners", grain: "0-1 film grain" }, rules: "Applied to the whole frame after every layer, in preview and export; an engine effect, seen but not edited per layer. bloom, aberration, vignette and grain take a value, keyframes or an expression (raise aberration on a hit)." }; /** Where the connector-only user (no Skill installed) gets the essentials: claude.ai does not show `initialize.instructions`, but it does show tool results. */ const START = "Before designing, ask the user once for the canvas size unless they gave it: 9:16 1080x1920, 1:1 1080x1080, 4:5 1080x1350, 16:9 1920x1080 (or custom). Build one scene per beat in scenes[] and join them with transitions. Deliver only the open and download links from create_hcmotion, never the JSON. In code layers and components never declare a name the engine provides (S, H, ctx, t, w, h, helpers like range: field reservedCodeNames). For a look the basics do not give (riso print, springs, camera, audio, UI mockups, per-scene backgrounds, 13 style looks) call get_motion_recipe first."; @@ -39,6 +42,7 @@ export function getCapabilities() { start: START, transitions: TRANSITIONS, userMaterials: USER_MATERIALS, + post: POST_FX, reservedCodeNames: { rule: RESERVED_RULE, codeLayer: CODE_RESERVED, component: COMPONENT_RESERVED }, screen: screenRule(), fullyValidated: false }; diff --git a/src/files.js b/src/files.js index 853c152..2e86881 100644 --- a/src/files.js +++ b/src/files.js @@ -28,7 +28,7 @@ export async function store() { const r = await b.get(path, { access: "private" }); return r && r.statusCode === 200 ? new Uint8Array(await new Response(r.stream).arrayBuffer()) : null; }, - list: async cursor => b.list({ prefix: "files/", cursor, limit: 1000 }), + list: async (cursor, prefix = "files/") => b.list({ prefix, cursor, limit: 1000 }), del: paths => b.del(paths) }; } diff --git a/src/hcmotion.js b/src/hcmotion.js index 8de7389..787a9a9 100644 --- a/src/hcmotion.js +++ b/src/hcmotion.js @@ -3,19 +3,28 @@ * Same layout as packages/core/src/hcmotion.ts in the Studio repo (keep both in sync): * * 0 "HCM" magic (3 ASCII bytes) - * 3 version u8 (1) + * 3 version u8 (1 or 2) * 4 flags u8 (bit 0: gzip) * 5 reserved 3 zero bytes * 8 JSON size u32 LE (uncompressed) * 12 data size u32 LE (bytes after the header) * 16 SHA-256 32 bytes, of the data - * 48 data UTF-8 JSON (gzip when flagged) + * 48 data + * + * Version 1: data is the JSON (gzip when flagged). + * Version 2: the JSON and, after it, the project's files (images, videos, audio) as raw bytes: + * u32 LE gzip-JSON size · gzip JSON · u32 LE file count · + * per file: u16 LE + id (UTF-8) · u16 LE + MIME type · u32 LE + bytes + * Studio puts each file back in assets[id] as a dataURL. Version 1 is written when there are no files. + * A .hcmotion is never larger than 200 MB. */ +export const MAX_FILE_BYTES = 200 * 1024 * 1024; const MAGIC = [0x48, 0x43, 0x4d]; const HEAD = 48; const LAYER_TYPES = new Set(["text","rect","ellipse","polygon","particles","image","video","code","camera","svg","ui","cursor","html","component","runtime"]); const isObj = x => !!x && typeof x === "object" && !Array.isArray(x); const num = x => typeof x === "number" && Number.isFinite(x); +const mb = n => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`; async function gzip(bytes) { const out = new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))); @@ -45,17 +54,78 @@ export function checkComposition(c) { return out; } -export async function encodeHcmotion(comp) { - const json = new TextEncoder().encode(JSON.stringify(comp)); - const data = await gzip(json); +const DATA_URL = /^data:([\w.+-]+\/[\w.+-]+)(?:;[\w=.+-]+)*;base64,/; +function fromDataUrl(s) { + const m = typeof s === "string" && DATA_URL.exec(s); + if (!m) return null; + return { mime: m[1], bytes: new Uint8Array(Buffer.from(s.slice(m[0].length), "base64")) }; +} + +function container(version, jsonLen, data, hash) { const out = new Uint8Array(HEAD + data.length); const v = new DataView(out.buffer); out.set(MAGIC, 0); - v.setUint8(3, 1); + v.setUint8(3, version); v.setUint8(4, 1); - v.setUint32(8, json.length, true); + v.setUint32(8, jsonLen, true); v.setUint32(12, data.length, true); - out.set(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), 16); + out.set(hash, 16); out.set(data, HEAD); return out; } + +/** Error with a code the tools return as-is. */ +export class TooLarge extends Error { constructor(m) { super(m); this.code = "FILE_TOO_LARGE"; } } + +/** + * Packs the composition. `files` ([{id, mime, bytes}]) and any dataURL in comp.assets travel as raw + * bytes after the JSON (version 2). Throws TooLarge past `max` (200 MB). + */ +export async function encodeHcmotion(comp, files = [], { max = MAX_FILE_BYTES } = {}) { + const all = [...files]; + const kept = {}; + for (const [id, v] of Object.entries(comp.assets ?? {})) { + const f = fromDataUrl(v); + if (f && !all.some(x => x.id === id)) all.push({ id, ...f }); + else if (!f) kept[id] = v; + } + const enc = new TextEncoder(); + const sha = async d => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); + if (!all.length) { + const json = enc.encode(JSON.stringify(comp)); + const data = await gzip(json); + if (HEAD + data.length > max) throw new TooLarge(`The project would be ${mb(HEAD + data.length)}; a .hcmotion cannot exceed ${mb(max)}.`); + return container(1, json.length, data, await sha(data)); + } + const rest = { ...comp }; + if (Object.keys(kept).length) rest.assets = kept; else delete rest.assets; + for (const f of all) delete rest.assets?.[f.id]; + const json = enc.encode(JSON.stringify(rest)); + const gz = await gzip(json); + const metas = all.map(f => ({ id: enc.encode(f.id), mime: enc.encode(f.mime), f })); + let size = 8 + gz.length; + for (const m of metas) size += 8 + m.id.length + m.mime.length + m.f.bytes.length; + if (HEAD + size > max) { + const big = [...all].sort((a, b) => b.bytes.length - a.bytes.length).slice(0, 3).map(f => `${f.id} (${mb(f.bytes.length)})`).join(", "); + throw new TooLarge(`With its files the project would be ${mb(HEAD + size)}; a .hcmotion cannot exceed ${mb(max)}. Largest: ${big}. Send smaller or compressed versions.`); + } + const data = new Uint8Array(size); + const v = new DataView(data.buffer); + let o = 0; + v.setUint32(o, gz.length, true); data.set(gz, o += 4); o += gz.length; + v.setUint32(o, metas.length, true); o += 4; + for (const m of metas) { + v.setUint16(o, m.id.length, true); data.set(m.id, o += 2); o += m.id.length; + v.setUint16(o, m.mime.length, true); data.set(m.mime, o += 2); o += m.mime.length; + v.setUint32(o, m.f.bytes.length, true); data.set(m.f.bytes, o += 4); o += m.f.bytes.length; + } + return container(2, json.length, data, await sha(data)); +} + +/** The gzip JSON of a .hcmotion (version 1 or 2), for reading its name. */ +export function jsonPart(bytes) { + const d = bytes.subarray(HEAD); + if (bytes[3] < 2) return d; + const len = new DataView(d.buffer, d.byteOffset, d.byteLength).getUint32(0, true); + return d.subarray(4, 4 + len); +} diff --git a/src/media.js b/src/media.js new file mode 100644 index 0000000..4fb8b51 --- /dev/null +++ b/src/media.js @@ -0,0 +1,179 @@ +/** + * The user's images, videos and audio for a project, packed inside its .hcmotion (version 2). + * Two ways in, both read by create_hcmotion's `media`: + * - {id, url}: a public https link the server downloads. + * - {id, upload}: a file sent to an upload session (start_media_upload): by the caller's shell + * (curl, in parts that fit a function's request body) or by the user on the drop page. + * Sessions live in the private Blob store under media// and die after 24 h like the files. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { store, PUBLIC_URL, TTL_MS } from "./files.js"; +import { MAX_FILE_BYTES } from "./hcmotion.js"; + +export const PART_BYTES = 3_000_000; +export const MAX_ONE = 150 * 1024 * 1024; +const MAX_PARTS = Math.ceil(MAX_ONE / PART_BYTES); +const MAX_FILES = 40; +const ID = /^[A-Za-z0-9][\w.-]{0,63}$/; +const ALLOWED = /^(image\/(png|jpeg|webp|gif|avif|svg\+xml)|video\/(mp4|webm|quicktime)|audio\/(mpeg|mp4|wav|x-wav|ogg|webm|aac)|font\/(ttf|otf|woff2?))$/; + +export const validMediaId = id => typeof id === "string" && ID.test(id); +const newUpload = (now = Date.now()) => `u${now.toString(36)}-${randomBytes(6).toString("hex")}`; +const validUpload = u => typeof u === "string" && /^u[0-9a-z]{6,12}-[0-9a-f]{12}$/.test(u); +const ageOf = u => Date.now() - parseInt(u.slice(1).split("-")[0], 36); +const sameKey = (a, b) => typeof a === "string" && typeof b === "string" && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b)); +const metaPath = u => `media/${u}/meta.json`; +const partPath = (u, id, n) => `media/${u}/${id}/${n}`; +const getJson = async p => { const b = await (await store()).get(p); return b ? JSON.parse(Buffer.from(b).toString()) : null; }; +const putJson = async (p, v) => (await store()).put(p, new TextEncoder().encode(JSON.stringify(v))); + +/** What the first bytes say the file is (the type a client claims is not trusted). */ +export function sniff(b) { + const s = (o, n) => Buffer.from(b.subarray(o, o + n)).toString("latin1"); + if (b[0] === 0x89 && s(1, 3) === "PNG") return "image/png"; + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; + if (s(0, 4) === "RIFF" && s(8, 4) === "WEBP") return "image/webp"; + if (s(0, 4) === "RIFF" && s(8, 4) === "WAVE") return "audio/wav"; + if (s(0, 3) === "GIF") return "image/gif"; + if (s(4, 4) === "ftyp") { const brand = s(8, 4); return /avif|avis/.test(brand) ? "image/avif" : /^qt/.test(brand) ? "video/quicktime" : /M4A /.test(brand) ? "audio/mp4" : "video/mp4"; } + if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return "video/webm"; + if (s(0, 3) === "ID3" || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return "audio/mpeg"; + if (s(0, 4) === "OggS") return "audio/ogg"; + if (s(0, 4) === "wOF2") return "font/woff2"; + if (s(0, 4) === "wOFF") return "font/woff"; + if (s(0, 4) === "OTTO") return "font/otf"; + if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "font/ttf"; + if (/^\s*(<\?xml[^>]*>\s*)?]/i.test(s(0, 256))) return "image/svg+xml"; + return null; +} + +/** start_media_upload: an upload session for these ids. */ +export async function startUpload(files, { now = Date.now() } = {}) { + if (!Array.isArray(files) || !files.length || files.length > MAX_FILES) return { error: `files must list 1 to ${MAX_FILES} files: [{id, name?}].` }; + const ids = files.map(f => f?.id); + const bad = ids.find(id => !validMediaId(id)); + if (bad !== undefined) return { error: `Invalid id ${JSON.stringify(bad)}: use letters, digits, dot, dash or underscore (max 64).` }; + if (new Set(ids).size !== ids.length) return { error: "Each file needs a different id." }; + const upload = newUpload(now), key = randomBytes(16).toString("hex"); + await putJson(metaPath(upload), { key, created: now, files: files.map(f => ({ id: f.id, name: typeof f.name === "string" ? f.name.slice(0, 120) : "" })), done: {} }); + const base = `${PUBLIC_URL}/api/media?upload=${upload}&key=${key}`; + return { + upload, page: `${PUBLIC_URL}/m/${upload}/${key}`, partBytes: PART_BYTES, maxFileBytes: MAX_ONE, + part: `${base}&op=part&file=&part=`, done: `${base}&op=done&file=&parts=`, status: `${base}&op=status`, + script: `send() { id="$1"; f="$2"; rm -rf .hcm_parts; mkdir .hcm_parts; split -b ${PART_BYTES} -d -a 3 "$f" .hcm_parts/p; n=0; for p in .hcm_parts/p*; do curl -sf -H "content-type: application/octet-stream" --data-binary @"$p" "${base}&op=part&file=$id&part=$n" >/dev/null || return 1; n=$((n+1)); done; curl -sf -X POST "${base}&op=done&file=$id&parts=$n"; rm -rf .hcm_parts; }; send # once per file`, + expiresAt: new Date(now + TTL_MS).toISOString(), + }; +} + +async function session(upload, key) { + if (!validUpload(upload) || ageOf(upload) > TTL_MS) return null; + const m = await getJson(metaPath(upload)); + return m && sameKey(m.key, key) ? m : null; +} + +export async function putPart(upload, key, id, part, bytes) { + const m = await session(upload, key); + if (!m) return { ok: false, error: "No upload session with that key (they last 24 h): call start_media_upload again." }; + if (!m.files.some(f => f.id === id)) return { ok: false, error: `This session has no file ${JSON.stringify(id)}.` }; + const n = Number(part); + if (!Number.isInteger(n) || n < 0 || n >= MAX_PARTS) return { ok: false, error: `part must be 0 to ${MAX_PARTS - 1}.` }; + if (!bytes?.length || bytes.length > PART_BYTES) return { ok: false, error: `Each part must have 1 to ${PART_BYTES} bytes.` }; + if (n === 0 && !sniff(bytes)) return { ok: false, error: "That is not an image, video, audio or font file this editor opens (png, jpeg, webp, gif, avif, svg, mp4, webm, mov, mp3, m4a, wav, ogg, ttf, otf, woff)." }; + await (await store()).put(partPath(upload, id, n), bytes); + return { ok: true }; +} + +export async function finishFile(upload, key, id, parts) { + const m = await session(upload, key); + if (!m) return { ok: false, error: "No upload session with that key (they last 24 h): call start_media_upload again." }; + if (!m.files.some(f => f.id === id)) return { ok: false, error: `This session has no file ${JSON.stringify(id)}.` }; + const count = Number(parts); + if (!Number.isInteger(count) || count < 1 || count > MAX_PARTS) return { ok: false, error: `parts must be 1 to ${MAX_PARTS}.` }; + const s = await store(); + const first = await s.get(partPath(upload, id, 0)); + if (!first) return { ok: false, error: "Part 0 is missing: send it again, then call done again." }; + for (let i = 1; i < count; i++) if (!(await s.get(partPath(upload, id, i)))) return { ok: false, error: `Part ${i} is missing: send it again, then call done again.` }; + m.done[id] = { parts: count, mime: sniff(first) }; + await putJson(metaPath(upload), m); + const left = m.files.filter(f => !m.done[f.id]).map(f => f.id); + return { ok: true, received: id, waiting: left }; +} + +export async function uploadStatus(upload, key) { + const m = await session(upload, key); + if (!m) return null; + return { files: m.files.map(f => ({ id: f.id, name: f.name, received: !!m.done[f.id] })) }; +} + +/** Downloads a public https file (images, video, audio, fonts only), capped. */ +async function fromUrl(url, room, fetcher = fetch) { + let u; + try { u = new URL(url); } catch { return { error: "is not a valid URL" }; } + if (u.protocol !== "https:") return { error: "must be an https:// link" }; + if (/^(localhost|127\.|10\.|192\.168\.|169\.254\.|\[?::1\]?$)/i.test(u.hostname) || /\.(local|internal)$/i.test(u.hostname)) return { error: "must be a public link" }; + const r = await fetcher(u, { redirect: "follow", signal: AbortSignal.timeout(25000) }).catch(e => ({ ok: false, status: 0, e })); + if (!r.ok) return { error: `could not be downloaded (HTTP ${r.status || "error"}); if the site blocks servers, send it with start_media_upload` }; + const len = Number(r.headers.get("content-length") || 0); + if (len > Math.min(MAX_ONE, room)) return { error: `is ${(len / 1048576).toFixed(0)} MB, too large` }; + const bytes = new Uint8Array(await r.arrayBuffer()); + if (bytes.length > Math.min(MAX_ONE, room)) return { error: "is too large" }; + const mime = sniff(bytes); + if (!mime) return { error: "is not an image, video, audio or font file this editor opens" }; + return { mime, bytes }; +} + +/** + * create_hcmotion's `media`: [{id, url} | {id, upload}] → [{id, mime, bytes}] plus errors per item. + * The total stays under the 200 MB of a .hcmotion. + */ +export async function collectMedia(list, uploads = {}, { fetcher } = {}) { + const files = [], errors = []; + if (list === undefined) return { files, errors }; + if (!Array.isArray(list) || list.length > MAX_FILES) return { files, errors: [{ path: "$media", code: "MEDIA_SHAPE", message: `media must be an array of up to ${MAX_FILES} items: {id, url} or {id, upload}.` }] }; + let total = 0; + const seen = new Set(); + for (const [i, item] of list.entries()) { + const path = `$media[${i}]`; + const id = item?.id; + if (!validMediaId(id)) { errors.push({ path: path + ".id", code: "MEDIA_ID", message: "id: letters, digits, dot, dash or underscore (max 64); layers use it as props.asset." }); continue; } + if (seen.has(id)) { errors.push({ path: path + ".id", code: "MEDIA_ID", message: `id ${id} is listed twice.` }); continue; } + seen.add(id); + const room = MAX_FILE_BYTES - 4 * 1024 * 1024 - total; + let got; + if (typeof item.url === "string") got = await fromUrl(item.url, room, fetcher); + else if (typeof item.upload === "string") { + const m = validUpload(item.upload) && ageOf(item.upload) <= TTL_MS ? await getJson(metaPath(item.upload)) : null; + const done = m?.done?.[id]; + if (!m) got = { error: "names an upload session that does not exist or expired" }; + else if (!done) got = { error: `has not been received yet in upload ${item.upload} (send it, then call done)` }; + else { + const s = await store(); + const chunks = []; + let size = 0; + for (let n = 0; n < done.parts; n++) { const c = await s.get(partPath(item.upload, id, n)); if (!c) { got = { error: `lost part ${n}` }; break; } chunks.push(c); size += c.length; } + if (!got) { + if (size > room) got = { error: "is too large" }; + else { const bytes = new Uint8Array(size); let o = 0; for (const c of chunks) { bytes.set(c, o); o += c.length; } got = { mime: done.mime || sniff(bytes), bytes }; } + } + } + } else got = { error: "needs url (public https link) or upload (from start_media_upload)" }; + if (got.error) { errors.push({ path, code: "MEDIA_UNAVAILABLE", message: `${id} ${got.error}.` }); continue; } + if (!ALLOWED.test(got.mime)) { errors.push({ path, code: "MEDIA_TYPE", message: `${id} is ${got.mime}, which Studio does not open.` }); continue; } + total += got.bytes.length; + files.push({ id, mime: got.mime, bytes: got.bytes }); + } + return { files, errors }; +} + +/** Cron: deletes upload sessions older than 24 h. */ +export async function sweepMedia(now = Date.now()) { + const s = await store(); + let cursor, n = 0; + do { + const page = await s.list(cursor, "media/"); + const old = page.blobs.map(b => b.pathname).filter(p => { const u = p.split("/")[1]; return !validUpload(u) || now - parseInt(u.slice(1).split("-")[0], 36) > TTL_MS; }); + if (old.length) { await s.del(old); n += old.length; } + cursor = page.hasMore ? page.cursor : undefined; + } while (cursor); + return n; +} diff --git a/src/textfit.js b/src/textfit.js index 6fa3e16..be89e1f 100644 --- a/src/textfit.js +++ b/src/textfit.js @@ -133,8 +133,14 @@ function checkText(layer, path, W, H, fail, warn) { const scale = axis(props.scale, DEFAULTS.scale) * axis(props.scaleX, 1); const scaleY = axis(props.scale, DEFAULTS.scale) * axis(props.scaleY, 1); const blocks = variants.map((text) => blockSize(text, size, Math.max(lineHeight, 1), tracking)); - const boxW = Math.max(...blocks.map((b) => b.w)) * scale; - const boxH = Math.max(...blocks.map((b) => b.h)) * scaleY; + const textW = Math.max(...blocks.map((b) => b.w)) * scale; + const textH = Math.max(...blocks.map((b) => b.h)) * scaleY; + // rotated text (a vertical word down the side): what the screen has to hold is the turned box + const rots = rotationsOf(props.rotation); + if (rots === null) warn(`${path}.props`, "TEXT_FIT_UNEVALUATED", "rotation is an expression, so the turned size was not measured."); + const boxes = (rots ?? [0]).map((deg) => turned(textW, textH, deg)); + const boxW = Math.max(...boxes.map((b) => b.w)); + const boxH = Math.max(...boxes.map((b) => b.h)); if (boxW > frame.safeW + 1 || boxH > frame.safeH + 1) { fail(`${path}.props`, "TEXT_DOES_NOT_FIT", `This text is about ${Math.ceil(boxW)}×${Math.ceil(boxH)} px. The ${frame.name} safe area is ${frame.safeW}×${frame.safeH} px (canvas ${W}×${H}; starting title ${frame.title} px, not a minimum). Lower size or wrap lines with \\n. The screen wins over a requested size.`); } @@ -143,6 +149,19 @@ function checkText(layer, path, W, H, fail, warn) { placeBox(path, props, boxW, boxH, W, H, frame, fail, warn); } +/** The angles (degrees) a rotation takes; null for an expression. */ +function rotationsOf(value) { + const s = series(value); + if (s.expr) return null; + return s.values.length ? s.values : [0]; +} + +/** Bounding box of a w×h box turned by deg degrees. */ +function turned(w, h, deg) { + const a = (deg * Math.PI) / 180, c = Math.abs(Math.cos(a)), sn = Math.abs(Math.sin(a)); + return { w: w * c + h * sn, h: w * sn + h * c }; +} + function axis(value, fallback) { const s = series(value); if (s.expr || !s.values.length) return fallback; diff --git a/src/transitions.js b/src/transitions.js index 915b14b..97e6ae8 100644 --- a/src/transitions.js +++ b/src/transitions.js @@ -2,13 +2,14 @@ * Scene transitions as Studio plays them (Studio: packages/core/src/types.ts SceneTransition, * render.ts transitionAt / drawTransition). An ENTRY transition on scene i > 0, played at scene.start. */ -export const TRANSITION_TYPES = ["fade", "slide", "push", "wipe", "clockWipe", "iris", "zoom", "flip", "blur"]; +export const TRANSITION_TYPES = ["fade", "slide", "push", "wipe", "clockWipe", "iris", "zoom", "flip", "blur", "glass"]; export const TRANSITION_DIRS = ["left", "right", "up", "down"]; /** Compact contract shown by get_capabilities (kept short: it is paid for in every conversation that calls it). */ export const TRANSITIONS = { where: "composition.scenes[i].transition on every scene after the first; scenes {id,name,start,end} are consecutive and never overlap", - shape: { type: TRANSITION_TYPES.join("|"), dur: "seconds > 0, typically 0.4-0.8, at most the scene length", dir: "left|right|up|down = side the new scene enters from (slide, push, wipe, flip; default right)", ease: "easing name, default easeInOutCubic" }, + shape: { type: TRANSITION_TYPES.join("|"), dur: "seconds > 0, typically 0.4-0.8, at most the scene length", dir: "left|right|up|down = side the new scene enters from (slide, push, wipe, flip; default right); for glass, the edge the impact starts from (no dir = the centre)", ease: "easing name, default easeInOutCubic" }, + glass: "The outgoing scene cracks from the impact, breaks into glass shards that refract it, flash at the edges with colour fringing and fly off over the new scene: an engine effect, seen but not edited. Best at dur 0.35-0.6 on a hit.", rules: "Entry transition at scene.start; the outgoing scene is frozen at its last frame while it plays, so keep motion in it finished before the cut. Ignored on the first scene and when dur <= 0; an unknown type is a hard cut.", }; diff --git a/tests/contract.test.js b/tests/contract.test.js index b4c923f..2f07f2f 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","start_media_upload","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..d1ddcba 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","start_media_upload","get_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/media.test.js b/tests/media.test.js new file mode 100644 index 0000000..cdb5000 --- /dev/null +++ b/tests/media.test.js @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { gunzipSync } from "node:zlib"; +import handler from "../api/mcp.js"; +import media from "../api/media.js"; +import download from "../api/d.js"; +import { encodeHcmotion, jsonPart, TooLarge } from "../src/hcmotion.js"; +import { setStore } from "../src/files.js"; +import { collectMedia, sniff, PART_BYTES } from "../src/media.js"; +import { validateJson, getCapabilities } from "../src/contract.js"; +import { checkScenes } from "../src/transitions.js"; + +const files = new Map(); +setStore({ + put: async (p, b) => { files.set(p, typeof b === "string" ? new TextEncoder().encode(b) : b); }, + get: async p => files.get(p) ?? null, + list: async (_c, prefix = "files/") => ({ blobs: [...files.keys()].filter(k => k.startsWith(prefix)).map(pathname => ({ pathname })), hasMore: false }), + del: async ps => { for (const p of ps) files.delete(p); } +}); +const png = n => { const b = new Uint8Array(n); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); for (let i = 8; i < n; i++) b[i] = (i * 31) & 255; return b; }; +const comp = () => ({ version: 1, name: "Goku", width: 1080, height: 1920, fps: 30, duration: 4, background: "#000", params: {}, + layers: [{ id: "img", name: "Foto", type: "image", start: 0, end: 4, props: { asset: "goku-1", x: 540, y: 960, w: 1080, h: 1920 } }] }); +async function call(name, args) { + let raw; const res = { statusCode: 200, setHeader() {}, end(x) { raw = x; } }; + await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } } }, res); + return JSON.parse(raw).result.structuredContent; +} +async function hit(method, query, body) { + const res = { statusCode: 200, headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(x) { this.body = x; }, write(x) { (this.chunks ??= []).push(x); return true; } }; + await media({ method, url: "/api/media", query, body }, res); + return { status: res.statusCode, body: res.headers["Content-Type"]?.includes("json") ? JSON.parse(res.body) : res.body }; +} + +test("v2 layout: JSON, then the files as raw bytes; assets left out of the JSON", async () => { + const b = await encodeHcmotion(comp(), [{ id: "goku-1", mime: "image/png", bytes: png(5000) }]); + assert.equal(b[3], 2); + const json = JSON.parse(gunzipSync(jsonPart(b))); + assert.equal(json.assets, undefined); + assert.equal(json.name, "Goku"); + assert.ok(b.length > 5000 && b.length < 7000, "no base64"); + await assert.rejects(encodeHcmotion(comp(), [{ id: "goku-1", mime: "image/png", bytes: png(5000) }], { max: 3000 }), TooLarge); + assert.equal((await encodeHcmotion(comp()))[3], 1, "no files: still version 1"); +}); + +test("media by public url: downloaded, sniffed and refused when it is not media", async () => { + const fetcher = async u => String(u).includes("ok") ? new Response(png(900)) : new Response("", { headers: { "content-type": "text/html" } }); + const got = await collectMedia([{ id: "goku-1", url: "https://cdn.example.com/ok.png" }, { id: "x", url: "https://example.com/page" }, { id: "y", url: "http://example.com/a.png" }, { id: "z", url: "https://localhost/a.png" }], {}, { fetcher }); + assert.deepEqual(got.files.map(f => [f.id, f.mime, f.bytes.length]), [["goku-1", "image/png", 900]]); + assert.deepEqual(got.errors.map(e => e.path), ["$media[1]", "$media[2]", "$media[3]"]); +}); + +test("upload session: parts by curl or the drop page, then create_hcmotion packs it", async () => { + const up = await call("start_media_upload", { files: [{ id: "goku-1", name: "goku.png" }] }); + assert.match(up.page, /\/m\/u[0-9a-z]+-[0-9a-f]+\/[0-9a-f]{32}$/); + assert.match(up.script, /split -b 3000000/); + const key = up.page.split("/").pop(); + const q = (op, extra = {}) => ({ upload: up.upload, key, op, ...extra }); + assert.equal((await hit("GET", { upload: up.upload, key })).status, 200, "drop page"); + assert.equal((await hit("POST", q("part", { file: "goku-1", part: "0" }), Buffer.from("not an image"))).status, 400); + const img = png(PART_BYTES + 1000); + assert.equal((await hit("POST", q("part", { file: "goku-1", part: "0" }), Buffer.from(img.subarray(0, PART_BYTES)))).status, 200); + const early = await call("create_hcmotion", { json: comp(), media: [{ id: "goku-1", upload: up.upload }] }); + assert.equal(early.created, false, "not finished yet"); + assert.equal((await hit("POST", q("part", { file: "goku-1", part: "1" }), Buffer.from(img.subarray(PART_BYTES)))).status, 200); + const done = await hit("POST", q("done", { file: "goku-1", parts: "2" })); + assert.deepEqual(done.body, { ok: true, received: "goku-1", waiting: [] }); + assert.equal((await hit("POST", { ...q("part", { file: "goku-1", part: "0" }), key: "0".repeat(32) }, Buffer.from(img.subarray(0, 10)))).status, 400, "wrong key"); + const out = await call("create_hcmotion", { json: comp(), media: [{ id: "goku-1", upload: up.upload }] }); + assert.equal(out.created, true); + assert.deepEqual(out.media, [{ id: "goku-1", type: "image/png", bytes: img.length }]); + // the download serves the whole file, in pieces when it is large + const id = out.download.split("/").pop(); + const res = { headers: {}, setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, write(x) { (this.chunks ??= []).push(x); return true; }, end(x) { this.body = x; } }; + await download({ method: "GET", url: "/api/d", query: { id } }, res); + assert.equal(res.headers["content-disposition"], 'attachment; filename="goku.hcmotion"'); + assert.equal(Number(res.headers["content-length"]), out.bytes); +}); + +test("create_hcmotion warns about packed media no layer uses", async () => { + const out = await call("create_hcmotion", { json: comp(), media: [{ id: "spare", url: "https://bad host" }] }); + assert.equal(out.created, false); + assert.equal(out.errors[0].code, "MEDIA_UNAVAILABLE"); +}); + +test("sniff knows the formats Studio opens", () => { + assert.equal(sniff(png(20)), "image/png"); + assert.equal(sniff(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg"); + assert.equal(sniff(new Uint8Array([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d])), "video/mp4"); + assert.equal(sniff(new TextEncoder().encode("hello")), null); +}); + +test("rotated text is measured as the turned box", () => { + const c = comp(); + c.layers = [{ id: "w", name: "Word", type: "text", start: 0, end: 4, props: { text: "SON GOKU", size: 150, x: 150, y: 960, rotation: 90 } }]; + const v = validateJson(c); + assert.ok(!v.errors.some(e => e.code === "TEXT_OUTSIDE_CANVAS" || e.code === "TEXT_DOES_NOT_FIT"), JSON.stringify(v.errors)); + c.layers[0].props.rotation = 0; + assert.ok(validateJson(c).errors.some(e => e.code === "TEXT_OUTSIDE_CANVAS"), "unturned it does cross the canvas"); +}); + +test("glass transition and post are part of the contract", () => { + const c = { ...comp(), scenes: [{ id: "a", start: 0, end: 2 }, { id: "b", start: 2, end: 4, transition: { type: "glass", dur: 0.4 } }] }; + assert.deepEqual(checkScenes(c), []); + const cap = getCapabilities(); + assert.match(cap.transitions.shape.type, /glass/); + assert.equal(cap.post.where, "composition.post"); + assert.match(cap.userMaterials, /create_hcmotion media/); +}); diff --git a/vercel.json b/vercel.json index cb8a91a..fa46027 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,10 @@ { "version": 2, "rewrites": [ + { + "source": "/m/:upload/:key", + "destination": "/api/media?upload=:upload&key=:key" + }, { "source": "/subir/:id/:key", "destination": "/api/subir?id=:id&key=:key" @@ -28,7 +32,13 @@ ], "functions": { "api/mcp.js": { + "maxDuration": 120 + }, + "api/d.js": { "maxDuration": 60 + }, + "api/media.js": { + "maxDuration": 30 } }, "crons": [