Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/cleanup.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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) {
const secret = process.env.CRON_SECRET;
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 }));
}
10 changes: 8 additions & 2 deletions api/d.js
Original file line number Diff line number Diff line change
@@ -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"; }
}

Expand All @@ -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();
}
24 changes: 19 additions & 5 deletions api/mcp.js

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions api/media.js
Original file line number Diff line number Diff line change
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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/<upload>/<key> drop page for the user
* GET /api/media?upload=&key=&op=status which files arrived
* POST /api/media?upload=&key=&op=part&file=<id>&part=<n> one part (≤ 3 MB, raw bytes)
* POST /api/media?upload=&key=&op=done&file=<id>&parts=<n> 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("<p>Enlace de subida no válido o caducado (duran 24 h).</p>"); }
const base = `/api/media?upload=${encodeURIComponent(upload)}&key=${encodeURIComponent(key)}`;
res.statusCode = 200;
res.end(`<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sube tus archivos · HyperCodeMotion</title>
<style>:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0d0e14;color:#eceaf4;font:15px/1.5 system-ui,sans-serif}main{width:min(620px,92vw);padding:32px 0}h1{font-size:22px;margin:0 0 6px}p{color:#a9a7b8;margin:0 0 18px}ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}li{display:flex;align-items:center;gap:12px;padding:12px 14px;border:2px dashed #4b4d68;border-radius:12px;cursor:pointer;transition:.15s}li.on{border-color:#9b5cf6;background:#9b5cf611}li.ok{border-style:solid;border-color:#3c7a57}li b{font-family:ui-monospace,monospace}li span{flex:1;color:#a9a7b8;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}li i{font-style:normal;font-size:13px}.ok i{color:#7be0a3}.err{color:#ff8a8a}#all{margin-top:14px;border:2px dashed #4b4d68;border-radius:14px;padding:26px;text-align:center;cursor:pointer}</style></head><body><main>
<h1>Sube los archivos del proyecto</h1>
<p>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».</p>
<ul id="list">${s.files.map(f => `<li data-id="${esc(f.id)}" class="${f.received ? "ok" : ""}"><b>${esc(f.id)}</b><span>${esc(f.name || "")}</span><i>${f.received ? "recibido" : "soltar aquí"}</i><input type="file" hidden></li>`).join("")}</ul>
<label id="all"><input type="file" multiple hidden><b>Soltar varios a la vez</b></label>
<p id="msg"></p>
<script>
const base=${JSON.stringify(base)},P=${PART_BYTES},MAX=${MAX_ONE};
const msg=document.getElementById('msg');
async function send(li,file){
const id=li.dataset.id,st=li.querySelector('i');
if(file.size>MAX){st.textContent='pesa demasiado';st.className='err';return;}
const n=Math.max(1,Math.ceil(file.size/P));
try{
for(let i=0;i<n;i++){
st.textContent='enviando '+Math.round(i/n*100)+'%';st.className='';
const r=await fetch(base+'&op=part&file='+encodeURIComponent(id)+'&part='+i,{method:'POST',body:new Blob([await file.slice(i*P,(i+1)*P).arrayBuffer()])});
const j=await r.json().catch(()=>({}));if(!r.ok||!j.ok)throw new Error(j.error||('respuesta '+r.status));
}
const d=await fetch(base+'&op=done&file='+encodeURIComponent(id)+'&parts='+n,{method:'POST'});const j=await d.json().catch(()=>({}));
if(!d.ok||!j.ok)throw new Error(j.error||('respuesta '+d.status));
li.classList.add('ok');st.textContent='recibido';
if(!document.querySelector('#list li:not(.ok)'))msg.textContent='Listo. Vuelve al chat y escribe «listo».';
}catch(e){st.textContent=e.message;st.className='err';}
}
for(const li of document.querySelectorAll('#list li')){
const inp=li.querySelector('input');li.onclick=()=>inp.click();inp.onchange=()=>inp.files[0]&&send(li,inp.files[0]);
li.addEventListener('dragover',e=>{e.preventDefault();li.classList.add('on')});li.addEventListener('dragleave',()=>li.classList.remove('on'));
li.addEventListener('drop',e=>{e.preventDefault();li.classList.remove('on');e.dataTransfer.files[0]&&send(li,e.dataTransfer.files[0]);});
}
const all=document.getElementById('all'),ai=all.querySelector('input');
const many=fs=>{const free=[...document.querySelectorAll('#list li:not(.ok)')];[...fs].forEach((f,i)=>free[i]&&send(free[i],f));};
ai.onchange=()=>many(ai.files);all.addEventListener('dragover',e=>e.preventDefault());all.addEventListener('drop',e=>{e.preventDefault();many(e.dataTransfer.files);});
</script></main></body></html>`);
}
4 changes: 2 additions & 2 deletions skills/hypercodemotion/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions skills/hypercodemotion/references/motion-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading