import React, { useMemo, useState, useEffect } from "react"; import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig, delayRender, continueRender, staticFile, } from "remotion"; import { ThreeCanvas } from "@remotion/three"; import { RoundedBox, Environment, Lightformer, MeshReflectorMaterial, ContactShadows } from "@react-three/drei"; import * as THREE from "three"; import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing"; import { z } from "zod"; import { colorSchema } from "../lib/branding"; import { FONT } from "../lib/fonts"; import { useLayout } from "../lib/aspect"; import { hexToRgba, mixHex } from "../lib/anim"; export const appShowcase3DSchema = z.object({ appName: z.string(), tagline: z.string(), cta: z.string(), /** Optional app screenshot (textured onto the phone screen). Empty → procedural mock UI. */ screenUrl: z.string(), ...colorSchema, }); type Props = z.infer; // ── Screen texture: the user's uploaded app image, or a procedural mock UI ──── function useScreenTexture(screenUrl: string, accent: string, secondary: string): THREE.Texture { const procedural = useMemo(() => { const W = 540, H = 1170; const c = document.createElement("canvas"); c.width = W; c.height = H; const x = c.getContext("2d")!; // background x.fillStyle = "#ffffff"; x.fillRect(0, 0, W, H); // status bar x.fillStyle = "#0f172a"; x.font = "600 26px Arial"; x.textBaseline = "middle"; x.textAlign = "left"; x.fillText("9:41", 40, 48); x.textAlign = "right"; for (let i = 0; i < 3; i++) { x.globalAlpha = 1; x.fillRect(W - 40 - i * 26, 40, 16, 16); } x.globalAlpha = 1; x.textAlign = "left"; // header band (accent) const hg = x.createLinearGradient(0, 90, W, 230); hg.addColorStop(0, accent); hg.addColorStop(1, mixHex(accent, secondary, 0.6)); x.fillStyle = hg; roundRect(x, 0, 90, W, 150, 0); x.fill(); // logo chip + title placeholder bars x.fillStyle = "rgba(255,255,255,0.95)"; roundRect(x, 40, 130, 70, 70, 18); x.fill(); x.fillStyle = "rgba(255,255,255,0.95)"; roundRect(x, 130, 140, 220, 22, 11); x.fill(); x.fillStyle = "rgba(255,255,255,0.6)"; roundRect(x, 130, 178, 150, 16, 8); x.fill(); // hero card const cg = x.createLinearGradient(40, 280, W - 40, 540); cg.addColorStop(0, mixHex(accent, "#ffffff", 0.1)); cg.addColorStop(1, secondary); x.fillStyle = cg; roundRect(x, 40, 280, W - 80, 260, 28); x.fill(); x.fillStyle = "rgba(255,255,255,0.9)"; roundRect(x, 70, 430, 200, 26, 13); x.fill(); x.fillStyle = "rgba(255,255,255,0.6)"; roundRect(x, 70, 472, 300, 18, 9); x.fill(); // list rows for (let i = 0; i < 4; i++) { const y = 580 + i * 110; x.fillStyle = "#f1f5f9"; roundRect(x, 40, y, W - 80, 90, 22); x.fill(); x.fillStyle = accent; x.beginPath(); x.arc(95, y + 45, 28, 0, Math.PI * 2); x.fill(); x.fillStyle = "#cbd5e1"; roundRect(x, 145, y + 24, 240, 18, 9); x.fill(); x.fillStyle = "#e2e8f0"; roundRect(x, 145, y + 52, 160, 14, 7); x.fill(); } // bottom tab bar x.fillStyle = "#ffffff"; x.fillRect(0, H - 110, W, 110); x.strokeStyle = "#e2e8f0"; x.lineWidth = 2; x.beginPath(); x.moveTo(0, H - 110); x.lineTo(W, H - 110); x.stroke(); for (let i = 0; i < 4; i++) { x.fillStyle = i === 0 ? accent : "#cbd5e1"; x.beginPath(); x.arc(80 + i * 130, H - 55, 16, 0, Math.PI * 2); x.fill(); } const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8; return tex; }, [accent, secondary]); // Load the user's screenshot (if provided) as the screen texture; else procedural. const [imgTex, setImgTex] = useState(null); useEffect(() => { if (!screenUrl) { setImgTex(null); return; } const handle = delayRender("load app screenshot"); const loader = new THREE.TextureLoader(); loader.setCrossOrigin("anonymous"); // Full http(s) URLs (user uploads / MinIO) load directly; bare paths resolve from public/. const url = /^https?:\/\//.test(screenUrl) || screenUrl.startsWith("data:") ? screenUrl : staticFile(screenUrl); loader.load( url, (t) => { t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; setImgTex(t); continueRender(handle); }, undefined, () => continueRender(handle), ); }, [screenUrl]); return screenUrl && imgTex ? imgTex : procedural; } function roundRect(x: CanvasRenderingContext2D, X: number, Y: number, W: number, H: number, r: number) { x.beginPath(); x.moveTo(X + r, Y); x.arcTo(X + W, Y, X + W, Y + H, r); x.arcTo(X + W, Y + H, X, Y + H, r); x.arcTo(X, Y + H, X, Y, r); x.arcTo(X, Y, X + W, Y, r); x.closePath(); } // A rounded-rectangle screen (real phones have rounded screen corners, not square). const RoundedScreen: React.FC<{ w: number; h: number; radius: number; z: number; tex: THREE.Texture; opacity: number; glow: string; glowOpacity: number }> = ({ w, h, radius, z, tex, opacity, glow, glowOpacity }) => { const geo = useMemo(() => { const s = new THREE.Shape(); const x = -w / 2, y = -h / 2, r = Math.min(radius, w / 2, h / 2); s.moveTo(x + r, y); s.lineTo(x + w - r, y); s.quadraticCurveTo(x + w, y, x + w, y + r); s.lineTo(x + w, y + h - r); s.quadraticCurveTo(x + w, y + h, x + w - r, y + h); s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r); s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y); const g = new THREE.ShapeGeometry(s, 16); g.computeBoundingBox(); const bb = g.boundingBox!; const sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y; const pos = g.attributes.position; const uv: number[] = []; for (let i = 0; i < pos.count; i++) uv.push((pos.getX(i) - bb.min.x) / sx, (pos.getY(i) - bb.min.y) / sy); g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2)); return g; }, [w, h, radius]); return ( {glowOpacity > 0.001 && ( )} ); }; // ── The 3D phone (generic premium flagship — Natural Titanium) ──────────────── const Phone: React.FC<{ screen: THREE.Texture; accent: string }> = ({ screen, accent }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const W = 2.02, H = 4.34, D = 0.26, rim = 0.05, bezel = 0.05; const TITANIUM = "#c6c3ba"; // warm natural titanium const enter = spring({ frame: frame - 4, fps, config: { damping: 14, stiffness: 70, mass: 0.9 } }); const scale = interpolate(enter, [0, 1], [0.7, 1]); const floatY = Math.sin(frame / 34) * 0.08; const sway = -0.2 + Math.sin(frame / 72) * 0.1; // show the titanium side edge const tiltX = interpolate(enter, [0, 1], [0.45, -0.04]); const screenOn = interpolate(frame, [12, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); const btn = mixHex(TITANIUM, "#000000", 0.22); return ( {/* titanium frame */} {/* glossy black glass front — edge-to-edge minus a thin titanium rim */} {/* rounded, textured screen + power-on glow */} {/* dynamic island + camera */} {/* side buttons (titanium) */} ); }; const Scene: React.FC<{ accent: string; secondary: string; phoneX: number; phoneY: number; phoneScale: number; screen: THREE.Texture }> = ({ accent, secondary, phoneX, phoneY, phoneScale, screen }) => { const frame = useCurrentFrame(); const orbit = Math.sin(frame / 120) * 0.05; return ( {/* light, clean keynote studio */} {/* key light (no harsh cast shadow — ContactShadows grounds the phone instead) */} {/* sharp rim from behind for a hot metallic edge highlight */} {/* Contrasty studio env → bright softbox STREAKS on the titanium (the "shine"). A darker fill behind the bright strips gives metal the light/dark contrast that reads as polished metal; the visible studio stays light (2D gradient behind canvas). */} {/* light bases (keep the titanium silvery, not dark) front + back */} {/* bright softbox strips → hot reflection streaks on the rounded edges */} {/* soft contact shadow grounds the phone cleanly */} {/* subtle reflective light floor for the keynote feel */} ); }; const StoreBadge: React.FC<{ kind: "ios" | "android"; L: ReturnType }> = ({ kind, L }) => (
{kind === "ios" ? "Download on the" : "GET IT ON"}
{kind === "ios" ? "App Store" : "Google Play"}
); // ── Finishing layers ───────────────────────────────────────────────────────── const LightSweep: React.FC = () => { const f = useCurrentFrame(); const { width } = useVideoConfig(); const x = interpolate(f, [26, 72], [-width * 0.35, width * 0.6], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); const op = interpolate(f, [26, 40, 64, 76], [0, 0.45, 0.45, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); return (
); }; const Grain: React.FC = () => { const f = useCurrentFrame(); return ( ); }; export const AppShowcase3D: React.FC = ({ appName, tagline, cta, screenUrl, accentColor, secondaryColor, backgroundColor, textColor, }) => { const frame = useCurrentFrame(); const { width, height, fps } = useVideoConfig(); const L = useLayout(); const screen = useScreenTexture(screenUrl, accentColor, secondaryColor); // Per-aspect re-flow: wide → phone left + text right; square/tall → phone up, text below. const phoneX = L.pick(-1.85, 0, 0); const phoneY = L.pick(0.1, 1.05, 0.55); // raise in square/tall so the phone clears the text const phoneScale = L.pick(1.18, 0.8, 0.9); // shrink in square/tall so the whole device fits const wide = L.isWide; // Text reveals. const nameSp = spring({ frame: frame - 70, fps, config: { damping: 13, stiffness: 90 } }); const nameY = interpolate(nameSp, [0, 1], [L.vmin(40), 0]); const nameOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); const tagOp = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); const ctaSp = spring({ frame: frame - 116, fps, config: { damping: 12, stiffness: 110 } }); const ctaScale = interpolate(ctaSp, [0, 1], [0.6, 1]); const ctaOp = interpolate(frame, [116, 134], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); const badgeOp = interpolate(frame, [134, 152], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); return ( {/* light studio: soft vertical gradient + faint accent glow behind the phone */} {/* text + CTA + badges — layout in LTR (predictable), text rendered RTL */}
{appName}
{tagline}
{cta}
); };