diff --git a/.gitignore b/.gitignore index 376ad118..92da07b6 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ config/s0-prod.config.jsonc # misc .DS_Store +apps/web/.og-render/ # Alchemy .alchemy/ diff --git a/apps/web/package.json b/apps/web/package.json index f321c4ba..7885110c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,8 @@ "deploy:pre": "nub run --cwd ../../packages/infra deploy:web:pre", "deploy:prod": "nub run --cwd ../../packages/infra deploy:web:prod", "typecheck": "tsrs --noEmit", - "render:release": "tsx scripts/render-release-card.ts render-release" + "render:release": "tsx scripts/render-release-card.ts render-release", + "render:og": "tsx scripts/render-og.ts" }, "dependencies": { "@cloudflare/kumo": "^2.12.0", diff --git a/apps/web/scripts/render-og.ts b/apps/web/scripts/render-og.ts new file mode 100644 index 00000000..53ef397c --- /dev/null +++ b/apps/web/scripts/render-og.ts @@ -0,0 +1,75 @@ +import { dirname, isAbsolute, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" +import * as Console from "effect/Console" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import { ogConcepts, type OgConceptId } from "../src/creative/og/scenes" +import { renderOgConcepts } from "../src/creative/og/node" + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..") + +class OgCliError extends Schema.TaggedError()("OgCliError", { + message: Schema.String, + cause: Schema.optionalKey(Schema.Unknown), +}) {} + +function option(args: readonly string[], name: string): string | undefined { + const index = args.indexOf(name) + + if (index < 0) return undefined + + const value = args[index + 1] + + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`) + + return value +} + +function conceptId(value: string | undefined): OgConceptId | undefined { + if (!value) return undefined + + const match = ogConcepts.find((concept) => concept.id === value) + + if (!match) { + throw new Error( + `--concept must be one of ${ogConcepts.map((concept) => concept.id).join(", ")}.`, + ) + } + + return match.id +} + +const main = Effect.fn("og.cli")(function* () { + const args = process.argv.slice(2) + + const requested = yield* Effect.try({ + try: () => ({ + conceptId: conceptId(option(args, "--concept")), + output: option(args, "--output"), + }), + catch: (cause) => + new OgCliError({ + message: cause instanceof Error ? cause.message : "The command arguments are invalid.", + cause, + }), + }) + + const outputDir = requested.output + ? isAbsolute(requested.output) + ? requested.output + : resolve(REPO_ROOT, requested.output) + : join(REPO_ROOT, "apps/web/.og-render") + + const written = yield* renderOgConcepts({ + conceptId: requested.conceptId, + outputDir, + repoRoot: REPO_ROOT, + }) + + for (const path of written) { + yield* Console.log(path) + } +}) + +await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer))) diff --git a/apps/web/src/components/home-agent-sessions.tsx b/apps/web/src/components/home-agent-sessions.tsx index cc0c1bb9..f49d038e 100644 --- a/apps/web/src/components/home-agent-sessions.tsx +++ b/apps/web/src/components/home-agent-sessions.tsx @@ -22,6 +22,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { SessionsListQuery } from "@solzero/api" import { AGENT_RUNTIMES, formatAgentRuntimeLabel, resolveAgentRuntime } from "@solzero/shared" import { S0Loader, TableCellState } from "@/components/s0-loader" +import { primaryNavLabels } from "@/components/primary-nav-labels" import { archiveSession, formatSessionLabel, @@ -487,7 +488,9 @@ export function PreviousSessionsTable() {
-

Previous sessions

+

+ {primaryNavLabels.previousSessions} +

Search, filter, sort, and reopen prior agent sessions.

