feat(remotion): premium 3D app-showcase template (AppShowcase3D)
- 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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user