Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
266 lines
9.6 KiB
TypeScript
266 lines
9.6 KiB
TypeScript
/**
|
|
* 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.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;
|
|
}
|
|
|
|
/**
|
|
* Rótulo do projeto na vitrine: categoria e situação como frase ("Loja virtual · em produção"). Os rótulos não são mais
|
|
* em caixa alta, então a grafia dos dados ("loja virtual", "Em Produção") passou a aparecer como está.
|
|
*/
|
|
export function projectKicker(category: string, status: string) {
|
|
const first = category.charAt(0).toLocaleUpperCase("pt-BR");
|
|
return `${first}${category.slice(1)} · ${status.toLocaleLowerCase("pt-BR")}`;
|
|
}
|