diff --git a/package.json b/package.json index 47d2f5f..77b3ba1 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,12 @@ "video:render:product-hunt": "remotion render remotion/index.ts skillsboard-launch-product-hunt public/launch/skills-board-product-demo-product-hunt.mp4 --codec=h264 --crf=18 --pixel-format=yuv420p --gl=swangle --timeout=240000 --concurrency=2 --muted", "video:render:social": "remotion render remotion/index.ts skillsboard-launch-social public/launch/skills-board-product-demo.mp4 --codec=h264 --crf=18 --pixel-format=yuv420p --gl=swangle --timeout=240000 --concurrency=2 --muted", "video:render:team-loop": "remotion render remotion/index.ts skillsboard-team-loop public/launch/skills-board-team-loop.mp4 --codec=h264 --crf=18 --pixel-format=yuv420p --gl=swangle --timeout=240000 --concurrency=2 --muted", - "video:still:product-demo-poster": "remotion still remotion/index.ts skillsboard-launch-product-hunt public/launch/skills-board-product-demo-poster.jpg --frame=580 --image-format=jpeg --gl=swangle --timeout=240000", - "video:render:launch": "pnpm video:render:product-hunt && pnpm video:render:social && pnpm video:render:team-loop && pnpm video:still:product-demo-poster", + "video:render:mcp-setup": "remotion render remotion/index.ts skillsboard-mcp-setup public/launch/skills-board-mcp-setup.mp4 --codec=h264 --crf=18 --pixel-format=yuv420p --gl=swangle --timeout=240000 --concurrency=2 --muted", + "video:render:mcp-agent": "remotion render remotion/index.ts skillsboard-mcp-agent public/launch/skills-board-mcp-agent.mp4 --codec=h264 --crf=18 --pixel-format=yuv420p --gl=swangle --timeout=240000 --concurrency=2 --muted", + "video:still:product-demo-poster": "remotion still remotion/index.ts skillsboard-launch-product-hunt public/launch/skills-board-product-demo-poster.jpg --frame=580 --image-format=jpeg --gl=swangle --timeout=240000", + "video:still:mcp-setup-poster": "remotion still remotion/index.ts skillsboard-mcp-setup public/launch/skills-board-mcp-setup-poster.jpg --frame=306 --image-format=jpeg --gl=swangle --timeout=240000", + "video:still:mcp-agent-poster": "remotion still remotion/index.ts skillsboard-mcp-agent public/launch/skills-board-mcp-agent-poster.jpg --frame=700 --image-format=jpeg --gl=swangle --timeout=240000", + "video:render:launch": "pnpm video:render:product-hunt && pnpm video:render:social && pnpm video:render:team-loop && pnpm video:still:product-demo-poster", "db:generate": "drizzle-kit generate", "db:check": "drizzle-kit check && node scripts/check-schema-migrations.mjs", "db:migrate": "drizzle-kit migrate", diff --git a/remotion/mcp/content.ts b/remotion/mcp/content.ts new file mode 100644 index 0000000..f088dc5 --- /dev/null +++ b/remotion/mcp/content.ts @@ -0,0 +1,123 @@ +/** + * Every string the two MCP videos put on screen. + * + * The facts come out of this repository rather than out of a script: the config + * block is `plugin/mcp.json` verbatim, the endpoint is the one + * `getMcpResource()` returns in production, the scopes are the ones + * `app/api/[transport]/route.ts` checks before a write, and every tool name is + * registered in that route and listed on `/connect`. Only the example team, the + * teammate, and the example skill are invented, and none of them carries a + * product claim. + */ + +export const brand = { + wordmark: "Skills Board", + /** The category phrase, verbatim, as the marketing context pins it. */ + category: "the agent-native skills registry for teams", + domain: "www.skillsboard.sh", + licence: "Free forever. MIT.", +} as const; + +/** `plugin/mcp.json`, line for line. */ +export const configLines = [ + "{", + ' "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",', + ' "mcpServers": {', + ' "skills-board": {', + ' "type": "streamable-http",', + ' "url": "https://www.skillsboard.sh/api/mcp"', + " }", + " }", + "}", +] as const; + +/** The line the highlight lands on once the block is on screen. */ +export const CONFIG_URL_LINE = 5; + +export const setup = { + hook: { + lines: [ + ["Give", "your", "agent"], + ["your", "team's", "AI", "skills."], + ], + accentWord: "teams", + sub: "Paste the config. Approve access.", + }, + config: { + step: "Step 1", + heading: "Add the MCP server to your client", + file: "mcp.json", + caption: "Streamable HTTP, and no API key to copy.", + }, + consent: { + step: "Step 2", + heading: "Approve access in the browser", + client: "Claude Code", + title: "Claude Code wants to reach your team libraries", + scopes: [ + { name: "skills:read", detail: "List and search team skills and collections" }, + { name: "skills:write", detail: "Save skills and organize collections" }, + ], + approve: "Approve", + granted: "Access granted", + footer: "You sign in through your browser.", + }, + tools: { + step: "Done", + heading: "13 tools in your client", + writeBadge: "write", + }, +} as const; + +/** The 13 tools `app/api/[transport]/route.ts` registers, in registration order. */ +export const tools = [ + { name: "list_skills", write: false }, + { name: "search_skills", write: false }, + { name: "get_skill_command", write: false }, + { name: "discover_skills", write: false }, + { name: "discover_repository_skills", write: false }, + { name: "add_skill", write: true }, + { name: "list_collections", write: false }, + { name: "search_collections", write: false }, + { name: "get_collection_skills", write: false }, + { name: "get_collection_install_command", write: false }, + { name: "create_collection", write: true }, + { name: "add_skill_to_collection", write: true }, + { name: "remove_skill_from_collection", write: true }, +] as const; + +/** + * The example skill, its team, and the teammate who saved it are invented. The + * tool name, its argument shape, and the endpoint printed in the client header + * are the real ones. + */ +export const agentRun = { + client: "Claude Code", + server: "skills-board", + endpoint: "www.skillsboard.sh/api/mcp", + prompt: "draft the release notes for v2.4 the way our team does them", + call: 'search_skills({ query: "release notes" })', + hit: { + source: "github.com/northwind/agent-skills", + title: "release-notes", + description: "Turn merged pull requests into release notes grouped by user impact.", + tags: ["release", "docs"], + savedBy: "Marta R.", + team: "Northwind", + }, + note: "Group by user impact. Link the pull request on every entry.", + applyLine: "Skills Board carries the note your teammate saved.", + output: { + title: "## v2.4", + rows: [ + "Faster team library search · #1841", + "Collections in the sidebar · #1836", + "ZIP download fixed on Safari · #1852", + ], + done: "release notes drafted", + }, + reach: { + line: "The same saved skill, from every MCP client.", + clients: ["Claude Code", "Claude Desktop", "Cursor", "VS Code"], + }, +} as const; diff --git a/remotion/mcp/index.tsx b/remotion/mcp/index.tsx new file mode 100644 index 0000000..51171cc --- /dev/null +++ b/remotion/mcp/index.tsx @@ -0,0 +1,102 @@ +import { AbsoluteFill } from "remotion"; +import { linearTiming, TransitionSeries } from "@remotion/transitions"; +import { fade } from "@remotion/transitions/fade"; +import { focusPull } from "@/components/remocn/focus-pull"; +import { pushThrough } from "@/components/remocn/push-through"; +import { zoomBlur } from "@/components/remocn/zoom-blur"; +import "../product-demo/fonts"; +import { light } from "../product-demo/theme"; +import { ConfigScene } from "./scenes/config"; +import { ConsentScene } from "./scenes/consent"; +import { EndCardScene } from "./scenes/end-card"; +import { HookScene } from "./scenes/hook"; +import { ReachScene } from "./scenes/reach"; +import { SessionScene } from "./scenes/session"; +import { ToolsScene } from "./scenes/tools"; + +/** The thirty second cut of the agent video, built as a spot. */ +export { + MCP_AGENT_SPOT_DURATION, + MCP_AGENT_SPOT_POSTER_FRAME, + SkillsboardMcpAgentSpot, +} from "./spot"; + +export const MCP_FPS = 30; + +/** 1110 frames of scene, 88 of overlap. */ +export const MCP_SETUP_DURATION = 1022; +/** 1180 frames of scene, 44 of overlap. */ +export const MCP_AGENT_DURATION = 1136; + +/** Frames the two posters are cut from: the endpoint, and the finished session. */ +export const MCP_SETUP_POSTER_FRAME = 306; +export const MCP_AGENT_POSTER_FRAME = 700; + +function timing(durationInFrames: number) { + return linearTiming({ durationInFrames }); +} + +/** + * Connecting a client: the problem, the config, the authorization, the tools. + * Every fact on screen is read out of the repository, so the video and the + * product cannot drift apart without the source drifting first. + */ +export function SkillsboardMcpSetup() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * The connection at work: one task, one search against the team library, and + * the teammate's own note carried into the answer. It runs as a single session + * in a single window, on the same light stage as the setup video, so the search + * and the answer read as one job rather than as two examples. + */ +export function SkillsboardMcpAgent() { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/remotion/mcp/scenes/config.tsx b/remotion/mcp/scenes/config.tsx new file mode 100644 index 0000000..cd27420 --- /dev/null +++ b/remotion/mcp/scenes/config.tsx @@ -0,0 +1,38 @@ +import { interpolate } from "remotion"; +import { clamp, Scene, useSceneFrame } from "../../product-demo/stage"; +import { light } from "../../product-demo/theme"; +import { CONFIG_URL_LINE, setup } from "../content"; +import { Rise, SceneHead, t } from "../ui/chrome"; +import { ConfigBlock } from "../ui/code-block"; + +const REF = 300; +const { config } = setup; + +/** Step one: the block from `plugin/mcp.json`, pasted into the client. */ +export function ConfigScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + + return ( + +
+ + + + + + +
+ {config.caption} +
+
+
+ ); +} diff --git a/remotion/mcp/scenes/consent.tsx b/remotion/mcp/scenes/consent.tsx new file mode 100644 index 0000000..666b4ad --- /dev/null +++ b/remotion/mcp/scenes/consent.tsx @@ -0,0 +1,25 @@ +import { Scene, useSceneFrame } from "../../product-demo/stage"; +import { setup } from "../content"; +import { Rise, SceneHead } from "../ui/chrome"; +import { ConsentCard } from "../ui/consent-card"; + +const REF = 260; +const { consent } = setup; + +/** Step two: the browser authorization, on Skills Board's own tokens. */ +export function ConsentScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + + return ( + +
+ + + + +
+
+ ); +} diff --git a/remotion/mcp/scenes/end-card.tsx b/remotion/mcp/scenes/end-card.tsx new file mode 100644 index 0000000..d879436 --- /dev/null +++ b/remotion/mcp/scenes/end-card.tsx @@ -0,0 +1,66 @@ +import { interpolate } from "remotion"; +import { clamp, outEasing, Scene, useSceneFrame } from "../../product-demo/stage"; +import { displayTracking, light, MONO } from "../../product-demo/theme"; +import { BrandMark } from "../../product-demo/ui/atoms"; +import { brand } from "../content"; + +const REF = 160; + +/** The same close on both videos: mark, category, domain, licence. */ +export function EndCardScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + + return ( + +
+
+ + + {brand.wordmark} + +
+ +
+ {brand.category} +
+ +
+ {brand.domain} +
+ +
+ {brand.licence} +
+
+
+ ); +} diff --git a/remotion/mcp/scenes/hook.tsx b/remotion/mcp/scenes/hook.tsx new file mode 100644 index 0000000..4797b3d --- /dev/null +++ b/remotion/mcp/scenes/hook.tsx @@ -0,0 +1,78 @@ +import { Easing, interpolate } from "remotion"; +import { clamp, Scene, useSceneFrame } from "../../product-demo/stage"; +import { displayTracking, light } from "../../product-demo/theme"; +import { setup } from "../content"; + +const REF = 130; +const { hook } = setup; + +/** The problem, in one sentence, before anything about the product. */ +export function HookScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + let wordIndex = -1; + + return ( + +
+
+ {hook.lines.map((line) => ( +
+ {line.map((word, index) => { + wordIndex += 1; + const start = 8 + wordIndex * 4; + const accent = word.replace(/[^a-z]/gi, "") === hook.accentWord; + + return ( + + {word} + + ); + })} +
+ ))} +
+ +
+ {hook.sub} +
+
+
+ ); +} diff --git a/remotion/mcp/scenes/reach.tsx b/remotion/mcp/scenes/reach.tsx new file mode 100644 index 0000000..4d17316 --- /dev/null +++ b/remotion/mcp/scenes/reach.tsx @@ -0,0 +1,62 @@ +import { interpolate } from "remotion"; +import { clamp, outEasing, Scene, useSceneFrame } from "../../product-demo/stage"; +import { displayTracking, light, radius } from "../../product-demo/theme"; +import { agentRun } from "../content"; + +const REF = 250; +const { reach } = agentRun; + +/** One saved skill, reached from every client that speaks MCP. */ +export function ReachScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + + return ( + +
+
+ {reach.line} +
+ +
+ {reach.clients.map((client, index) => { + const reveal = interpolate( + frame, + [40 + index * 12, 62 + index * 12], + [0, 1], + outEasing, + ); + + return ( + + {client} + + ); + })} +
+
+
+ ); +} diff --git a/remotion/mcp/scenes/session.tsx b/remotion/mcp/scenes/session.tsx new file mode 100644 index 0000000..ade16e3 --- /dev/null +++ b/remotion/mcp/scenes/session.tsx @@ -0,0 +1,103 @@ +import { interpolate } from "remotion"; +import { clamp, outEasing, Scene, useSceneFrame } from "../../product-demo/stage"; +import { light } from "../../product-demo/theme"; +import { agentRun } from "../content"; +import { + BLOCK_HEIGHT, + CallRow, + Console, + CONSOLE_VIEWPORT, + HitCard, + NoteRow, + OutputBlock, + PromptRow, + STREAM_GAP, + STREAM_PAD_BOTTOM, + STREAM_PAD_TOP, +} from "../ui/agent-console"; + +const REF = 770; + +/** + * One session, one window, one continuous run of frames: the task is typed, the + * call goes out, the skill comes back and stays on screen while the note it + * carries turns into the answer. Nothing here cuts away or clears. + */ +const T = { + prompt: 34, + call: 178, + hit: 262, + note: 408, + output: 500, + caption: 656, +} as const; + +/** Typing speed in frames per character. The agent types faster than the user. */ +const PACE = { + prompt: 1.8, + call: 1.4, + output: 0.7, +} as const; + +const CONTENT_HEIGHT = + STREAM_PAD_TOP + + STREAM_PAD_BOTTOM + + BLOCK_HEIGHT.prompt + + BLOCK_HEIGHT.call + + BLOCK_HEIGHT.hit + + BLOCK_HEIGHT.note + + BLOCK_HEIGHT.output + + STREAM_GAP * 4; + +/** + * The column outruns the window by exactly the height of the first row and the + * gap under it, so the one scroll of the video parks the call at the top edge + * with the returned skill whole underneath it. + */ +const SCROLL = Math.max(0, CONTENT_HEIGHT - CONSOLE_VIEWPORT); + +export function SessionScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + const scroll = interpolate( + frame, + [588, 616, 626, 652], + [0, SCROLL / 2, SCROLL / 2, SCROLL], + outEasing, + ); + + return ( + +
+ + + = T.call && frame < T.hit} + frame={frame} + perChar={PACE.call} + /> + + + + + +
+ {agentRun.applyLine} +
+
+
+ ); +} diff --git a/remotion/mcp/scenes/tools.tsx b/remotion/mcp/scenes/tools.tsx new file mode 100644 index 0000000..8260443 --- /dev/null +++ b/remotion/mcp/scenes/tools.tsx @@ -0,0 +1,22 @@ +import { Scene, useSceneFrame } from "../../product-demo/stage"; +import { setup } from "../content"; +import { SceneHead } from "../ui/chrome"; +import { ToolGrid } from "../ui/tool-grid"; + +const REF = 260; + +/** The payoff: the tools the client now lists, by their registered names. */ +export function ToolsScene({ durationInFrames }: { durationInFrames: number }) { + const frame = useSceneFrame(REF, durationInFrames); + + return ( + +
+ + +
+
+ ); +} diff --git a/remotion/mcp/spot/beat-card-opens.tsx b/remotion/mcp/spot/beat-card-opens.tsx new file mode 100644 index 0000000..e288119 --- /dev/null +++ b/remotion/mcp/spot/beat-card-opens.tsx @@ -0,0 +1,342 @@ +import { GitForkIcon } from "lucide-react"; +import { + AbsoluteFill, + Easing, + interpolate, + interpolateColors, + useCurrentFrame, +} from "remotion"; +import { clamp } from "../../product-demo/stage"; +import { displayTracking, light, MONO, radius, SANS } from "../../product-demo/theme"; +import { Kicker, RAIL, snap, SPOT } from "./kit"; +import { agentRun, noteLines, skillFileHeading } from "./spot-content"; + +/** + * The card, opening into the file. + * + * This was two beats and a hard cut: the card the search came back with, then + * the page inside it. The cut was the weak seam in the film, because the two + * frames are one fact and the audience was made to take delivery of it twice + * (maintainer feedback, 31/08). So they are one beat now, and the join is a + * match on the thing both frames already had in common: the name of the skill. + * `release-notes` is a single element on a single trajectory. It sits in the + * card at title size and it arrives at the top of the page at heading size, + * green, with a hash in front of it. It is set in the mono face at both ends, + * which is a change to the card: the display face was tried first and had to + * cross over to mono somewhere in the middle of the move, and a face crossing + * over is two words in the same place, which is the one thing this beat is + * supposed to have got rid of. A skill name is a file name anyway, and the + * source and the tags on that card were already mono. Nothing swaps and nothing + * dissolves into a copy of itself: the object moves, the frame holds still, and + * everything that was card falls away around it. + * + * The layout is written out in absolute pixels rather than left to the browser, + * because a match cut is only as good as the two coordinates it joins, and a + * gap that resolves to a different number at render time would put the name + * somewhere the eye can watch it jump. + */ + +const { hit } = agentRun; + +/* Phase A: the card, at the size and the rail it always had. */ +const CARD_TOP = 279; +const CARD_PAD_TOP = 52; +const CARD_PAD_X = 56; +const CARD_WIDTH = 1440; +/** The row above the name, so the name's own top is a number and not a guess. */ +const SOURCE_HEIGHT = 40; +const TITLE_GAP = 20; +const TITLE_SIZE = 82; +/** The box the name is lifted out of, held open in the card while it travels. */ +const TITLE_BOX = 92; +const TITLE_LEFT = RAIL + CARD_PAD_X; +const TITLE_TOP = CARD_TOP + CARD_PAD_TOP + SOURCE_HEIGHT + TITLE_GAP; + +/* Phase B: the page, on the same rail, with the green rule for a margin. */ +const KICKER_TOP = 234; +const HEAD_TOP = 296; +const HEAD_SIZE = 44; +const COLUMN_LEFT = RAIL + 50; +/** Geist Mono is a 0.6em face, so `# ` is two of those in front of the name. */ +const HEAD_LEFT = COLUMN_LEFT + Math.round(2 * 0.6 * HEAD_SIZE); +const LINES_TOP = 373; +const RULE_HEIGHT = 485; +const CAPTION_TOP = 811; + +/* The clock: the card, then the move, then the page. */ +/** The frame the card starts opening on. */ +const OPEN = 88; +/** How long the name takes to cross: long enough to follow, short enough to cut. */ +const TRAVEL = 22; +/** + * The first instruction. It starts before the name has finished landing, on + * purpose: a page that waits politely for the heading to park reads as two + * events, and this beat is one. + */ +const LINES_AT = 106; +const CAPTION_AT = 142; + +/** The length of the whole beat, so the table upstairs and this clock agree. */ +export const CARD_OPENS_LENGTH = 210; + +/** + * The move: off one rest and onto another, decisive through the middle, no + * overshoot at either end. A spring here would read as a bounce, and nothing in + * this film bounces. + */ +const travel = { ...clamp, easing: Easing.bezier(0.5, 0, 0.12, 1) }; +/** Everything that leaves: slow to let go, then gone. */ +const leave = { ...clamp, easing: Easing.bezier(0.4, 0, 0.7, 1) }; + +export function CardOpensBeat() { + const frame = useCurrentFrame(); + + /** The card landing, at the top of the beat. */ + const enter = interpolate(frame, [5, 15], [0, 1], snap); + const drop = (1 - enter) * 30; + + /** The one trajectory. Position, size, and colour all ride on it. */ + const t = interpolate(frame, [OPEN, OPEN + TRAVEL], [0, 1], travel); + const nameLeft = interpolate(t, [0, 1], [TITLE_LEFT, HEAD_LEFT]); + const nameTop = interpolate(t, [0, 1], [TITLE_TOP, HEAD_TOP]); + const nameSize = interpolate(t, [0, 1], [TITLE_SIZE, HEAD_SIZE]); + const nameColour = interpolateColors(t, [0, 1], [light.foreground, light.primary]); + /** The hash arrives on the way, not on landing. */ + const hash = interpolate(frame, [OPEN + 9, OPEN + 17], [0, 1], snap); + + /** The card, going. The border and the paper go with it. */ + const chrome = interpolate(frame, [OPEN, OPEN + 12], [1, 0], leave); + /** + * Its rows collapse rather than fading where they stand, and all of them go + * the same way the name is going, so nothing crosses the name's path. + */ + const above = interpolate(frame, [OPEN, OPEN + 10], [0, 1], leave); + const below = interpolate(frame, [OPEN + 2, OPEN + 12], [0, 1], leave); + + /** The marker sweeps in under the teammate, and sweeps back out to leave. */ + const marker = + interpolate(frame, [40, 50], [0, 1], snap) * + interpolate(frame, [OPEN, OPEN + 10], [1, 0], leave); + + /** The green the marker gave up, drawing down the margin of the page. */ + const rule = interpolate(frame, [OPEN + 8, OPEN + 20], [0, 1], snap); + + /** The teammate, named again at the bottom of her own page. */ + const caption = interpolate(frame, [CAPTION_AT, CAPTION_AT + 9], [0, 1], snap); + + return ( + + + +
+
+ + {hit.source} +
+ + {/* The name is drawn over the card, not in it. Its box is held open here. */} +
+ +
+ {hit.description} +
+ +
+ {hit.tags.map((tag) => ( + + {tag} + + ))} +
+ +
+ + + + Saved by {hit.savedBy} in{" "} + {hit.team} + + +
+
+ +
+ SKILL.md +
+ +
+ + {/* The element that is in both frames, on its way between them. */} +
+ + {skillFileHeading.name} + + {skillFileHeading.hash} + + +
+ +
+ {noteLines.map((line, index) => { + const at = LINES_AT + index * 5; + const landed = interpolate(frame, [at, at + 9], [0, 1], snap); + const gap = line.key ? 12 : 14; + + return ( +
+ {line.text} +
+ ); + })} +
+ +
+ written by {hit.savedBy} +
+ + ); +} diff --git a/remotion/mcp/spot/beats-console.tsx b/remotion/mcp/spot/beats-console.tsx new file mode 100644 index 0000000..b959bac --- /dev/null +++ b/remotion/mcp/spot/beats-console.tsx @@ -0,0 +1,50 @@ +import { CheckIcon } from "lucide-react"; +import { interpolate, useCurrentFrame } from "remotion"; +import { clamp } from "../../product-demo/stage"; +import { light } from "../../product-demo/theme"; +import { Beat, Kicker, mono, snap } from "./kit"; +import { agentRun, spotClient } from "./spot-content"; + +/** + * The beat cut out of the window. + * + * The window itself gets six and a half seconds in the middle of the film, at + * which point everything in it is the right size to follow and the wrong size to + * read from the far side of a room. So the beat after it takes one object out of + * that window, sets it at billboard size against the paper, and holds it for one + * idea's worth of time. The strings are the ones the session uses. The skill + * that came back with the search is lifted the same way, in `beat-card-opens`. + */ + +const { output } = agentRun; + +/** + * The confirmation, alone on the frame. One line, and the whole point of the + * run, so it gets a cut of its own rather than a corner of the window it was + * lifted out of. + */ +export function DoneBeat() { + const frame = useCurrentFrame(); + const t = interpolate(frame, [6, 14], [0, 1], snap); + + return ( + + {spotClient} +
+ + + {output.done} + +
+
+ ); +} diff --git a/remotion/mcp/spot/beats-desktop.tsx b/remotion/mcp/spot/beats-desktop.tsx new file mode 100644 index 0000000..95092c1 --- /dev/null +++ b/remotion/mcp/spot/beats-desktop.tsx @@ -0,0 +1,400 @@ +import { ArrowUpIcon, CheckIcon, WrenchIcon } from "lucide-react"; +import type { CSSProperties, ReactNode } from "react"; +import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion"; +import { clamp } from "../../product-demo/stage"; +import { light, MONO, SANS } from "../../product-demo/theme"; +import { Land, snap, TypedLines } from "./kit"; +import { + agentRun, + desktop, + libraryHeading, + promptLines, + toolArgs, + toolName, +} from "./spot-content"; + +/** + * The one beat that shows the whole window. + * + * Every other beat in the spot is a fragment: one object lifted out of a client + * and set at billboard size, which is how a spot buys legibility. That trade has + * a cost, and the cost is that a viewer never sees where any of it happens. So + * the middle of the film gives the window back, whole, for six and a half + * seconds: the app the skill arrives in, the message a person actually sends, + * the tool block the client draws when an MCP server answers, and the composer + * at the bottom, which is the single element that makes a screenshot of this app + * recognisable from across a room. + * + * It is the only place in the film that leaves the brand palette. A chat client + * painted cream and green would be a mock of nothing; painted in its own warm + * ivory and terracotta it is a place, and the cut back to cream afterwards is + * what tells you the film is not the app. + */ + +/** The app's own colours, which are not the brand's. */ +const CLAUDE = { + surface: "#F4F3EE", + raised: "#FBFAF7", + sunken: "#EFEDE4", + composer: "#FFFFFF", + ink: "#1F1E1D", + muted: "#6E6B64", + faint: "#8D8981", + line: "#E3E0D6", + bubble: "#E9E6DC", + accent: "#D97757", +} as const; + +/** The window, inset far enough that the cream reads as a desk. */ +const WINDOW = { left: 108, top: 44, width: 1704, height: 992 } as const; +const TITLE_BAR = 78; +/** The conversation column, centred in the window the way the app centres it. */ +const COLUMN = 1360; + +const TRAFFIC = ["#FF5F57", "#FEBC2E", "#28C840"] as const; + +/** + * The sparkle in the title bar. Eight tapered rays out of one centre, which is + * the shape the app puts next to its name. + */ +function ClaudeMark({ size = 34 }: { size?: number }) { + return ( + + ); +} + +function TitleBar() { + return ( +
+
+ {TRAFFIC.map((dot) => ( + + ))} +
+ +
+ + + {desktop.app} + +
+
+ ); +} + +/** The pill the client puts around the server a tool belongs to. */ +function Chip({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const row: CSSProperties = { display: "flex", alignItems: "center" }; + +/** + * The tool block. Header with the tool, the server it came from and a state on + * the right; the arguments under it in a sunken row; the result once the state + * has flipped. + */ +function ToolCall({ done, spin }: { done: number; spin: number }) { + return ( +
+
+ + + {toolName} + + {agentRun.server} + + + {done > 0 ? ( + + ) : ( + + )} + + {done > 0 ? desktop.done : desktop.running} + + +
+ +
+ {toolArgs} +
+ +
+ + {agentRun.hit.title} + + + {libraryHeading} + +
+
+ ); +} + +/** Frames the three release note rows arrive on. */ +const ROW_AT = [102, 112, 122] as const; + +export function DesktopBeat() { + const frame = useCurrentFrame(); + const done = interpolate(frame, [74, 82], [0, 1], clamp); + + return ( + + + +
+ + +
+
+ +
+
+ +
+
+
+ + + + + + +
+ {agentRun.output.title} +
+
+ +
+ {agentRun.output.rows.map((line, index) => { + const at = ROW_AT[index]; + const t = interpolate(frame, [at, at + 7], [0, 1], snap); + + return ( +
+ + + {line} + +
+ ); + })} +
+ + +
+ + + {agentRun.output.done} + +
+
+
+
+ +
+
+ + {desktop.placeholder} + + + + +
+
+
+
+ ); +} diff --git a/remotion/mcp/spot/beats-library.tsx b/remotion/mcp/spot/beats-library.tsx new file mode 100644 index 0000000..4b305dd --- /dev/null +++ b/remotion/mcp/spot/beats-library.tsx @@ -0,0 +1,119 @@ +import { interpolate, useCurrentFrame } from "remotion"; +import { displayTracking, light, MONO, radius } from "../../product-demo/theme"; +import { Beat, display, Kicker, Land, snap } from "./kit"; +import { + agentRun, + libraryHeading, + librarySkillCount, + librarySkills, +} from "./spot-content"; + +/** + * The shelf. + * + * Without this beat the film is about release notes, because release notes is + * the only thing it ever shows. Five cards fix that in three seconds and without + * a sentence: a grid reads as a plural before anyone has finished the heading + * over it, and every card after this one is understood as one entry off a shelf + * rather than as the product. The names are set in the same mono the client uses + * for tool names, because that is what they are on disk. + * + * The first card is the one the rest of the film follows. It arrives with the + * others and then, once the grid has settled, takes a green edge, which is the + * whole handoff into the next cut. + */ + +/** Three across, so five entries leave one gap and the grid reads as unfinished. */ +const CARD = { width: 512, height: 168 } as const; + +export function LibraryBeat() { + const frame = useCurrentFrame(); + const mark = interpolate(frame, [46, 56], [0, 1], snap); + + return ( + + {agentRun.server} + +
+ +
{libraryHeading}
+
+ +
+ {librarySkillCount} +
+
+
+ +
+ {librarySkills.map((skill, index) => { + const lead = index === 0; + + return ( + +
+
+ {skill.name} +
+ +
+ + {skill.tag} + +
+ + {lead ? ( +
+ ) : null} +
+ + ); + })} +
+ + ); +} diff --git a/remotion/mcp/spot/beats-type.tsx b/remotion/mcp/spot/beats-type.tsx new file mode 100644 index 0000000..ed1653b --- /dev/null +++ b/remotion/mcp/spot/beats-type.tsx @@ -0,0 +1,209 @@ +import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion"; +import { displayTracking, light, MONO, radius, SANS } from "../../product-demo/theme"; +import { BrandMark } from "../../product-demo/ui/atoms"; +import { Beat, display, Land, RAIL, snap, SPOT } from "./kit"; +import { + agentRun, + applyLines, + brand, + endpointHeading, + hookLines, + reachLines, +} from "./spot-content"; + +/** + * The five beats that are pure type. + * + * These are the posters between the fragments: one sentence, set as large as + * the frame allows, on the same paper and the same rail as everything else. + * They land in a third of a second and then do not move again, because the cut + * is what carries the rhythm from here on. + */ + +/** The job, before any mention of the product. */ +export function HookBeat() { + return ( + + {hookLines.map((line, index) => ( + segment.text).join("")} + over={11} + > +
+ {line.map((segment) => ( + + {segment.text} + + ))} +
+
+ ))} +
+ ); +} + +/** What the run just proved, in the product's own words. */ +export function ApplyBeat() { + const [first, second] = applyLines; + const rest = first.slice(brand.wordmark.length); + + return ( + + +
+ {brand.wordmark} + {rest} +
+
+ +
{second}
+
+
+ ); +} + +/** The same skill, from anywhere that speaks the protocol. */ +export function ReachBeat() { + const frame = useCurrentFrame(); + + return ( + +
+ {reachLines.map((line, index) => ( + +
{line}
+
+ ))} +
+ +
+ {agentRun.reach.clients.map((client, index) => { + const at = 30 + index * 6; + const t = interpolate(frame, [at, at + 8], [0, 1], snap); + + return ( + + {client} + + ); + })} +
+
+ ); +} + +/** + * The product, which is a URL. The one inverted frame in the spot: a plate of + * brand green wipes across the lower half and the endpoint sits on it in paper + * white, big enough to read off a phone at arm's length. + */ +export function EndpointBeat() { + const frame = useCurrentFrame(); + const heading = interpolate(frame, [0, 10], [0, 1], snap); + const plate = interpolate(frame, [4, 14], [0, 1], snap); + const url = interpolate(frame, [13, 22], [0, 1], snap); + + return ( + + + +
+ {endpointHeading} +
+ +
+
+ + {agentRun.endpoint} + +
+
+
+ ); +} + +/** The close: mark, category, domain, licence. */ +export function EndCardBeat() { + return ( + + +
+ + {brand.wordmark} +
+
+ + +
{brand.category}
+
+ + +
+ {brand.domain} +
+
+ + +
{brand.licence}
+
+
+ ); +} diff --git a/remotion/mcp/spot/index.tsx b/remotion/mcp/spot/index.tsx new file mode 100644 index 0000000..54227a0 --- /dev/null +++ b/remotion/mcp/spot/index.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; +import { AbsoluteFill, Sequence } from "remotion"; +import "../../product-demo/fonts"; +import { light } from "../../product-demo/theme"; +import { CARD_OPENS_LENGTH, CardOpensBeat } from "./beat-card-opens"; +import { DoneBeat } from "./beats-console"; +import { DesktopBeat } from "./beats-desktop"; +import { LibraryBeat } from "./beats-library"; +import { + ApplyBeat, + EndCardBeat, + EndpointBeat, + HookBeat, + ReachBeat, +} from "./beats-type"; +import { GreenWipe } from "./kit"; + +/** + * The thirty three second spot. + * + * The long agent video is a demonstration: one window, one continuous session, + * every step in order and at reading speed. This is the other job. It is cut, + * not filmed. There is no camera move anywhere in it, because a screen recording + * that sways is a screen recording with a problem, not a film. What carries it + * instead is the thing spots have always been carried by: the cut, the size of + * the type, and how long each idea is allowed to stay on screen. + * + * Nine beats, hard cut, one idea each, and they answer two questions in that + * order. What is the product? A shelf of skills a team wrote down, which is why + * the second beat is a grid of five and not a sentence about one. Where does it + * land? In the client, which is why the middle of the film hands back the whole + * window instead of another fragment of one. The example run is release notes, + * but it does not start until the shelf it came off is already on screen. + * + * Three of the cuts are made with a band of brand green crossing the frame. The + * fourth join is not a cut at all: the card the search came back with opens into + * the file it stands for, carried across by the name of the skill, which is the + * one object on screen in both halves of that beat. Every fact is a string out + * of `../content`. + */ + +export const MCP_SPOT_FPS = 30; + +/** Frames per beat, in order. They sum to `MCP_AGENT_SPOT_DURATION`. */ +const BEATS = [ + { at: 0, length: 86, node: }, + { at: 86, length: 110, node: }, + { at: 196, length: 200, node: }, + { at: 396, length: CARD_OPENS_LENGTH, node: }, + { at: 606, length: 46, node: }, + { at: 652, length: 76, node: }, + { at: 728, length: 96, node: }, + { at: 824, length: 78, node: }, + { at: 902, length: 112, node: }, +] as const satisfies readonly { at: number; length: number; node: ReactNode }[]; + +/** 1014 frames at 30fps: thirty three seconds and four fifths. */ +export const MCP_AGENT_SPOT_DURATION = 1014; + +/** How long a wipe takes to cross, and where its midpoint sits. */ +const WIPE = 12; +/** The three cuts the green band makes: into the client, into the payoff, into the endpoint. */ +const WIPE_CUTS = [196, 652, 824] as const; + +/** The frame the poster would be cut from: the whole window, answer and all. */ +export const MCP_AGENT_SPOT_POSTER_FRAME = 380; + +export function SkillsboardMcpAgentSpot() { + return ( + + {BEATS.map((beat) => ( + + {beat.node} + + ))} + + {WIPE_CUTS.map((cut) => ( + + + + ))} + + ); +} diff --git a/remotion/mcp/spot/kit.tsx b/remotion/mcp/spot/kit.tsx new file mode 100644 index 0000000..87953b4 --- /dev/null +++ b/remotion/mcp/spot/kit.tsx @@ -0,0 +1,255 @@ +import type { CSSProperties, ReactNode } from "react"; +import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from "remotion"; +import { clamp } from "../../product-demo/stage"; +import { displayTracking, light, MONO, SANS } from "../../product-demo/theme"; + +/** + * The grammar of the spot. + * + * A spot is cut, not flown: there is no camera here, no push, no drift, and + * nothing that keeps moving after it has arrived. Everything on this page either + * lands inside a third of a second and then holds perfectly still, or wipes the + * frame clean for the next cut. Every beat is built on one left rail, so ten + * hard cuts read as one film instead of ten slides. + */ + +/** The rail every beat aligns to, and the matching air on the right. */ +export const RAIL = 150; + +/** The frame the beats are composed inside. */ +export const SPOT = { width: 1920, height: 1080 } as const; + +/** Entrances: fast out of the gate, dead stop at the end, no overshoot. */ +export const snap = { ...clamp, easing: Easing.bezier(0.16, 1, 0.3, 1) }; +/** Wipes: symmetric, because a wipe is a machine, not a gesture. */ +export const wipeEase = { ...clamp, easing: Easing.bezier(0.4, 0, 0.2, 1) }; + +export const display: CSSProperties = { + fontFamily: SANS, + fontWeight: 600, + letterSpacing: displayTracking, + lineHeight: 1.08, + color: light.foreground, +}; + +export const mono: CSSProperties = { + fontFamily: MONO, + fontWeight: 400, + color: light.foreground, +}; + +/** The cream stage. Fixed size, left rail, vertically centred column. */ +export function Beat({ + children, + align = "flex-start", + background = light.background, + gap = 0, +}: { + children: ReactNode; + align?: CSSProperties["alignItems"]; + background?: string; + gap?: number; +}) { + return ( + + + {children} + + ); +} + +/** + * One element arriving. Twelve frames from invisible and slightly low to placed + * and still; after `at + over` the style is constant, which is what makes the + * hold read as a held frame rather than as a slow move. + */ +export function Land({ + at, + children, + from = 26, + over = 10, + style, +}: { + at: number; + children: ReactNode; + from?: number; + over?: number; + style?: CSSProperties; +}) { + const frame = useCurrentFrame(); + const t = interpolate(frame, [at, at + over], [0, 1], snap); + + return ( +
+ {children} +
+ ); +} + +/** The label that names which surface a fragment was cut out of. */ +export function Kicker({ at, children }: { at: number; children: ReactNode }) { + return ( + +
+ + {children} +
+
+ ); +} + +/** A green rule that draws from the rail outwards and then stops. */ +export function Rule({ + at, + color = light.primary, + height = 6, + over = 10, + width, +}: { + at: number; + color?: string; + height?: number; + over?: number; + width: number; +}) { + const frame = useCurrentFrame(); + const t = interpolate(frame, [at, at + over], [0, 1], snap); + + return ( +
+ ); +} + +const BLINK = 12; + +/** Solid while characters are landing, blinking while the line waits. */ +function caretOn(frame: number, typing: boolean) { + return typing || Math.floor(frame / BLINK) % 2 === 0; +} + +function Caret({ color, size }: { color: string; size: number }) { + return ( + + ); +} + +/** + * A monospaced sentence arriving one character at a time across fixed rows. + * The rows are fixed on purpose: a real console reflows while you type, and a + * word jumping down a line mid take is the one thing a spot cannot afford. + */ +export function TypedLines({ + at, + caret = light.primary, + frame, + lines, + perChar, + size, + style, +}: { + at: number; + /** The block that follows the last character. Green on paper, warm in a client. */ + caret?: string; + frame: number; + lines: readonly string[]; + perChar: number; + size: number; + style?: CSSProperties; +}) { + const total = lines.reduce((sum, line) => sum + line.length, 0); + const shown = Math.floor( + interpolate(frame, [at, at + total * perChar], [0, total], clamp), + ); + const typing = frame >= at && shown < total; + let consumed = 0; + + return ( +
+ {lines.map((line) => { + const start = consumed; + consumed += line.length; + const count = Math.min(Math.max(shown - start, 0), line.length); + const holds = shown >= start && shown < start + line.length; + const parks = shown >= total && consumed === total; + + return ( +
+ {line.slice(0, count)} + {(holds || parks) && caretOn(frame, typing) ? ( + + ) : null} +
+ ); + })} +
+ ); +} + +/** + * A band of brand green that crosses the frame and takes the cut with it. It + * covers the whole frame for exactly one frame at its midpoint, which is where + * the beat underneath changes, so the audience never sees a dissolve. + */ +export function GreenWipe({ durationInFrames }: { durationInFrames: number }) { + const frame = useCurrentFrame(); + const half = durationInFrames / 2; + const on = interpolate(frame, [0, half], [0, 1], wipeEase); + const off = interpolate(frame, [half, durationInFrames], [0, 1], wipeEase); + + return ( + + ); +} diff --git a/remotion/mcp/spot/spot-content.ts b/remotion/mcp/spot/spot-content.ts new file mode 100644 index 0000000..c3e2f0a --- /dev/null +++ b/remotion/mcp/spot/spot-content.ts @@ -0,0 +1,146 @@ +/** + * The strings the spot puts on screen. + * + * Almost everything is re-exported from `../content`, which is the file the two + * long MCP videos already read from, so the spot cannot claim anything the + * longer cuts do not. What is added here is line breaking: a spot sets type at + * billboard size, and a sentence that fits one console row has to be broken by + * hand to fit one frame. The breaks are computed from the source sentence, so a + * copy change upstream moves the break instead of silently going off screen. + */ + +import { agentRun, brand, setup } from "../content"; + +export { agentRun, brand }; + +/** Breaks `text` at the last space at or before `at`, keeping both halves whole. */ +function splitLine(text: string, at: number): [string, string] { + const cut = text.lastIndexOf(" ", at); + + return cut < 0 ? [text, ""] : [text.slice(0, cut), text.slice(cut + 1)]; +} + +/** + * The opening card. The one sentence written for the spot rather than lifted + * from the product: it names the habit the whole film is about and stays clear + * of the example, which does not arrive for another eight seconds. + */ +export interface Segment { + text: string; + accent?: boolean; +} + +export const hookLines: Segment[][] = [ + [{ text: "Every team has " }, { text: "its own way", accent: true }], + [{ text: "of doing things." }], +]; + +/** + * The library beat. + * + * The product is the shelf, not the one skill the example run pulls off it, so + * the shelf has to be on screen before the run starts. The team is the invented + * one the long videos already use, and the five entries under it are invented + * too: they are there to show a plural, and none of them carries a product + * claim. `release-notes` is the entry the rest of the film follows, which is why + * it is listed first and why it is the one that gets marked. + */ +export const libraryHeading = `${agentRun.hit.team} team library`; + +export const librarySkills = [ + { name: agentRun.hit.title, tag: agentRun.hit.tags[0] }, + { name: "pr-review", tag: "code" }, + { name: "bug-triage", tag: "support" }, + { name: "onboarding-guide", tag: "docs" }, + { name: "customer-feedback", tag: "research" }, +] as const; + +/** The count under the heading, kept true to the grid above it. */ +export const librarySkillCount = `${librarySkills.length} skills saved by the team`; + +/** What the card fragment is a fragment of, once the library has been seen. */ + +/** + * The client the spot films. + * + * The long agent video films `agentRun.client`; the spot films the desktop app, + * because a chat window reads at broadcast size in a way a shell prompt does + * not. It is the second name on the list the closing chips already carry, so the + * film never shows a client the product has not named. + */ +export const spotClient = agentRun.reach.clients[1]; + +/** The furniture of that window: app chrome, not product copy. */ +export const desktop = { + app: "Claude", + placeholder: "Reply to Claude...", + running: "Running", + done: "Completed", +} as const; + +const callOpens = agentRun.call.indexOf("("); + +/** `search_skills`, taken off the front of the call the long video prints. */ +export const toolName = agentRun.call.slice(0, callOpens); + +/** The argument of that same call, printed the way a client prints JSON. */ +export const toolArgs = agentRun.call.slice(callOpens + 1, -1).replace(/(\w+):/, '"$1":'); + +/** The task, as the person at the keyboard types it, over two rows. */ +export const promptLines = splitLine(agentRun.prompt, 32); + +/** One instruction inside the skill file. `key` is one of the two the run used. */ +export interface NoteLine { + text: string; + key?: boolean; +} + +/** The teammate's note, one sentence per row, as `../content` keeps it. */ +const savedNote = agentRun.note + .split(". ") + .map((part, index, all) => (index < all.length - 1 ? `${part}.` : part)); + +/** + * The instructions inside the skill file. + * + * `../content` keeps the note at the two sentences the long videos print, and it + * stays that way: those two are the ones the run visibly obeys, so a longer + * string there would put words in the output the output never uses. A file with + * two lines in it is not a file anybody wrote, though, so the spot shows the + * rest of the page around them (maintainer feedback, 31/08). The two saved + * sentences come straight out of the note and keep their full size; the other + * three are set smaller and quieter, which is how a real page reads: you see the + * shape of a list and you read the lines that matter. + */ +export const noteLines: NoteLine[] = [ + { text: "Start from the merged pull requests since the last tag." }, + { key: true, text: savedNote[0] }, + { key: true, text: savedNote[1] }, + { text: "Keep every entry under twelve words." }, + { text: "Skip internal refactors unless they change behavior." }, +]; + +/** + * The thesis beat. The film says what the product is with the canonical + * definition from the marketing contract, not the "note" line: after the + * release-notes de-emphasis the word note made the product read as a + * note-taking tool (maintainer feedback, 31/08). + */ +export const applyLines = [ + "Skills Board, the web app where a team", + "keeps and shares its AI skills.", +] as const; + +/** + * The skill file heading, kept in two pieces. The name is the one object the + * fused beat moves: it starts as the title of the card and ends as the heading + * of the file, so it cannot be a single baked string with the hash already in + * it. The hash arrives on its own, while the name is still travelling. + */ +export const skillFileHeading = { hash: "# ", name: agentRun.hit.title } as const; + +/** `agentRun.reach.line`, broken after the comma. */ +export const reachLines = splitLine(agentRun.reach.line, 22); + +/** The instruction over the endpoint plate, the same one the setup video uses. */ +export const endpointHeading = setup.config.heading; diff --git a/remotion/mcp/ui/agent-console.tsx b/remotion/mcp/ui/agent-console.tsx new file mode 100644 index 0000000..b07adb8 --- /dev/null +++ b/remotion/mcp/ui/agent-console.tsx @@ -0,0 +1,417 @@ +import { CheckIcon, GitForkIcon } from "lucide-react"; +import type { CSSProperties, ReactNode } from "react"; +import { interpolate } from "remotion"; +import { clamp, outEasing } from "../../product-demo/stage"; +import { displayTracking, light, MONO, radius } from "../../product-demo/theme"; +import { agentRun } from "../content"; + +/** + * The client window for the agent video, built on the same card the setup video + * uses for `mcp.json`: one hairline border, a bar with three dots, and the paper + * background. One session runs inside it from the first keystroke to the last + * line, so the window is measured rather than laid out loosely: every block + * declares its height, and the scene scrolls the column by the exact difference + * between the content and the viewport. + */ +export const CONSOLE_WIDTH = 1400; +export const CONSOLE_VIEWPORT = 720; +export const STREAM_GAP = 22; +export const STREAM_PAD_TOP = 28; +export const STREAM_PAD_BOTTOM = 30; + +export const BLOCK_HEIGHT = { + prompt: 42, + call: 42, + hit: 250, + note: 92, + output: 240, +} as const; + +const BODY_SIZE = 27; +/** Frames the caret spends on, then off, while nothing is being typed. */ +const BLINK = 15; + +/** How many characters of `text` have landed by `frame`. */ +export function typedCount(text: string, frame: number, at: number, perChar: number) { + return Math.floor( + interpolate(frame, [at, at + text.length * perChar], [0, text.length], clamp), + ); +} + +function Caret() { + return ( + + ); +} + +/** + * A caret is solid while the line it sits on is being typed and blinks when the + * session is waiting, which is how a client renders it. + */ +function caretVisible(frame: number, typing: boolean) { + return typing || Math.floor(frame / BLINK) % 2 === 0; +} + +/** Text that lands one character at a time, with the caret riding the end. */ +export function Typed({ + at, + caret = false, + frame, + perChar, + style, + text, +}: { + at: number; + caret?: boolean; + frame: number; + perChar: number; + style?: CSSProperties; + text: string; +}) { + const shown = typedCount(text, frame, at, perChar); + const typing = frame >= at && shown < text.length; + + return ( + + {text.slice(0, shown)} + {caret && caretVisible(frame, typing) ? : null} + + ); +} + +/** One measured row of the session, so the scroll arithmetic stays exact. */ +export function StreamBlock({ + children, + height, + style, +}: { + children: ReactNode; + height: number; + style?: CSSProperties; +}) { + return
{children}
; +} + +/** The bar names the connection the way a client does, endpoint included. */ +function ConsoleBar() { + return ( +
+ {[0, 1, 2].map((dot) => ( + + ))} + + {agentRun.client} + + + + {agentRun.server} + {agentRun.endpoint} + +
+ ); +} + +/** The window itself. `scroll` moves the column, never the frame. */ +export function Console({ + children, + frame, + scroll, + width = CONSOLE_WIDTH, +}: { + children: ReactNode; + frame: number; + scroll: number; + width?: number; +}) { + return ( +
+ +
+
+ {children} +
+
+
+ ); +} + +/** The task, typed by the person at the keyboard. */ +export function PromptRow({ + at, + caret, + frame, + perChar, +}: { + at: number; + caret: boolean; + frame: number; + perChar: number; +}) { + return ( + + >{" "} + + + ); +} + +/** The call the agent types back, with the tool name lifted out of the arguments. */ +export function CallRow({ + at, + caret, + frame, + perChar, +}: { + at: number; + caret: boolean; + frame: number; + perChar: number; +}) { + const call = agentRun.call; + const shown = typedCount(call, frame, at, perChar); + const typing = frame >= at && shown < call.length; + const split = call.indexOf("("); + const name = call.slice(0, Math.min(shown, split)); + const args = shown > split ? call.slice(split, shown) : ""; + + return ( + = at ? 1 : 0 }}> + + {"· "} + {name} + {args} + {caret && caretVisible(frame, typing) ? : null} + + + ); +} + +/** The one skill the search returns, with the source it was saved from. */ +export function HitCard({ at, frame }: { at: number; frame: number }) { + const { hit } = agentRun; + const reveal = interpolate(frame, [at, at + 22], [0, 1], outEasing); + + return ( + +
+
+ + {hit.source} +
+
+ {hit.title} +
+
+ {hit.description} +
+
+ {hit.tags.map((tag) => ( + + {tag} + + ))} + + Saved by {hit.savedBy} in {hit.team} + +
+
+
+ ); +} + +/** The teammate's note, quoted the way the library stores it. */ +export function NoteRow({ at, frame }: { at: number; frame: number }) { + const reveal = interpolate(frame, [at, at + 18], [0, 1], outEasing); + + return ( + +
+ {agentRun.note} +
+ note from {agentRun.hit.savedBy} +
+
+
+ ); +} + +/** Offsets from the first output frame, so the scene can time its scroll. */ +export const OUTPUT_STEPS = { + title: 0, + rows: [26, 57, 88], + done: 126, +} as const; + +/** What the agent hands back once it has followed the note. */ +export function OutputBlock({ + at, + frame, + perChar, +}: { + at: number; + frame: number; + perChar: number; +}) { + const { output } = agentRun; + const doneAt = at + OUTPUT_STEPS.done; + + return ( + +
+ = at && frame < at + OUTPUT_STEPS.rows[0]} + frame={frame} + perChar={2.5} + style={{ color: light.primary, fontWeight: 500, fontSize: 30 }} + text={output.title} + /> + {output.rows.map((row, index) => { + const rowAt = at + OUTPUT_STEPS.rows[index]; + const next = OUTPUT_STEPS.rows[index + 1] ?? OUTPUT_STEPS.done; + + return ( + = rowAt && frame < at + next} + frame={frame} + key={row} + perChar={perChar} + style={{ color: light.foreground, fontSize: 26 }} + text={row} + /> + ); + })} + + + = doneAt} + frame={frame} + perChar={perChar} + text={output.done} + /> + +
+
+ ); +} diff --git a/remotion/mcp/ui/chrome.tsx b/remotion/mcp/ui/chrome.tsx new file mode 100644 index 0000000..0bcad76 --- /dev/null +++ b/remotion/mcp/ui/chrome.tsx @@ -0,0 +1,143 @@ +import type { CSSProperties, ReactNode } from "react"; +import { interpolate } from "remotion"; +import { clamp, outEasing } from "../../product-demo/stage"; +import { displayTracking, light, MONO, radius } from "../../product-demo/theme"; + +/** The type ramp for a 1920 x 1080 frame, one step up from the app's own. */ +export const t = { + step: { + fontFamily: MONO, + fontSize: 22, + fontWeight: 600, + letterSpacing: "0.18em", + textTransform: "uppercase", + color: light.primary, + } as CSSProperties, + heading: { + fontSize: 58, + fontWeight: 600, + letterSpacing: displayTracking, + color: light.foreground, + } as CSSProperties, + caption: { + fontSize: 28, + color: light.mutedForeground, + } as CSSProperties, + code: { + fontFamily: MONO, + fontSize: 27, + lineHeight: 1.62, + } as CSSProperties, +}; + +/** + * One entrance for every block on the light scenes: a short rise out of a blur, + * on the app's own `--ease-out`. + */ +export function Rise({ + at, + frame, + children, + distance = 18, + style, +}: { + at: number; + frame: number; + children: ReactNode; + distance?: number; + style?: CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} + +/** The step label and the headline, stacked the way `/connect` stacks them. */ +export function SceneHead({ + step, + heading, + frame, +}: { + step: string; + heading: string; + frame: number; +}) { + return ( +
+ + {step} + + + {heading} + +
+ ); +} + +/** The app's card: one border, one radius, the paper background. */ +export function Card({ + children, + width, + style, +}: { + children: ReactNode; + width?: number; + style?: CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} + +/** The window bar over a code panel, with the file name in mono. */ +export function WindowBar({ file }: { file: string }) { + return ( +
+ {[0, 1, 2].map((dot) => ( + + ))} + + {file} + +
+ ); +} diff --git a/remotion/mcp/ui/code-block.tsx b/remotion/mcp/ui/code-block.tsx new file mode 100644 index 0000000..e460588 --- /dev/null +++ b/remotion/mcp/ui/code-block.tsx @@ -0,0 +1,94 @@ +import { interpolate } from "remotion"; +import { clamp, outEasing } from "../../product-demo/stage"; +import { light, radius } from "../../product-demo/theme"; +import { configLines } from "../content"; +import { Card, t, WindowBar } from "./chrome"; + +/** + * `"key": value` is the only shape in this file, so one split colours the whole + * block without a tokenizer: the key takes the foreground, the value takes the + * primary, and the braces stay muted. + */ +const PAIR = /^(\s*)("[^"]*")(:\s*)(.*)$/; + +function JsonLine({ line }: { line: string }) { + const parts = PAIR.exec(line); + + if (!parts) { + return {line}; + } + + const [, indent, key, colon, value] = parts; + + return ( + <> + {indent} + {key} + {colon} + {value} + + ); +} + +const LINE_HEIGHT = Math.round(27 * 1.62); + +/** + * The config as an editor shows it. Lines cascade in two frames apart, which + * reads as a paste landing rather than as something typed by hand, and the + * endpoint line takes a highlight once the block has settled. + */ +export function ConfigBlock({ + frame, + at, + highlightAt, + highlightLine, + width = 1440, +}: { + frame: number; + at: number; + highlightAt: number; + highlightLine: number; + width?: number; +}) { + const highlight = interpolate(frame, [highlightAt, highlightAt + 14], [0, 1], outEasing); + + return ( + + +
+
+
+ {configLines.map((line, index) => ( +
+ +
+ ))} +
+
+ + ); +} diff --git a/remotion/mcp/ui/consent-card.tsx b/remotion/mcp/ui/consent-card.tsx new file mode 100644 index 0000000..c6eec53 --- /dev/null +++ b/remotion/mcp/ui/consent-card.tsx @@ -0,0 +1,165 @@ +import { CheckIcon, LockIcon } from "lucide-react"; +import { interpolate } from "remotion"; +import { clamp, outEasing } from "../../product-demo/stage"; +import { displayTracking, light, MONO, radius } from "../../product-demo/theme"; +import { BrandMark, Btn } from "../../product-demo/ui/atoms"; +import { setup } from "../content"; +import { Card } from "./chrome"; + +const { consent } = setup; + +/** + * The authorization screen, drawn from the app's own tokens. The two scopes are + * the ones the MCP route checks: `skills:read` gates the connection, + * `skills:write` gates the four write tools. + */ +export function ConsentCard({ + frame, + at, + pressAt, + width = 1000, +}: { + frame: number; + at: number; + pressAt: number; + width?: number; +}) { + const press = interpolate(frame, [pressAt, pressAt + 8], [1, 0], clamp); + const granted = interpolate(frame, [pressAt + 10, pressAt + 26], [0, 1], outEasing); + + return ( + +
+ + + Skills Board + + + + {consent.footer} + +
+ +
+
+ {consent.title} +
+ +
+ {consent.scopes.map((scope, index) => { + const reveal = interpolate( + frame, + [at + 12 + index * 10, at + 30 + index * 10], + [0, 1], + outEasing, + ); + + return ( +
+ + + + + {scope.name} + + {scope.detail} +
+ ); + })} +
+ +
+
+ + {consent.approve} + +
+
+ + {consent.granted} +
+
+
+
+ ); +} diff --git a/remotion/mcp/ui/tool-grid.tsx b/remotion/mcp/ui/tool-grid.tsx new file mode 100644 index 0000000..45968df --- /dev/null +++ b/remotion/mcp/ui/tool-grid.tsx @@ -0,0 +1,75 @@ +import { interpolate } from "remotion"; +import { outEasing } from "../../product-demo/stage"; +import { light, MONO, radius } from "../../product-demo/theme"; +import { setup, tools } from "../content"; + +/** + * The tool list as a client prints it after the connection is approved. Names + * are the ones registered in `app/api/[transport]/route.ts`; the badge marks + * the four that need `skills:write`. + */ +export function ToolGrid({ frame, at, width = 1320 }: { frame: number; at: number; width?: number }) { + return ( +
+ {tools.map((tool, index) => { + const reveal = interpolate( + frame, + [at + index * 4, at + 18 + index * 4], + [0, 1], + outEasing, + ); + + return ( +
+ + {tool.name} + + {tool.write ? ( + + {setup.tools.writeBadge} + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/remotion/root.tsx b/remotion/root.tsx index f225b97..44d4c48 100644 --- a/remotion/root.tsx +++ b/remotion/root.tsx @@ -1,4 +1,13 @@ import { Composition } from "remotion"; +import { + MCP_AGENT_DURATION, + MCP_AGENT_SPOT_DURATION, + MCP_FPS, + MCP_SETUP_DURATION, + SkillsboardMcpAgent, + SkillsboardMcpAgentSpot, + SkillsboardMcpSetup, +} from "./mcp"; import { FPS, PRODUCT_HUNT_DURATION, @@ -45,6 +54,30 @@ export function RemotionRoot() { id="skillsboard-launch-social" width={1440} /> + + + ); }