diff --git a/apps/web/src/components/home-hero-copy.ts b/apps/web/src/components/home-hero-copy.ts new file mode 100644 index 00000000..2fc3bf7b --- /dev/null +++ b/apps/web/src/components/home-hero-copy.ts @@ -0,0 +1,11 @@ +export const homeComposerPlaceholder = "Chat, build, and automate with project context" + +export const homeStartNewAgentLabel = "Start a new Agent" + +export function homeWelcomeHeading(brandName: string): string { + return `Welcome to ${brandName}` +} + +export function homePreparingAgentLabel(runtimeLabel: string): string { + return `Preparing ${runtimeLabel} agent...` +} diff --git a/apps/web/src/components/home-page/content.tsx b/apps/web/src/components/home-page/content.tsx index a90d0a11..25f9153a 100644 --- a/apps/web/src/components/home-page/content.tsx +++ b/apps/web/src/components/home-page/content.tsx @@ -14,6 +14,13 @@ import { } from "@solzero/shared" import { S0Loader } from "@/components/s0-loader" import { S0AnimatedIcon } from "@/components/s0-animated-icon" +import { + homeComposerPlaceholder, + homePreparingAgentLabel, + homeStartNewAgentLabel, + homeWelcomeHeading, +} from "@/components/home-hero-copy" +import { primaryNavLabels } from "@/components/primary-nav-labels" import { HomeToolbarIconButton, TOOLS_TOOLBAR_CLASS_NAME, @@ -319,7 +326,7 @@ export function HomeContent({ className="pointer-events-auto invisible flex -translate-y-2 items-center gap-2 rounded-full bg-kumo-elevated px-4 py-2 text-sm text-kumo-subtle opacity-0 shadow-lg ring-1 ring-kumo-hairline transition hover:bg-kumo-tint hover:text-kumo-default focus:outline-none focus:ring-2 focus:ring-kumo-brand" > - Start a new Agent + {homeStartNewAgentLabel}
@@ -331,7 +338,7 @@ export function HomeContent({

- Welcome to {brand.name} + {homeWelcomeHeading(brand.name)}

{!isAuthenticated ? (

Sign in to start a new agent

@@ -425,7 +432,7 @@ export function HomeContent({ value={prompt} onChange={(e) => handlePromptChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Chat, build, and automate with project context" + placeholder={homeComposerPlaceholder} disabled={creating} className="session-composer-textarea session-composer-textarea--ringed min-h-28 w-full resize-none bg-transparent px-4 py-4 text-kumo-default placeholder:text-kumo-placeholder focus:outline-none disabled:opacity-50" rows={3} @@ -536,7 +543,7 @@ export function HomeContent({
{isCreatingSession && ( - Preparing {selectedAgentRuntimeLabel} agent... + {homePreparingAgentLabel(selectedAgentRuntimeLabel)} )} {modelControlLoading ? ( @@ -591,11 +598,11 @@ export function HomeContent({
diff --git a/apps/web/src/components/primary-nav-labels.ts b/apps/web/src/components/primary-nav-labels.ts new file mode 100644 index 00000000..ad5358b7 --- /dev/null +++ b/apps/web/src/components/primary-nav-labels.ts @@ -0,0 +1,6 @@ +export const primaryNavLabels = { + agents: "Agents", + workflows: "Workflows", + bots: "Bots", + previousSessions: "Previous sessions", +} as const diff --git a/apps/web/src/components/s0-animated-icon.tsx b/apps/web/src/components/s0-animated-icon.tsx index 01e9bb71..d19e7b38 100644 --- a/apps/web/src/components/s0-animated-icon.tsx +++ b/apps/web/src/components/s0-animated-icon.tsx @@ -2,6 +2,7 @@ import { GemSmoke } from "@paper-design/shaders-react" import { S0LogoSvg } from "@/components/s0-logo-svg" +import { solzeroGemSmokeColors } from "@/components/s0-gem-smoke" import { usePaperShaderReady, usePrefersReducedMotion } from "@/hooks/use-paper-shader" import { useTheme } from "@/lib/theme" @@ -41,7 +42,7 @@ export function S0AnimatedIcon({ size = 20, className = "" }: S0AnimatedIconProp width={size} height={size} image="/images/solzero-logo.svg" - colors={["#0078d7", "#4da6e8", "#ffffff"]} + colors={[...solzeroGemSmokeColors]} colorBack="rgba(0, 0, 0, 0)" colorInner={isDark ? "#000000" : "#FFFFFF"} innerDistortion={1} diff --git a/apps/web/src/components/s0-gem-smoke.ts b/apps/web/src/components/s0-gem-smoke.ts new file mode 100644 index 00000000..eaf4336a --- /dev/null +++ b/apps/web/src/components/s0-gem-smoke.ts @@ -0,0 +1 @@ +export const solzeroGemSmokeColors = ["#0078d7", "#4da6e8", "#ffffff"] as const diff --git a/apps/web/src/components/s0-loader-motion.ts b/apps/web/src/components/s0-loader-motion.ts new file mode 100644 index 00000000..3087ed8f --- /dev/null +++ b/apps/web/src/components/s0-loader-motion.ts @@ -0,0 +1,36 @@ +export const SOLZERO_OUTER_PATH = + "M376 188V377C376 480.76 291.76 565 188 565C84.24 565 0 480.76 0 377V188C0 84.24 84.24 0 188 0C291.76 0 376 84.24 376 188Z" + +export const SOLZERO_STAR_PATH = + "M188 94.5C188 197.634 272.866 282.5 376 282.5C272.866 282.5 188 367.366 188 470.5C188 367.366 103.134 282.5 0 282.5C103.134 282.5 188 197.634 188 94.5Z" + +export const SOLZERO_MARK_PATH = `${SOLZERO_OUTER_PATH}${SOLZERO_STAR_PATH}` + +export const LOADER_CYCLE_SECONDS = 2.8 + +export const LOADER_ASSEMBLY_START = LOADER_CYCLE_SECONDS * 0.12 + +export const LOADER_ASSEMBLY_DURATION = LOADER_CYCLE_SECONDS * 0.28 + +export const LOADER_DEPARTURE_START = LOADER_CYCLE_SECONDS * 0.64 + +export const LOADER_DEPARTURE_DURATION = LOADER_CYCLE_SECONDS * 0.14 + +export const LOADER_PIECE_STAGGER = LOADER_CYCLE_SECONDS * 0.06 + +export const LOADER_PIECES = [ + { name: "north-west", height: 282.5, offsetX: -34, offsetY: -26, width: 188, x: 0, y: 0 }, + { name: "north-east", height: 282.5, offsetX: 34, offsetY: -26, width: 188, x: 188, y: 0 }, + { + name: "south-east", + height: 282.5, + offsetX: 34, + offsetY: 26, + width: 188, + x: 188, + y: 282.5, + }, + { name: "south-west", height: 282.5, offsetX: -34, offsetY: 26, width: 188, x: 0, y: 282.5 }, +] as const + +export type LoaderPiece = (typeof LOADER_PIECES)[number] diff --git a/apps/web/src/components/s0-loader.tsx b/apps/web/src/components/s0-loader.tsx index afdfb4a8..a83a2a36 100644 --- a/apps/web/src/components/s0-loader.tsx +++ b/apps/web/src/components/s0-loader.tsx @@ -4,42 +4,22 @@ import { useEffect, useId, useRef, type ReactNode } from "react" import { hasCustomS0Logo } from "@solzero/shared" import { getS0Brand } from "@/lib/brand" import { S0LogoSvg } from "@/components/s0-logo-svg" +import { + LOADER_ASSEMBLY_DURATION, + LOADER_ASSEMBLY_START, + LOADER_CYCLE_SECONDS, + LOADER_DEPARTURE_DURATION, + LOADER_DEPARTURE_START, + LOADER_PIECE_STAGGER, + LOADER_PIECES, + SOLZERO_MARK_PATH, +} from "@/components/s0-loader-motion" interface S0LoaderProps { size?: number className?: string } -const SOLZERO_OUTER_PATH = - "M376 188V377C376 480.76 291.76 565 188 565C84.24 565 0 480.76 0 377V188C0 84.24 84.24 0 188 0C291.76 0 376 84.24 376 188Z" - -const SOLZERO_STAR_PATH = - "M188 94.5C188 197.634 272.866 282.5 376 282.5C272.866 282.5 188 367.366 188 470.5C188 367.366 103.134 282.5 0 282.5C103.134 282.5 188 197.634 188 94.5Z" - -const SOLZERO_MARK_PATH = `${SOLZERO_OUTER_PATH}${SOLZERO_STAR_PATH}` - -const CYCLE_DURATION = 2.8 -const ASSEMBLY_START = CYCLE_DURATION * 0.12 -const ASSEMBLY_DURATION = CYCLE_DURATION * 0.28 -const DEPARTURE_START = CYCLE_DURATION * 0.64 -const DEPARTURE_DURATION = CYCLE_DURATION * 0.14 -const PIECE_STAGGER = CYCLE_DURATION * 0.06 - -const LOADER_PIECES = [ - { name: "north-west", height: 282.5, offsetX: -34, offsetY: -26, width: 188, x: 0, y: 0 }, - { name: "north-east", height: 282.5, offsetX: 34, offsetY: -26, width: 188, x: 188, y: 0 }, - { - name: "south-east", - height: 282.5, - offsetX: 34, - offsetY: 26, - width: 188, - x: 188, - y: 282.5, - }, - { name: "south-west", height: 282.5, offsetX: -34, offsetY: 26, width: 188, x: 0, y: 282.5 }, -] as const - function sanitizeSvgId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, "") } @@ -120,30 +100,30 @@ function SolZeroLoader({ size = 64, className = "" }: S0LoaderProps) { .to( piece, { - duration: ASSEMBLY_DURATION, + duration: LOADER_ASSEMBLY_DURATION, ease: "power2.out", opacity: 1, scale: 1, x: 0, y: 0, }, - ASSEMBLY_START + index * PIECE_STAGGER, + LOADER_ASSEMBLY_START + index * LOADER_PIECE_STAGGER, ) .to( piece, { - duration: DEPARTURE_DURATION, + duration: LOADER_DEPARTURE_DURATION, ease: "power2.in", opacity: 0, scale: 0.94, x: motion.offsetX, y: motion.offsetY, }, - DEPARTURE_START + index * PIECE_STAGGER, + LOADER_DEPARTURE_START + index * LOADER_PIECE_STAGGER, ) }) - timeline.set({}, {}, CYCLE_DURATION) + timeline.set({}, {}, LOADER_CYCLE_SECONDS) }) }, containerRef) diff --git a/apps/web/src/components/session-sidebar.tsx b/apps/web/src/components/session-sidebar.tsx index fb609598..4708da05 100644 --- a/apps/web/src/components/session-sidebar.tsx +++ b/apps/web/src/components/session-sidebar.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react" import { useState } from "react" import { S0LogoSvg } from "@/components/s0-logo-svg" +import { primaryNavLabels } from "@/components/primary-nav-labels" import { ADMIN_SIDEBAR_ITEMS, getAdminViewFromLocation, @@ -139,7 +140,7 @@ export function SessionSidebar({ content, isOpen }: SessionSidebarProps) { }`} > - Agents + {primaryNavLabels.agents}
) : null} @@ -182,7 +183,7 @@ export function SessionSidebar({ content, isOpen }: SessionSidebarProps) { className={navLinkClass(activeNav === "workflows")} > - Workflows + {primaryNavLabels.workflows} - Bots + {primaryNavLabels.bots} item.type === type) + + if (!node) throw new Error(`Workflow catalog is missing ${type}.`) + + return node.label +} + +function titleWord(word: string): string { + return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase() +} diff --git a/apps/web/src/creative/og/frame.ts b/apps/web/src/creative/og/frame.ts new file mode 100644 index 00000000..47a36ba2 --- /dev/null +++ b/apps/web/src/creative/og/frame.ts @@ -0,0 +1,18 @@ +export const OG_WIDTH = 1200 +export const OG_HEIGHT = 630 +export const OG_FPS = 12 +export const OG_LOOP_MS = 5000 + +export const OG_HOLD = "0%, 6%, 94%, 100%" + +export function loopAnimation(name: string): string { + return `${name} ${OG_LOOP_MS}ms ease-in-out infinite` +} + +export function frameCount(): number { + return Math.round((OG_LOOP_MS / 1000) * OG_FPS) +} + +export function frameTimeMs(frameIndex: number): number { + return (frameIndex / OG_FPS) * 1000 +} diff --git a/apps/web/src/creative/og/mark.ts b/apps/web/src/creative/og/mark.ts new file mode 100644 index 00000000..edfe3d5e --- /dev/null +++ b/apps/web/src/creative/og/mark.ts @@ -0,0 +1,72 @@ +import { + LOADER_ASSEMBLY_DURATION, + LOADER_DEPARTURE_DURATION, + LOADER_PIECE_STAGGER, + LOADER_PIECES, +} from "@/components/s0-loader-motion" +import { OG_HOLD } from "./frame" + +export const MARK_SIZE = 460 + +/** The loader's path offsets are sized for a small icon. This scale keeps the same vectors readable at OG size. */ +export const MARK_TRAVEL_SCALE = 2.4 + +export interface MarkOffset { + readonly offsetX: number + readonly offsetY: number +} + +export function markOffsets(svg: string): readonly MarkOffset[] { + const pixelsPerPathUnit = (markScale(svg) * MARK_SIZE * MARK_TRAVEL_SCALE) / squareViewBox(svg) + + return LOADER_PIECES.map((piece) => ({ + offsetX: piece.offsetX * pixelsPerPathUnit, + offsetY: piece.offsetY * pixelsPerPathUnit, + })) +} + +export function markMotionCss(pieces: readonly MarkOffset[]): string { + const staggerRatio = LOADER_PIECE_STAGGER / LOADER_DEPARTURE_DURATION + const assembleRatio = LOADER_ASSEMBLY_DURATION / LOADER_DEPARTURE_DURATION + const departSpan = 8 + + return pieces + .map((piece, index) => { + const departStart = 10 + index * staggerRatio * departSpan + const departEnd = departStart + departSpan + const assembleStart = 48 + index * staggerRatio * departSpan + const assembleEnd = assembleStart + assembleRatio * departSpan + const away = `translate(${piece.offsetX.toFixed(1)}px, ${piece.offsetY.toFixed(1)}px)` + + return ` + @keyframes mark-piece-${index} { + ${OG_HOLD} { transform: translate(0px, 0px); opacity: 1; } + ${departStart.toFixed(2)}% { transform: translate(0px, 0px); opacity: 1; animation-timing-function: ease-in; } + ${departEnd.toFixed(2)}% { transform: ${away}; opacity: 1; animation-timing-function: linear; } + ${assembleStart.toFixed(2)}% { transform: ${away}; opacity: 1; animation-timing-function: ease-out; } + ${assembleEnd.toFixed(2)}% { transform: translate(0px, 0px); opacity: 1; } + } + ` + }) + .join("\n") +} + +function squareViewBox(svg: string): number { + const match = svg.match(/viewBox="0 0 ([0-9.]+) \1"/) + const size = match?.[1] ? Number(match[1]) : Number.NaN + + if (!Number.isFinite(size) || size <= 0) { + throw new Error("solzero-logo.svg has no square viewBox.") + } + + return size +} + +function markScale(svg: string): number { + const scales = [...svg.matchAll(/matrix\(([-\d.]+),0,0,\1,/g)].map((match) => Number(match[1])) + const scale = scales.find((value) => value !== 1 && Number.isFinite(value)) + + if (!scale) throw new Error("solzero-logo.svg has no mark scale.") + + return scale +} diff --git a/apps/web/src/creative/og/node.ts b/apps/web/src/creative/og/node.ts new file mode 100644 index 00000000..afbe0211 --- /dev/null +++ b/apps/web/src/creative/og/node.ts @@ -0,0 +1,373 @@ +import { spawn } from "node:child_process" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import type { Writable } from "node:stream" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Schema from "effect/Schema" +import { createElement } from "react" +import { resolveS0Brand } from "@solzero/shared" +import { fromJsx } from "takumi-js/helpers/jsx" +import { Renderer } from "takumi-js/node" +import { createOgCopy, productTagline } from "./copy" +import { frameCount, frameTimeMs, OG_FPS, OG_HEIGHT, OG_LOOP_MS, OG_WIDTH } from "./frame" +import { markMotionCss, markOffsets } from "./mark" +import { ogConcepts, type OgConceptId, type OgSceneProps } from "./scenes" +import { OgContactSheet, sheetSize } from "./sheet" +import { readOgTheme } from "./theme" + +const MANROPE_REGULAR_PATH = fileURLToPath( + import.meta.resolve("@fontsource/manrope/files/manrope-latin-400-normal.woff2"), +) + +const MANROPE_BOLD_PATH = fileURLToPath( + import.meta.resolve("@fontsource/manrope/files/manrope-latin-700-normal.woff2"), +) + +export class OgRenderError extends Schema.TaggedError()("OgRenderError", { + operation: Schema.Literals([ + "read-assets", + "register-fonts", + "compile-template", + "render-frame", + "encode-gif", + "encode-video", + ]), + message: Schema.String, + cause: Schema.Unknown, +}) {} + +export interface RenderOgOptions { + readonly conceptId?: OgConceptId + readonly outputDir: string + readonly repoRoot: string +} + +function rendererEffect(operation: OgRenderError["operation"], evaluate: () => Promise) { + return Effect.tryPromise({ + try: evaluate, + catch: (cause) => + new OgRenderError({ + operation, + message: `Open Graph ${operation} failed.`, + cause, + }), + }) +} + +export const renderOgConcepts = Effect.fn("og.render")(function* (options: RenderOgOptions) { + const fs = yield* FileSystem.FileSystem + const kumoStyles = fileURLToPath(import.meta.resolve("@cloudflare/kumo/styles")) + + const assets = yield* Effect.all({ + regular: fs.readFile(MANROPE_REGULAR_PATH), + bold: fs.readFile(MANROPE_BOLD_PATH), + logoSvg: fs.readFileString(join(options.repoRoot, "apps/web/public/images/solzero-logo.svg")), + appCss: fs.readFileString(join(options.repoRoot, "apps/web/src/styles/app.css")), + kumoTheme: fs.readFileString(join(dirname(kumoStyles), "theme-kumo.css")), + readme: fs.readFileString(join(options.repoRoot, "README.md")), + }).pipe( + Effect.mapError( + (cause) => + new OgRenderError({ + operation: "read-assets", + message: "Open Graph assets could not be read.", + cause, + }), + ), + ) + + const theme = yield* Effect.try({ + try: () => readOgTheme(assets.kumoTheme, assets.appCss), + catch: (cause) => + new OgRenderError({ + operation: "read-assets", + message: cause instanceof Error ? cause.message : "Kumo tokens could not be read.", + cause, + }), + }) + + const copy = yield* Effect.try({ + try: () => + createOgCopy({ + brandName: resolveS0Brand({}).name, + tagline: productTagline(assets.readme), + }), + catch: (cause) => + new OgRenderError({ + operation: "read-assets", + message: cause instanceof Error ? cause.message : "Open Graph copy could not be read.", + cause, + }), + }) + + const offsets = yield* Effect.try({ + try: () => markOffsets(assets.logoSvg), + catch: (cause) => + new OgRenderError({ + operation: "read-assets", + message: cause instanceof Error ? cause.message : "The SolZero mark could not be measured.", + cause, + }), + }) + + const renderer = new Renderer() + + yield* rendererEffect("register-fonts", () => + Promise.all([ + renderer.registerFont({ name: "Manrope", data: assets.regular, weight: 400 }), + renderer.registerFont({ name: "Manrope", data: assets.bold, weight: 700 }), + ]).then(() => undefined), + ) + + const images = logoImages(assets.logoSvg, theme.logoFill) + + const sceneProps: OgSceneProps = { + copy, + markCss: markMotionCss(offsets), + offsets, + themeCss: theme.css, + } + + yield* fs.makeDirectory(options.outputDir, { recursive: true }) + + const selected = options.conceptId + ? ogConcepts.filter((concept) => concept.id === options.conceptId) + : [...ogConcepts] + + const written: string[] = [] + + for (const concept of selected) { + const compiled = yield* rendererEffect("compile-template", () => + fromJsx(createElement(concept.Scene, sceneProps)), + ) + + const still = yield* renderFrame(renderer, compiled.node, compiled.css, images, 0, "png") + const stillPath = join(options.outputDir, `${concept.id}.png`) + + yield* fs.writeFile(stillPath, still) + written.push(stillPath) + + const videoPath = join(options.outputDir, `${concept.id}.mp4`) + + yield* encodeVideo(renderer, compiled.node, compiled.css, images, videoPath) + written.push(videoPath) + + const gif = yield* rendererEffect("encode-gif", () => + renderer.renderAnimation({ + width: OG_WIDTH, + height: OG_HEIGHT, + fps: OG_FPS, + format: "gif", + fontFamilies: ["Manrope"], + css: compiled.css, + images, + scenes: [{ durationMs: OG_LOOP_MS, node: compiled.node }], + }), + ) + + const gifPath = join(options.outputDir, `${concept.id}.gif`) + + yield* fs.writeFile(gifPath, gif) + written.push(gifPath) + } + + if (!options.conceptId) { + const sheet = yield* renderContactSheet( + renderer, + fs, + options.outputDir, + theme.css, + selected.map((concept) => concept.name), + ) + + written.push(sheet) + } + + return written +}) + +function logoImages(logoSvg: string, fill: string) { + const encode = (svg: string) => new TextEncoder().encode(svg) + + return [ + { src: "solzero-logo", data: encode(logoSvg.replaceAll("#000", fill)) }, + { src: "solzero-logo-mask", data: encode(logoSvg) }, + ] +} + +function renderFrame( + renderer: Renderer, + node: Awaited>["node"], + css: Awaited>["css"], + images: ReturnType, + timeMs: number, + format: "png" | "raw", +) { + return rendererEffect("render-frame", () => + renderer.render(node, { + width: OG_WIDTH, + height: OG_HEIGHT, + format, + timeMs, + fontFamilies: ["Manrope"], + css, + images, + dithering: "ordered-bayer", + }), + ) +} + +const encodeVideo = Effect.fn("og.encodeVideo")(function* ( + renderer: Renderer, + node: Awaited>["node"], + css: Awaited>["css"], + images: ReturnType, + outputPath: string, +) { + const count = frameCount() + + const ffmpeg = spawn( + "ffmpeg", + [ + "-y", + "-f", + "rawvideo", + "-pixel_format", + "rgba", + "-video_size", + `${OG_WIDTH}x${OG_HEIGHT}`, + "-framerate", + String(OG_FPS), + "-i", + "pipe:0", + "-an", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-crf", + "18", + "-movflags", + "+faststart", + outputPath, + ], + { stdio: ["pipe", "ignore", "pipe"] }, + ) + + const stderr: Buffer[] = [] + + ffmpeg.stderr.on("data", (chunk: Buffer) => { + stderr.push(chunk) + }) + + const closed = new Promise((resolvePromise, reject) => { + ffmpeg.on("error", reject) + ffmpeg.on("close", (code) => { + if (code === 0) resolvePromise() + else { + reject( + new Error( + `ffmpeg exited ${code}: ${Buffer.concat(stderr).toString("utf8").slice(-1600)}`, + ), + ) + } + }) + }) + + const stdin = ffmpeg.stdin + + if (!stdin) { + return yield* Effect.fail( + new OgRenderError({ + operation: "encode-video", + message: "ffmpeg did not open a frame pipe.", + cause: outputPath, + }), + ) + } + + for (let index = 0; index < count; index++) { + const frame = yield* renderFrame(renderer, node, css, images, frameTimeMs(index), "raw") + + yield* Effect.tryPromise({ + try: () => writeFrame(stdin, frame), + catch: (cause) => + new OgRenderError({ + operation: "encode-video", + message: "ffmpeg did not accept a frame.", + cause, + }), + }) + } + + stdin.end() + + yield* Effect.tryPromise({ + try: () => closed, + catch: (cause) => + new OgRenderError({ + operation: "encode-video", + message: "ffmpeg could not encode the Open Graph video.", + cause, + }), + }) +}) + +function writeFrame(stdin: Writable, frame: Uint8Array): Promise { + return new Promise((resolvePromise, reject) => { + stdin.write(frame, (error) => { + if (error) reject(error) + else resolvePromise() + }) + }) +} + +const renderContactSheet = Effect.fn("og.contactSheet")(function* ( + renderer: Renderer, + fs: FileSystem.FileSystem, + outputDir: string, + themeCss: string, + names: readonly string[], +) { + const stills = yield* Effect.all( + names.map((_, index) => { + const concept = ogConcepts[index] + + if (!concept) { + return Effect.fail( + new OgRenderError({ + operation: "render-frame", + message: "Contact sheet is missing a concept still.", + cause: index, + }), + ) + } + + return fs.readFile(join(outputDir, `${concept.id}.png`)) + }), + ) + + const size = sheetSize(names.length) + + const compiled = yield* rendererEffect("compile-template", () => + fromJsx(createElement(OgContactSheet, { names, themeCss })), + ) + + const sheet = yield* rendererEffect("render-frame", () => + renderer.render(compiled.node, { + width: size.width, + height: size.height, + format: "png", + fontFamilies: ["Manrope"], + css: compiled.css, + images: stills.map((data, index) => ({ src: `still-${index}`, data })), + }), + ) + + const outputPath = join(outputDir, "contact-sheet.png") + + yield* fs.writeFile(outputPath, sheet) + + return outputPath +}) diff --git a/apps/web/src/creative/og/scenes.tsx b/apps/web/src/creative/og/scenes.tsx new file mode 100644 index 00000000..73af7b63 --- /dev/null +++ b/apps/web/src/creative/og/scenes.tsx @@ -0,0 +1,652 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource react */ +import type { CSSProperties, ReactElement, ReactNode } from "react" +import type { OgCopy } from "./copy" +import { OG_HEIGHT, OG_HOLD, OG_WIDTH, loopAnimation } from "./frame" +import { MARK_SIZE } from "./mark" + +export interface OgSceneProps { + readonly copy: OgCopy + readonly markCss: string + readonly offsets: readonly { readonly offsetX: number; readonly offsetY: number }[] + readonly themeCss: string +} + +const stageStyle: CSSProperties = { + width: OG_WIDTH, + height: OG_HEIGHT, + display: "flex", + position: "relative", + overflow: "hidden", + boxSizing: "border-box", + backgroundColor: "var(--color-kumo-canvas)", + color: "var(--text-color-kumo-default)", + fontFamily: "Manrope", +} + +function Stage({ + themeCss, + motionCss, + children, +}: { + readonly themeCss: string + readonly motionCss: string + readonly children: ReactNode +}): ReactElement { + return ( +
+ + {children} +
+ ) +} + +function Lockup({ + name, + size = 48, +}: { + readonly name: string + readonly size?: number +}): ReactElement { + return ( +
+ + + {name} + +
+ ) +} + +const inUseMotion = ` + @keyframes in-use-line { + ${OG_HOLD} { transform: scaleX(1); } + 36% { transform: scaleX(0.12); } + } + @keyframes in-use-reply { + ${OG_HOLD} { opacity: 1; transform: translateY(0px); } + 30%, 52% { opacity: 0; transform: translateY(10px); } + } + @keyframes in-use-send { + ${OG_HOLD} { transform: scale(1); } + 28%, 36% { transform: scale(0.82); } + } +` + +function InUseScene({ copy, themeCss }: OgSceneProps): ReactElement { + return ( + +
+
+ + + {copy.placeholder} + +
+
+ + {copy.preparingAgent} + +
+
+
+
+
+
+ + ) +} + +function smokeOrb( + color: string, + left: number, + top: number, + size: number, + name: string, +): ReactElement { + return ( +
+ ) +} + +const heroMotion = ` + @keyframes smoke-a { + ${OG_HOLD} { transform: translate(0px, 0px); } + 40% { transform: translate(84px, -28px); } + 70% { transform: translate(-36px, 30px); } + } + @keyframes smoke-b { + ${OG_HOLD} { transform: translate(0px, 0px); } + 46% { transform: translate(-78px, 22px); } + 74% { transform: translate(42px, -18px); } + } + @keyframes smoke-c { + ${OG_HOLD} { transform: translate(0px, 0px) scale(1); } + 52% { transform: translate(20px, 16px) scale(1.16); } + } + @keyframes smoke-fill { + ${OG_HOLD} { background-position: 20% 40%; } + 50% { background-position: 80% 60%; } + } +` + +function HeroSmokeScene({ copy, themeCss }: OgSceneProps): ReactElement { + const [blue, bright, white] = copy.smokeColors + + return ( + + {smokeOrb(blue, 280, -30, 540, "smoke-a")} + {smokeOrb(bright, 560, 20, 420, "smoke-b")} + {smokeOrb(blue, 430, 70, 260, "smoke-c")} +
+
+ + {copy.welcome} + + + {copy.placeholder} + +
+ + ) +} + +const doneMotion = ` + @keyframes done-glow { + ${OG_HOLD} { transform: scale(1); opacity: 0.45; } + 48% { transform: scale(1.28); opacity: 0.85; } + 72% { transform: scale(1.04); opacity: 0.3; } + } + @keyframes done-mark { + ${OG_HOLD} { transform: scale(1); } + 42% { transform: scale(1.08); } + 68% { transform: scale(0.98); } + } +` + +function DoneScene({ copy, themeCss }: OgSceneProps): ReactElement { + return ( + +
+
+
+
+
+ + Done. + + + {copy.tagline} + +
+ + ) +} + +const beatMotion = ` + @keyframes beat-0 { + 0%, 6%, 94%, 100% { opacity: 1; } + 24%, 78% { opacity: 0; } + } + @keyframes beat-1 { + 0%, 28%, 52%, 100% { opacity: 0; } + 32%, 46% { opacity: 1; } + } + @keyframes beat-2 { + 0%, 56%, 84%, 100% { opacity: 0; } + 60%, 76% { opacity: 1; } + } + @keyframes beat-dot-0 { + 0%, 6%, 94%, 100% { transform: scale(1); background-color: var(--color-kumo-brand); } + 24%, 78% { transform: scale(0.72); background-color: var(--color-kumo-line); } + } + @keyframes beat-dot-1 { + 0%, 28%, 52%, 100% { transform: scale(0.72); background-color: var(--color-kumo-line); } + 32%, 46% { transform: scale(1); background-color: var(--color-kumo-brand); } + } + @keyframes beat-dot-2 { + 0%, 56%, 84%, 100% { transform: scale(0.72); background-color: var(--color-kumo-line); } + 60%, 76% { transform: scale(1); background-color: var(--color-kumo-brand); } + } +` + +function ThreeBeatsScene({ copy, themeCss }: OgSceneProps): ReactElement { + return ( + +
+ +
+ {copy.beats.map((beat, index) => ( +
+ + {beat} + +
+ ))} +
+ {copy.beats.map((beat, index) => ( +
+ ))} +
+ + ) +} + +const surfaceMotion = ` + @keyframes surface-0 { + ${OG_HOLD} { opacity: 1; transform: translateY(0px); } + 32%, 74% { opacity: 0; transform: translateY(-18px); } + } + @keyframes surface-1 { + ${OG_HOLD}, 26%, 60% { opacity: 0; transform: translateY(18px); } + 36%, 52% { opacity: 1; transform: translateY(0px); } + } + @keyframes surface-2 { + ${OG_HOLD}, 52% { opacity: 0; transform: translateY(18px); } + 64%, 82% { opacity: 1; transform: translateY(0px); } + } +` + +function SurfacesScene({ copy, themeCss }: OgSceneProps): ReactElement { + return ( + +
+
+ + + {copy.tagline} + +
+
+
+ {copy.surfaces.map((surface, index) => ( +
+ + {surface} + +
+ ))} +
+
+ + ) +} + +const workflowMotion = ` + @keyframes workflow-lap { + 0%, 6% { offset-distance: 0%; } + 94%, 100% { offset-distance: 100%; } + } +` + +const WORKFLOW_TRACK = { left: 360, top: 248, width: 700, height: 210 } + +function WorkflowScene({ copy, themeCss }: OgSceneProps): ReactElement { + const [webhook, agent, approval, email] = copy.stops + + return ( + +
+ +
+ + {copy.workflowTitle} + +
+ {webhook} + + {agent} + + + {approval} + + + {email} + +
+ + ) +} + +function stopStyle(left: number, top: number, align: "left" | "right" = "left"): CSSProperties { + return { + position: "absolute", + left, + top, + width: 320, + fontSize: 28, + fontWeight: 700, + letterSpacing: "-0.03em", + textAlign: align, + } +} + +const MARK_PANES = [ + { left: 0, top: 0, shiftX: 0, shiftY: 0 }, + { left: 0.5, top: 0, shiftX: -0.5, shiftY: 0 }, + { left: 0.5, top: 0.5, shiftX: -0.5, shiftY: -0.5 }, + { left: 0, top: 0.5, shiftX: 0, shiftY: -0.5 }, +] as const + +function MarkScene({ copy, markCss, offsets, themeCss }: OgSceneProps): ReactElement { + return ( + +
+
+ {offsets.map((offset, index) => { + const pane = MARK_PANES[index] + + if (!pane) return null + + return ( +
+ +
+ ) + })} +
+
+
+ +
+
+ ) +} + +export const ogConcepts = [ + { id: "in-use", name: "In use", Scene: InUseScene }, + { id: "hero-smoke", name: "Landing smoke", Scene: HeroSmokeScene }, + { id: "done", name: "Done", Scene: DoneScene }, + { id: "three-beats", name: "Chat, build, automate", Scene: ThreeBeatsScene }, + { id: "surfaces", name: "Agents, workflows, bots", Scene: SurfacesScene }, + { id: "workflow", name: "Workflow loop", Scene: WorkflowScene }, + { id: "mark", name: "The mark", Scene: MarkScene }, +] as const + +export type OgConceptId = (typeof ogConcepts)[number]["id"] diff --git a/apps/web/src/creative/og/sheet.tsx b/apps/web/src/creative/og/sheet.tsx new file mode 100644 index 00000000..41cd2c3a --- /dev/null +++ b/apps/web/src/creative/og/sheet.tsx @@ -0,0 +1,53 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource react */ +import type { ReactElement } from "react" +import { OG_HEIGHT, OG_WIDTH } from "./frame" + +const THUMB_WIDTH = 300 +const THUMB_HEIGHT = Math.round((THUMB_WIDTH * OG_HEIGHT) / OG_WIDTH) +const GAP = 18 +const PAD = 28 + +export function sheetSize(count: number) { + return { + width: PAD * 2 + count * THUMB_WIDTH + (count - 1) * GAP, + height: PAD * 2 + THUMB_HEIGHT + 44, + } +} + +export function OgContactSheet({ + names, + themeCss, +}: { + readonly names: readonly string[] + readonly themeCss: string +}): ReactElement { + const size = sheetSize(names.length) + + return ( +
+ + {names.map((name, index) => ( +
+ + {name} +
+ ))} +
+ ) +} diff --git a/apps/web/src/creative/og/theme.ts b/apps/web/src/creative/og/theme.ts new file mode 100644 index 00000000..019b4126 --- /dev/null +++ b/apps/web/src/creative/og/theme.ts @@ -0,0 +1,186 @@ +const REQUIRED_TOKENS = [ + "--color-kumo-canvas", + "--color-kumo-elevated", + "--color-kumo-brand", + "--color-kumo-line", + "--color-kumo-success", + "--text-color-kumo-default", + "--text-color-kumo-subtle", + "--text-color-kumo-strong", +] as const + +export interface OgTheme { + readonly css: string + readonly logoFill: string +} + +export function readOgTheme(kumoCss: string, appCss: string): OgTheme { + const merged = new Map() + + for (const block of blocksMatching(kumoCss, ':root[data-mode="dark"]')) { + for (const [name, value] of readDeclarations(block)) merged.set(name, value) + } + + for (const block of blocksMatching(appCss, ":root")) { + for (const [name, value] of readDeclarations(block)) merged.set(name, value) + } + + for (const block of blocksMatching(appCss, '[data-mode="dark"]')) { + for (const [name, value] of readDeclarations(block)) merged.set(name, value) + } + + const missing = REQUIRED_TOKENS.filter((token) => !merged.has(token)) + + if (missing.length > 0) { + throw new Error(`Kumo theme is missing ${missing.join(", ")}.`) + } + + const logoFill = svgFill(merged.get("--text-color-kumo-strong") ?? "") + const lines = [...merged].map(([name, value]) => ` ${name}: ${value};`) + + return { + css: `:root {\n${lines.join("\n")}\n}`, + logoFill, + } +} + +export function svgFill(value: string): string { + const color = concreteColor(value) + + if (color.startsWith("#")) return color + + const oklch = color.match(/^oklch\(\s*([0-9.]+)%\s+([0-9.]+)\s+([0-9.]+)/i) + const lightness = oklch?.[1] + const chroma = oklch?.[2] + const hue = oklch?.[3] + + if (!lightness || !chroma || !hue) { + throw new Error(`SVG fill cannot use ${color}.`) + } + + return oklchToHex(Number(lightness) / 100, Number(chroma), Number(hue)) +} + +function oklchToHex(lightness: number, chroma: number, hue: number): string { + const radians = (hue * Math.PI) / 180 + const a = chroma * Math.cos(radians) + const b = chroma * Math.sin(radians) + const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3) + const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3) + const s = Math.pow(lightness - 0.0894841775 * a - 1.291485548 * b, 3) + const red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s + const green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s + const blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + + return `#${[red, green, blue].map((channel) => channelHex(channel)).join("")}` +} + +function channelHex(channel: number): string { + const linear = Math.min(1, Math.max(0, channel)) + const encoded = linear <= 0.0031308 ? 12.92 * linear : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055 + + return Math.round(Math.min(1, Math.max(0, encoded)) * 255) + .toString(16) + .padStart(2, "0") +} + +export function concreteColor(value: string): string { + const current = value.trim() + const wrapped = current.match(/^var\(\s*--[A-Za-z0-9-]+\s*,\s*([\s\S]*)\)$/) + const fallback = wrapped?.[1]?.trim() + + return fallback ? concreteColor(fallback) : current +} + +function blocksMatching(css: string, marker: string): string[] { + const blocks: string[] = [] + let from = 0 + + while (from < css.length) { + const at = css.indexOf(marker, from) + + if (at < 0) break + + const open = css.indexOf("{", at) + + if (open < 0) break + + if (css.slice(at, open).includes(";")) { + from = at + marker.length + continue + } + + const body = blockBody(css, open) + + if (body) blocks.push(body) + + from = open + 1 + } + + return blocks +} + +function blockBody(css: string, open: number): string | undefined { + let depth = 0 + + for (let index = open; index < css.length; index++) { + const char = css[index] + + if (char === "{") depth += 1 + + if (char === "}") { + depth -= 1 + + if (depth === 0) return css.slice(open + 1, index) + } + } + + return undefined +} + +function readDeclarations(block: string): Map { + const declarations = new Map() + let index = 0 + + while (index < block.length) { + const start = block.indexOf("--", index) + + if (start < 0) break + + const colon = block.indexOf(":", start) + + if (colon < 0 || colon - start > 80) { + index = start + 2 + continue + } + + const name = block.slice(start, colon).trim() + + if (!/^--[a-z0-9-]+$/i.test(name)) { + index = start + 2 + continue + } + + let depth = 0 + let end = colon + 1 + + for (; end < block.length; end++) { + const char = block[end] + + if (char === "(") depth += 1 + else if (char === ")") depth -= 1 + else if (char === ";" && depth === 0) break + } + + const value = block + .slice(colon + 1, end) + .replace(/\s+/g, " ") + .trim() + + if (value) declarations.set(name, value) + + index = end + 1 + } + + return declarations +} diff --git a/package.json b/package.json index 6758f2a7..cbeef15a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "migration:storage": "tsx scripts/migration/storage.ts", "typecheck:migration": "tsrs --noEmit -p tsconfig.migration.json", "creative:release-card": "nub run --filter @solzero/background-web render:release", + "creative:og": "nub run --filter @solzero/background-web render:og", "github:sync-env-secrets": "tsx scripts/sync-github-env-secrets.ts", "tegami": "tsx scripts/tegami.mts", "workflow:runtime:audit": "tsx scripts/workflows/audit-runtime-abi.ts",