diff --git a/services/site/DESIGN.md b/services/site/DESIGN.md index 4b40e922431..0ef1040cb6b 100644 --- a/services/site/DESIGN.md +++ b/services/site/DESIGN.md @@ -115,7 +115,7 @@ Motion decisions follow Emil Kowalski's skills ([emilkowalski/skills](https://gi - Show real product footage and screenshots only. - Ship no placeholders; hide a media slot until its real content exists, as on the changelog. → [Timeline](/dev/components#changelog-timeline) - Put heavy media (video, audio, zip, glb, wasm, anything over 256KB) on the CDN, not in git; SVGs, favicons, Open Graph images and small posters stay in git. -- Give every video a poster; card videos rest on a still and play on hover. → [Product carousel](/dev/components#carousel) +- Give every video a poster; card videos rest on a short loop, started from its still once the page has loaded, and play the demo on hover. → [Product carousel](/dev/components#carousel) - Caption videos that have speech. ## Accessibility diff --git a/services/site/MEDIA.md b/services/site/MEDIA.md index 8eb584b73de..f195681c58e 100644 --- a/services/site/MEDIA.md +++ b/services/site/MEDIA.md @@ -9,7 +9,7 @@ The site's heavy media (product videos, arcade sounds, 3D models and download zi - The build (`script/build.ts`) drops every `mediaFile` from the Worker's assets, so a local copy in `public/` never ships. - In development, `vite dev` proxies `/files` to `https://dev.opencode.ai`, so pages play the dev bucket's copies. - Images (`png`, `jpg`, `webp`, `avif`, `svg`), posters, fonts and the 3D decoders (`arcade3d/draco`, `arcade3d/basis`) stay in git and in the static assets. -- Only the H.264 originals are served. The AV1 and HEVC copies `media-manifest.json` once described were never uploaded, so `videoSources()` offers the original alone. +- `videoSources()` offers a video's AV1 and HEVC copies (`media/....mp4`, listed in `src/component/site/media-manifest.json`) before the H.264 original. The manifest lists only copies that are in the bucket; a video without copies plays its original. | Folder | Files | | --- | --- | diff --git a/services/site/media-manifest.txt b/services/site/media-manifest.txt index 403e4f69196..77de2ed5684 100644 --- a/services/site/media-manifest.txt +++ b/services/site/media-manifest.txt @@ -102,6 +102,8 @@ b3b147cdb22ec2117d539c18490c7103adaa088a13600852b5a5363f667ee051 186628 /brand 8998172a4ff22a6b292da18c87a21998728bc86f94b3828e536298ca6b00da54 149085 /brand/badges/opencode-works-with.zip 140f2c17fa7235f2199a3db22f1eda3eccbfb8ce98da39ee7b1e3e9eb9387846 6801928 /font/film/opencode-font-film-v3-1080p.mp4 c0d7932b3c2f5d27368e2039e580c169ac94802b8317260e5e679551c41e8461 6300093 /font/film/opencode-font-film-v3-4x5.mp4 +a8d0f5bc1395a756affd9cff1cd0db46e97fba0c1d72508d0e665c62a8a92c2d 146992 /media/card-loops/mobile-orb-75.720.av1.a8d0f5bc.mp4 +a82f1012a18962731b12ee4b07da137a8a89165b3aef838705c43d75d186ef90 336142 /media/card-loops/mobile-orb-75.720.hevc.a82f1012.mp4 927d3f29a862b2af9853d2ba951f29e4ff381e0c5f1b08c7d8b34cd62b7128e2 2818872 /opencode-brand-assets.zip 0f1d6dfc747f2d88650b2073bb04cd41380241adfa4de3e2f070bdc4bb367579 272426 /press/anomaly/anomaly-logo.zip 9cba0d1d2108c8af4e22cc54cafcd8532101d6bbd2809ac18dd26ff1aaf615b3 5999115 /press-media/zips/opencode-desktop-media.zip diff --git a/services/site/script/check-client-bundle.ts b/services/site/script/check-client-bundle.ts index ba9771928a4..f7fc1277aba 100644 --- a/services/site/script/check-client-bundle.ts +++ b/services/site/script/check-client-bundle.ts @@ -66,23 +66,23 @@ console.log( const budgets: Record = { "[...404].tsx": 104.1, "brand/index.tsx": 104.5, - "changelog/[tag].tsx": 97.8, - "changelog/index.tsx": 97, - "desktop.tsx": 126.5, + "changelog/[tag].tsx": 99.2, + "changelog/index.tsx": 98.4, + "desktop.tsx": 127.9, "devices.tsx": 56.8, - "product/browse.tsx": 102.8, - "font/index.tsx": 118.7, - "ide.tsx": 126.5, - "index.tsx": 113.4, + "product/browse.tsx": 104.2, + "font/index.tsx": 120.1, + "ide.tsx": 127.9, + "index.tsx": 114.9, "legal/index.tsx": 148.3, "legal/privacy-policy/index.tsx": 137.8, "legal/terms-of-service/index.tsx": 133.2, - "mini.tsx": 126.5, - "mobile.tsx": 126.5, - "press/index.tsx": 111.8, - "product/index.tsx": 111.3, - "sdk.tsx": 126.5, - "terminal.tsx": 126.5, + "mini.tsx": 127.9, + "mobile.tsx": 127.9, + "press/index.tsx": 113.2, + "product/index.tsx": 112.7, + "sdk.tsx": 127.9, + "terminal.tsx": 127.9, "video-editor/index.tsx": 55.2, } type Chunk = { file: string; imports?: string[] } diff --git a/services/site/src/component/site/media-manifest.json b/services/site/src/component/site/media-manifest.json index 752fe62eafb..dc3cb7cb477 100644 --- a/services/site/src/component/site/media-manifest.json +++ b/services/site/src/component/site/media-manifest.json @@ -4,30 +4,6 @@ [1920, "av1", "f4aac9d0", 8], [1920, "hevc", "9a8fdd1e", 120] ], - "/product-videos/card-hover/angled-continue/desktop.mp4": [ - [1080, "av1", "3d944be5", 8, "hi"], - [720, "av1", "8c2f11ae", 4], - [1080, "hevc", "06dafc49", 120, "hi"], - [720, "hevc", "dd4ef775", 93] - ], - "/product-videos/card-hover/angled-continue/mobile.mp4": [ - [1080, "av1", "1d02d18f", 8, "hi"], - [720, "av1", "fc122802", 4], - [1080, "hevc", "029f5c19", 120, "hi"], - [720, "hevc", "c94ebb22", 93] - ], - "/product-videos/card-hover/angled-continue/sdk.mp4": [ - [1080, "av1", "2eef5975", 8, "hi"], - [720, "av1", "35b2a488", 4], - [1080, "hevc", "d7a76c34", 120, "hi"], - [720, "hevc", "270b3d5f", 93] - ], - "/product-videos/card-hover/angled-continue/terminal.mp4": [ - [1080, "av1", "de1a465e", 8, "hi"], - [720, "av1", "32caf68d", 4], - [1080, "hevc", "1fb02df6", 120, "hi"], - [720, "hevc", "383908d2", 93] - ], "/product-videos/card-hover/angled/desktop.mp4": [ [1080, "av1", "000848bb", 8, "hi"], [720, "av1", "7a8ced27", 4], @@ -52,54 +28,6 @@ [1080, "hevc", "5802fcd0", 120, "hi"], [720, "hevc", "33b9daa2", 93] ], - "/product-videos/card-hover/cinematic-continue/desktop.mp4": [ - [1080, "av1", "79e8094e", 8, "hi"], - [720, "av1", "50aaddc3", 4], - [1080, "hevc", "efc2e19a", 120, "hi"], - [720, "hevc", "7b98ff3e", 93] - ], - "/product-videos/card-hover/cinematic-continue/mobile.mp4": [ - [1080, "av1", "735cceb4", 8, "hi"], - [720, "av1", "97fa967a", 4], - [1080, "hevc", "d0d339af", 120, "hi"], - [720, "hevc", "ed5ead76", 93] - ], - "/product-videos/card-hover/cinematic-continue/sdk.mp4": [ - [1080, "av1", "5b45cb59", 8, "hi"], - [720, "av1", "e2cc8efd", 4], - [1080, "hevc", "fb1bc058", 120, "hi"], - [720, "hevc", "e0752306", 93] - ], - "/product-videos/card-hover/cinematic-continue/terminal.mp4": [ - [1080, "av1", "c6c59bed", 8, "hi"], - [720, "av1", "2f9535de", 4], - [1080, "hevc", "6995f361", 120, "hi"], - [720, "hevc", "80276e87", 93] - ], - "/product-videos/card-hover/cinematic/desktop.mp4": [ - [1080, "av1", "829c3da8", 8, "hi"], - [720, "av1", "598a8997", 4], - [1080, "hevc", "176438ec", 120, "hi"], - [720, "hevc", "70baa9dc", 93] - ], - "/product-videos/card-hover/cinematic/mobile.mp4": [ - [1080, "av1", "0c67eb98", 8, "hi"], - [720, "av1", "b5fa7ce2", 4], - [1080, "hevc", "5abf1f1e", 120, "hi"], - [720, "hevc", "d9b47452", 93] - ], - "/product-videos/card-hover/cinematic/sdk.mp4": [ - [1080, "av1", "0de9145c", 8, "hi"], - [720, "av1", "847247e6", 4], - [1080, "hevc", "1c34707c", 120, "hi"], - [720, "hevc", "0a03196c", 93] - ], - "/product-videos/card-hover/cinematic/terminal.mp4": [ - [1080, "av1", "9776cf70", 8, "hi"], - [720, "av1", "76a5c8b0", 4], - [1080, "hevc", "fa24ac2a", 120, "hi"], - [720, "hevc", "24c9487e", 93] - ], "/product-videos/card-loops/desktop.ja.mp4": [ [1064, "av1", "8d53f4c4", 8, "hi"], [720, "av1", "03051f4d", 4], @@ -117,11 +45,9 @@ [1064, "hevc", "de5e01b6", 120, "hi"], [720, "hevc", "2adfbc82", 93] ], - "/product-videos/card-loops/mobile.mp4": [ - [1064, "av1", "969038f3", 8, "hi"], - [720, "av1", "0ad54196", 4], - [1064, "hevc", "b522b660", 120, "hi"], - [720, "hevc", "13e6540a", 93] + "/product-videos/card-loops/mobile-orb-75.mp4": [ + [720, "av1", "a8d0f5bc", 4], + [720, "hevc", "a82f1012", 93] ], "/product-videos/card-loops/sdk.mp4": [ [1064, "av1", "57dfbb5d", 8, "hi"], @@ -135,30 +61,6 @@ [1064, "hevc", "cb91d991", 120, "hi"], [720, "hevc", "e8322dc4", 93] ], - "/product-videos/features/desktop-1.mp4": [ - [1200, "av1", "ff4a4260", 5, "hi"], - [800, "av1", "ef555136", 4], - [1200, "hevc", "9cdc5d28", 93, "hi"], - [800, "hevc", "d0e2cfef", 90] - ], - "/product-videos/features/desktop-2.mp4": [ - [1200, "av1", "b92cab97", 5, "hi"], - [800, "av1", "34d0517c", 4], - [1200, "hevc", "d598ea33", 93, "hi"], - [800, "hevc", "96b8b510", 90] - ], - "/product-videos/features/desktop-3.mp4": [ - [1200, "av1", "10f94e71", 5, "hi"], - [800, "av1", "47a5a8a9", 4], - [1200, "hevc", "aa172509", 93, "hi"], - [800, "hevc", "ba0c50ee", 90] - ], - "/product-videos/features/desktop-4.mp4": [ - [1200, "av1", "6cfaeefa", 5, "hi"], - [800, "av1", "0316f39c", 4], - [1200, "hevc", "5719cc56", 93, "hi"], - [800, "hevc", "1fcebe5c", 90] - ], "/product-videos/features/focused/desktop-1.mp4": [ [1200, "av1", "a4464782", 5, "hi"], [800, "av1", "ff6b7fc8", 4], @@ -231,70 +133,6 @@ [1200, "hevc", "ddc881b8", 93, "hi"], [800, "hevc", "1bfc3877", 90] ], - "/product-videos/features/mobile-1.mp4": [ - [1200, "av1", "dc15a609", 5, "hi"], - [800, "av1", "95c72454", 4], - [1200, "hevc", "8013fea3", 93, "hi"], - [800, "hevc", "be07c96c", 90] - ], - "/product-videos/features/mobile-2.mp4": [ - [1200, "av1", "3a868173", 5, "hi"], - [800, "av1", "b333f8f4", 4], - [1200, "hevc", "d1cc1d25", 93, "hi"], - [800, "hevc", "27840268", 90] - ], - "/product-videos/features/mobile-3.mp4": [ - [1200, "av1", "0924e4e2", 5, "hi"], - [800, "av1", "a5a118d2", 4], - [1200, "hevc", "596b8211", 93, "hi"], - [800, "hevc", "b819f9f5", 90] - ], - "/product-videos/features/mobile-4.mp4": [ - [1200, "av1", "3eb473b1", 5, "hi"], - [800, "av1", "dea200a1", 4], - [1200, "hevc", "b066c55e", 93, "hi"], - [800, "hevc", "10402b50", 90] - ], - "/product-videos/features/sdk-1.mp4": [ - [1200, "av1", "eead75dc", 5, "hi"], - [800, "av1", "8d1247ee", 4], - [1200, "hevc", "11e6218a", 93, "hi"], - [800, "hevc", "c62e3038", 90] - ], - "/product-videos/features/sdk-2.mp4": [ - [1200, "av1", "178c6b50", 5, "hi"], - [800, "av1", "d52a969f", 4], - [1200, "hevc", "810895c4", 93, "hi"], - [800, "hevc", "2d8678dc", 90] - ], - "/product-videos/features/sdk-3.mp4": [ - [1200, "av1", "fe427d04", 5, "hi"], - [800, "av1", "22015ab8", 4], - [1200, "hevc", "fa20dfe7", 93, "hi"], - [800, "hevc", "3f1c498c", 90] - ], - "/product-videos/features/terminal-1.mp4": [ - [1200, "av1", "8e233258", 5, "hi"], - [800, "av1", "4fa5af14", 4], - [1200, "hevc", "17334020", 93, "hi"], - [800, "hevc", "d614132e", 90] - ], - "/product-videos/features/terminal-2.mp4": [ - [1200, "av1", "65d38182", 5, "hi"], - [800, "av1", "f0cbdd1a", 4], - [1200, "hevc", "561a6203", 93, "hi"], - [800, "hevc", "a617561a", 90] - ], - "/product-videos/features/terminal-3.mp4": [ - [1200, "av1", "9e447bd5", 5, "hi"], - [800, "av1", "0a311ac9", 4], - [1200, "hevc", "8615f916", 93, "hi"], - [800, "hevc", "f43c3475", 90] - ], - "/product-videos/features/terminal-4.mp4": [ - [800, "av1", "114cdf52", 4], - [800, "hevc", "4123e308", 90] - ], "/product-videos/heroes/desktop-landscape-dark.mp4": [ [1280, "av1", "87488462", 5, "phone"], [1920, "av1", "476dd57a", 8], diff --git a/services/site/src/component/site/media-sources.json b/services/site/src/component/site/media-sources.json index ee098d73ced..322fc7c0986 100644 --- a/services/site/src/component/site/media-sources.json +++ b/services/site/src/component/site/media-sources.json @@ -77,6 +77,7 @@ "/product-videos/card-loops/mobile-orb-100.webp": "ea6453e6b91748ba", "/product-videos/card-loops/mobile-orb-25.webp": "41f7dc05c0f80eea", "/product-videos/card-loops/mobile-orb-50.webp": "20859953b02bfb65", + "/product-videos/card-loops/mobile-orb-75.mp4": "41351d6aaa74c958", "/product-videos/card-loops/mobile-orb-75.webp": "7facb79fd8e4b423", "/product-videos/card-loops/mobile.mp4": "8c1efd91fe0f25ca", "/product-videos/card-loops/mobile.webp": "0854c8b479cb9fb6", diff --git a/services/site/src/component/site/media.tsx b/services/site/src/component/site/media.tsx index f3967caf99d..4a91ad3575c 100644 --- a/services/site/src/component/site/media.tsx +++ b/services/site/src/component/site/media.tsx @@ -7,6 +7,10 @@ import { mediaUrl } from "~/lib/media" * for where they are drawn and content-hashed so they cache for a year. media-manifest.json lists only * copies that met the quality bar (VMAF ≥ 95 and SSIM ≥ 0.98 for video, SSIMULACRA2 ≥ 85 for * stills) against the original, which stays in place as the last fallback. Bundled at build time. + * The Mobile orb loop's copies are the exception: every dot of its grid changes brightness in every + * frame, so no copy much smaller than the original scores VMAF 95. Its 720-wide AV1 (147 kB) and HEVC + * (336 kB) copies, against the 578 kB original, score SSIM 0.99 and were matched by eye at the + * card's size. */ // Copy names follow one pattern, so the manifest holds only what varies (media-opt/manifest.py): // /media/....mp4 and /media/..., @@ -19,26 +23,39 @@ type Manifest = { } type MediaKey = "hi" | "phone" -// Only the stills' half: the pages offer the original videos alone (videoSources), so the video -// copies' rows stay out of the bundle. -const images: Manifest["image"] = - Object.values(import.meta.glob("./media-manifest.json", { eager: true, import: "image" }))[0] ?? {} +const manifest: Manifest = Object.values( + import.meta.glob("./media-manifest.json", { eager: true, import: "default" }), +)[0] ?? { video: {}, image: {} } + +// The 1080-wide card and 1200-wide clip files go to screens of 2.5 device pixels per CSS pixel or +// more (most phones); the 1280-wide hero files to phone-width windows. +const queries: Record = { + hi: "(min-resolution: 2.5dppx)", + phone: "(max-width: 40rem)", +} const base = (src: string) => `/media/${src.replace(/^\/(product-videos\/)?/, "").replace(/\.\w+$/, "")}` export type VideoSource = { src: string; type?: string; media?: string } /** - * A video's source: the original H.264 file in the files bucket. The manifest's AV1 and HEVC copies - * were never uploaded, so they are not offered. + * A video's sources in the order the browser should try them, all in the files bucket: the AV1 + * copies, the HEVC ones (Safari without an AV1 decoder), then the original H.264 file. */ export function videoSources(src: string): VideoSource[] { - return [{ src: mediaUrl(src), type: "video/mp4" }] + return [ + ...(manifest.video[src] ?? []).map(([width, codec, hash, level, key]) => ({ + src: mediaUrl(`${base(src)}.${width}.${codec}.${hash}.mp4`), + type: `video/mp4; codecs="${codec === "av1" ? `av01.0.${String(level).padStart(2, "0")}M.08` : `hvc1.1.6.L${level}.B0`}"`, + media: key ? queries[key] : undefined, + })), + { src: mediaUrl(src), type: "video/mp4" }, + ] } /** A still's AVIF and WebP srcsets (if it has copies), and the file for `src`: the smallest WebP. */ export function imageSources(src: string) { - const entry = images[src] + const entry = manifest.image[src] const file = (format: "avif" | "webp", [width, hash]: [number, string]) => `${base(src)}.${width}.${hash}.${format}` const set = (format: "avif" | "webp") => entry?.[format]?.map((item) => `${file(format, item)} ${item[0]}w`).join(", ") diff --git a/services/site/src/component/site/products.tsx b/services/site/src/component/site/products.tsx index 5711f7d51c1..82d87774873 100644 --- a/services/site/src/component/site/products.tsx +++ b/services/site/src/component/site/products.tsx @@ -582,9 +582,9 @@ export function carouselCss() { * ("hover · reverse") or the still fades back over it ("hover · fade"). "autoplay" plays all the * time. Reduced motion keeps the still. * - * With `loop`, the card rests on that short idle loop instead of a still: it plays while the card - * is on screen, crossfades to the product video on hover and back on leave. Reduced motion shows - * its first frame (`still`). + * With `loop`, the card rests on that short idle loop instead of a still: it shows its first frame + * (`still`) until the page has loaded, then plays while the card is on screen, crossfades to the + * product video on hover and back on leave. Reduced motion and Save-Data keep the first frame. */ export function CardVideo(props: { src: string @@ -652,10 +652,17 @@ export function CardVideo(props: { } createEffect(speed) - // The idle loop plays only while the card is on screen and resting. + // Reduced motion and Save-Data keep a card on its still: no resting loop and no sizzle. + const cover = () => + prefersReducedMotion() || + (navigator as typeof window.navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true + + // The idle loop plays only while the card is on screen and resting, and only once the page has + // loaded (ready), so it never competes with the stills: it starts from the frame the still shows. + let ready = false const rest = () => { if (!idle) return - if (visible && !active && mode() !== "autoplay" && !prefersReducedMotion()) { + if (ready && visible && !active && mode() !== "autoplay" && !cover()) { void idle.play().catch(() => {}) return } @@ -774,8 +781,6 @@ export function CardVideo(props: { // Leaving pauses the sizzle where it is and coming back resumes it, unless the card was away // for over 30s: then it starts again from the cover. Reduced motion and Save-Data keep the cover. setSizzle(window.matchMedia("(hover: none), (pointer: coarse)").matches || isMobile(detectPlatform())) - const connection = (navigator as typeof window.navigator & { connection?: { saveData?: boolean } }).connection - const cover = () => prefersReducedMotion() || connection?.saveData === true let wait: ReturnType | undefined let away = 0 // Back to the cover with the sizzle rewound, without a fade (the card is off screen). @@ -821,7 +826,13 @@ export function CardVideo(props: { { rootMargin: "100% 50%" }, ) if (media) ahead.observe(media) + const start = () => { + ready = true + rest() + } + afterLoad(start) onCleanup(() => { + window.removeEventListener("load", start) view.disconnect() ahead.disconnect() clearTimeout(wait) @@ -839,13 +850,16 @@ export function CardVideo(props: { diff --git a/services/site/src/routes/index.css b/services/site/src/routes/index.css index b92142a50e0..2cd8267563f 100644 --- a/services/site/src/routes/index.css +++ b/services/site/src/routes/index.css @@ -1796,6 +1796,7 @@ width: 1.25rem; height: 1.25rem; background: currentColor; + mask-image: var(--logo); mask-size: contain; mask-repeat: no-repeat; mask-position: center; @@ -1806,11 +1807,18 @@ height: calc(var(--height) * var(--logo-size)); translate: 0 calc(var(--shift) * var(--logo-size)); background: currentColor; + mask-image: var(--logo); mask-size: contain; mask-repeat: no-repeat; mask-position: center; } + /* Until the grid is near the screen (data-near, ToolGrid), its logos stay empty masks, so their + SVGs don't load with the page. */ + &:not([data-near]) :is([data-slot="icon"], [data-slot="wordmark"]) { + mask-image: linear-gradient(transparent 0 0); + } + /* The typed name stays as the tile's accessible text. */ [data-slot="name"] { position: absolute; diff --git a/services/site/src/routes/index.tsx b/services/site/src/routes/index.tsx index f645a56500a..215354ef72c 100644 --- a/services/site/src/routes/index.tsx +++ b/services/site/src/routes/index.tsx @@ -1351,6 +1351,20 @@ function ToolGrid(props: { return wash.armed() && style ? { animation: style, vars: revealMotion(style, siteSettings.logoTuning) } : undefined } createEffect(on([animation, () => siteSettings.toolsStagger], wash.replay, { defer: true })) + // The tiles' logos (SVG masks, index.css) load once the grid is within a screen of the window, so + // a page doesn't fetch them with its first screen. + onMount(() => { + const near = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + grid?.setAttribute("data-near", "") + near.disconnect() + }, + { rootMargin: "100% 0px" }, + ) + if (grid) near.observe(grid) + onCleanup(() => near.disconnect()) + }) return (
    {props.iconTiles ? null : tool.name} @@ -1404,8 +1418,7 @@ function ToolGrid(props: { data-slot="wordmark" aria-hidden="true" style={{ - "mask-image": `url(${tool.wordmark})`, - "-webkit-mask-image": `url(${tool.wordmark})`, + "--logo": `url(${tool.wordmark})`, ...(tool.metrics ? balanceLogo(tool.metrics) : {}), }} />