diff --git a/public/template-media/AppShowcase3D-16x9.png b/public/template-media/AppShowcase3D-16x9.png new file mode 100644 index 0000000..77b89d9 Binary files /dev/null and b/public/template-media/AppShowcase3D-16x9.png differ diff --git a/public/template-media/AppShowcase3D-1x1.png b/public/template-media/AppShowcase3D-1x1.png new file mode 100644 index 0000000..2fd6e08 Binary files /dev/null and b/public/template-media/AppShowcase3D-1x1.png differ diff --git a/public/template-media/AppShowcase3D-9x16.png b/public/template-media/AppShowcase3D-9x16.png new file mode 100644 index 0000000..49999d8 Binary files /dev/null and b/public/template-media/AppShowcase3D-9x16.png differ diff --git a/public/template-media/AppShowcase3D.mp4 b/public/template-media/AppShowcase3D.mp4 new file mode 100644 index 0000000..7400ade Binary files /dev/null and b/public/template-media/AppShowcase3D.mp4 differ diff --git a/scripts/seed_remotion_templates.py b/scripts/seed_remotion_templates.py index 441d56b..978364e 100644 --- a/scripts/seed_remotion_templates.py +++ b/scripts/seed_remotion_templates.py @@ -51,12 +51,20 @@ T = [ [("greeting","تبریک","تولدت مبارک"),("name","نام","سارا"),("message","پیام","بهترین‌ها را برایت آرزومندیم 🎉")],("#fb7185","#a855f7","#1a1226")), ("Promo3D","fr-promo-3d","فروش ویژه سه‌بعدی","تبلیغ سه‌بعدی فروش و تخفیف با جعبه‌های هدیه و نورپردازی سینمایی",6, [("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")), + ("AppShowcase3D","fr-app-showcase","معرفی اپلیکیشن سه‌بعدی","نمایش سه‌بعدی و حرفه‌ای اپلیکیشن روی گوشی پرچم‌دار با نورپردازی استودیویی",6, + [("appName","نام اپلیکیشن","اپلیکیشن شما"),("tagline","شعار","تجربه‌ای روان، سریع و زیبا"),("cta","دکمه","همین حالا دانلود کنید")],("#3b82f6","#8b5cf6","#f4f5f7")), ] # Optional Media (image) content elements per template — these surface in the # studio as upload/replace fields. key = the Remotion prop the image binds to. MEDIA = { "GlitterReveal": [("logoUrl", "لوگو (تصویر دلخواه)")], + "AppShowcase3D": [("screenUrl", "تصویر اپلیکیشن (اسکرین‌شات)")], +} + +# Per-template text colour (default white for dark backgrounds; dark for light studios). +TEXTCOLORS = { + "AppShowcase3D": "#0f172a", } def swatch_svg(colors): @@ -75,7 +83,8 @@ for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T): cid = uid("c-" + tid) thumb16 = f"{MINIO}/template-media/{tid}-16x9.png" preview = f"{MINIO}/template-media/{tid}.mp4" - colors = [("accentColor", accent), ("secondaryColor", sec), ("backgroundColor", bg), ("textColor", "#ffffff")] + txt = TEXTCOLORS.get(tid, "#ffffff") + colors = [("accentColor", accent), ("secondaryColor", sec), ("backgroundColor", bg), ("textColor", txt)] out.append( "INSERT INTO content.project_containers (id,tenant_id,slug,name,description,image,demo,full_demo,mini_demo," "is_published,is_premium,is_mockup,primary_mode,sort) VALUES (" @@ -92,7 +101,7 @@ for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T): f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);") out.append( "INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES (" - f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,'#ffffff']))},{dur},0);") + f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,txt]))},{dur},0);") for pos, (k, title, val) in enumerate(texts): out.append( "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" diff --git a/services/remotion/src/compositions/AppShowcase3D.tsx b/services/remotion/src/compositions/AppShowcase3D.tsx new file mode 100644 index 0000000..c3ffa28 --- /dev/null +++ b/services/remotion/src/compositions/AppShowcase3D.tsx @@ -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; + +// ── 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 = "#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 ( + + {/* 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 metallic edge highlight */} + + + + + + + + + + + + + {/* 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} +
+
+ + +
+
+ + +
+ ); +}; diff --git a/services/remotion/src/lib/aspect.ts b/services/remotion/src/lib/aspect.ts index 06809e8..81c8ec1 100644 --- a/services/remotion/src/lib/aspect.ts +++ b/services/remotion/src/lib/aspect.ts @@ -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: (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: (wide: T, square: T, tall: T): T => + kind === "wide" ? wide : kind === "tall" ? tall : square, }; } diff --git a/services/remotion/src/templates.tsx b/services/remotion/src/templates.tsx index 24bb8ff..134c83e 100644 --- a/services/remotion/src/templates.tsx +++ b/services/remotion/src/templates.tsx @@ -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", + }, + }, ];