feat(remotion): premium 3D app-showcase template (AppShowcase3D)
CI/CD / CI · Web (tsc) (push) Successful in 1m19s
CI/CD / Deploy · full stack (push) Failing after 12s

- New @remotion/three template: titanium flagship phone (thin rim, glossy black
  glass, rounded-corner screen via ShapeGeometry, dynamic island, side buttons),
  light keynote studio (contact shadow + env reflections + DOF + soft bloom),
  film grain + entrance light-sweep. All 3 aspects re-flowed.
- Editable screenUrl (user app screenshot textured onto the screen via TextureLoader
  + delayRender), appName/tagline/cta, 4 colours (dark text on light bg).
- Add pick(wide,square,tall) helper to lib/aspect.ts (Tier-0 from the R&D).
- Seed: AppShowcase3D + per-template text colour; built with the flat-artist skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 21:28:49 +03:30
parent f83d657844
commit 1795bc855b
8 changed files with 379 additions and 2 deletions
@@ -0,0 +1,344 @@
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<typeof appShowcase3DSchema>;
// ── 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<THREE.Texture | null>(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 (
<group position={[0, 0, z]}>
{glowOpacity > 0.001 && (
<mesh geometry={geo} position={[0, 0, -0.002]}>
<meshBasicMaterial color={glow} transparent opacity={glowOpacity} toneMapped={false} />
</mesh>
)}
<mesh geometry={geo}>
<meshBasicMaterial map={tex} toneMapped={false} transparent opacity={opacity} />
</mesh>
</group>
);
};
// ── 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 = "#bcb9b1"; // 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 (
<group position={[0, floatY, 0]} rotation={[tiltX, sway, 0]} scale={scale}>
{/* titanium frame */}
<RoundedBox args={[W, H, D]} radius={0.33} smoothness={10} castShadow>
<meshStandardMaterial color={TITANIUM} metalness={1} roughness={0.42} envMapIntensity={1.6} />
</RoundedBox>
{/* glossy black glass front — edge-to-edge minus a thin titanium rim */}
<RoundedBox args={[W - rim * 2, H - rim * 2, 0.06]} radius={0.29} smoothness={10} position={[0, 0, D / 2 - 0.028]}>
<meshPhysicalMaterial color="#05070b" metalness={0} roughness={0.1} clearcoat={1} clearcoatRoughness={0.05} envMapIntensity={1.3} />
</RoundedBox>
{/* rounded, textured screen + power-on glow */}
<RoundedScreen w={W - rim * 2 - bezel * 2} h={H - rim * 2 - bezel * 2} radius={0.24} z={D / 2 + 0.006} tex={screen} opacity={screenOn} glow={accent} glowOpacity={(1 - screenOn) * 0.5} />
{/* dynamic island + camera */}
<group position={[0, H / 2 - rim - bezel - 0.16, D / 2 + 0.02]}>
<RoundedBox args={[0.6, 0.15, 0.05]} radius={0.075} smoothness={6}>
<meshStandardMaterial color="#02030a" roughness={0.25} metalness={0.2} />
</RoundedBox>
<mesh position={[0.2, 0, 0.03]}>
<circleGeometry args={[0.032, 20]} />
<meshStandardMaterial color="#0a1622" metalness={0.7} roughness={0.18} />
</mesh>
</group>
{/* side buttons (titanium) */}
<mesh position={[W / 2 + 0.006, 0.6, 0]}><boxGeometry args={[0.035, 0.52, 0.13]} /><meshStandardMaterial color={btn} metalness={1} roughness={0.45} /></mesh>
<mesh position={[W / 2 + 0.006, -0.05, 0]}><boxGeometry args={[0.035, 0.3, 0.13]} /><meshStandardMaterial color={btn} metalness={1} roughness={0.45} /></mesh>
<mesh position={[-W / 2 - 0.006, 0.75, 0]}><boxGeometry args={[0.035, 0.36, 0.13]} /><meshStandardMaterial color={btn} metalness={1} roughness={0.45} /></mesh>
</group>
);
};
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 (
<group rotation={[0, orbit, 0]}>
{/* light, clean keynote studio */}
<ambientLight intensity={0.8} />
{/* key light (no harsh cast shadow — ContactShadows grounds the phone instead) */}
<directionalLight position={[3, 6, 6]} intensity={2.4} color="#ffffff" />
<directionalLight position={[-5, 3, 2]} intensity={0.8} color={mixHex("#ffffff", secondary, 0.3)} />
{/* sharp rim from behind for a metallic edge highlight */}
<spotLight position={[-3, 5, -4]} angle={0.6} penumbra={0.8} intensity={120} color="#ffffff" />
<pointLight position={[4, -1, 4]} intensity={10} color={mixHex("#ffffff", accent, 0.4)} />
<Environment resolution={256}>
<Lightformer intensity={2} position={[0, 4, 4]} scale={[12, 6, 1]} color="#ffffff" />
<Lightformer intensity={1.2} position={[-5, 1, 2]} scale={[3, 9, 1]} color="#dfe6f0" />
<Lightformer intensity={1.2} position={[5, 2, 1]} scale={[3, 9, 1]} color="#f2f4f7" />
</Environment>
<group position={[phoneX, phoneY, 0]} scale={phoneScale}>
<Phone screen={screen} accent={accent} />
</group>
{/* soft contact shadow grounds the phone cleanly */}
<ContactShadows position={[phoneX, -2.5 + phoneY * 0.05, 0]} scale={11} blur={2.8} opacity={0.32} far={6} color="#1e293b" />
{/* subtle reflective light floor for the keynote feel */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -2.5, 0]}>
<planeGeometry args={[40, 40]} />
<MeshReflectorMaterial blur={[300, 90]} resolution={1024} mixBlur={1.2} mixStrength={6} roughness={0.95} depthScale={0.5} minDepthThreshold={0.5} maxDepthThreshold={1.2} color="#e9ebf0" metalness={0.1} />
</mesh>
</group>
);
};
const StoreBadge: React.FC<{ kind: "ios" | "android"; L: ReturnType<typeof useLayout> }> = ({ kind, L }) => (
<div style={{ display: "flex", alignItems: "center", gap: L.vmin(8), background: "#0f172a", color: "#fff", borderRadius: L.vmin(12), padding: `${L.vmin(8)}px ${L.vmin(16)}px` }}>
<div style={{ width: L.vmin(26), height: L.vmin(26), borderRadius: "50%", background: "#fff", opacity: 0.95 }} />
<div style={{ textAlign: "left", lineHeight: 1.1 }}>
<div style={{ fontSize: L.vmin(12), opacity: 0.8 }}>{kind === "ios" ? "Download on the" : "GET IT ON"}</div>
<div style={{ fontSize: L.vmin(19), fontWeight: 700 }}>{kind === "ios" ? "App Store" : "Google Play"}</div>
</div>
</div>
);
// ── 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 (
<AbsoluteFill style={{ pointerEvents: "none", overflow: "hidden" }}>
<div style={{ position: "absolute", top: "-20%", height: "140%", width: 220, transform: `translateX(${x}px) rotate(12deg)`, background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent)", filter: "blur(34px)", opacity: op, mixBlendMode: "screen" }} />
</AbsoluteFill>
);
};
const Grain: React.FC = () => {
const f = useCurrentFrame();
return (
<AbsoluteFill style={{ pointerEvents: "none", opacity: 0.045, mixBlendMode: "overlay" }}>
<svg width="100%" height="100%">
<filter id="appGrain">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed={f % 73} stitchTiles="stitch" />
</filter>
<rect width="100%" height="100%" filter="url(#appGrain)" />
</svg>
</AbsoluteFill>
);
};
export const AppShowcase3D: React.FC<Props> = ({
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 (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", backgroundColor }}>
{/* light studio: soft vertical gradient + faint accent glow behind the phone */}
<AbsoluteFill style={{ background: `linear-gradient(180deg, #fbfbfd 0%, ${backgroundColor} 52%, ${mixHex(backgroundColor, "#000000", 0.09)} 100%)` }} />
<AbsoluteFill style={{ background: `radial-gradient(circle at ${L.pick("29%", "50%", "50%")} ${L.pick("48%", "38%", "32%")}, ${hexToRgba(accentColor, 0.2)} 0%, transparent 46%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 0.2, 8.2], fov: 42 }}
shadows
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene accent={accentColor} secondary={secondaryColor} phoneX={phoneX} phoneY={phoneY} phoneScale={phoneScale} screen={screen} />
<EffectComposer>
<Bloom intensity={0.28} luminanceThreshold={0.75} luminanceSmoothing={0.3} mipmapBlur />
<DepthOfField focusDistance={0.012} focalLength={0.05} bokehScale={2.5} />
<Vignette eskil={false} offset={0.3} darkness={0.35} />
</EffectComposer>
</ThreeCanvas>
<LightSweep />
{/* text + CTA + badges — layout in LTR (predictable), text rendered RTL */}
<div
style={{
position: "absolute",
direction: "ltr",
display: "flex",
flexDirection: "column",
gap: L.vmin(6),
...(wide
? { top: 0, bottom: 0, right: width * 0.05, width: width * 0.4, justifyContent: "center", alignItems: "flex-end" }
: { left: 0, right: 0, bottom: L.vmin(80), alignItems: "center", paddingInline: L.vmin(50) }),
}}
>
<div style={{ direction: "rtl", textAlign: wide ? "right" : "center", transform: `translateY(${nameY}px)`, opacity: nameOp, fontWeight: 900, fontSize: L.pick(L.vmin(82), L.vmin(78), L.vmin(72)), color: textColor, lineHeight: 1.05, maxWidth: wide ? "100%" : width * 0.86 }}>
{appName}
</div>
<div style={{ direction: "rtl", textAlign: wide ? "right" : "center", opacity: tagOp, fontWeight: 500, fontSize: L.pick(L.vmin(30), L.vmin(29), L.vmin(27)), color: hexToRgba(textColor, 0.68), maxWidth: wide ? "100%" : width * 0.82 }}>
{tagline}
</div>
<div style={{ direction: "rtl", marginTop: L.vmin(24), opacity: ctaOp, transform: `scale(${ctaScale})`, padding: `${L.vmin(16)}px ${L.vmin(42)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, color: "#fff", fontWeight: 800, fontSize: L.vmin(28), boxShadow: `0 ${L.vmin(8)}px ${L.vmin(24)}px ${hexToRgba(accentColor, 0.35)}` }}>
{cta}
</div>
<div style={{ marginTop: L.vmin(16), opacity: badgeOp, display: "flex", gap: L.vmin(12) }}>
<StoreBadge kind="ios" L={L} />
<StoreBadge kind="android" L={L} />
</div>
</div>
<Grain />
</AbsoluteFill>
);
};
+5
View File
@@ -13,6 +13,9 @@ export interface Layout {
unit: number;
/** Convenience scalers relative to the design baseline (1080 short side). */
vmin: (n: number) => number;
/** Pick a value per aspect — pick(wide, square, tall). Use to RE-FLOW layouts,
* not just scale (see the remotion-aspect-ratios skill). */
pick: <T>(wide: T, square: T, tall: T) => T;
}
/** Classify a width×height into one of the three supported aspects. */
@@ -42,6 +45,8 @@ export function useLayout(): Layout {
isTall: kind === "tall",
unit,
vmin: (n: number) => (n * short) / 1080,
pick: <T,>(wide: T, square: T, tall: T): T =>
kind === "wide" ? wide : kind === "tall" ? tall : square,
};
}
+19
View File
@@ -24,6 +24,7 @@ import { Hero3D, hero3DSchema } from "./compositions/Hero3D";
import { Nowruz3D, nowruz3DSchema } from "./compositions/Nowruz3D";
import { Birthday3D, birthday3DSchema } from "./compositions/Birthday3D";
import { Promo3D, promo3DSchema } from "./compositions/Promo3D";
import { AppShowcase3D, appShowcase3DSchema } from "./compositions/AppShowcase3D";
export interface TemplateDef {
/** Base id; the registered composition ids are `${id}-${aspect}`. */
@@ -232,4 +233,22 @@ export const TEMPLATES: TemplateDef[] = [
textColor: "#ffffff",
},
},
{
id: "AppShowcase3D",
name: "معرفی اپلیکیشن سه‌بعدی",
description: "نمایش سه‌بعدی و حرفه‌ای اپلیکیشن روی گوشی پرچم‌دار با نورپردازی استودیویی",
component: AppShowcase3D,
schema: appShowcase3DSchema,
durationSec: 6,
defaultProps: {
appName: "اپلیکیشن شما",
tagline: "تجربه‌ای روان، سریع و زیبا",
cta: "همین حالا دانلود کنید",
screenUrl: "",
accentColor: "#3b82f6",
secondaryColor: "#8b5cf6",
backgroundColor: "#f4f5f7",
textColor: "#0f172a",
},
},
];