diff --git a/README.md b/README.md index 078906b..952bd66 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHu Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece. +## Vídeos dos projetos + +Cada card de projeto da home mostra um vídeo curto, em loop e sem áudio, da página rolando em velocidade constante. Os arquivos ficam em `public/videos//` (`desktop` em WebM, MP4 e poster WebP, até 3 MB cada vídeo, mais a versão `mobile`) e são ligados aos cards em `lib/project-videos.ts`. + +Em `app/components/ProjectVideo.tsx`, o vídeo só começa a ser baixado quando o card entra na tela (IntersectionObserver), pausa ao sair, e quem tem `prefers-reduced-motion` vê só o poster. Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho do script). + ## Stack | Área | Pacotes usados | diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx index 1b309e4..993f939 100644 --- a/app/components/ProjectCover.tsx +++ b/app/components/ProjectCover.tsx @@ -1,4 +1,6 @@ import Image from "next/image"; +import { getProjectVideo } from "@/lib/project-videos"; +import { ProjectVideo } from "./ProjectVideo"; type CoverProject = { id: string; title: string }; @@ -31,8 +33,17 @@ export function ProjectCover({ project: CoverProject; priority?: boolean; }) { + const video = getProjectVideo(project.id); const src = SCREENSHOTS[project.id]; + if (video) { + return ( +
+ +
+ ); + } + if (src) { return (
diff --git a/app/components/ProjectVideo.test.tsx b/app/components/ProjectVideo.test.tsx new file mode 100644 index 0000000..a4b5405 --- /dev/null +++ b/app/components/ProjectVideo.test.tsx @@ -0,0 +1,131 @@ +import { existsSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { act, render, screen } from "@testing-library/react"; +import { + getProjectVideo, + PROJECT_VIDEO_IDS, + type ProjectVideoAsset, +} from "@/lib/project-videos"; +import { ProjectVideo } from "./ProjectVideo"; + +let reducedMotion = false; +jest.mock("@/app/hooks/useReducedMotion", () => ({ + useReducedMotion: () => reducedMotion, +})); + +const video: ProjectVideoAsset = { + poster: "/videos/demo/desktop.webp", + width: 1440, + height: 900, + sources: [ + { src: "/videos/demo/desktop.webm", type: 'video/webm; codecs="vp9"' }, + { src: "/videos/demo/desktop.mp4", type: "video/mp4" }, + ], +}; + +type Callback = IntersectionObserverCallback; +let callbacks: Callback[] = []; +const originalObserver = global.IntersectionObserver; + +beforeEach(() => { + reducedMotion = false; + callbacks = []; + // observador controlável: o teste decide quando o card entra e sai da tela + global.IntersectionObserver = class { + constructor(callback: Callback) { + callbacks.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } as unknown as typeof IntersectionObserver; + jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); +}); + +afterEach(() => { + global.IntersectionObserver = originalObserver; + jest.restoreAllMocks(); +}); + +function setVisible(isIntersecting: boolean) { + act(() => { + for (const callback of callbacks) { + callback( + [{ isIntersecting } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + } + }); +} + +describe("ProjectVideo", () => { + it("renders a muted looping inline video that downloads nothing before it is visible", () => { + const { container } = render(); + const element = container.querySelector("video"); + + expect(element).not.toBeNull(); + expect(element).toHaveAttribute("autoplay"); + expect(element).toHaveAttribute("loop"); + expect(element).toHaveAttribute("playsinline"); + expect(element).toHaveAttribute("preload", "none"); + expect(element).toHaveAttribute("poster", video.poster); + expect(element?.muted).toBe(true); + expect(container.querySelectorAll("source")).toHaveLength(0); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + }); + + it("attaches the sources and plays once the card enters the viewport, then pauses when it leaves", () => { + const { container } = render(); + + setVisible(true); + const sources = [...container.querySelectorAll("source")]; + expect(sources.map((source) => source.getAttribute("src"))).toEqual([ + "/videos/demo/desktop.webm", + "/videos/demo/desktop.mp4", + ]); + expect(HTMLMediaElement.prototype.load).toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.play).toHaveBeenCalled(); + + setVisible(false); + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled(); + }); + + it("shows only the poster, with no video element, when the user prefers reduced motion", () => { + reducedMotion = true; + const { container } = render(); + + expect(container.querySelector("video")).toBeNull(); + expect( + screen.getByRole("img", { name: "Captura de tela de Demo" }), + ).toBeInTheDocument(); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + }); +}); + +describe("getProjectVideo", () => { + it("returns undefined for projects without a recorded video", () => { + expect(getProjectVideo("reddit-explorer")).toBeUndefined(); + }); + + it.each(PROJECT_VIDEO_IDS)( + "has webm, mp4 and poster on disk for %s, each within 3 MB", + (id) => { + const asset = getProjectVideo(id); + expect(asset).toBeDefined(); + const files = [ + asset?.poster, + ...(asset?.sources.map((s) => s.src) ?? []), + ]; + for (const file of files) { + const path = join(process.cwd(), "public", file ?? ""); + expect(existsSync(path)).toBe(true); + expect(statSync(path).size).toBeLessThanOrEqual(3 * 1024 * 1024); + } + }, + ); +}); diff --git a/app/components/ProjectVideo.tsx b/app/components/ProjectVideo.tsx new file mode 100644 index 0000000..b38adc8 --- /dev/null +++ b/app/components/ProjectVideo.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import type { ProjectVideoAsset } from "@/lib/project-videos"; + +/** + * Vídeo de rolagem do projeto na capa do card. Nada de vídeo é baixado até o card entrar na tela + * (IntersectionObserver); fora da tela ele pausa. Com prefers-reduced-motion só o poster aparece. + */ +export function ProjectVideo({ + video, + title, +}: { + video: ProjectVideoAsset; + title: string; +}) { + const reducedMotion = useReducedMotion(); + const ref = useRef(null); + const [activated, setActivated] = useState(false); + + useEffect(() => { + const element = ref.current; + if (reducedMotion || !element) return; + if (typeof IntersectionObserver === "undefined") { + setActivated(true); + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setActivated(true); + element.play().catch(() => {}); + } else { + element.pause(); + } + }, + { rootMargin: "120px 0px", threshold: 0.2 }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [reducedMotion]); + + useEffect(() => { + const element = ref.current; + if (!activated || reducedMotion || !element) return; + element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real + element.load(); + element.play().catch(() => {}); + }, [activated, reducedMotion]); + + if (reducedMotion) { + return ( + {`Captura + ); + } + + return ( + + ); +} diff --git a/app/globals.css b/app/globals.css index b8fb70b..958b19e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1284,7 +1284,12 @@ html.lenis { border: 1px solid var(--border); background: var(--ink); - & img { + & video { + display: block; + } + + & img, + & video { width: 100%; height: 100%; object-fit: cover; @@ -1292,7 +1297,8 @@ html.lenis { transition: transform 0.7s var(--ease-out); } - .ds-card:hover & img { + .ds-card:hover & img, + .ds-card:hover & video { transform: scale(1.035); } } diff --git a/lib/project-videos.ts b/lib/project-videos.ts new file mode 100644 index 0000000..5e0db07 --- /dev/null +++ b/lib/project-videos.ts @@ -0,0 +1,34 @@ +/** + * Vídeos de rolagem dos projetos: public/videos//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(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" }, + ], + }; +}