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:
1 parent
8be2ba572d
commit
ecadb7c540
20 files changed
+637
-83
No files matched your search
+16
@@ -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.
|
||||||
@@ -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."
|
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
|
disableChildrenAnimation
|
||||||
aside={
|
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" />
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
|
||||||
Garuva, SC | Remote worldwide
|
Garuva, SC | Remote worldwide
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,9 @@ export default function About() {
|
|||||||
<div className="ds-kicker">{step.label}</div>
|
<div className="ds-kicker">{step.label}</div>
|
||||||
<DrawLine delay={0.03} />
|
<DrawLine delay={0.03} />
|
||||||
<h3 className="ds-card-title">{step.title}</h3>
|
<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.article>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -141,13 +143,13 @@ export default function About() {
|
|||||||
<div className="ds-kicker">Technical profile</div>
|
<div className="ds-kicker">Technical profile</div>
|
||||||
<DrawLine delay={0.05} />
|
<DrawLine delay={0.05} />
|
||||||
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
|
<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,
|
Sou engenheiro da computação e desenvolvedor de software full-stack,
|
||||||
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
|
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
|
||||||
separadas: hardware, servicos, operacao e interface. Meu trabalho e
|
separadas: hardware, servicos, operacao e interface. Meu trabalho e
|
||||||
dar forma clara ao que costuma nascer fragmentado.
|
dar forma clara ao que costuma nascer fragmentado.
|
||||||
</p>
|
</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) => (
|
{PROFILE_POINTS.map((point) => (
|
||||||
<div
|
<div
|
||||||
key={point.title}
|
key={point.title}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function CompanyLogos() {
|
|||||||
// Animated infinite scroll
|
// Animated infinite scroll
|
||||||
return (
|
return (
|
||||||
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
|
<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
|
<motion.div
|
||||||
className="flex gap-sp-12 items-center"
|
className="flex gap-sp-12 items-center"
|
||||||
animate={{
|
animate={{
|
||||||
|
|||||||
@@ -90,19 +90,29 @@ export default function Contact() {
|
|||||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
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} />
|
<DrawLine delay={0.04} />
|
||||||
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3>
|
<h3 className="ds-card-title" data-reel="top">
|
||||||
<p className="ds-copy">
|
Resposta inicial em ate 24h
|
||||||
|
</h3>
|
||||||
|
<p className="ds-copy" data-reel="right">
|
||||||
Ideal para discovery tecnico, revisao de arquitetura, rescue de
|
Ideal para discovery tecnico, revisao de arquitetura, rescue de
|
||||||
projeto, performance ou construcao de produto com ownership real.
|
projeto, performance ou construcao de produto com ownership real.
|
||||||
</p>
|
</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>WhatsApp: {whatsAppDisplay}</div>
|
||||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
||||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||||
</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
|
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
|
||||||
conversa tecnica objetiva.
|
conversa tecnica objetiva.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function Experience({
|
|||||||
<div className="max-w-[720px]">
|
<div className="max-w-[720px]">
|
||||||
<div className="ds-kicker">{exp.period}</div>
|
<div className="ds-kicker">{exp.period}</div>
|
||||||
<DrawLine delay={0.04} />
|
<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)]">
|
<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}
|
{exp.company} • {exp.type}
|
||||||
</p>
|
</p>
|
||||||
@@ -87,7 +87,7 @@ export default function Experience({
|
|||||||
<div className="ds-metric">{exp.location}</div>
|
<div className="ds-metric">{exp.location}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="ds-copy">{exp.description}</p>
|
<p className="ds-copy" data-reel="right">{exp.description}</p>
|
||||||
|
|
||||||
<ul className="ds-list">
|
<ul className="ds-list">
|
||||||
{exp.achievements?.map((achievement) => (
|
{exp.achievements?.map((achievement) => (
|
||||||
@@ -95,7 +95,7 @@ export default function Experience({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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) => (
|
{exp.technologies?.slice(0, 6).map((tech) => (
|
||||||
<li key={tech} className="ds-tag">
|
<li key={tech} className="ds-tag">
|
||||||
{tech}
|
{tech}
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export default function LighthouseMetrics() {
|
|||||||
dark
|
dark
|
||||||
disableChildrenAnimation={false}
|
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) => (
|
{METRICS.map((metric, i) => (
|
||||||
<motion.article
|
<motion.article
|
||||||
key={metric.label}
|
key={metric.label}
|
||||||
@@ -96,7 +99,7 @@ export default function LighthouseMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Details */}
|
{/* 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)]">
|
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
|
||||||
{metric.label}
|
{metric.label}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function Projects({
|
|||||||
|
|
||||||
<p className="ds-copy">{project.description}</p>
|
<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) => (
|
{project.technologies.slice(0, 5).map((tech) => (
|
||||||
<li key={tech} className="ds-tag">
|
<li key={tech} className="ds-tag">
|
||||||
{tech}
|
{tech}
|
||||||
@@ -101,7 +101,7 @@ export default function Projects({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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 && (
|
{project.githubUrl && (
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
@@ -131,7 +131,7 @@ export default function Projects({
|
|||||||
))}
|
))}
|
||||||
</motion.div>
|
</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
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
href="/cases"
|
href="/cases"
|
||||||
|
|||||||
@@ -69,13 +69,15 @@ export default function Skills({
|
|||||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
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} />
|
<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
|
Ferramentas que uso para construir com clareza tecnica e ritmo de
|
||||||
entrega.
|
entrega.
|
||||||
</p>
|
</p>
|
||||||
<ul className="ds-tags">
|
<ul className="ds-tags" data-reel-stagger="fade">
|
||||||
{category.skills.map((skill) => (
|
{category.skills.map((skill) => (
|
||||||
<li key={skill} className="ds-tag">
|
<li key={skill} className="ds-tag">
|
||||||
{skill}
|
{skill}
|
||||||
|
|||||||
@@ -128,11 +128,11 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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}
|
{tier.description}
|
||||||
</p>
|
</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) => (
|
{tier.deliverables.map((deliverable) => (
|
||||||
<li key={deliverable} className="pl-sp-1">
|
<li key={deliverable} className="pl-sp-1">
|
||||||
{deliverable}
|
{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="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="max-w-[760px]">
|
<div className="max-w-[760px]">
|
||||||
<div className="ds-kicker">Páginas de serviço</div>
|
<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
|
Especialidades organizadas por intenção de busca
|
||||||
</h3>
|
</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
|
Se você chegou buscando uma stack, um tipo de consultoria ou um
|
||||||
contexto técnico específico, aqui está o caminho mais direto para
|
contexto técnico específico, aqui está o caminho mais direto para
|
||||||
a página certa.
|
a página certa.
|
||||||
@@ -221,7 +221,7 @@ export function WorkModalities() {
|
|||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</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) => (
|
{serviceHubLinks.map((service) => (
|
||||||
<Link
|
<Link
|
||||||
key={service.href}
|
key={service.href}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Hero from "../Hero";
|
|||||||
import Projects from "../Projects";
|
import Projects from "../Projects";
|
||||||
import { SectionLoading } from "../SectionLoading";
|
import { SectionLoading } from "../SectionLoading";
|
||||||
import ScrollStory from "../story/ScrollStory";
|
import ScrollStory from "../story/ScrollStory";
|
||||||
|
import ReelDirector from "../story/ReelDirector";
|
||||||
|
import PrintReel from "../story/PrintReel";
|
||||||
import CinematicSection from "./CinematicSection";
|
import CinematicSection from "./CinematicSection";
|
||||||
|
|
||||||
const About = dynamic(() => import("../About"), {
|
const About = dynamic(() => import("../About"), {
|
||||||
@@ -52,6 +54,7 @@ export default function CinematicHome({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||||
<main className="ds-main">
|
<main className="ds-main">
|
||||||
|
<ReelDirector />
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|
||||||
{/* Marquee divider after 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">
|
<CinematicSection effect="parallax-depth">
|
||||||
<About />
|
<About />
|
||||||
</CinematicSection>
|
</CinematicSection>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -65,10 +65,10 @@ describe("ScrollStory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
|
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
|
||||||
mockMatchMedia(false);
|
|
||||||
const { container } = render(<ScrollStory {...props} />);
|
const { container } = render(<ScrollStory {...props} />);
|
||||||
await waitFor(() =>
|
await waitFor(
|
||||||
expect(container.querySelector("[data-story]")).toBeInTheDocument(),
|
() => expect(container.querySelector("[data-story]")).toBeInTheDocument(),
|
||||||
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
|
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
|
||||||
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
|
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
|
||||||
@@ -76,5 +76,5 @@ describe("ScrollStory", () => {
|
|||||||
// count-up é alimentado pela timeline: markup carrega o alvo real
|
// count-up é alimentado pela timeline: markup carrega o alvo real
|
||||||
const num = container.querySelector("[data-story-num]");
|
const num = container.querySelector("[data-story-num]");
|
||||||
expect(num?.getAttribute("data-story-num-to")).toBe("5");
|
expect(num?.getAttribute("data-story-num-to")).toBe("5");
|
||||||
});
|
}, 20000);
|
||||||
});
|
});
|
||||||
@@ -1,72 +1,160 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei";
|
import {
|
||||||
import { useRef } from "react";
|
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 * as THREE from "three";
|
||||||
|
import { createPhoneScreen, type ScreenPainter } from "./phoneScreenTexture";
|
||||||
|
|
||||||
type ProgressRef = { current: number };
|
type ProgressRef = { current: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Palco WebGL do scrollytelling: um "dispositivo" 3D procedural que gira,
|
* Palco WebGL do scrollytelling.
|
||||||
* inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem
|
* Modelo 3D do aparelho: "Phone" by Quaternius — CC0 (Public Domain), via
|
||||||
* re-render do React por frame). O lerp interno suaviza o scrub em touch.
|
* poly.pizza (https://poly.pizza/m/k2kgBepoMU). Creditado em CREDITS.md.
|
||||||
* Composição estilo product-page: dispositivo no terço direito, texto à esquerda.
|
* 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 rig = useRef<THREE.Group>(null);
|
||||||
const halo = useRef<THREE.Mesh>(null);
|
const halo = useRef<THREE.Mesh>(null);
|
||||||
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
|
|
||||||
const glow = useRef<THREE.PointLight>(null);
|
|
||||||
const smooth = useRef(0);
|
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);
|
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||||
const p = smooth.current;
|
const p = smooth.current;
|
||||||
const g = rig.current;
|
if (rig.current) motionRig(rig.current, p);
|
||||||
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 (halo.current) {
|
if (halo.current) {
|
||||||
halo.current.rotation.z += dt * 0.25;
|
halo.current.rotation.z += dt * 0.25;
|
||||||
const m = halo.current.material as THREE.MeshBasicMaterial;
|
const m = halo.current.material as THREE.MeshBasicMaterial;
|
||||||
m.opacity = 0.1 + p * 0.08;
|
m.opacity = 0.1 + p * 0.08;
|
||||||
}
|
}
|
||||||
if (screenMat.current) {
|
// pinta o mini-site ~4fps quando há mudança real (quantizada no painter)
|
||||||
// tela "liga" cena a cena: 4 pulsos ao longo do reel
|
const t = state.clock.elapsedTime * 1000;
|
||||||
screenMat.current.emissiveIntensity =
|
if (painter.ready() && painter.paint(p, t)) {
|
||||||
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
|
texture.needsUpdate = true;
|
||||||
}
|
|
||||||
if (glow.current) {
|
|
||||||
glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<group ref={rig}>
|
<group ref={rig}>
|
||||||
{/* corpo do dispositivo */}
|
|
||||||
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
|
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
|
||||||
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
|
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
|
||||||
</RoundedBox>
|
</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]}>
|
<mesh position={[0, 0, 0.098]}>
|
||||||
<planeGeometry args={[1.42, 3.06]} />
|
<planeGeometry args={[1.42, 3.06]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
@@ -77,26 +165,34 @@ function DeviceRig({ progress }: { progress: ProgressRef }) {
|
|||||||
toneMapped={false}
|
toneMapped={false}
|
||||||
transparent
|
transparent
|
||||||
opacity={0.92}
|
opacity={0.92}
|
||||||
roughness={0.3}
|
|
||||||
metalness={0.1}
|
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</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>
|
</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 }) {
|
function CameraRig({ progress }: { progress: ProgressRef }) {
|
||||||
const smooth = useRef(0);
|
const smooth = useRef(0);
|
||||||
useFrame(({ camera }, dt) => {
|
useFrame(({ camera }, dt) => {
|
||||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||||
const p = smooth.current;
|
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.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
|
||||||
camera.position.y = -0.3 + p * 0.6;
|
camera.position.y = -0.3 + p * 0.6;
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
@@ -111,9 +207,12 @@ export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
|
|||||||
camera={{ position: [0, 0, 7.8], fov: 30 }}
|
camera={{ position: [0, 0, 7.8], fov: 30 }}
|
||||||
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.32} />
|
<ambientLight intensity={0.55} />
|
||||||
<directionalLight position={[4, 6, 5]} intensity={1.1} />
|
<directionalLight position={[4, 6, 5]} intensity={1.4} />
|
||||||
<DeviceRig progress={progress} />
|
<directionalLight position={[-5, -2, 6]} intensity={0.6} color="#cfd2da" />
|
||||||
|
<Suspense fallback={<ProceduralPhone progress={progress} />}>
|
||||||
|
<PhoneBoundary progress={progress} />
|
||||||
|
</Suspense>
|
||||||
<CameraRig progress={progress} />
|
<CameraRig progress={progress} />
|
||||||
<Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" />
|
<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}>
|
<Environment resolution={64} frames={1}>
|
||||||
|
|||||||
@@ -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.
Reference in new issue
Block a user