/** * Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos. * * Arquivos em public/videos// (gravados com scripts/record-project-videos.mjs): * - desktop.webp e mobile.webp: pôsteres; a versão de celular vale abaixo de 768 px (lib/device.ts); * - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo; * - scrub-desktop.mp4 (960x600, H.264 sem áudio, 12 quadros por segundo, até 1,2 MB): o mesmo vídeo com um * quadro-chave a cada 6 quadros, para a imagem acompanhar a rolagem sem travar quando o tempo é mudado a cada * quadro (scripts/encode-scrub-video.mjs). É o único vídeo da vitrine fixa: o celular da moldura mostra só o * pôster, então não existe scrub-mobile.mp4; * - og.jpg: capa 1200x630 das imagens de compartilhamento. */ export interface ShowcaseChapter { label: string; /** Segundo do vídeo desktop em que o trecho começa. */ desktop: number; /** Segundo do vídeo mobile em que o mesmo trecho começa (as páginas têm alturas diferentes). */ mobile: number; } export interface ShowcaseConfig { id: string; /** Cor de destaque do site, usada só no brilho ao redor do vídeo. */ accent: string; /** Duração dos vídeos gravados, em segundos (os capítulos são medidos nelas). */ durations: { desktop: number; mobile: number }; /** Comprimento da rolagem deste projeto na vitrine, em alturas de tela (o mesmo em todos: ver SHOWCASE). */ weight: number; chapters: readonly ShowcaseChapter[]; } /** Máximo de capítulos por projeto na vitrine: dois bastam (de onde o site parte e o trecho que o define). */ export const MAX_CHAPTERS = 2; /** * Ordem da vitrine = ordem do SHOWCASE para os projetos que o provedor recebe. Capítulos marcados nos quadros * dos próprios vídeos, no máximo MAX_CHAPTERS por projeto, com os tempos originais: a abertura e o trecho principal, * o par que deixa menos segundos de vídeo sob o rótulo do outro (o último trecho vai até o fim do vídeo). * * O peso é o mesmo em todos, meia altura de tela por projeto, um quarto de tela por capítulo: os cinco projetos de * cliente da home (sev-modaintima, lavacao-047, sc-plus, as-servicos e julia-beauty) prendem 2,5 telas de rolagem e * a seção Projetos inteira, com o título e o botão, fica em cerca de 3,9 telas. Dentro do projeto a rolagem também é * repartida por igual entre os capítulos (ver `videoFractionAtScroll`), qualquer que seja a duração deles no vídeo. */ export const SHOWCASE: readonly ShowcaseConfig[] = [ { id: "sev-modaintima", accent: "#c2303a", durations: { desktop: 14.4, mobile: 30.17 }, weight: 0.5, chapters: [ { label: "Abertura", desktop: 0, mobile: 0 }, { label: "Coleção completa", desktop: 8, mobile: 9.2 }, ], }, { id: "lavacao-047", accent: "#4aa3ff", durations: { desktop: 30.07, mobile: 30.1 }, weight: 0.5, chapters: [ { label: "Abertura", desktop: 0, mobile: 0 }, { label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 }, ], }, { // fora da home atual, mas usado em /cases (og-card): a configuração fica id: "nutriscan", accent: "#2f9e5f", durations: { desktop: 15.67, mobile: 26.4 }, weight: 0.5, chapters: [ { label: "Busca", desktop: 0, mobile: 0 }, { label: "Quatro etapas", desktop: 7, mobile: 8 }, ], }, { id: "sc-plus", accent: "#22c55e", durations: { desktop: 14.7, mobile: 22.4 }, weight: 0.5, chapters: [ { label: "Abertura", desktop: 0, mobile: 0 }, { label: "Áreas e projetos", desktop: 5.8, mobile: 7.6 }, ], }, { id: "as-servicos", accent: "#2563eb", durations: { desktop: 5.07, mobile: 7.7 }, weight: 0.5, chapters: [ { label: "Três áreas", desktop: 0, mobile: 0 }, { label: "Contato", desktop: 2.4, mobile: 4.4 }, ], }, { id: "julia-beauty", accent: "#c6f19d", durations: { desktop: 23.1, mobile: 24.97 }, weight: 0.5, chapters: [ { label: "Abertura", desktop: 0, mobile: 0 }, { label: "Simulador de efeito", desktop: 2.5, mobile: 3.5 }, ], }, ]; export const PROJECT_VIDEO_IDS = SHOWCASE.map((config) => config.id); const byId = new Map(SHOWCASE.map((config) => [config.id, config])); export function getShowcaseConfig(id: string): ShowcaseConfig | undefined { return byId.get(id); } export type Device = "desktop" | "mobile"; export interface ShowcaseAssets { poster: string; width: number; height: number; sources: Array<{ src: string; type: string }>; } export function getShowcaseAssets(id: string, device: Device) { const base = `/videos/${id}/${device}`; return { poster: `${base}.webp`, width: device === "desktop" ? 1440 : 390, height: device === "desktop" ? 900 : 844, sources: [ { src: `${base}.webm`, type: 'video/webm; codecs="vp9"' }, { src: `${base}.mp4`, type: "video/mp4" }, ], } satisfies ShowcaseAssets; } /** Vídeo da vitrine fixa (só o do computador: o celular da moldura mostra o pôster). */ export function getScrubSource(id: string) { return `/videos/${id}/scrub-desktop.mp4`; } export function getOgImagePath(id: string) { return `/videos/${id}/og.jpg`; } // --------------------------------------------------------------------------------------------------------- // Capítulos e sincronia entre as duas telas // --------------------------------------------------------------------------------------------------------- /** Pontos [fração do desktop, fração do mobile] que dizem onde cada capítulo começa em cada vídeo. */ export function chapterAnchors( config: ShowcaseConfig, ): Array<[number, number]> { const { desktop, mobile } = config.durations; const points: Array<[number, number]> = [[0, 0]]; for (const chapter of config.chapters) { const point: [number, number] = [ chapter.desktop / desktop, chapter.mobile / mobile, ]; const last = points[points.length - 1]; if (point[0] > last[0] && point[1] > last[1]) points.push(point); } points.push([1, 1]); return points; } function interpolate( points: Array<[number, number]>, value: number, from: 0 | 1, ): number { const to = from === 0 ? 1 : 0; const x = Math.min(1, Math.max(0, value)); for (let i = 1; i < points.length; i += 1) { const [a, b] = [points[i - 1], points[i]]; if (x <= b[from]) { const span = b[from] - a[from]; const ratio = span === 0 ? 0 : (x - a[from]) / span; return a[to] + ratio * (b[to] - a[to]); } } return 1; } /** Fração do vídeo mobile que mostra o mesmo ponto do site que a fração `fraction` do vídeo desktop. */ export function mobileFractionAt(config: ShowcaseConfig, fraction: number) { return interpolate(chapterAnchors(config), fraction, 0); } /** O contrário: fração do desktop para uma fração do vídeo mobile. */ export function desktopFractionAt(config: ShowcaseConfig, fraction: number) { return interpolate(chapterAnchors(config), fraction, 1); } /** * Pontos [fração da rolagem do projeto, fração do vídeo desktop] da vitrine fixa: o capítulo `i` de `n` começa em * `i / n` da rolagem, quando o vídeo chega ao ponto em que o capítulo começa nele. Assim cada capítulo ganha o mesmo * tanto de rolagem, dure ele 0,9 s ou 24 s no vídeo (o vídeo anda mais depressa nos longos). */ function scrollAnchors(config: ShowcaseConfig): Array<[number, number]> { const { chapters, durations } = config; const points = chapters.map((chapter, i): [number, number] => [ i / chapters.length, chapter.desktop / durations.desktop, ]); points.push([1, 1]); return points; } /** Fração do vídeo desktop (0 a 1) que a rolagem do projeto, em `progress` (0 a 1), mostra: capítulos de rolagem igual. */ export function videoFractionAtScroll( config: ShowcaseConfig, progress: number, ) { return interpolate(scrollAnchors(config), progress, 0); } /** Capítulo em exibição quando a rolagem do projeto está em `progress` (0 a 1): `n` partes iguais. */ export function chapterIndexAtScroll(config: ShowcaseConfig, progress: number) { const count = config.chapters.length; const index = Math.floor(Math.min(1, Math.max(0, progress)) * count + 1e-6); return Math.min(count - 1, index); } /** Fração da rolagem do projeto (0 a 1) em que o capítulo `index` começa. */ export function chapterScrollFraction(config: ShowcaseConfig, index: number) { const { length } = config.chapters; return Math.min(length - 1, Math.max(0, index)) / length; } /** Capítulo em exibição quando o vídeo desktop está na fração `fraction`. */ export function chapterIndexAt(config: ShowcaseConfig, fraction: number) { const seconds = Math.min(1, Math.max(0, fraction)) * config.durations.desktop; let index = 0; config.chapters.forEach((chapter, i) => { if (seconds >= chapter.desktop - 1e-6) index = i; }); return index; } /** Fração (0 a 1) do vídeo `device` em que o capítulo `index` começa. */ export function chapterFraction( config: ShowcaseConfig, index: number, device: Device, ) { const chapter = config.chapters[index]; if (!chapter) return 0; return device === "desktop" ? chapter.desktop / config.durations.desktop : chapter.mobile / config.durations.mobile; }