diff --git a/.gitignore b/.gitignore index 9f7622b..512fa98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ .vercel +dist/ diff --git a/README.md b/README.md index 6fa6289..d69d27c 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,71 @@ Both links expire **24 hours** after they are created. Save the project from Stu - *“Make a 10-second explainer with three steps for brewing pour-over coffee, 16:9, and package it for HyperCodeMotion.”* - *“Which layer types and effects does HyperCodeMotion support?”* +## Install the skill (recommended) + +The connector only checks and packages a project. The **HyperCodeMotion skill** is what makes Claude ask for the canvas size, plan scenes and transitions, and follow the design guidance (typography, HTML scenes, motion, validation). A skill is a separate install: connecting the connector does **not** add it, and the connector cannot install it for you. Install both. + +Download: [`hypercodemotion-skill.zip`](https://github.com/jasp402/HyperCodeMotion-MCP/releases/download/skill-latest/hypercodemotion-skill.zip) (a rolling release, rebuilt from `main` by `.github/workflows/release-skill.yml`; its `SHA256SUMS` is next to it), or build it yourself from this repository (see [Build the ZIP](#build-the-zip)). + +### claude.ai (web) and Claude Desktop + +1. Turn on **Code execution and file creation** in **Settings → Capabilities** (skills need it; the setting is called *Create and edit files* in the newer layout). +2. Open **Customize → Skills**, click **+**, then **+ Create skill**, then **Upload a skill**. +3. Choose `hypercodemotion-skill.zip`. The skill appears in the list; make sure its toggle is on. +4. In a new chat, turn on the HyperCodeMotion connector as well and ask for a video. Claude should ask for the canvas size first (1080×1920, 1080×1080, 1080×1350, 1920×1080 or custom). + +Requirements Anthropic documents for the ZIP: the skill folder is the ZIP root (`hypercodemotion/SKILL.md`, not loose files), the folder name equals the skill `name`, `description` is at most 200 characters. `npm run build:skill` checks all three. + +Free accounts: Anthropic's Help Center (*Use skills in Claude* and *How to create custom skills*, read on 2026-10-24) states that skills are available on Free, Pro, Max, Team and Enterprise plans, and that on Free, Pro and Max you upload your own in Customize → Skills. On Team and Enterprise an owner can disable user-created skills. Other Anthropic pages have worded the plan list differently over time; if **Customize → Skills** is missing or greyed out, code execution is off (or your organization turned skills off), which is the case the Help Center describes. We have not tried an upload on a Free account ourselves. A skill uploaded on claude.ai does not appear in the API; each surface keeps its own skills. + +Claude Desktop uses the same Claude account and the same **Customize → Skills** screen as claude.ai. Anthropic's article does not describe a Desktop-specific route and we did not click through the Desktop UI, so treat the steps above as the expected route there. + +### Claude Code + +Any of these puts the skill folder in `~/.claude/skills/hypercodemotion/SKILL.md`: + +- **One command** (verified: installs to `~/.claude/skills/hypercodemotion`; needs Node 22.20 or newer): + + ```bash + npx skills add jasp402/HyperCodeMotion-MCP --skill hypercodemotion -a claude-code -g -y + ``` + +- **By hand** (clone or download this repository): + + ```bash + mkdir -p ~/.claude/skills + cp -r skills/hypercodemotion ~/.claude/skills/ + ``` + + Use `.claude/skills/` inside a project to install it for that project only. + +- **Through your account:** Claude Code v2.1.273 or newer syncs the skills you enabled on claude.ai when you sign in with that same account (not with an API key). Run `/skills` to see them under *claude.ai sync*. + +Then add the connector: `claude mcp add --transport http hypercodemotion https://mcp.hypercodemotion.com/mcp`. + +Restart the session and run `/skills` to confirm `hypercodemotion` is listed. We did not run Claude Code itself for this document: the install paths above were checked on disk and against Anthropic's documentation. We do not ship a Claude Code plugin or marketplace manifest. + +### Build the ZIP + +```bash +npm run build:skill # writes dist/hypercodemotion-skill.zip (Node only, no zip binary needed) +``` + +The script packs `skills/hypercodemotion` with the folder as ZIP root, in a reproducible order, and fails if `SKILL.md` would be rejected (name not equal to the folder name, description over 200 characters). `npm test` rebuilds the archive and reads it back with an independent ZIP reader (every file, size and CRC-32). + +### What you lose with the connector alone + +With only the connector, Claude still validates and packages projects, but the guidance that lives in the skill is missing: no canvas-size question (it may pick a format for you), no art-direction, typography or HTML-scene guidance, no motion and transition vocabulary beyond what `get_capabilities` returns, and no checklist before delivery. + +To keep that loss small, `get_capabilities` returns a short `start` guide (ask the canvas size, one scene per beat joined with transitions, deliver only the links) and the transition contract (`transitions`). Claude calls it before designing, and its result is text Claude always receives. We did **not** rely on the server `instructions` string returned by `initialize`: per community reports in [anthropics/claude-ai-mcp#93](https://github.com/anthropics/claude-ai-mcp/issues/93) claude.ai does not pass it to the model (Claude Code does), and in [#1105](https://github.com/anthropics/claude-ai-mcp/issues/1105) a reporter saw custom-connector tools reach the model as bare names until a tool search. These are third-party reports, not Anthropic documentation, and we have not reproduced them ourselves. + +Bigger options, not implemented (each adds always-loaded tokens or needs a decision): a `get_guide(topic)` tool that returns the skill's reference pages on demand, an MCP *prompt* the user picks from the menu, or MCP *resources* for the references. See [docs/CAPABILITY-GAP-AUDIT.md](docs/CAPABILITY-GAP-AUDIT.md). + ## Tools | Tool | What it does | Annotations | | --- | --- | --- | -| `get_capabilities` | Lists the formats, layer types, layer properties and effect presets that Studio supports, how to reference files the user keeps in Studio (photos, logo, video clips) by id, the screen rule that decides type size, a runtime layer that keeps an existing Lottie, glTF or GSAP file and seeks it, and the Studio revision they come from. | read-only | +| `get_capabilities` | Lists the formats, layer types, layer properties and effect presets that Studio supports, how to reference files the user keeps in Studio (photos, logo, video clips) by id, the screen rule that decides type size, a runtime layer that keeps an existing Lottie, glTF or GSAP file and seeks it, the scene transitions (`transitions`: types, `dur`, `dir`, `ease`), a short start guide (`start`) and the Studio revision they come from. | read-only | | `create_hcmotion` | Validates a finished composition and packages it as a `.hcmotion` file. Invalid input returns `created: false` with the errors to fix and stores nothing. Valid input returns `open` and `download` links (24 h). | write (creates a temporary file), not destructive | | `get_template` | Returns the full design of a Studio gallery template by its id and format (brief, scenes, HTML scene documents and layers), to use as a design reference. | read-only | | `validate_json` | Checks a composition, design or improvement patch and returns errors and warnings. Rejects measurable text that cannot fit the canvas and line-height below 1. It does not prove a correct render. | read-only | @@ -91,6 +151,7 @@ Files are served at `https://mcp.hypercodemotion.com/d/` and open in Studio ```bash npm test # unit and integration tests npm run test:live # smoke test against production +npm run build:skill # builds dist/hypercodemotion-skill.zip ``` GitHub Actions runs both. Neither guarantees full Studio rendering compatibility. @@ -101,7 +162,7 @@ The catalog is derived from Studio source files (`packages/core/src/schema.ts`, ### HyperCodeMotion Creative Skill -The repository also contains the [HyperCodeMotion Agent Skill](skills/hypercodemotion/SKILL.md), which guides AI assistants from brief to art direction, scenes, editable JSON and the `.hcmotion` handoff. It is distributed separately from the connector and does not activate just because the connector is connected. +The repository also contains the [HyperCodeMotion Agent Skill](skills/hypercodemotion/SKILL.md), which guides AI assistants from brief to art direction, scenes, editable JSON and the `.hcmotion` handoff. It is distributed separately from the connector and does not activate just because the connector is connected: see [Install the skill](#install-the-skill-recommended). `validate_json` also warns about scene and transition mistakes (`SCENE*` and `TRANSITION_*` codes: unknown type, first scene, missing `dur`, overlap). ## License diff --git a/api/mcp.js b/api/mcp.js index 4b605c5..31fe1db 100644 --- a/api/mcp.js +++ b/api/mcp.js @@ -18,7 +18,7 @@ async function createHcmotion(args){ return {created:true,download:file.download,open:file.open,expiresAt:file.expiresAt,bytes:bytes.length,errors:[],warnings:report.warnings}; } 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, and the Studio contract revision they come from.",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"}},layerProperties:{type:"object",additionalProperties:{type:"array",items:{type:"string"}}},presetSlots:{type:"object",additionalProperties:{type:"array",items:{type:"string"}}},userMaterials:{type:"string"},screen:{type:"string"},fullyValidated:{type:"boolean"}},required:["contractVersion","formats","layerProperties","presetSlots","screen","fullyValidated"]}}, +{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), 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"},screen:{type:"string"},fullyValidated:{type:"boolean"}},required:["contractVersion","formats","layerProperties","presetSlots","screen","fullyValidated"]}}, {name:"create_hcmotion",title:"Create HyperCodeMotion project file",description:"Validate a finished HyperCodeMotion composition and package it as a .hcmotion project file, the format HyperCodeMotion Studio opens. If the composition has problems, returns created: false with the errors to fix and stores nothing. On success, stores the file temporarily and returns an `open` link (opens the project in HyperCodeMotion Studio) and a `download` link; both expire 24 hours after creation. It does not render video or generate media.",annotations:{title:"Create HyperCodeMotion project file",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"The full composition (version, name, width, height, fps, duration, background, layers…), as an object or JSON-encoded string.",anyOf:[{type:"object"},{type:"string"}]},name:{type:"string",description:"Optional project name."}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{created:{type:"boolean"},download:{type:"string"},open:{type:"string"},expiresAt:{type:"string"},bytes:{type:"number"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["created","errors","warnings"]}}, {name:"get_template",title:"Get HyperCodeMotion template",description:"Return the full design of a template from the HyperCodeMotion Studio gallery, by its id and format: brief (palette, fonts, frame, rhythm), scenes with timings, and the composition with its HTML scene documents and layers. Use it as the design reference when asked to follow a template; it does not create or store anything.",annotations:{title:"Get HyperCodeMotion template",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{template:{type:"string",description:"Template id, such as \"mix-promo-editorial\"."},format:{type:"string",enum:TEMPLATE_FORMATS,description:"Aspect ratio of the video being made. Defaults to 16:9."}},required:["template"],additionalProperties:false},outputSchema:{type:"object",properties:{found:{type:"boolean"},template:{type:"string"},name:{type:"string"},format:{type:"string"},brief:{type:"string"},scenes:{type:"array",items:{type:"object"}},composition:{type:"object"},error:{type:"string"}},required:["found"]}}, {name:"validate_json",title:"Validate HyperCodeMotion JSON",description:"Check a composition (layers), design (elements), or improvement patch (add/change/remove/sfx) and return actionable errors and warnings. Rejects measurable text that cannot fit the canvas safe area, text that crosses the canvas, and line-height below 1. This is not proof of a successful render.",annotations:{title:"Validate HyperCodeMotion JSON",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"HyperCodeMotion JSON object or JSON-encoded string to validate.",anyOf:[{type:"object"},{type:"string"}]},composition:{type:"object",description:"Optional original composition used to resolve patch references and timings."}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{valid:{type:"boolean"},format:{type:"string"},fullyValidated:{type:"boolean"},mode:{type:"string"},contractVersion:{type:"string"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["valid","format","fullyValidated","mode","errors","warnings","contractVersion"]}}, diff --git a/docs/CAPABILITY-GAP-AUDIT.md b/docs/CAPABILITY-GAP-AUDIT.md new file mode 100644 index 0000000..c94d899 --- /dev/null +++ b/docs/CAPABILITY-GAP-AUDIT.md @@ -0,0 +1,137 @@ +# Capability gap audit: what Studio can do vs. what the model is told + +Scope: HyperCodeMotion Studio at `18a29cd` (read-only) against this repository at `747a15a` (plus this PR). +All token counts are **estimates** (tiktoken `cl100k_base`; Claude's tokenizer differs, usually a little higher). +Line numbers are Studio paths unless prefixed `mcp:`. + +Hard constraint behind every recommendation: the connector must stay usable on **free claude.ai accounts**, so the fixed cost +(tool descriptions + skill front matter, paid in every conversation) must not grow, and new knowledge must be **small, on demand and tested**. + +## 1. Short answer + +- Studio has far more motion knowledge than the model ever sees. It lives in **code and in the Studio's own prompt builders**, not in anything the MCP or the skill serves. +- The **"hundred powerful skills" are not in either repository.** See [section 2](#2-the-hundred-skills-were-they-found). +- The model sees: a list of property **names** per layer type (`get_capabilities`), ~7 k tokens of prose (skill + 5 references), and two example files. + It is not told what the names mean, which values are valid, or which combinations look good. +- The only way to see a *good* design is `get_template`, which returns a **whole composition: 6 k to 72 k tokens, median ≈ 24.8 k** (measured on the 39 vertical gallery files). Too expensive to use as a recipe on a free account. +- Fix in one line: a tiny always-loaded skill plus **on-demand, individually tested recipes** (200–600 tokens each) served by one parameterized tool, plus a short self-review checklist. Details in [section 4](#4-proposed-architecture). + +## 2. The "hundred skills": were they found? + +| Where I looked | Result | +| --- | --- | +| `HyperCodeMotion` (Studio) repository, every `SKILL.md` | **None** (`find . -name SKILL.md` outside `node_modules`). No `.claude/`, `.cursor/`, `.agents/` folders. | +| `HyperCodeMotion-MCP` repository | Exactly **one** skill: `skills/hypercodemotion` (SKILL.md + 5 references + 2 examples; 3 with this PR). | +| Mentions of HyperFrames in Studio | Only as *inspiration*: `packages/core/src/looks.ts:6`, `htmlprompt.ts:3`, `apps/studio/src/html/scene.ts:2`, `apps/studio/src/docs/guides.tsx:303`. No code or skill files imported from it. | +| Upstream HyperFrames (`heygen-com/hyperframes`, cloned to `/workspace/hf-src` for this audit) | **26** `SKILL.md` files (`hyperframes`, `hyperframes-cli`, `hyperframes-registry`, `remotion-to-hyperframes`, `slideshow`, `figma`, `media-use`, …). Third-party, not part of either repo, not 100. | +| Distilled knowledge | `looks.ts:6` says the 13 "looks" are *"inspired by public style catalogs (HyperFrames, bang-motion, motion-designer, myfskills, vertical-video-editing-skill, motion-graphic-skill), with own values and names"*. So the "skills" were turned into **data in Studio code** (13 looks, 11 UI kinds, springs, brief detectors), and the Studio's AI prompt carries them. The MCP never sees them. | + +Conclusion: nothing "hundred-sized" exists to expose. What exists is ~4 k lines of Studio TypeScript outside the audio engine (`looks.ts` 605, `ui.ts` 678, `easing.ts` 203, `uimotion.ts` 99, `choreo.ts` 161, `rhythm.ts` 211, `design.ts` 344, `plan.ts` 281, `prompt.ts` 576, `htmlprompt.ts` 219, `schema.ts` 400; plus `audio.ts` 1,252) and 9 documented recipes (`apps/studio/src/docs/recipes.ts`, 129 lines) of which 5 ship a real AI result (`docs/results/{A1,A2b,A3,UI1,HTML2}.json`). That is the right raw material for recipes. + +## 3. Gap table + +"Told" = what `get_capabilities` + SKILL.md + references say today. Cost = estimated tokens of the proposed fix, paid only when the model asks for it. + +| # | Studio capability (evidence) | What the model is told (evidence) | Effect on quality | Token-cheap fix | +| --- | --- | --- | --- | --- | +| G1 | **Scene transitions**: 9 types, `dir`, `ease` (`types.ts:67` (`SceneTransition`), `:154`, `render.ts:1733` `transitionAt`, `1876-1966` `drawTransition`) | Nothing. `composition.md:51` only says `scenes` exist ("sequential periods and transitions"). | Hard cuts everywhere. 36 of 39 gallery templates have `scenes`, only **1** (`workspace-saas`) has a `transition`, so the gallery does not teach it either. | **Done in this PR**: `transitions` in `get_capabilities` (≈ 130 tokens), a section in `motion-design.md`, a tested example, `TRANSITION_*` validator warnings. | +| G2 | **UI motion**: `ui` layer with 11 kinds (`ui.ts:20-32`), spring tables (`easing.ts:50`, `155`), camera `follow`, brief detector `uimotion.ts` | `get_capabilities` lists the `ui` property names (`kind,text,sub,icon,items,data,…`) but not the 11 `kind` values or what each does. No skill text mentions `ui` beyond the layer-type list (`composition.md:45`). | Model never builds toasts, islands, trackers, tab bars, charts, palettes. UI-style promos fall back to plain rects and text. | Recipe `ui-` (≈ 250 tokens each, from `docs/results/UI1.json`) + one line with the 11 kinds in the recipe index. | +| G3 | **13 looks** (`looks.ts:71-248`): palette, fonts, shape, deco, default motion, rules | Not exposed. The skill says "define palette, typography…" and leaves invention to the model. | Generic palettes and fonts; the "AI look". | `get_motion_recipe("look:")` ≈ 150 tokens each (id list ≈ 40 tokens in the index). | +| G4 | **Camera** layer: `x,y,zoom,rotation,shake,follow,fill,maxZoom,lag` (`schema.ts:227`, `render.ts`) | Names only (`get_capabilities`). Meaning, units, `follow` target format: undocumented. 5 of 39 templates use it. | No push-ins, parallax or shake. | Recipe `camera-push-in`, `camera-follow` (≈ 200 tokens). | +| G5 | **Text animation**: `anim` (chars/words/lines), `stagger`, `animDur`, `animEase`, `fromY/X/Scale/Rotation/Opacity/Blur`, `reveal`, `copies` (text props in `mcp: src/studio-contract.json`) | Names only. Skill/refs never mention `anim`, `stagger` or `reveal`. `fx` presets `letters`, `words`, `type` are listed in `motion-design.md`. | Kinetic typography is not attempted; titles just fade or rise. Only 8 of 39 templates use native `text` layers and few set `anim` (38 use `html` layers). | Recipes `text-letters-rise`, `text-words-stagger`, `text-type` (≈ 200 each), tested with `validateJson`. | +| G6 | **Audio**: `audio` structure, Tidal/Sonic Pi/Tone.js engines, `sfx`, `plan`/`markers` (`audio.ts`, `rhythm.ts`, `plan.ts`) | `composition.md:51` names `audio`, `plan`, `markers` and says "confirm its schema in Studio before authoring complex audio". `get_template` **strips `audio`** (`mcp: src/templates.js:18`). 35 of 39 templates carry audio. | No music or SFX in any generated project; a silent video. | Recipe `audio-basic` (shape + 2 working examples, ≈ 300 tokens); stop stripping `audio` in `get_template` or offer `include:["audio"]`. | +| G7 | **Runtime engines** Lottie / glTF / GSAP (`runtime.ts`) | Covered: SKILL.md:53, `composition.md:49`, capabilities text. | OK. 0 of 39 templates use it, so it is not exercised. | None. | +| G8 | **HTML layers** (default path), `data-hcm` pieces, `withFrame` iframe queue (`apps/studio/src/html/scene.ts:293-323`) | Well covered: `html-scenes.md`. | Good, but heavy: 70–80 % of output tokens are HTML escaped inside JSON, and a heavy first paint (see section 5). | Keep; add a "weight budget" rule (inlined fonts per piece) to `html-scenes.md`. | +| G9 | **Easings** (`EASINGS` + springs, `easing.ts:66`; unknown name falls back to `easeInOutCubic`, `:119`, `:143`) | Covered in `motion-design.md`. Unknown names silently become `easeInOutCubic`; validator does not check (`mcp: validator.js:88` `CONTRACT_NOT_CHECKED`). | Silent typos. | Validator warning `UNKNOWN_EASE` from the Studio list (≈ 0 tokens at runtime). | +| G10 | **Particles, cursor, code, svg, component** layers | Names only; no semantics. | Rarely used. Cursor and code are the building blocks of product demos. | Recipes `cursor-click`, `code-typing` (≈ 200 each). | +| G11 | **Gallery** (39 templates × 3 formats) | `get_template` returns everything (6 k–72 k tokens). | Too expensive to use as a reference on free accounts; models do not call it unless the user names an id. | Derive **recipes** (fragments) from the templates at build time; keep `get_template` for "follow this template". | +| G12 | **Validator scope**: `scenes`, camera, audio, `kf` easing names are unchecked (`mcp: validator.js:46`, `:88`) | Skill says what is and is not validated (`validation.md`). | Mistakes surface only when someone opens Studio. | This PR adds scene/transition warnings. Next: `UNKNOWN_EASE`, camera/audio shape checks. | +| G13 | **Loading a `.hcmotion` does not normalize or clamp transitions** (`hcmotion.ts:52` `decodeHcmotion` → `checkComposition` `:89`). Only the AI-prompt import path does (`prompt.ts:366-378` `cleanTransition`: `dur` 0.05–5, default 0.5). | n/a | A file with `dur: 0` or a bad `dir` opens but plays a hard cut, a static slide, or a wipe running top-down. | Validator warnings (done). Suggest the same `cleanTransition` call in `decodeHcmotion` (Studio change, not made here). | +| G14 | **Connector-only users get no guidance.** claude.ai does not pass `initialize.instructions` to the model (community reports `anthropics/claude-ai-mcp#93`, `#1105`; not Anthropic docs, not reproduced here). | `initialize.instructions` (263 tokens) is used only by Claude Code. | No canvas-size question, no art direction. | **Done**: `start` field in the `get_capabilities` result (≈ 70 tokens). | +| G15 | **Fixed cost**: the 4 publishing tools are ≈ 1,727 of 2,487 tool-description tokens (≈ 70 %) and only moderators use them. | Every user pays them in every conversation. | Wastes a free account's budget. | Move them to a second connector (moderators), or collapse them into one `template_admin(op, …)` tool. Saves ≈ 1,000 tokens per conversation: the budget that pays for a recipe tool. | + +## 4. Proposed architecture + +Principle: **the skill is a router, the MCP is the library.** Nothing big is loaded unless asked for. + +``` +Always loaded On demand (model decides) Gate +------------- ------------------------- ---- +SKILL.md front matter (52 tok) SKILL.md body (≈ 2.0 k tok) skill trigger +tool list (≈ 4.2 k tok) get_capabilities (≈ 1.8 k tok) before designing + get_motion_recipe(name) (150-600 tok) per effect the plan needs + references/*.md (0.4-1.8 k tok each) by topic + validate_json (+ review checklist) before delivery +``` + +### 4.1 `get_motion_recipe(name)` (one tool, small tested fragments) + +- Input `name`: one of an index returned in `get_capabilities.recipes` (about 40–60 ids, ≈ 60 tokens), or `"index"`. +- Output: a **JSON fragment** that drops into a composition (a `scenes` block, a layer, an `fx`, a camera `kf` track) plus 2–3 lines of "when to use / what to change", and `fits`: the canvas ratios it was tested on. +- Families (source in brackets): + - `transition-*` (the 9 types, with the typical `dur`/`dir`), [`render.ts` `drawTransition`] + - `text-reveal-*`, `kinetic-*` (letters, words, type, stagger), [`schema.ts` text props, gallery] + - `camera-*` (push-in, follow, shake), [`schema.ts`, templates with `camera`] + - `ui-*` (11 kinds), `device-mockup`, `chart-*`, [`ui.ts`, `docs/results/UI1.json`] + - `look-*` (13 looks as ≈ 150-token palettes with fonts and default motion), [`looks.ts`] + - `audio-*` (music bed + SFX), [`audio.ts`, templates] + - `loop-*`, `bg-*` (grain, grid, vignette, blobs), [`looks.ts` `LookDeco`] +- **Tested**: a recipe file is generated from a gallery template or a `docs/results` file by a script, then `npm test` runs every recipe through `validateJson` (no `UNKNOWN_*` warning, no errors) and, where possible, through the Studio's own `decodeHcmotion` (as done for the transition example in this PR with `tsx`). +- Cost: index ≈ 60 tokens inside `get_capabilities`; each call 150–600 tokens; a typical 15-second promo needs 3–5 calls ≈ 1–2 k tokens, versus 24.8 k for one `get_template`. +- Fixed cost of the tool itself: ≈ 120–180 tokens in `tools/list`, offset by G15 (≈ −1,000). + +### 4.2 Alternative: `search_motion_knowledge(query)` + +Free text instead of an id. More flexible but costs a second round trip and returns less predictable size. Use it **only if** ids prove too hard to discover; a closed index is cheaper and testable. + +### 4.3 Self-review step (quality checklist) + +A 10-line checklist (≈ 120 tokens), returned inside the `validate_json` result when `valid: true` and shown once: *canvas size confirmed; every scene has a focal layer; transitions vary and sit on every scene after the first; no hold longer than 3 s without motion; text contrast and safe area; one persistent element across scenes; audio present or intentionally absent; loop or end frame decided.* The model answers it in one line before calling `create_hcmotion`. The skill keeps no copy of it, so there is no always-loaded cost. + +### 4.4 Skill after the change (target ≤ 2 k tokens body) + +SKILL.md keeps: canvas-size question, the workflow, and *"call `get_capabilities`, then `get_motion_recipe` for each effect in your plan, then `validate_json`"*. References shrink to what is not data (HTML-scene rules, visual design principles). Everything that is a list of names or JSON moves into recipes, where it is tested. + +### 4.5 Token budget summary + +| Item | Today | Proposed | +| --- | --- | --- | +| Fixed: tool descriptions | 4,100 (list JSON) | ≈ 3,300 (after moving moderator tools out, + recipe tool) | +| Fixed: skill front matter | 68 | 52 (done) | +| `get_capabilities` result | 1,992 | ≈ 1,830 (done) + 60 index | +| Skill body + refs on trigger | 6,138 | ≈ 3,500 | +| Per recipe call | n/a | 150–600 | +| Reference template (`get_template`) | 6 k–72 k, median 24.8 k | unchanged, only for "follow template X" | + +## 5. Why S1's text did not render (item d) + +Artifacts: `/workspace/hcm-skilltest/sc1.hcmotion.json` (S1) and `sc2.hcmotion.json` (S2); montages `s1_montage.png`, `s2_montage.png`. +Observation: in every S1 frame the **native shapes render** while **all `html` layers (bases and text) are absent**; S2, built the same way, rendered its HTML. + +**Ruled out from code and artifacts** (confirmed by inspection): +- Part-name mismatch between `data-hcm` pieces and `html` layers: 24 of 24 match. +- Text colour or contrast against the background, shapes covering the text by z-order, transitions, `normalizeDoc` loose attributes, scripts, missing w/h. + +**Hypotheses** (none decisively confirmed; both need a browser run that was not done): +- **H1 (most likely, unverified): the capture was taken before the pieces finished rasterizing.** + Mechanism in code: HTML is laid out inside one hidden iframe through a **serial queue** (`apps/studio/src/html/scene.ts:293-323` `withFrame`); each piece is rasterized through SVG `foreignObject` with **fonts and stylesheets fetched and inlined into every piece's SVG**; S1 inlined **443,152 bytes of CSS vs 100,696 for S2 (4.4×)** across 24 pieces and 5 docs. `prepareAll` has `PREPARE_LIMIT = 30_000` (`apps/studio/src/html/init.ts:13`): playback unlocks after 30 s **even if pieces are pending**, and `drawHtmlPiece` (`render.ts:967`) draws nothing when the raster is not ready. A screenshot at that moment shows shapes only. +- **H2 (unverified): a raster or placement failure specific to S1's documents.** `pieceBox` (`scene.ts:381`, used at `:485`, `:585`) can return `null` and `htmlPlacement`/`applyPlacement` (`scene.ts:750`, `795`; `init.ts:87`) draw nothing when w or h ≤ 0; if `rasterize` returns `null` it would be retried every frame. +- **Minor difference:** S1 uses a `zoom` fx (amount 1.2); probably irrelevant. + +How to settle it (needs the Studio running): open `sc1.hcmotion`, wait more than 30 s, read `globalThis.__hcmHtml` (`htmlKeysOf`, raster state per piece). If pieces are ready after the wait, H1; if some stay `null`, H2. +Cheap mitigation regardless: a weight rule in `html-scenes.md` (few font families, no per-piece `` stylesheets) and a loading indicator in Studio. + +## 6. Roadmap, in priority order + +1. **Merge this PR**: install guide, ZIP build, transitions contract, `start` guide, `commonProperties` dedupe (≈ −160 tokens per `get_capabilities` call). +2. **Fix the fixed cost (G15)**: move or collapse the four publishing tools (≈ −1,000 tokens in every conversation). Prerequisite for 3. +3. **`get_motion_recipe` + 12 first recipes** (3 transitions, 3 text, 2 camera, 3 ui, 1 audio), generated from gallery/`docs/results`, each tested with `validateJson` and Studio `decodeHcmotion`. +4. **Review checklist** in the `validate_json` result (G12) and `UNKNOWN_EASE` warning (G9). +5. **Looks as recipes** (G3), then audio (G6): stop stripping `audio` in `get_template` or make it opt-in. +6. **Studio side** (not done here): call `cleanTransition` when decoding `.hcmotion` (G13); settle S1 H1/H2 and, if H1, add a "preparing pieces" state and lighter font inlining. +7. Shrink the skill body to the router described in 4.4 once recipes carry the data. + +## 7. What was verified and what was not + +Verified by running code in this PR: `npm test` (103 tests); the transition example passes `validateJson` with no `UNKNOWN_*`/`TRANSITION_*` warning and is accepted by the **live** `validate_json`; the encoded `.hcmotion` round-trips through the Studio's own `decodeHcmotion` + `checkComposition` (no errors) and `transitionAt` returns the expected scene, type and progress at t = 2.0, 2.3, 4.0, 4.25 (run with `tsx` from a scratch folder; Studio not modified); the skill ZIP passes Python `zipfile.testzip()`, `unzip -t` and an independent reader in `tests/skill-zip.test.js`; `npx skills add … -a claude-code -g -y` installs to `~/.claude/skills/hypercodemotion`. +Not verified: that the claude.ai Skills upload accepts the ZIP on a Free account (the Help Center says Free is supported; I did not upload); the Claude Desktop UI; Claude Code itself loading the skill; that claude.ai drops `instructions` and defers connector tools (third-party issue reports); how the new transitions *look* in Studio (code-level acceptance only, no browser); H1 and H2 above. diff --git a/package.json b/package.json index e6a75c7..5be7ae9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "test": "node --test", - "test:live": "node scripts/smoke.mjs" + "test:live": "node scripts/smoke.mjs", + "build:skill": "node scripts/build-skill-zip.mjs" }, "dependencies": { "@vercel/blob": "^2.8.2" diff --git a/scripts/build-skill-zip.mjs b/scripts/build-skill-zip.mjs new file mode 100644 index 0000000..6e0cded --- /dev/null +++ b/scripts/build-skill-zip.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Builds dist/hypercodemotion-skill.zip from skills/hypercodemotion with no dependencies + * (no `zip` binary needed: works on Windows, macOS and Linux with Node 18+). + * + * npm run build:skill -> dist/hypercodemotion-skill.zip + * node scripts/build-skill-zip.mjs [out.zip] + * + * Layout required by claude.ai: the skill folder is the ZIP root (hypercodemotion/SKILL.md, ...), + * the folder name equals the `name` in SKILL.md. The script also checks the SKILL.md frontmatter + * against the published limits (name <= 64 chars, description <= 200 chars) and fails if it breaks them. + */ +import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateRawSync } from "node:zlib"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +export const SKILL_NAME = "hypercodemotion"; +const skillDir = join(repo, "skills", SKILL_NAME); + +const table = Array.from({ length: 256 }, (_, n) => { for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; return n >>> 0; }); +export const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = table[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; + +/** Throws if SKILL.md would be rejected by claude.ai (limits from the Claude Help Center). */ +export function checkSkillMd(text, folder = SKILL_NAME) { + const m = /^---\r?\nname: (.+)\r?\ndescription: (.+)\r?\n---/.exec(text); + if (!m) throw new Error("SKILL.md must start with single-line `name:` and `description:` frontmatter"); + const [, name, description] = m; + if (name !== folder) throw new Error(`skill name "${name}" must equal the folder name "${folder}"`); + if (!/^[a-z0-9-]{1,64}$/.test(name) || /anthropic|claude/.test(name)) throw new Error("name: lowercase letters, numbers, hyphens; max 64; no 'anthropic'/'claude'"); + if (description.length > 200) throw new Error(`description is ${description.length} chars; claude.ai allows at most 200`); + return { name, description }; +} + +function* walk(dir) { + for (const entry of readdirSync(dir).sort()) { + if (entry === ".DS_Store" || entry === "__MACOSX") continue; + const p = join(dir, entry); + if (statSync(p).isDirectory()) yield* walk(p); else yield p; + } +} + +/** Returns the ZIP bytes (deterministic: fixed timestamp, sorted entries). */ +export function buildSkillZip() { + checkSkillMd(readFileSync(join(skillDir, "SKILL.md"), "utf8")); + const dosTime = 0, dosDate = (0 << 9) | (1 << 5) | 1; // 1980-01-01 + const parts = [], central = []; + let offset = 0; + for (const file of walk(skillDir)) { + const name = Buffer.from(SKILL_NAME + "/" + relative(skillDir, file).split(sep).join("/")); + const raw = readFileSync(file); + const data = deflateRawSync(raw, { level: 9 }); + const crc = crc32(raw); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(8, 8); + local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(name.length, 26); + const head = Buffer.alloc(46); + head.writeUInt32LE(0x02014b50, 0); head.writeUInt16LE((3 << 8) | 20, 4); head.writeUInt16LE(20, 6); head.writeUInt16LE(0x0800, 8); head.writeUInt16LE(8, 10); + head.writeUInt16LE(dosTime, 12); head.writeUInt16LE(dosDate, 14); head.writeUInt32LE(crc, 16); + head.writeUInt32LE(data.length, 20); head.writeUInt32LE(raw.length, 24); head.writeUInt16LE(name.length, 28); + head.writeUInt32LE(((0o100644) << 16) >>> 0, 38); head.writeUInt32LE(offset, 42); + parts.push(local, name, data); + central.push(head, name); + offset += local.length + name.length + data.length; + } + const cd = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(central.length / 2, 8); end.writeUInt16LE(central.length / 2, 10); + end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16); + return Buffer.concat([...parts, cd, end]); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const out = resolve(process.argv[2] ?? join(repo, "dist", "hypercodemotion-skill.zip")); + mkdirSync(dirname(out), { recursive: true }); + const zip = buildSkillZip(); + writeFileSync(out, zip); + console.log(`${out} (${zip.length} bytes)`); +} diff --git a/skills/hypercodemotion/SKILL.md b/skills/hypercodemotion/SKILL.md index b2365ea..ec02b5a 100644 --- a/skills/hypercodemotion/SKILL.md +++ b/skills/hypercodemotion/SKILL.md @@ -1,6 +1,6 @@ --- name: hypercodemotion -description: Create and edit motion graphics videos (launch promos, explainers, social reels, animated titles) as editable HyperCodeMotion projects. Asks the canvas size first, then guides art direction, scenes, motion and assets, and delivers a .hcmotion file through the HyperCodeMotion MCP. +description: Create motion graphics videos (promos, explainers, reels, titles) as editable HyperCodeMotion projects: asks canvas size, directs scenes and motion, delivers a .hcmotion via the HyperCodeMotion MCP. --- # HyperCodeMotion — Creative Composition Skill @@ -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. +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)). 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. 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. 7. **MCP validation** — The HyperCodeMotion MCP (`https://mcp.hypercodemotion.com/mcp`) is required. Call `get_capabilities` before selecting layer/effect fields; `validate_json` is optional for drafts. **Do not claim validation if no call occurred.** @@ -43,7 +43,7 @@ Load only the applicable reference files instead of pasting all reference conten ## Creative acceptance criteria - Clear narrative and design hierarchy; readable at target size. -- Deliberate transitions; motion supports meaning rather than decorating every element. +- Deliberate transitions: a `scenes` array with a `transition` on every scene after the first, varied by meaning rather than one effect everywhere; motion supports meaning rather than decorating every element. - Consistent colors, type, spacing and asset treatment across scenes. - No overlaps, cropped labels, flicker, unintentional empty frames or jumps in continuity (visually verify in Studio; do not assert without rendering). Type size yields to the canvas: see [composition.md](references/composition.md). - Headlines read as separate words: keep letter-spacing at or above `-0.03em` for big type (tighter makes «Escribe menos» look like one word). diff --git a/skills/hypercodemotion/examples/scene-transitions.json b/skills/hypercodemotion/examples/scene-transitions.json new file mode 100644 index 0000000..28c99b0 --- /dev/null +++ b/skills/hypercodemotion/examples/scene-transitions.json @@ -0,0 +1,210 @@ +{ + "version": 1, + "name": "Scene Transitions \u2014 Reference", + "width": 1080, + "height": 1920, + "fps": 30, + "duration": 6, + "background": "#101018", + "params": {}, + "scenes": [ + { + "id": "s1", + "name": "Too slow.", + "start": 0, + "end": 2 + }, + { + "id": "s2", + "name": "Make it move.", + "start": 2, + "end": 4, + "transition": { + "type": "push", + "dur": 0.6, + "dir": "right", + "ease": "easeInOutCubic" + } + }, + { + "id": "s3", + "name": "Ship it.", + "start": 4, + "end": 6, + "transition": { + "type": "zoom", + "dur": 0.5 + } + } + ], + "layers": [ + { + "id": "bg1", + "name": "Scene 1 background", + "type": "rect", + "start": 0, + "end": 2, + "props": { + "x": 540.0, + "y": 960.0, + "w": 1080, + "h": 1920, + "fill": "#101018" + } + }, + { + "id": "eyebrow1", + "name": "Scene 1 label", + "type": "text", + "start": 0.3, + "end": 1.9, + "props": { + "text": "01 \u00b7 PROBLEM", + "x": 540, + "y": 820, + "size": 44, + "color": "#F6F6F5" + }, + "fx": { + "in": { + "id": "fade", + "dur": 0.4, + "amount": 1 + } + } + }, + { + "id": "headline1", + "name": "Scene 1 headline", + "type": "text", + "start": 0.4, + "end": 1.9, + "props": { + "text": "Too slow.", + "x": 540, + "y": 960, + "size": 104, + "color": "#E8F27A" + }, + "fx": { + "in": { + "id": "rise", + "dur": 0.6, + "amount": 1 + } + } + }, + { + "id": "bg2", + "name": "Scene 2 background", + "type": "rect", + "start": 2, + "end": 4, + "props": { + "x": 540.0, + "y": 960.0, + "w": 1080, + "h": 1920, + "fill": "#1B1030" + } + }, + { + "id": "eyebrow2", + "name": "Scene 2 label", + "type": "text", + "start": 2.3, + "end": 3.9, + "props": { + "text": "02 \u00b7 IDEA", + "x": 540, + "y": 820, + "size": 44, + "color": "#F6F6F5" + }, + "fx": { + "in": { + "id": "fade", + "dur": 0.4, + "amount": 1 + } + } + }, + { + "id": "headline2", + "name": "Scene 2 headline", + "type": "text", + "start": 2.4, + "end": 3.9, + "props": { + "text": "Make it move.", + "x": 540, + "y": 960, + "size": 104, + "color": "#E8F27A" + }, + "fx": { + "in": { + "id": "rise", + "dur": 0.6, + "amount": 1 + } + } + }, + { + "id": "bg3", + "name": "Scene 3 background", + "type": "rect", + "start": 4, + "end": 6, + "props": { + "x": 540.0, + "y": 960.0, + "w": 1080, + "h": 1920, + "fill": "#0B2A2A" + } + }, + { + "id": "eyebrow3", + "name": "Scene 3 label", + "type": "text", + "start": 4.3, + "end": 5.9, + "props": { + "text": "03 \u00b7 RESULT", + "x": 540, + "y": 820, + "size": 44, + "color": "#F6F6F5" + }, + "fx": { + "in": { + "id": "fade", + "dur": 0.4, + "amount": 1 + } + } + }, + { + "id": "headline3", + "name": "Scene 3 headline", + "type": "text", + "start": 4.4, + "end": 5.9, + "props": { + "text": "Ship it.", + "x": 540, + "y": 960, + "size": 104, + "color": "#E8F27A" + }, + "fx": { + "in": { + "id": "rise", + "dur": 0.6, + "amount": 1 + } + } + } + ] +} diff --git a/skills/hypercodemotion/references/motion-design.md b/skills/hypercodemotion/references/motion-design.md index 84fc705..291278e 100644 --- a/skills/hypercodemotion/references/motion-design.md +++ b/skills/hypercodemotion/references/motion-design.md @@ -34,6 +34,29 @@ A layer has at most one effect per slot: `"fx": {"in": FxRef, "out": FxRef, "loo An `out` preset is the same movement as its entrance, played backwards. Type limits from `presets.ts`: `wipe` only on `image`, `video` and `html`; `type`, `letters` and `words` only on `text`; `draw` only on `rect`, `ellipse`, `polygon` and `svg`; `outline` and `neon` only on `text`, `rect`, `ellipse` and `polygon`. On another type Studio ignores the effect. +## Scene transitions + +Studio plays a transition when one scene hands over to the next. Declare `scenes` at the composition root (consecutive, never overlapping) and put a `transition` on every scene after the first: + +```json +"scenes": [ + {"id": "s1", "name": "Problem", "start": 0, "end": 2}, + {"id": "s2", "name": "Idea", "start": 2, "end": 4, "transition": {"type": "push", "dur": 0.6, "dir": "right", "ease": "easeInOutCubic"}}, + {"id": "s3", "name": "Result", "start": 4, "end": 6, "transition": {"type": "zoom", "dur": 0.5}} +] +``` + +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. +- `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. +- `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. + +Choose by meaning: `push` and `slide` for sequence or progress, `zoom` and `iris` to enter a detail, `fade` and `blur` for a calm change of topic, `wipe` for a reveal, `flip` for before and after. Vary them across the video, but keep one family so the edit feels designed. The MCP `validate_json` warns about the mistakes above (`TRANSITION_*` and `SCENE*` codes) but does not render: it cannot tell you the transition looks good. + ## Keyframes Use global-second `kf` for physical changes and exact timing. Each keyframe's `ease` is the curve **towards the next** keyframe (the last keyframe's `ease` is unused). Valid names, from Studio `easing.ts`: diff --git a/src/contract.js b/src/contract.js index d0f5b41..fd361c2 100644 --- a/src/contract.js +++ b/src/contract.js @@ -1,15 +1,28 @@ import contract from "./studio-contract.json" with { type: "json" }; import { validateComposition } from "./validator.js"; import { screenRule } from "./frame.js"; +import { TRANSITIONS, checkScenes } from "./transitions.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."; +/** 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."; + +/** Properties every drawable layer type shares. Listed once instead of once per type (13 copies of 15 names). */ +const COMMON = ["x","y","scale","scaleX","scaleY","rotation","opacity","depth","rotateX","rotateY","perspective","glow","glowColor","blur","blend"]; +const hasCommon = (props) => COMMON.every((k) => props.includes(k)); +const ownProperties = () => Object.fromEntries(Object.entries(contract.types).map(([type, props]) => [type, hasCommon(props) ? props.filter((k) => !COMMON.includes(k)) : props])); + export function getCapabilities() { return { contractVersion: "studio-snapshot-1", source: contract.source, sourceSchemaSha: contract.sourceSchemaSha, sourcePresetsSha: contract.sourcePresetsSha, formats: ["composition", "design", "improvement"], - layerProperties: contract.types, presetSlots: contract.presets, + commonProperties: COMMON, + layerProperties: ownProperties(), layerPropertiesNote: "Each type also accepts every commonProperties name, except camera and cursor, whose lists are complete.", + presetSlots: contract.presets, + start: START, + transitions: TRANSITIONS, userMaterials: USER_MATERIALS, screen: screenRule(), fullyValidated: false }; @@ -36,6 +49,7 @@ export function validateJson(input, options = {}) { } }; if (report.format === "composition") input.layers.forEach((v,i)=>check(v,"$.layers["+i+"]")); + if (report.format === "composition") notices.push(...checkScenes(input)); if (report.format === "improvement" && Array.isArray(input.add)) input.add.forEach((v,i)=>check(v,"$.add["+i+"]")); return {...report,contractVersion:"studio-snapshot-1",warnings:[...report.warnings,...notices]}; diff --git a/src/transitions.js b/src/transitions.js new file mode 100644 index 0000000..915b14b --- /dev/null +++ b/src/transitions.js @@ -0,0 +1,42 @@ +/** + * 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_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" }, + 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.", +}; + +const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x); +const num = (x) => typeof x === "number" && Number.isFinite(x); + +/** Warnings for `scenes` (never errors: Studio opens the file either way, but plays a hard cut or ignores the transition). */ +export function checkScenes(comp) { + const out = []; + const warn = (path, code, message) => out.push({ path, code, message }); + if (comp.scenes === undefined) return out; + if (!Array.isArray(comp.scenes)) return [{ path: "$.scenes", code: "SCENES_NOT_ARRAY", message: "scenes must be an array of {id,name,start,end,transition?}." }]; + comp.scenes.forEach((s, i) => { + const p = `$.scenes[${i}]`; + if (!isObj(s)) return warn(p, "SCENE_NOT_OBJECT", "A scene must be an object {id,name,start,end,transition?}."); + if (typeof s.id !== "string" || !s.id) warn(p + ".id", "SCENE_FIELDS", "A scene needs a string id."); + if (!num(s.start) || !num(s.end) || s.end <= s.start) warn(p, "SCENE_FIELDS", "A scene needs numeric start < end (seconds)."); + const prev = comp.scenes[i - 1]; + if (isObj(prev) && num(prev.end) && num(s.start) && s.start < prev.end - 1e-3) + warn(p + ".start", "SCENES_OVERLAP", "Scenes must be consecutive: this one starts before the previous one ends."); + if (s.transition === undefined) return; + const t = s.transition, tp = p + ".transition"; + if (!isObj(t)) return warn(tp, "TRANSITION_SHAPE", 'transition must be {"type":"fade","dur":0.5}.'); + if (!TRANSITION_TYPES.includes(t.type)) warn(tp + ".type", "TRANSITION_UNKNOWN_TYPE", `Studio plays a hard cut for this type. Use one of: ${TRANSITION_TYPES.join(", ")}.`); + if (i === 0 || (num(s.start) && s.start <= 0)) warn(tp, "TRANSITION_ON_FIRST_SCENE", "Studio ignores the transition of the first scene (nothing to come from)."); + if (!num(t.dur) || t.dur <= 0) warn(tp + ".dur", "TRANSITION_NO_DURATION", "dur must be a number of seconds above 0 or Studio ignores the transition."); + else if (num(s.start) && num(s.end) && t.dur > s.end - s.start + 1e-3) warn(tp + ".dur", "TRANSITION_LONGER_THAN_SCENE", "dur is longer than the scene: it would run into the next scene."); + if (t.dir !== undefined && !TRANSITION_DIRS.includes(t.dir)) warn(tp + ".dir", "TRANSITION_BAD_DIR", `dir must be one of ${TRANSITION_DIRS.join(", ")}; slide, push, wipe and flip do not move correctly otherwise.`); + }); + return out; +} diff --git a/tests/contract.test.js b/tests/contract.test.js index 7e3a177..c98ca99 100644 --- a/tests/contract.test.js +++ b/tests/contract.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import {getCapabilities,validateJson} from "../src/contract.js"; import handler from "../api/mcp.js"; const json={version:1,name:"Test",width:1080,height:1920,fps:30,duration:6,background:"#111",params:{},layers:[{id:"a",name:"A",type:"text",start:0,end:6,props:{text:"Hello"}}]}; -test("catalog is derived from Studio schema",()=>{const c=getCapabilities();assert.ok(c.layerProperties.text.includes("tracking"));assert.ok(c.layerProperties.rect.includes("opacity"));assert.ok(c.presetSlots.fade.includes("in"));}); +test("catalog is derived from Studio schema",()=>{const c=getCapabilities();assert.ok(c.layerProperties.text.includes("tracking"));assert.ok(c.commonProperties.includes("opacity"));assert.ok(c.layerProperties.rect.includes("w"));assert.ok(c.presetSlots.fade.includes("in"));}); test("improvement patches use correct format",()=>{const r=validateJson({add:[{id:"b",name:"B",type:"svg",z:"fondo",start:0,end:3,props:{icon:"martini"}}],sfx:[{t:0.5,sound:"whoosh"}]}, {composition:json});assert.equal(r.valid,true);assert.equal(r.format,"improvement");}); test("unsupported effect is reported, without claiming full validation",()=>{const r=validateJson({...json,layers:[{...json.layers[0],fx:{loop:{id:"flicker",dur:1}}}]});assert.ok(r.warnings.some(w=>w.code==="UNKNOWN_EFFECT"));assert.equal(r.fullyValidated,false);}); test("MCP initialize, tools/list and validation",async()=>{ diff --git a/tests/skill-zip.test.js b/tests/skill-zip.test.js new file mode 100644 index 0000000..e0dc9df --- /dev/null +++ b/tests/skill-zip.test.js @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { inflateRawSync } from "node:zlib"; +import { readFileSync, readdirSync } from "node:fs"; +import { buildSkillZip, checkSkillMd, crc32 } from "../scripts/build-skill-zip.mjs"; + +/** Minimal ZIP reader (central directory), independent from the writer. */ +function read(zip) { + const eocd = zip.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(eocd >= 0, "end of central directory"); + const n = zip.readUInt16LE(eocd + 10); + let p = zip.readUInt32LE(eocd + 16); + const files = {}; + for (let i = 0; i < n; i++) { + assert.equal(zip.readUInt32LE(p), 0x02014b50); + const method = zip.readUInt16LE(p + 10), crc = zip.readUInt32LE(p + 16), csize = zip.readUInt32LE(p + 20), size = zip.readUInt32LE(p + 24); + const nl = zip.readUInt16LE(p + 28), el = zip.readUInt16LE(p + 30), cl = zip.readUInt16LE(p + 32), off = zip.readUInt32LE(p + 42); + const name = zip.subarray(p + 46, p + 46 + nl).toString("utf8"); + assert.equal(zip.readUInt32LE(off), 0x04034b50); + const start = off + 30 + zip.readUInt16LE(off + 26) + zip.readUInt16LE(off + 28); + const data = method === 8 ? inflateRawSync(zip.subarray(start, start + csize)) : zip.subarray(start, start + csize); + assert.equal(data.length, size, name); + assert.equal(crc32(data), crc, name + " crc"); + files[name] = data; + p += 46 + nl + el + cl; + } + return files; +} + +test("skill ZIP has the skill folder as its root and every file intact", () => { + const files = read(buildSkillZip()); + const names = Object.keys(files); + assert.ok(names.every((n) => n.startsWith("hypercodemotion/")), names.join()); + assert.ok(names.includes("hypercodemotion/SKILL.md")); + const onDisk = (dir) => readdirSync("skills/hypercodemotion/" + dir).map((f) => `hypercodemotion/${dir}/${f}`); + for (const n of [...onDisk("references"), ...onDisk("examples")]) assert.ok(names.includes(n), n); + assert.deepEqual(files["hypercodemotion/SKILL.md"], readFileSync("skills/hypercodemotion/SKILL.md")); +}); +test("the ZIP build is reproducible", () => assert.deepEqual(buildSkillZip(), buildSkillZip())); +test("checkSkillMd rejects what claude.ai rejects", () => { + const md = (d, n = "hypercodemotion") => `---\nname: ${n}\ndescription: ${d}\n---\nbody`; + assert.doesNotThrow(() => checkSkillMd(md("ok"))); + assert.throws(() => checkSkillMd(md("x".repeat(201))), /200/); + assert.throws(() => checkSkillMd(md("ok", "other")), /folder/); + assert.throws(() => checkSkillMd("no frontmatter"), /frontmatter/); +}); diff --git a/tests/skill.test.js b/tests/skill.test.js index 9a306c9..40ccb0e 100644 --- a/tests/skill.test.js +++ b/tests/skill.test.js @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import {readFileSync,existsSync} from "node:fs"; import {validateJson} from "../src/contract.js"; +import {TRANSITION_TYPES} from "../src/transitions.js"; const root="skills/hypercodemotion/"; test("Skill manifest has valid name and description",()=>{ const s=readFileSync(root+"SKILL.md","utf8"); @@ -9,6 +10,17 @@ test("Skill manifest has valid name and description",()=>{ assert.ok(s.includes("MCP validation")); assert.match(s,/\.hcmotion/); }); +test("Skill description fits claude.ai's 200 character limit",()=>{ + const m=/^---\nname: (.+)\ndescription: (.+)\n---/.exec(readFileSync(root+"SKILL.md","utf8")); + assert.ok(m[2].length<=200,m[2].length+" chars"); + assert.ok(m[1].length<=64&&/^[a-z0-9-]+$/.test(m[1])); +}); +test("Skill documents scene transitions with the Studio's exact types",()=>{ + const doc=readFileSync(root+"references/motion-design.md","utf8"); + assert.ok(doc.includes("## Scene transitions")); + for(const t of TRANSITION_TYPES)assert.ok(doc.includes("`"+t+"`"),t); + assert.match(readFileSync(root+"SKILL.md","utf8"),/motion-design\.md#scene-transitions/); +}); test("Skill asks for the canvas size before designing",()=>{ const s=readFileSync(root+"SKILL.md","utf8"); const first=s.indexOf("1. **Canvas size first**"); @@ -19,7 +31,7 @@ test("Skill references exist",()=>{ for(const name of ["composition","visual-design","motion-design","html-scenes","validation"]) assert.ok(existsSync(root+"references/"+name+".md"),name); }); -for(const ex of ["editorial-launch","html-scene-launch"])test(`Skill example ${ex} passes MCP structural preflight`,()=>{ +for(const ex of ["editorial-launch","html-scene-launch","scene-transitions"])test(`Skill example ${ex} passes MCP structural preflight`,()=>{ const c=JSON.parse(readFileSync(root+"examples/"+ex+".json","utf8")); const out=validateJson(c); assert.equal(out.valid,true,JSON.stringify(out.errors)); diff --git a/tests/transitions.test.js b/tests/transitions.test.js new file mode 100644 index 0000000..dd61201 --- /dev/null +++ b/tests/transitions.test.js @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { getCapabilities, validateJson } from "../src/contract.js"; +import contract from "../src/studio-contract.json" with { type: "json" }; +import { TRANSITION_TYPES } from "../src/transitions.js"; + +const base = JSON.parse(readFileSync("skills/hypercodemotion/examples/scene-transitions.json", "utf8")); +const withScenes = (scenes) => validateJson({ ...base, scenes }); +const codes = (r) => r.warnings.map((w) => w.code); + +test("get_capabilities lists the transition contract and the start guide", () => { + const c = getCapabilities(); + assert.deepEqual(c.transitions.shape.type.split("|"), TRANSITION_TYPES); + assert.match(c.transitions.shape.dir, /enters from/); + assert.match(c.start, /canvas size/); +}); +test("common properties are listed once and nothing is lost", () => { + const c = getCapabilities(); + for (const [type, full] of Object.entries(contract.types)) { + const own = c.layerProperties[type]; + assert.deepEqual([...new Set([...own, ...(own.length === full.length ? [] : c.commonProperties)])].sort(), [...full].sort(), type); + } + assert.deepEqual(c.layerProperties.camera, contract.types.camera); + assert.equal(c.layerProperties.rect.includes("opacity"), false); +}); +test("a composition with valid transitions raises no UNKNOWN_ or TRANSITION_ warning", () => { + const r = validateJson(base); + assert.equal(r.valid, true); + assert.deepEqual(codes(r).filter((c) => /^UNKNOWN_|^TRANSITION_|^SCENE/.test(c)), []); +}); +test("every documented transition type is accepted", () => { + for (const type of TRANSITION_TYPES) { + const scenes = structuredClone(base.scenes); + scenes[1].transition = { type, dur: 0.5, dir: "left" }; + assert.deepEqual(codes(withScenes(scenes)).filter((c) => /^TRANSITION_/.test(c)), [], type); + } +}); +test("transition mistakes Studio would silently ignore are warned about", () => { + const mk = (i, t) => { const s = structuredClone(base.scenes); s[i].transition = t; return codes(withScenes(s)); }; + assert.ok(mk(1, { type: "dissolve", dur: 0.5 }).includes("TRANSITION_UNKNOWN_TYPE")); + assert.ok(mk(0, { type: "fade", dur: 0.5 }).includes("TRANSITION_ON_FIRST_SCENE")); + assert.ok(mk(1, { type: "fade" }).includes("TRANSITION_NO_DURATION")); + assert.ok(mk(1, { type: "fade", dur: 0 }).includes("TRANSITION_NO_DURATION")); + assert.ok(mk(1, { type: "fade", dur: 9 }).includes("TRANSITION_LONGER_THAN_SCENE")); + assert.ok(mk(1, { type: "slide", dur: 0.5, dir: "diagonal" }).includes("TRANSITION_BAD_DIR")); + assert.ok(mk(1, "fade").includes("TRANSITION_SHAPE")); +}); +test("overlapping or malformed scenes are warned about, warnings never invalidate", () => { + const s = structuredClone(base.scenes); s[1].start = 1.5; + const r = withScenes(s); + assert.ok(codes(r).includes("SCENES_OVERLAP")); + assert.equal(r.valid, true); + assert.ok(codes(validateJson({ ...base, scenes: "none" })).includes("SCENES_NOT_ARRAY")); +});