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
@@ -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;
|
||||
Reference in new issue
Block a user