feat(remotion): +LogoMotion3D template (Tech/3D cinematic logo reveal)

First template built through the new flow (brief → quality-gate approval → build →
seed → deploy). Tech/3D logo motion: a 3D metallic card + radial light rays + lens
flare + bloom (genuine @remotion/three), with the user's uploaded logo composited
on the card as a reliable HTML <Img> (renders any SVG/PNG/data-URI; static camera
keeps it aligned), brand text + tagline, grain. Falls back to a branded play-mark
when no logo is set. Re-flows across 16:9/1:1/9:16.

- LogoMotion3D.tsx registered per aspect in Root.tsx.
- Seeded as fr-logo-motion-3d: text fields (brandText, tagline) + a logoUrl image
  upload field + the dark-tech palette (light text) + per-aspect previews.
- 3 thumbnails + 3 preview MP4s rendered, deployed; detail page + assets serve 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-24 00:13:26 +03:30
parent 7394c5ce78
commit de8849bd94
10 changed files with 160 additions and 1 deletions
+16
View File
@@ -5,6 +5,7 @@ import { Three3DTest } from "./compositions/Three3DTest";
import { AssetSheet } from "./compositions/AssetSheet";
import { StoryScenes, STORY_SCENES_DURATION } from "./compositions/StoryScenes";
import { FlexStory, flexStorySchema, flexStoryDefaults, calcFlexStoryMetadata } from "./compositions/FlexStory";
import { LogoMotion3D, logoMotion3DSchema, logoMotion3DDefaults } from "./compositions/LogoMotion3D";
import { CHARACTER_JOURNEY } from "./scenes/presets";
import {
IlluminatedCircles,
@@ -107,6 +108,21 @@ export const RemotionRoot: React.FC = () => {
}}
/>
{/* Tech/3D logo motion — quality-preview composition */}
{ASPECTS.map((a) => (
<Composition
key={`LogoMotion3D-${a.id}`}
id={`LogoMotion3D-${a.id}`}
component={LogoMotion3D}
durationInFrames={5 * FPS}
fps={FPS}
width={a.width}
height={a.height}
schema={logoMotion3DSchema}
defaultProps={logoMotion3DDefaults}
/>
))}
{/* 3D feasibility test */}
<Composition
id="Three3DTest"
@@ -0,0 +1,139 @@
import React from "react";
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { ThreeCanvas } from "@remotion/three";
import { Environment, Lightformer } from "@react-three/drei";
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
import { AdditiveBlending } from "three";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, rand } from "../lib/anim";
// ── Tech/3D logo reveal: a 3D metallic card + radial light rays + lens flare +
// bloom (genuine @remotion/three), with the user's logo composited on the card as
// a reliable HTML <Img> (renders any SVG/PNG/data-URI). Static camera keeps the
// flat logo aligned to the card.
export const logoMotion3DSchema = z.object({
logoUrl: z.string(), // uploaded logo (SVG/PNG/data-URI); "" → branded placeholder mark
brandText: z.string(),
tagline: z.string(),
...colorSchema,
});
type Props = z.infer<typeof logoMotion3DSchema>;
export const logoMotion3DDefaults: Props = {
logoUrl: "",
brandText: "فلت‌رندر",
tagline: "موشن، ساده و حرفه‌ای",
accentColor: "#38bdf8",
secondaryColor: "#818cf8",
backgroundColor: "#0a0e1a",
textColor: "#f8fafc",
};
const resolveSrc = (u: string) => (/^https?:\/\//.test(u) || u.startsWith("data:") ? u : staticFile(u));
const Rays: React.FC<{ frame: number; color: string; op: number }> = ({ frame, color, op }) => {
const n = 16;
const pulse = 1 + Math.sin(frame / 11) * 0.08;
return (
<group rotation={[0, 0, frame * 0.004]} position={[0, 0, -1.4]}>
{Array.from({ length: n }).map((_, i) => (
<mesh key={i} rotation={[0, 0, (i / n) * Math.PI * 2]}>
<planeGeometry args={[0.05 + (i % 2) * 0.03, 7]} />
<meshBasicMaterial color={color} transparent opacity={0.1 * pulse * op} blending={AdditiveBlending} depthWrite={false} />
</mesh>
))}
</group>
);
};
const Flare: React.FC<{ core: number; streak: number; accent: string }> = ({ core, streak, accent }) => (
<group position={[0, 0, -0.6]}>
<mesh><circleGeometry args={[0.55, 40]} /><meshBasicMaterial color="#ffffff" transparent opacity={core} blending={AdditiveBlending} depthWrite={false} /></mesh>
<mesh><circleGeometry args={[1.1, 40]} /><meshBasicMaterial color={accent} transparent opacity={core * 0.4} blending={AdditiveBlending} depthWrite={false} /></mesh>
<mesh><planeGeometry args={[9, 0.1]} /><meshBasicMaterial color="#ffffff" transparent opacity={streak} blending={AdditiveBlending} depthWrite={false} /></mesh>
<mesh><planeGeometry args={[0.1, 5]} /><meshBasicMaterial color={accent} transparent opacity={streak * 0.7} blending={AdditiveBlending} depthWrite={false} /></mesh>
</group>
);
const Card: React.FC<{ scale: number; accent: string }> = ({ scale, accent }) => (
<group scale={scale}>
<mesh>
<boxGeometry args={[1.7, 1.7, 0.2]} />
<meshStandardMaterial color={accent} metalness={1} roughness={0.16} envMapIntensity={2} />
</mesh>
</group>
);
export const LogoMotion3D: React.FC<Props> = (props) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const L = useLayout();
const accent = props.accentColor;
const popIn = spring({ frame: frame - 6, fps, config: { damping: 11, stiffness: 130 } });
const scale = interpolate(popIn, [0, 1], [0.2, 1]);
const raysOp = interpolate(frame, [4, 24], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const core = interpolate(frame, [8, 20, 40], [0, 1, 0.32], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const streak = interpolate(frame, [10, 22, 44], [0, 0.9, 0.18], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const brandOp = interpolate(frame, [30, 48], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const brandY = interpolate(popIn, [0, 1], [L.vmin(28), 0]);
const tagOp = interpolate(frame, [46, 64], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// The 3D card (1.7 units) projects to ≈43% of the frame height — match the HTML
// logo to it so it sits exactly on the card.
const cardPx = height * 0.43;
return (
<AbsoluteFill style={{ backgroundColor: props.backgroundColor, fontFamily: FONT }}>
<ThreeCanvas width={width} height={height} camera={{ position: [0, 0, 5.4], fov: 40 }} style={{ position: "absolute", inset: 0 }}>
<ambientLight intensity={0.3} />
<pointLight position={[4, 3, 4]} intensity={40} color="#ffffff" />
<pointLight position={[-4, -1, 2]} intensity={24} color={props.secondaryColor} />
<Environment resolution={128}>
<Lightformer intensity={2.2} position={[0, 3, 2]} scale={[7, 3, 1]} color={accent} />
<Lightformer intensity={1.6} position={[-4, 1, 2]} scale={[3, 5, 1]} color="#ffffff" />
<Lightformer intensity={1.6} position={[4, 1, 2]} scale={[3, 5, 1]} color={props.secondaryColor} />
</Environment>
<Rays frame={frame} color={accent} op={raysOp} />
<Flare core={core} streak={streak} accent={accent} />
<Card scale={scale} accent={accent} />
{Array.from({ length: 14 }).map((_, i) => {
const x = (rand(i) - 0.5) * 9;
const y = (rand(i + 3) - 0.5) * 5 + Math.sin((frame + i * 20) / 36) * 0.2;
const z = -1 - rand(i + 6) * 1.6;
return <mesh key={i} position={[x, y, z]}><circleGeometry args={[0.02 + rand(i) * 0.04, 12]} /><meshBasicMaterial color={accent} transparent opacity={0.5} blending={AdditiveBlending} depthWrite={false} /></mesh>;
})}
<EffectComposer>
<Bloom intensity={1.5} luminanceThreshold={0.35} luminanceSmoothing={0.4} mipmapBlur />
<Vignette eskil={false} offset={0.3} darkness={0.72} />
</EffectComposer>
</ThreeCanvas>
{/* Logo on the card (centered, scales in with the card). Any format renders. */}
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: cardPx, height: cardPx, transform: `scale(${scale})`, display: "flex", alignItems: "center", justifyContent: "center" }}>
{props.logoUrl ? (
<Img src={resolveSrc(props.logoUrl)} style={{ width: "74%", height: "74%", objectFit: "contain", filter: `drop-shadow(0 0 ${L.vmin(20)}px ${hexToRgba(accent, 0.45)})` }} />
) : (
<div style={{ width: 0, height: 0, borderTop: `${cardPx * 0.18}px solid transparent`, borderBottom: `${cardPx * 0.18}px solid transparent`, borderLeft: `${cardPx * 0.3}px solid #ffffff`, marginLeft: cardPx * 0.06, filter: `drop-shadow(0 0 ${L.vmin(24)}px ${hexToRgba("#ffffff", 0.6)})` }} />
)}
</div>
</AbsoluteFill>
{/* Brand + tagline below the card */}
<AbsoluteFill style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", paddingBottom: L.pick(height * 0.1, height * 0.08, height * 0.16) }}>
<div style={{ direction: "rtl", opacity: brandOp, transform: `translateY(${brandY}px)`, fontWeight: 900, fontSize: L.pick(L.vmin(80), L.vmin(74), L.vmin(70)), color: props.textColor, letterSpacing: -1, textShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accent, 0.5)}` }}>
{props.brandText}
</div>
<div style={{ direction: "rtl", opacity: tagOp, marginTop: L.vmin(12), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(30), L.vmin(28)), color: hexToRgba(props.textColor, 0.62), letterSpacing: 2 }}>
{props.tagline}
</div>
</AbsoluteFill>
<AbsoluteFill style={{ pointerEvents: "none", opacity: 0.06, mixBlendMode: "overlay", backgroundImage: "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")", backgroundSize: "160px 160px" }} />
</AbsoluteFill>
);
};