4f04f6bf75
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
70 lines
3.6 KiB
TypeScript
70 lines
3.6 KiB
TypeScript
import React from "react";
|
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
import { z } from "zod";
|
|
import { colorSchema } from "../lib/branding";
|
|
import { FONT } from "../lib/fonts";
|
|
import { useLayout } from "../lib/aspect";
|
|
import { BrandBackground, useReveal } from "../lib/kit";
|
|
import { hexToRgba } from "../lib/anim";
|
|
|
|
export const youTubeIntroSchema = z.object({
|
|
channelName: z.string(),
|
|
subtitle: z.string(),
|
|
cta: z.string(),
|
|
...colorSchema,
|
|
});
|
|
|
|
type Props = z.infer<typeof youTubeIntroSchema>;
|
|
|
|
export const YouTubeIntro: React.FC<Props> = ({
|
|
channelName,
|
|
subtitle,
|
|
cta,
|
|
accentColor,
|
|
secondaryColor,
|
|
backgroundColor,
|
|
textColor,
|
|
}) => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
const L = useLayout();
|
|
|
|
const playPop = spring({ frame, fps, config: { damping: 11, stiffness: 130, mass: 0.7 } });
|
|
const playScale = interpolate(playPop, [0, 1], [0, 1]);
|
|
const ripple = (frame % 45) / 45;
|
|
|
|
const name = useReveal(28, { from: 40 });
|
|
const sub = useReveal(44, { from: 26 });
|
|
const bell = useReveal(60, { from: 22, damping: 11 });
|
|
const bellWiggle = Math.sin(frame / 5) * (frame > 60 && frame < 90 ? 10 : 0);
|
|
|
|
return (
|
|
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
|
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={14} />
|
|
|
|
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
|
|
{/* Play button with ripple */}
|
|
<div style={{ position: "relative", width: L.vmin(170), height: L.vmin(170), display: "flex", alignItems: "center", justifyContent: "center", transform: `scale(${playScale})` }}>
|
|
<div style={{ position: "absolute", width: L.vmin(170) * (1 + ripple), height: L.vmin(170) * (1 + ripple), borderRadius: "50%", border: `${L.vmin(3)}px solid ${hexToRgba(accentColor, 1 - ripple)}` }} />
|
|
<div style={{ width: L.vmin(150), height: L.vmin(150), borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", boxShadow: `0 0 ${L.vmin(50)}px ${hexToRgba(accentColor, 0.6)}` }}>
|
|
<div style={{ width: 0, height: 0, marginInlineStart: L.vmin(10), borderTop: `${L.vmin(34)}px solid transparent`, borderBottom: `${L.vmin(34)}px solid transparent`, borderInlineEnd: `${L.vmin(56)}px solid #fff` }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginTop: L.vmin(46), opacity: name.opacity, transform: `translateY(${name.y}px)`, fontWeight: 900, fontSize: L.vmin(86), color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
|
|
{channelName}
|
|
</div>
|
|
<div style={{ marginTop: L.vmin(16), opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78), textAlign: "center" }}>
|
|
{subtitle}
|
|
</div>
|
|
|
|
{/* Subscribe pill */}
|
|
<div style={{ marginTop: L.vmin(50), opacity: bell.opacity, transform: `scale(${bell.scale})`, display: "flex", alignItems: "center", gap: L.vmin(14), padding: `${L.vmin(18)}px ${L.vmin(46)}px`, borderRadius: 999, background: "#ff0033", boxShadow: `0 0 ${L.vmin(36)}px ${hexToRgba("#ff0033", 0.5)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
|
|
<span style={{ display: "inline-block", transform: `rotate(${bellWiggle}deg)`, fontSize: L.vmin(34) }}>🔔</span>
|
|
{cta}
|
|
</div>
|
|
</AbsoluteFill>
|
|
</AbsoluteFill>
|
|
);
|
|
};
|