diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index fbf4c2c12..b3ff435d6 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -60,7 +60,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, }); @@ -72,6 +72,25 @@ describe("renderLocal browser GPU config", () => { }); }); + it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => { + const { renderLocal } = await import("./render.js"); + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: 30, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "auto", + hdrMode: "auto", + quiet: true, + }); + + expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "auto" }); + expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({ + browserGpuMode: "auto", + resolved: true, + }); + }); + it("passes an explicit hardware override for default local browser GPU", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { @@ -79,7 +98,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: true, + browserGpuMode: "hardware", hdrMode: "auto", quiet: true, }); @@ -94,12 +113,18 @@ describe("renderLocal browser GPU config", () => { it("resolves browser GPU from CLI flags, Docker mode, and env fallback", async () => { const { resolveBrowserGpuForCli } = await import("./render.js"); - expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe(true); - expect(resolveBrowserGpuForCli(false, undefined, "hardware")).toBe(true); - expect(resolveBrowserGpuForCli(false, undefined, "software")).toBe(false); - expect(resolveBrowserGpuForCli(false, true, "software")).toBe(true); - expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe(false); - expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe(false); + // Default (no flag, no env): auto — engine probes and chooses. + expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto"); + // Env override + expect(resolveBrowserGpuForCli(false, undefined, "hardware")).toBe("hardware"); + expect(resolveBrowserGpuForCli(false, undefined, "software")).toBe("software"); + expect(resolveBrowserGpuForCli(false, undefined, "auto")).toBe("auto"); + // Explicit CLI flag wins over env + expect(resolveBrowserGpuForCli(false, true, "software")).toBe("hardware"); + expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe("software"); + // Docker forces software regardless of flags/env + expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe("software"); + expect(resolveBrowserGpuForCli(true, undefined, "auto")).toBe("software"); }); it("forwards parsed --variables payload to createRenderJob", async () => { @@ -109,7 +134,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, variables: { title: "Hello", count: 3 }, @@ -125,7 +150,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, }); @@ -147,7 +172,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: true, + browserGpuMode: "hardware", hdrMode: "auto", quiet: true, exitAfterComplete: true, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 009da0b7b..ae2c8df8f 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -118,7 +118,7 @@ export default defineCommand({ "browser-gpu": { type: "boolean", description: - "Use host GPU acceleration for Chrome/WebGL capture. Enabled by default for local renders; use --no-browser-gpu to opt out.", + "Force host GPU acceleration for Chrome/WebGL capture. Default: auto (probe on first launch; fall back to software if no GPU). Use --no-browser-gpu to force software (SwiftShader).", }, quiet: { type: "boolean", @@ -224,7 +224,7 @@ export default defineCommand({ const useDocker = args.docker ?? false; const useGpu = args.gpu ?? false; const browserGpuArg = args["browser-gpu"]; - const useBrowserGpu = resolveBrowserGpuForCli(useDocker, browserGpuArg); + const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg); const quiet = args.quiet ?? false; const strictAll = args["strict-all"] ?? false; const strictErrors = (args.strict ?? false) || strictAll; @@ -275,10 +275,14 @@ export default defineCommand({ c.dim(" \u2192 " + outputPath), ); console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel)); - if (useGpu || useBrowserGpu) { + if (useGpu || browserGpuMode !== "software") { const gpuModes = [ useGpu ? "encoder GPU" : null, - useBrowserGpu ? "browser GPU (auto)" : null, + browserGpuMode === "hardware" + ? "browser GPU (forced)" + : browserGpuMode === "auto" + ? "browser GPU (auto-detect)" + : null, ].filter(Boolean); console.log(c.dim(" GPU: " + gpuModes.join(" + "))); } @@ -397,7 +401,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - browserGpu: useBrowserGpu, + browserGpuMode, hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, @@ -412,7 +416,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - browserGpu: useBrowserGpu, + browserGpuMode, hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, @@ -431,7 +435,12 @@ interface RenderOptions { format: "mp4" | "webm" | "mov"; workers?: number; gpu: boolean; - browserGpu: boolean; + /** + * Chrome WebGL backend mode. "auto" probes on first launch and falls back + * to "software" if no usable GPU. Defaults to "software" when omitted to + * stay backwards-compatible with callers that pre-date the tri-state. + */ + browserGpuMode?: "auto" | "hardware" | "software"; hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; videoBitrate?: string; @@ -579,15 +588,33 @@ export function validateVariablesAgainstProject( return validateVariables(values, meta.variables); } +/** + * Resolve the browser-GPU mode for a CLI render invocation. + * + * Priority (highest first): + * 1. Docker mode → always "software" (docker has no portable GPU + * passthrough; the engine's render path uses SwiftShader). + * 2. Explicit CLI flag — `--browser-gpu` → "hardware", + * `--no-browser-gpu` → "software". + * 3. Env var `PRODUCER_BROWSER_GPU_MODE` accepts "hardware" / "software" / + * "auto". + * 4. Default = "auto" — engine probes WebGL availability on first launch + * and falls back to software if the host lacks a usable GPU. + * + * Returning "auto" by default lets local renders Just Work whether or not the + * host has a GPU, while preserving the explicit overrides for CI / power + * users who want failure-on-misconfig. + */ export function resolveBrowserGpuForCli( useDocker: boolean, browserGpuArg: boolean | undefined, envMode = process.env.PRODUCER_BROWSER_GPU_MODE, -): boolean { - if (useDocker) return false; - if (browserGpuArg !== undefined) return browserGpuArg; - if (envMode === "software") return false; - return true; +): "auto" | "hardware" | "software" { + if (useDocker) return "software"; + if (browserGpuArg === true) return "hardware"; + if (browserGpuArg === false) return "software"; + if (envMode === "hardware" || envMode === "software" || envMode === "auto") return envMode; + return "auto"; } const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; @@ -707,7 +734,7 @@ async function renderDocker( format: options.format, workers: options.workers, gpu: options.gpu, - browserGpu: options.browserGpu, + browserGpu: options.browserGpuMode === "hardware", hdrMode: options.hdrMode, crf: options.crf, videoBitrate: options.videoBitrate, @@ -777,7 +804,7 @@ export async function renderLocal( workers: options.workers, useGpu: options.gpu, producerConfig: producer.resolveConfig({ - browserGpuMode: options.browserGpu ? "hardware" : "software", + browserGpuMode: options.browserGpuMode ?? "software", }), hdrMode: options.hdrMode, crf: options.crf, diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md index 15a4c44f5..c2b49c466 100644 --- a/packages/cli/src/docs/rendering.md +++ b/packages/cli/src/docs/rendering.md @@ -20,13 +20,13 @@ Requires: Docker installed and running. - `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`) - `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`) - `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI, QSV) -- `--browser-gpu` / `--no-browser-gpu` — Use or opt out of host GPU acceleration for local Chrome/WebGL capture (enabled by default for local renders, disabled in Docker) +- `--browser-gpu` / `--no-browser-gpu` — Force host GPU or software (SwiftShader) for Chrome/WebGL capture. Default for local renders is `auto` — probe WebGL availability on first launch and fall back to software if no GPU is reachable. Docker mode always uses software. - `-o, --output` — Custom output path ## Tips - Use `draft` quality for fast previews during development -- Local renders use browser GPU capture automatically; use `--no-browser-gpu` to compare against the software-browser path +- Local renders auto-detect GPU on first launch; use `--browser-gpu` to force hardware (errors if no GPU) or `--no-browser-gpu` to force SwiftShader - Use `--gpu` when a local render also benefits from hardware FFmpeg encoding - Use `npx hyperframes benchmark` to find optimal settings - 4 workers is usually the sweet spot for most compositions diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 02bc3f02f..0e29345b7 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -97,6 +97,13 @@ describe("resolveConfig", () => { expect(config.browserGpuMode).toBe("hardware"); }); + it("accepts 'auto' as a valid browser GPU mode env value", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "auto"); + + const config = resolveConfig(); + expect(config.browserGpuMode).toBe("auto"); + }); + it("falls back to software browser GPU mode for invalid env values", () => { setEnv("PRODUCER_BROWSER_GPU_MODE", "native"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index 9ae23ea6c..c2f9bc130 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -32,10 +32,15 @@ export interface EngineConfig { chromePath?: string; disableGpu: boolean; /** - * Chrome/WebGL rendering backend. "software" keeps the existing SwiftShader - * path for reproducible output; "hardware" lets Chrome use the host GPU. + * Chrome/WebGL rendering backend. + * - "software": SwiftShader (CPU-only). Always works; ~5-50× slower than GPU. + * - "hardware": host GPU via platform-native ANGLE backend (Metal/D3D11/EGL). + * Errors if no usable GPU is reachable from Chrome. + * - "auto": probe Chrome for WebGL availability on first launch in this + * process; fall back to software if hardware-mode WebGL is unavailable. + * Cost: one extra Chrome launch (~1-2 s) per process; result cached. */ - browserGpuMode: "software" | "hardware"; + browserGpuMode: "software" | "hardware" | "auto"; enableBrowserPool: boolean; browserTimeout: number; protocolTimeout: number; @@ -173,7 +178,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { }; const envBrowserGpuMode = (): EngineConfig["browserGpuMode"] => { const raw = env("PRODUCER_BROWSER_GPU_MODE"); - if (raw === "hardware" || raw === "software") return raw; + if (raw === "hardware" || raw === "software" || raw === "auto") return raw; return DEFAULT_CONFIG.browserGpuMode; }; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 28e533c76..88303bb1f 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -50,6 +50,7 @@ export { acquireBrowser, releaseBrowser, resolveHeadlessShellPath, + resolveBrowserGpuMode, buildChromeArgs, ENABLE_BROWSER_POOL, type BuildChromeArgsOptions, diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index f30cd3a3e..08af34802 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildChromeArgs, forceReleaseBrowser } from "./browserManager.js"; +import { + _resetAutoBrowserGpuModeCacheForTests, + buildChromeArgs, + forceReleaseBrowser, + resolveBrowserGpuMode, +} from "./browserManager.js"; describe("buildChromeArgs browser GPU mode", () => { const base = { width: 1920, height: 1080 }; @@ -10,6 +15,7 @@ describe("buildChromeArgs browser GPU mode", () => { expect(args).toContain("--enable-features=CanvasDrawElement"); expect(args).toContain("--use-gl=angle"); expect(args).toContain("--use-angle=swiftshader"); + expect(args).toContain("--enable-unsafe-swiftshader"); expect(args).not.toContain("--enable-gpu-rasterization"); }); @@ -48,6 +54,80 @@ describe("buildChromeArgs browser GPU mode", () => { }); }); +describe("resolveBrowserGpuMode", () => { + beforeEach(() => { + _resetAutoBrowserGpuModeCacheForTests(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + _resetAutoBrowserGpuModeCacheForTests(); + }); + + it("passes 'software' through unchanged without probing", async () => { + const mode = await resolveBrowserGpuMode("software"); + expect(mode).toBe("software"); + }); + + it("passes 'hardware' through unchanged without probing", async () => { + const mode = await resolveBrowserGpuMode("hardware"); + expect(mode).toBe("hardware"); + }); + + it("falls back to 'software' when the probe browser cannot launch", async () => { + // No chromePath, env unset, and (in the test env) no system Chrome to find + // → puppeteer.launch will throw → caller catches → software fallback. + // Force a definitely-missing chrome binary so the launch path errors fast. + const mode = await resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + expect(mode).toBe("software"); + }); + + it("caches the probe result across calls", async () => { + const first = await resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + // Second call uses cache — no new launch. Assert the same answer comes back + // even with a different chromePath that would have a different probe outcome. + const second = await resolveBrowserGpuMode("auto", { + chromePath: "/another/definitely/missing/path", + browserTimeout: 2000, + }); + expect(first).toBe("software"); + expect(second).toBe("software"); + // Reset and re-probe to confirm the test-only reset works. + _resetAutoBrowserGpuModeCacheForTests(); + const third = await resolveBrowserGpuMode("hardware"); + expect(third).toBe("hardware"); + }); + + it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => { + // Parallel coordinator fires N workers via Promise.all — without Promise- + // level caching, a `--workers 4` render against a no-GPU host would launch + // 4 simultaneous probe Chromes. Verify all concurrent callers get the + // exact same Promise reference (proving the probe runs once, not N times). + const p1 = resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + const p2 = resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + const p3 = resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + expect(p1).toBe(p2); + expect(p2).toBe(p3); + const results = await Promise.all([p1, p2, p3]); + expect(results).toEqual(["software", "software", "software"]); + }); +}); + describe("forceReleaseBrowser", () => { it("kills the browser process and disconnects", () => { const killFn = vi.fn(() => true); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 8c63de5eb..714b9a938 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -136,6 +136,119 @@ async function probeBeginFrameSupport(browser: Browser): Promise { } } +/** + * Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`. + * + * Caching the Promise (rather than the resolved value) deduplicates concurrent + * callers — the parallel coordinator runs N workers via `Promise.all`, so a + * `--workers 4` render against a no-GPU host would otherwise fire 4 + * simultaneous probe Chromes. The first call assigns the Promise and every + * other concurrent caller awaits the same one, paying the ~240 ms probe cost + * exactly once per process lifetime. + * + * Exported for tests; production callers go through `resolveBrowserGpuMode`. + */ +export let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined; + +/** Test-only: reset the cached probe result. */ +export function _resetAutoBrowserGpuModeCacheForTests(): void { + _autoBrowserGpuModeCache = undefined; +} + +/** + * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer. + * + * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"` + * it launches a tiny Chrome with the platform's hardware GPU args, runs a + * one-shot WebGL availability probe, and falls back to `"software"` if + * hardware-mode WebGL is unavailable. The Promise is cached for the process + * lifetime, so concurrent callers (parallel workers) share the same probe. + * + * Any failure (Chrome launch error, navigation timeout, missing canvas API, + * etc.) is treated as a `"software"` fallback. The render path with + * SwiftShader always works, so a misclassification toward software is the + * safe failure mode; misclassifying toward hardware would error on the real + * render. + */ +export function resolveBrowserGpuMode( + mode: EngineConfig["browserGpuMode"], + options: { + chromePath?: string; + browserTimeout?: number; + platform?: NodeJS.Platform; + } = {}, +): Promise<"software" | "hardware"> { + if (mode !== "auto") return Promise.resolve(mode); + if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache; + + _autoBrowserGpuModeCache = (async () => { + const platform = options.platform ?? process.platform; + const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; + const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); + + const probeArgs = [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-webgl", + "--ignore-gpu-blocklist", + ...getBrowserGpuArgs("hardware", platform), + ]; + + const ppt = await getPuppeteer().catch(() => null); + if (!ppt) { + logResolvedBrowserGpuMode("software", "puppeteer unavailable"); + return "software" as const; + } + + let probeBrowser: Browser | undefined; + try { + probeBrowser = await ppt.launch({ + headless: true, + args: probeArgs, + defaultViewport: { width: 64, height: 64 }, + executablePath, + timeout: browserTimeout, + }); + const page = await probeBrowser.newPage(); + const hasWebGL = await page.evaluate(() => { + try { + const c = document.createElement("canvas"); + const gl = + c.getContext("webgl") || + (c.getContext("experimental-webgl") as RenderingContext | null); + return gl !== null; + } catch { + return false; + } + }); + const resolved = hasWebGL ? ("hardware" as const) : ("software" as const); + logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable"); + return resolved; + } catch (err) { + logResolvedBrowserGpuMode( + "software", + `probe failed (${err instanceof Error ? err.message : String(err)})`, + ); + return "software" as const; + } finally { + await probeBrowser?.close().catch(() => {}); + } + })(); + + return _autoBrowserGpuModeCache; +} + +/** + * Single observability surface for the auto-detect outcome. Logged exactly + * once per process (the probe runs once); without this line, a regression + * to "always software even with a GPU present" would be invisible in + * production. Goes to stderr to stay out of stdout pipelines. + */ +function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void { + console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`); +} + export async function acquireBrowser( chromeArgs: string[], config?: Partial< @@ -344,7 +457,20 @@ function getBrowserGpuArgs( platform: NodeJS.Platform, ): string[] { if (mode === "software") { - return ["--use-gl=angle", "--use-angle=swiftshader"]; + // Chrome 120+ deprecated implicit SwiftShader fallback; the explicit + // path (--use-angle=swiftshader) keeps working but Chrome emits a + // deprecation warning unless --enable-unsafe-swiftshader is also set. + // Despite the name, this is exactly the behaviour Chrome had before; + // the flag exists to make CPU rasterisation an explicit opt-in rather + // than an implicit fallback for end users on the open web. + return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]; + } + + if (mode === "auto") { + // Should not reach here — `resolveBrowserGpuMode` collapses "auto" to + // "software" or "hardware" before args are built. Be defensive: software + // is the always-safe fallback. + return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]; } switch (platform) { diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index d19e8cf6f..98307ab54 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -18,6 +18,7 @@ import { releaseBrowser, forceReleaseBrowser, buildChromeArgs, + resolveBrowserGpuMode, resolveHeadlessShellPath, type CaptureMode, } from "./browserManager.js"; @@ -115,9 +116,14 @@ export async function createCaptureSession( const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot; const preMode: CaptureMode = headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot"; + const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode; + const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, { + chromePath: headlessShell ?? undefined, + browserTimeout: config?.browserTimeout, + }); const chromeArgs = buildChromeArgs( { width: options.width, height: options.height, captureMode: preMode }, - config, + { ...config, browserGpuMode: resolvedGpuMode }, ); const { browser, captureMode } = await acquireBrowser(chromeArgs, config); diff --git a/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html b/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html index 657dc7deb..2ac7ac054 100644 --- a/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html +++ b/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html @@ -52,20 +52,34 @@ overflow: hidden; } .text-source::before { - content: ''; + content: ""; position: absolute; - width: 700px; height: 700px; border-radius: 50%; - background: radial-gradient(circle, rgba(0,212,255,0.06) 0%, transparent 70%); - top: -150px; right: -100px; pointer-events: none; + width: 700px; + height: 700px; + border-radius: 50%; + background: radial-gradient(circle, rgba(0, 212, 255, 0.06) 0%, transparent 70%); + top: -150px; + right: -100px; + pointer-events: none; } .text-source::after { - content: ''; + content: ""; position: absolute; - width: 500px; height: 500px; border-radius: 50%; - background: radial-gradient(circle, rgba(124,58,237,0.05) 0%, transparent 70%); - bottom: -100px; left: -50px; pointer-events: none; + width: 500px; + height: 500px; + border-radius: 50%; + background: radial-gradient(circle, rgba(124, 58, 237, 0.05) 0%, transparent 70%); + bottom: -100px; + left: -50px; + pointer-events: none; + } + .badge { + font-size: 14px; + font-weight: 600; + letter-spacing: 3px; + text-transform: uppercase; + color: rgba(0, 212, 255, 0.8); } - .badge { font-size: 14px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; color: rgba(0,212,255,0.8); } .text-source h1 { font-size: 148px; font-weight: 900; @@ -83,7 +97,7 @@ .subtitle { font-size: 28px; font-weight: 400; - color: rgba(255,255,255,0.35); + color: rgba(255, 255, 255, 0.35); max-width: 800px; line-height: 1.5; } @@ -92,9 +106,23 @@ gap: 48px; margin-top: 16px; } - .stat { text-align: center; } - .stat-val { font-size: 42px; font-weight: 800; color: #00d4ff; letter-spacing: -1px; } - .stat-label { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 2px; margin-top: 4px; } + .stat { + text-align: center; + } + .stat-val { + font-size: 42px; + font-weight: 800; + color: #00d4ff; + letter-spacing: -1px; + } + .stat-label { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 2px; + margin-top: 4px; + } @@ -125,11 +153,23 @@
Write HTML → Render Video

Ship videos 10x faster

-
HTML is the source of truth for video. No timeline editors, no After Effects — just code.
+
+ HTML is the source of truth for video. No timeline editors, no After Effects — just + code. +
-
47x
Faster than AE
-
12.4K
Creators
-
2.4M
Videos Rendered
+
+
47x
+
Faster than AE
+
+
+
12.4K
+
Creators
+
+
+
2.4M
+
Videos Rendered
+
diff --git a/registry/registry.json b/registry/registry.json index 52660dc00..596c4494b 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -252,4 +252,4 @@ "type": "hyperframes:block" } ] -} \ No newline at end of file +}