feat(story): celular CC0 com site vivo na tela, ReelDirector scrub em todas as seções e filmstrip pinado de prints reais

This commit is contained in:
Evandro Fonseca Junior committed 2026-08-30 20:00:56 -03:00
1 parent 8be2ba572d
commit ecadb7c540
20 files changed
+637 -83

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Credits / Third-party assets
## 3D model — “Phone”
- Author: **Quaternius** (https://quaternius.com)
- Source: https://poly.pizza/m/k2kgBepoMU
- License: **CC0 1.0 Universal (Public Domain)**
- Used in: `app/components/story/StoryStage3D.tsx` (hero scrollytelling reel),
file at `public/models/phone-quaternius.glb`.
- Modifications: material overrides (body/frame metallic dark; the model's
`Black` surface renders a live CanvasTexture of the portfolio site). CC0 does
not require attribution; credit given anyway.
## Project screenshots
- `public/assets/prints/*.png` are captures taken with headless Chrome from
production deployments owned by the site author (evandro.dev.br), used as
on-screen content inside the 3D phone and section animations.
+6 -4
View File
@@ -91,7 +91,7 @@ export default function About() {
description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
disableChildrenAnimation
aside={
<div className="mt-sp-4 ds-metric">
<div className="mt-sp-4 ds-metric" data-reel="left">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
Garuva, SC | Remote worldwide
</div>
@@ -120,7 +120,9 @@ export default function About() {
<div className="ds-kicker">{step.label}</div>
<DrawLine delay={0.03} />
<h3 className="ds-card-title">{step.title}</h3>
<p className="ds-copy">{step.content}</p>
<p className="ds-copy" data-reel="right">
{step.content}
</p>
</motion.article>
))}
</motion.div>
@@ -141,13 +143,13 @@ export default function About() {
<div className="ds-kicker">Technical profile</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
<p className="ds-copy">
<p className="ds-copy" data-reel="top">
Sou engenheiro da computação e desenvolvedor de software full-stack,
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
separadas: hardware, servicos, operacao e interface. Meu trabalho e
dar forma clara ao que costuma nascer fragmentado.
</p>
<div className="mt-sp-6 space-y-sp-4">
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function CompanyLogos() {
// Animated infinite scroll
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="py-sp-8">
<div className="py-sp-8" data-reel="zoom" data-reel-distance="40">
<motion.div
className="flex gap-sp-12 items-center"
animate={{
+15 -5
View File
@@ -90,19 +90,29 @@ export default function Contact() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<div className="ds-kicker">Availability</div>
<div className="ds-kicker" data-reel="left">
Availability
</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3>
<p className="ds-copy">
<h3 className="ds-card-title" data-reel="top">
Resposta inicial em ate 24h
</h3>
<p className="ds-copy" data-reel="right">
Ideal para discovery tecnico, revisao de arquitetura, rescue de
projeto, performance ou construcao de produto com ownership real.
</p>
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
<div
className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]"
data-reel-stagger="bottom"
>
<div>WhatsApp: {whatsAppDisplay}</div>
<div>E-mail: evandrofonsecajunior@gmail.com</div>
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
</div>
<div className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]">
<div
className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]"
data-reel="fade"
>
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
conversa tecnica objetiva.
</div>
+3 -3
View File
@@ -79,7 +79,7 @@ export default function Experience({
<div className="max-w-[720px]">
<div className="ds-kicker">{exp.period}</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title">{exp.title}</h3>
<h3 className="ds-card-title" data-reel="left">{exp.title}</h3>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
{exp.company} • {exp.type}
</p>
@@ -87,7 +87,7 @@ export default function Experience({
<div className="ds-metric">{exp.location}</div>
</div>
<p className="ds-copy">{exp.description}</p>
<p className="ds-copy" data-reel="right">{exp.description}</p>
<ul className="ds-list">
{exp.achievements?.map((achievement) => (
@@ -95,7 +95,7 @@ export default function Experience({
))}
</ul>
<ul className="ds-tags mt-sp-6">
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{exp.technologies?.slice(0, 6).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
+5 -2
View File
@@ -43,7 +43,10 @@ export default function LighthouseMetrics() {
dark
disableChildrenAnimation={false}
>
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4">
<div
className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4"
data-reel="fade"
>
{METRICS.map((metric, i) => (
<motion.article
key={metric.label}
@@ -96,7 +99,7 @@ export default function LighthouseMetrics() {
</div>
{/* Content Details */}
<div className="flex-1">
<div className="flex-1" data-reel-stagger="bottom">
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
{metric.label}
</h3>
+3 -3
View File
@@ -93,7 +93,7 @@ export default function Projects({
<p className="ds-copy">{project.description}</p>
<ul className="ds-tags mt-sp-6">
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
@@ -101,7 +101,7 @@ export default function Projects({
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
{project.githubUrl && (
<MagneticButton
as="a"
@@ -131,7 +131,7 @@ export default function Projects({
))}
</motion.div>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton
as="a"
href="/cases"
+5 -3
View File
@@ -69,13 +69,15 @@ export default function Skills({
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<h3 className="ds-card-title !mt-0">{category.category}</h3>
<h3 className="ds-card-title !mt-0" data-reel="left">
{category.category}
</h3>
<DrawLine delay={0.05} />
<p className="ds-copy !mt-sp-4">
<p className="ds-copy !mt-sp-4" data-reel="right">
Ferramentas que uso para construir com clareza tecnica e ritmo de
entrega.
</p>
<ul className="ds-tags">
<ul className="ds-tags" data-reel-stagger="fade">
{category.skills.map((skill) => (
<li key={skill} className="ds-tag">
{skill}
+5 -5
View File
@@ -128,11 +128,11 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
</p>
</div>
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel="right">
{tier.description}
</p>
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel-stagger="fade">
{tier.deliverables.map((deliverable) => (
<li key={deliverable} className="pl-sp-1">
{deliverable}
@@ -202,10 +202,10 @@ export function WorkModalities() {
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[760px]">
<div className="ds-kicker">Páginas de serviço</div>
<h3 className="ds-card-title !mt-sp-3">
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
Especialidades organizadas por intenção de busca
</h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
Se você chegou buscando uma stack, um tipo de consultoria ou um
contexto técnico específico, aqui está o caminho mais direto para
a página certa.
@@ -221,7 +221,7 @@ export function WorkModalities() {
</MagneticButton>
</div>
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3">
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3" data-reel-stagger="left">
{serviceHubLinks.map((service) => (
<Link
key={service.href}
@@ -10,6 +10,8 @@ import Hero from "../Hero";
import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading";
import ScrollStory from "../story/ScrollStory";
import ReelDirector from "../story/ReelDirector";
import PrintReel from "../story/PrintReel";
import CinematicSection from "./CinematicSection";
const About = dynamic(() => import("../About"), {
@@ -52,6 +54,7 @@ export default function CinematicHome({
return (
<div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main">
<ReelDirector />
<Hero />
{/* Marquee divider after hero */}
@@ -69,6 +72,9 @@ export default function CinematicHome({
)}
/>
{/* Palco 2: filmstrip horizontal pinado com prints reais dos deployments */}
<PrintReel />
<CinematicSection effect="parallax-depth">
<About />
</CinematicSection>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { DrawLine } from "../animations/DrawLine";
import HorizontalScroll from "../cinematic/HorizontalScroll";
import { Section } from "../Section";
/**
* Palco extra estilo product-page: filmstrip horizontal PINNADO com prints
* reais capturados dos deployments em produção (public/assets/prints).
* O scroll vertical empurra a faixa na horizontal (scrub), cada card entra
* com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo.
*/
const PRINTS = [
{
src: "/assets/prints/print-nutriscan.png",
name: "NutriScan",
desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
url: "https://nutriscan.evandro.dev.br",
accent: true,
},
{
src: "/assets/prints/print-scplus.png",
name: "SCPLUS",
desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
url: "https://scplus.evandro.dev.br",
accent: false,
},
{
src: "/assets/prints/print-sev.png",
name: "SEV Moda Íntima",
desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
url: "https://sevmodaintima.com.br",
accent: true,
},
{
src: "/assets/prints/print-asservicos.png",
name: "AS Serviços",
desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
url: "https://asservicos.com",
accent: false,
},
];
export default function PrintReel() {
return (
<Section
id="print-reel"
eyebrow="No ar, rodando"
title="Prints ao vivo dos sistemas em produção"
description="Capturas reais dos deployments — role e a faixa corre na horizontal como um reel."
dark
disableChildrenAnimation
>
<div className="flex h-[100dvh] items-center">
<HorizontalScroll className="w-full">
{PRINTS.map((print) => (
<a
key={print.name}
data-scroll-item
href={print.url}
target="_blank"
rel="noopener noreferrer"
className="group flex w-[min(78vw,380px)] shrink-0 flex-col"
>
{/* moldura de navegador */}
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
<span
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
/>
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
{new URL(print.url).hostname}
</span>
</div>
</div>
<div className="relative h-[420px] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
{/* o print real, deslizando de leve dentro da moldura (Ken Burns) */}
<img
src={print.src}
alt={`Captura do projeto ${print.name} em produção`}
loading="lazy"
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.04] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-6 group-hover:scale-100"
/>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_60%,rgba(10,10,10,.85)_100%)]" />
</div>
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-sp-5">
<div className="flex items-center justify-between">
<h3 className="font-display text-[18px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
{print.name}
</h3>
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
ao vivo ↗
</span>
</div>
<DrawLine delay={0.1} />
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
{print.desc}
</p>
</div>
</a>
))}
</HorizontalScroll>
</div>
</Section>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef } from "react";
/**
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
*
* Contrato (declarativo, via atributos data-*):
* [data-reel="left|right|top|bottom|zoom|fade"]
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
* [data-reel-stagger="left|right|top|bottom|zoom|fade"]
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
* data-reel-distance="64" (opcional: px de deslocamento)
*
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
*/
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
const DISTANCE = 64;
function fromVars(dir: Direction, dist: number) {
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
if (dir === "left") return { ...base, x: -dist };
if (dir === "right") return { ...base, x: dist };
if (dir === "top") return { ...base, y: -dist * 0.75 };
if (dir === "bottom") return { ...base, y: dist };
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
return { ...base, scale: 1.02 };
}
const TO_VARS = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
filter: "blur(0px)",
ease: "none",
};
export default function ReelDirector() {
const alive = useRef(true);
useEffect(() => {
alive.current = true;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined;
let teardown = () => {};
const boot = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (!alive.current) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const scan = () => {
for (const el of document.querySelectorAll<HTMLElement>(
"[data-reel]:not([data-reel-init])",
)) {
el.dataset.reelInit = "1";
const dir = (el.dataset.reel || "bottom") as Direction;
const dist = Number(el.dataset.reelDistance || DISTANCE);
gsap.fromTo(el, fromVars(dir, dist), {
...TO_VARS,
scrollTrigger: {
trigger: el,
start: el.dataset.reelStart || "top 88%",
end: el.dataset.reelEnd || "top 26%",
scrub: 0.6,
},
});
}
for (const container of document.querySelectorAll<HTMLElement>(
"[data-reel-stagger]:not([data-reel-init])",
)) {
container.dataset.reelInit = "1";
const dir = (container.dataset.reelStagger || "bottom") as Direction;
const dist = Number(container.dataset.reelDistance || DISTANCE);
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) continue;
gsap.fromTo(
children,
fromVars(dir, dist),
{
...TO_VARS,
stagger: { each: 0.09 },
scrollTrigger: {
trigger: container,
start: container.dataset.reelStart || "top 85%",
end: container.dataset.reelEnd || "top 25%",
scrub: 0.6,
},
},
);
}
};
scan();
// seções chegam via next/dynamic depois do boot — observa nós novos
const observer = new MutationObserver(() => scan());
observer.observe(document.body, { childList: true, subtree: true });
teardown = () => {
observer.disconnect();
ctx.revert();
};
});
};
void boot();
return () => {
alive.current = false;
teardown();
};
}, []);
return null;
}
+4 -4
View File
@@ -65,10 +65,10 @@ describe("ScrollStory", () => {
});
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
mockMatchMedia(false);
const { container } = render(<ScrollStory {...props} />);
await waitFor(() =>
expect(container.querySelector("[data-story]")).toBeInTheDocument(),
await waitFor(
() => expect(container.querySelector("[data-story]")).toBeInTheDocument(),
{ timeout: 10000 },
);
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
@@ -76,5 +76,5 @@ describe("ScrollStory", () => {
// count-up é alimentado pela timeline: markup carrega o alvo real
const num = container.querySelector("[data-story-num]");
expect(num?.getAttribute("data-story-num-to")).toBe("5");
});
}, 20000);
});
+152 -53
View File
@@ -1,72 +1,160 @@
"use client";
import { Canvas, useFrame } from "@react-three/fiber";
import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei";
import { useRef } from "react";
import {
Environment,
Lightformer,
RoundedBox,
Sparkles,
useGLTF,
} from "@react-three/drei";
import {
Component,
Suspense,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import * as THREE from "three";
import { createPhoneScreen, type ScreenPainter } from "./phoneScreenTexture";
type ProgressRef = { current: number };
/**
* Palco WebGL do scrollytelling: um "dispositivo" 3D procedural que gira,
* inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem
* re-render do React por frame). O lerp interno suaviza o scrub em touch.
* Composição estilo product-page: dispositivo no terço direito, texto à esquerda.
* Palco WebGL do scrollytelling.
* Modelo 3D do aparelho: "Phone" by Quaternius — CC0 (Public Domain), via
* poly.pizza (https://poly.pizza/m/k2kgBepoMU). Creditado em CREDITS.md.
* A tela recebe um CanvasTexture com o mini-site do portfólio (prints reais dos
* deployments) rolando conforme o progresso do scroll — o celular está "ligado".
*/
function DeviceRig({ progress }: { progress: ProgressRef }) {
function motionRig(g: THREE.Object3D, p: number) {
// turntable contínuo: uma volta + extra, com inclinação de cena a cena
g.rotation.y = -0.7 + p * Math.PI * 2.2;
g.rotation.x = 0.08 + Math.sin(p * Math.PI) * 0.22;
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.05;
g.position.x = 1.4;
g.position.y = Math.sin(p * Math.PI * 2) * 0.2;
g.scale.setScalar(0.8 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.16);
}
function ScreenPhone({ progress }: { progress: ProgressRef }) {
// CC0: Quaternius "Phone" — poly.pizza/m/k2kgBepoMU
const { scene } = useGLTF("/models/phone-quaternius.glb");
const rig = useRef<THREE.Group>(null);
const halo = useRef<THREE.Mesh>(null);
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
const glow = useRef<THREE.PointLight>(null);
const smooth = useRef(0);
const [painter] = useState<ScreenPainter>(() => createPhoneScreen());
const texture = useMemo(() => {
const t = new THREE.CanvasTexture(painter.canvas);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 4;
return t;
}, [painter]);
useFrame((_, dt) => {
// materiais: corpo metálico; primitiva "Black" = tela com o site vivo
const materials = useMemo(() => {
const body = new THREE.MeshStandardMaterial({
color: "#232429",
metalness: 0.85,
roughness: 0.38,
});
const frame = new THREE.MeshStandardMaterial({
color: "#3a3b42",
metalness: 0.9,
roughness: 0.3,
});
const screen = new THREE.MeshStandardMaterial({
color: "#000000",
map: texture,
emissive: "#ffffff",
emissiveMap: texture,
emissiveIntensity: 1.15,
roughness: 0.4,
metalness: 0,
});
return { body, frame, screen };
}, [texture]);
useFrame((state, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current;
const g = rig.current;
if (!g) return;
// tomada contínua: ~360° + um extra, como um turntable pré-renderizado
g.rotation.y = -0.7 + p * Math.PI * 2.2;
g.rotation.x = 0.1 + Math.sin(p * Math.PI) * 0.28;
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.06;
g.position.x = 2.1;
g.position.y = Math.sin(p * Math.PI * 2) * 0.22;
// respiro de escala durante o reel (zoom de câmera sutil)
g.scale.setScalar(0.62 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.1);
if (rig.current) motionRig(rig.current, p);
if (halo.current) {
halo.current.rotation.z += dt * 0.25;
const m = halo.current.material as THREE.MeshBasicMaterial;
m.opacity = 0.1 + p * 0.08;
}
if (screenMat.current) {
// tela "liga" cena a cena: 4 pulsos ao longo do reel
screenMat.current.emissiveIntensity =
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
}
if (glow.current) {
glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4;
// pinta o mini-site ~4fps quando há mudança real (quantizada no painter)
const t = state.clock.elapsedTime * 1000;
if (painter.ready() && painter.paint(p, t)) {
texture.needsUpdate = true;
}
});
const cloned = useMemo(() => {
const root = scene.clone(true);
root.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return;
const name = (obj.material as THREE.Material & { name?: string })?.name ?? "";
if (name === "Black") {
obj.material = materials.screen;
// o GLB não tem TEXCOORD — UV plano na face (geo X/Z local, topo = +Z)
const geo = obj.geometry;
geo.computeBoundingBox();
const bb = geo.boundingBox!;
const sx = bb.max.x - bb.min.x || 1;
const sz = bb.max.z - bb.min.z || 1;
const pos = geo.attributes.position as THREE.BufferAttribute;
const uv = new Float32Array(pos.count * 2);
for (let i = 0; i < pos.count; i++) {
uv[i * 2] = (pos.getX(i) - bb.min.x) / sx;
uv[i * 2 + 1] = (pos.getZ(i) - bb.min.z) / sz;
}
geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
} else {
obj.material = name === "Grey" ? materials.frame : materials.body;
}
obj.frustumCulled = false;
});
// a primitiva Black aponta +Z mundo após a rotação -90°X do node — já dá de
// frente para a câmera em p=0; nenhum flip extra.
// o modelo mede ~0.9 unidades de altura; enquadrar no stage (~2.5u)
root.scale.setScalar(2.75);
return root;
}, [scene, materials]);
return (
<group ref={rig}>
<primitive object={cloned} />
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.2, -0.3]}>
<torusGeometry args={[1.9, 0.014, 10, 128]} />
<meshBasicMaterial color="#ccff00" transparent opacity={0.16} toneMapped={false} />
</mesh>
<pointLight position={[0, 0, 1.9]} color="#ccff00" intensity={0.6} distance={5} />
</group>
);
}
/** Fallback procedural (GLTF falhou / SwiftShader): rig de RoundedBox. */
function ProceduralPhone({ progress }: { progress: ProgressRef }) {
const rig = useRef<THREE.Group>(null);
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
const smooth = useRef(0);
useFrame((_, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current;
if (rig.current) motionRig(rig.current, p);
if (screenMat.current)
screenMat.current.emissiveIntensity =
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
});
return (
<group ref={rig}>
{/* corpo do dispositivo */}
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
</RoundedBox>
{/* borda luminosa (bezel slash) */}
<RoundedBox args={[1.7, 3.4, 0.1]} radius={0.17} smoothness={4} position={[0, 0, -0.01]}>
<meshStandardMaterial
color="#0a0a0a"
emissive="#ccff00"
emissiveIntensity={0.22}
metalness={0.6}
roughness={0.5}
/>
</RoundedBox>
{/* tela emissiva */}
<mesh position={[0, 0, 0.098]}>
<planeGeometry args={[1.42, 3.06]} />
<meshStandardMaterial
@@ -77,26 +165,34 @@ function DeviceRig({ progress }: { progress: ProgressRef }) {
toneMapped={false}
transparent
opacity={0.92}
roughness={0.3}
metalness={0.1}
/>
</mesh>
{/* halo orbital */}
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.1, -0.3]}>
<torusGeometry args={[1.9, 0.014, 10, 128]} />
<meshBasicMaterial color="#ccff00" transparent opacity={0.2} toneMapped={false} />
</mesh>
<pointLight ref={glow} position={[0, 0, 1.6]} color="#ccff00" intensity={2.4} distance={6} />
</group>
);
}
// Boundary funcional: se o GLTF estourar, mantém o rig procedural no lugar.
class PhoneBoundary extends Component<
{ progress: ProgressRef },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render(): ReactNode {
if (this.state.failed)
return <ProceduralPhone progress={this.props.progress} />;
return <ScreenPhone progress={this.props.progress} />;
}
}
function CameraRig({ progress }: { progress: ProgressRef }) {
const smooth = useRef(0);
useFrame(({ camera }, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current;
// leve steadicam: aproxima no meio da história
// steadicam sutil: aproxima no meio da história
camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
camera.position.y = -0.3 + p * 0.6;
camera.lookAt(0, 0, 0);
@@ -111,9 +207,12 @@ export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
camera={{ position: [0, 0, 7.8], fov: 30 }}
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
>
<ambientLight intensity={0.32} />
<directionalLight position={[4, 6, 5]} intensity={1.1} />
<DeviceRig progress={progress} />
<ambientLight intensity={0.55} />
<directionalLight position={[4, 6, 5]} intensity={1.4} />
<directionalLight position={[-5, -2, 6]} intensity={0.6} color="#cfd2da" />
<Suspense fallback={<ProceduralPhone progress={progress} />}>
<PhoneBoundary progress={progress} />
</Suspense>
<CameraRig progress={progress} />
<Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" />
<Environment resolution={64} frames={1}>
+183
View File
@@ -0,0 +1,183 @@
// Gera a textura "site vivo" da tela do celular: um mini-portfolio desenhado em
// canvas 2D, com os prints reais dos projetos em produção rolando verticalmente
// conforme o progresso do reel — como se o site estivesse aberto no aparelho.
// Fotos dos projetos: capturadas ao vivo dos deployments (headless Chrome).
// CC0 model: "Phone" by Quaternius (https://quaternius.com) via poly.pizza.
type Shot = { img: HTMLImageElement; name: string; tag: string };
const SHOTS: Array<{ src: string; name: string; tag: string }> = [
{ src: "/assets/prints/print-nutriscan.png", name: "NutriScan", tag: "Open Food Facts · PWA" },
{ src: "/assets/prints/print-scplus.png", name: "SCPLUS", tag: "Educação + IoT · Garuva-SC" },
{ src: "/assets/prints/print-sev.png", name: "SEV Moda Íntima", tag: "E-commerce · Next.js" },
{ src: "/assets/prints/print-asservicos.png", name: "AS Serviços", tag: "Institucional · SSR" },
];
export type ScreenPainter = {
canvas: HTMLCanvasElement;
/** desenha o frame para o progresso p∈[0,1] do reel; retorna true se mudou */
paint: (p: number, t: number) => boolean;
ready: () => boolean;
};
const W = 512;
const H = 1024;
const INK = "#0a0a0a";
const PAPER = "#f4f2ed";
const SLASH = "#ccff00";
export function createPhoneScreen(): ScreenPainter {
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
const images: Shot[] = [];
let lastKey = "";
for (const s of SHOTS) {
const img = new Image();
img.decoding = "async";
img.onload = () => images.push({ img, name: s.name, tag: s.tag });
img.src = s.src;
}
function roundRect(x: number, y: number, w: number, h: number, r: number) {
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function wrap(text: string, x: number, y: number, maxW: number, lh: number) {
if (!ctx) return;
const words = text.split(" ");
let line = "";
let yy = y;
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width > maxW) {
ctx.fillText(line, x, yy);
line = word;
yy += lh;
} else line = candidate;
}
if (line) ctx.fillText(line, x, yy);
}
function drawHero(offset: number, t: number) {
if (!ctx) return;
if (offset > H || offset < -H) return;
ctx.save();
ctx.translate(0, offset);
const pulse = 0.6 + 0.4 * Math.sin(t / 600);
ctx.fillStyle = SLASH;
ctx.globalAlpha = 0.14 + pulse * 0.06;
ctx.fillRect(0, 60, W, 2);
ctx.globalAlpha = 1;
ctx.fillStyle = "rgba(244,242,237,.55)";
ctx.font = "600 20px ui-monospace, monospace";
ctx.fillText("PORTFÓLIO · AO VIVO", 36, 150);
ctx.fillStyle = PAPER;
ctx.font = "800 72px system-ui, sans-serif";
ctx.fillText("Software", 36, 260);
ctx.fillText("que", 36, 340);
ctx.fillStyle = SLASH;
ctx.fillText("opera.", 36, 420);
ctx.fillStyle = "rgba(244,242,237,.66)";
ctx.font = "400 28px system-ui, sans-serif";
wrap("Engenharia de sistemas que precisam funcionar de verdade.", 36, 500, W - 72, 40);
ctx.fillStyle = SLASH;
roundRect(36, 700, 250, 72, 36);
ctx.fill();
ctx.fillStyle = INK;
ctx.font = "700 26px ui-monospace, monospace";
ctx.fillText("VER PROJETOS ↓", 60, 744);
ctx.restore();
}
function drawProject(shot: Shot, offset: number, i: number, t: number) {
if (!ctx) return;
ctx.save();
ctx.translate(0, offset);
ctx.fillStyle = "rgba(244,242,237,.5)";
ctx.font = "600 18px ui-monospace, monospace";
ctx.fillText(`EM PRODUÇÃO · 0${i + 1}`, 36, 110);
ctx.fillStyle = PAPER;
ctx.font = "800 46px system-ui, sans-serif";
ctx.fillText(shot.name, 36, 168);
ctx.fillStyle = "rgba(204,255,0,.85)";
ctx.font = "400 22px system-ui, sans-serif";
ctx.fillText(shot.tag, 36, 204);
// o print real, recortado, com leve auto-scroll dentro do card
const cardX = 36;
const cardY = 236;
const cardW = W - 72;
const cardH = 620;
ctx.save();
roundRect(cardX, cardY, cardW, cardH, 28);
ctx.clip();
ctx.fillStyle = "#111";
ctx.fillRect(cardX, cardY, cardW, cardH);
const scale = cardW / shot.img.width;
const drift =
(Math.sin(t / 1400 + i * 2.1) * 0.5 + 0.5) *
Math.max(0, shot.img.height * scale - cardH);
ctx.drawImage(shot.img, cardX, cardY - drift, cardW, shot.img.height * scale);
const grd = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH);
grd.addColorStop(0, "rgba(255,255,255,.08)");
grd.addColorStop(0.12, "rgba(255,255,255,0)");
ctx.fillStyle = grd;
ctx.fillRect(cardX, cardY, cardW, cardH);
ctx.restore();
ctx.strokeStyle = "rgba(255,255,255,.12)";
ctx.lineWidth = 2;
roundRect(cardX, cardY, cardW, cardH, 28);
ctx.stroke();
ctx.restore();
}
function paint(p: number, t: number) {
if (!ctx || images.length === 0) return false;
const key = `${p.toFixed(4)}|${Math.floor(t / 250)}|${images.length}`;
if (key === lastKey) return false;
lastKey = key;
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
// status bar do "aparelho"
ctx.fillStyle = PAPER;
ctx.font = "600 22px ui-monospace, monospace";
ctx.fillText("21:37", 28, 38);
ctx.fillStyle = SLASH;
ctx.fillRect(W - 96, 22, 44, 14);
ctx.fillStyle = PAPER;
ctx.font = "600 16px ui-monospace, monospace";
ctx.fillText("E:/", W - 40, 36);
// o mini-site rolando: hero + página por projeto
const pageH = H - 60;
const y = p * images.length * pageH;
drawHero(-y, t);
images.forEach((shot, i) => {
const top = (i + 1) * pageH - y;
if (top > H || top < -pageH) return;
drawProject(shot, top, i, t);
});
return true;
}
return {
canvas,
paint,
ready: () => images.length > 0,
};
}
export const PHONE_SCREEN_W = W;
export const PHONE_SCREEN_H = H;
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.