feat(showcase): moldura de navegador, um video por vez e video ampliado nos cards
- cards com moldura de navegador (endereco real) e brilho na cor do site no card que toca - um video por vez: o do card sob o mouse/foco, ou o mais perto do centro da tela; nada baixa antes de tocar, e um botao pausa os videos que se mexem sozinhos - janela ampliada em <dialog> nativo: troca computador/celular mantendo o ponto do site, capitulos clicaveis, controles proprios, setas entre projetos e foco devolvido ao fechar - dados e capitulos de cada projeto em lib/project-showcase.ts (substitui project-videos.ts) Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
60dc2f1878
commit
a1a1e91135
18 files changed
+3564
-155
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
chapterAnchors,
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
desktopFractionAt,
|
||||
getOgImagePath,
|
||||
getProjectVideo,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
mobileFractionAt,
|
||||
PROJECT_VIDEO_IDS,
|
||||
SHOWCASE,
|
||||
} from "./project-showcase";
|
||||
|
||||
const MAX_BYTES = 3 * 1024 * 1024;
|
||||
const publicFile = (path: string) => join(process.cwd(), "public", path);
|
||||
|
||||
describe("showcase chapters", () => {
|
||||
it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => {
|
||||
const [first] = config.chapters;
|
||||
expect(first.desktop).toBe(0);
|
||||
expect(first.mobile).toBe(0);
|
||||
config.chapters.forEach((chapter, index) => {
|
||||
expect(chapter.label.trim()).not.toBe("");
|
||||
expect(chapter.desktop).toBeLessThan(config.durations.desktop);
|
||||
expect(chapter.mobile).toBeLessThan(config.durations.mobile);
|
||||
if (index > 0) {
|
||||
expect(chapter.desktop).toBeGreaterThan(
|
||||
config.chapters[index - 1].desktop,
|
||||
);
|
||||
expect(chapter.mobile).toBeGreaterThan(
|
||||
config.chapters[index - 1].mobile,
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(config.weight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(SHOWCASE)("$id keeps the two timelines in sync", (config) => {
|
||||
const anchors = chapterAnchors(config);
|
||||
expect(anchors[0]).toEqual([0, 0]);
|
||||
expect(anchors[anchors.length - 1]).toEqual([1, 1]);
|
||||
for (const fraction of [0, 0.13, 0.5, 0.77, 1]) {
|
||||
const mobile = mobileFractionAt(config, fraction);
|
||||
expect(mobile).toBeGreaterThanOrEqual(0);
|
||||
expect(mobile).toBeLessThanOrEqual(1);
|
||||
expect(desktopFractionAt(config, mobile)).toBeCloseTo(fraction, 9);
|
||||
}
|
||||
config.chapters.forEach((_, index) => {
|
||||
expect(
|
||||
mobileFractionAt(config, chapterFraction(config, index, "desktop")),
|
||||
).toBeCloseTo(chapterFraction(config, index, "mobile"), 9);
|
||||
});
|
||||
});
|
||||
|
||||
it("finds the chapter on screen", () => {
|
||||
const lavacao = SHOWCASE.find((config) => config.id === "lavacao-047");
|
||||
expect(lavacao).toBeDefined();
|
||||
if (!lavacao) return;
|
||||
expect(chapterIndexAt(lavacao, 0)).toBe(0);
|
||||
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(1);
|
||||
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(2);
|
||||
expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAt(lavacao, -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showcase assets", () => {
|
||||
it("returns undefined for projects without a recorded video", () => {
|
||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(PROJECT_VIDEO_IDS)(
|
||||
"has every file on disk for %s, each video within 3 MB",
|
||||
(id) => {
|
||||
const files = [
|
||||
...(["desktop", "mobile"] as const).flatMap((device) => {
|
||||
const assets = getShowcaseAssets(id, device);
|
||||
return [assets.poster, ...assets.sources.map((s) => s.src)];
|
||||
}),
|
||||
getScrubSource(id, "desktop"),
|
||||
getScrubSource(id, "mobile"),
|
||||
getOgImagePath(id),
|
||||
];
|
||||
for (const file of files) {
|
||||
expect(existsSync(publicFile(file))).toBe(true);
|
||||
expect(statSync(publicFile(file)).size).toBeLessThanOrEqual(MAX_BYTES);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos.
|
||||
*
|
||||
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
||||
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
||||
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
|
||||
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
|
||||
* - 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. */
|
||||
weight: number;
|
||||
chapters: readonly ShowcaseChapter[];
|
||||
}
|
||||
|
||||
/** Ordem da vitrine = ordem dos cards na home. Capítulos marcados nos quadros dos próprios vídeos. */
|
||||
export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
{
|
||||
id: "sev-modaintima",
|
||||
accent: "#c2303a",
|
||||
durations: { desktop: 17.9, mobile: 30.1 },
|
||||
weight: 1,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Categorias", desktop: 2.7, mobile: 1.9 },
|
||||
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
|
||||
{ label: "Nossa essência", desktop: 10.7, mobile: 8.6 },
|
||||
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lavacao-047",
|
||||
accent: "#4aa3ff",
|
||||
durations: { desktop: 30.07, mobile: 30.1 },
|
||||
weight: 1.4,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Tabela de preços", desktop: 1.5, mobile: 1.9 },
|
||||
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
|
||||
{ label: "Como funciona", desktop: 13.5, mobile: 13.2 },
|
||||
{ label: "Galeria e mapa", desktop: 18.5, mobile: 17.5 },
|
||||
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "nutriscan",
|
||||
accent: "#2f9e5f",
|
||||
durations: { desktop: 15.67, mobile: 26.4 },
|
||||
weight: 0.9,
|
||||
chapters: [
|
||||
{ label: "Busca", desktop: 0, mobile: 0 },
|
||||
{ label: "Por que confiar", desktop: 2.3, mobile: 3 },
|
||||
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
|
||||
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sc-plus",
|
||||
accent: "#22c55e",
|
||||
durations: { desktop: 13, mobile: 19.67 },
|
||||
weight: 0.85,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Sobre", desktop: 1.5, mobile: 2.2 },
|
||||
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
|
||||
{ label: "Apoiadores e participação", desktop: 7.5, mobile: 12.8 },
|
||||
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "as-servicos",
|
||||
accent: "#2563eb",
|
||||
durations: { desktop: 5.07, mobile: 7.7 },
|
||||
weight: 0.6,
|
||||
chapters: [
|
||||
{ label: "Três áreas", desktop: 0, mobile: 0 },
|
||||
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
|
||||
{ label: "Contato direto", desktop: 2.4, mobile: 4.4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function getScrubSource(id: string, device: Device) {
|
||||
return `/videos/${id}/scrub-${device}.mp4`;
|
||||
}
|
||||
|
||||
export function getOgImagePath(id: string) {
|
||||
return `/videos/${id}/og.jpg`;
|
||||
}
|
||||
|
||||
/** Vídeo desktop de um projeto da vitrine: é o que as capas dos cards tocam. */
|
||||
export function getProjectVideo(id: string): ShowcaseAssets | undefined {
|
||||
return byId.has(id) ? getShowcaseAssets(id, "desktop") : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Vídeos de rolagem dos projetos: public/videos/<id>/desktop.{webm,mp4,webp}.
|
||||
* Gravados com scripts/record-project-videos.mjs; só entram aqui os ids que têm os três arquivos.
|
||||
*/
|
||||
export interface ProjectVideoAsset {
|
||||
poster: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sources: Array<{ src: string; type: string }>;
|
||||
}
|
||||
|
||||
export const PROJECT_VIDEO_IDS = [
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
] as const;
|
||||
|
||||
const recorded = new Set<string>(PROJECT_VIDEO_IDS);
|
||||
|
||||
export function getProjectVideo(id: string): ProjectVideoAsset | undefined {
|
||||
if (!recorded.has(id)) return undefined;
|
||||
const base = `/videos/${id}/desktop`;
|
||||
return {
|
||||
poster: `${base}.webp`,
|
||||
width: 1440,
|
||||
height: 900,
|
||||
sources: [
|
||||
{ src: `${base}.webm`, type: 'video/webm; codecs="vp9"' },
|
||||
{ src: `${base}.mp4`, type: "video/mp4" },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user