Files
flatrender/services/remotion/src/compositions/YouTubeIntro.tsx
T
soroush.asadi 4f04f6bf75
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s
feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
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>
2026-06-21 15:52:52 +03:30

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>
);
};