feat: AI-generated shorts render pipeline
- Remotion composition (ShortsVideo) renders 1080x1920 9:16 shorts from script segments (hook/setup/conflict/insight/cta) with animated gradient background, word-by-word captions, progress bar - render-worker.mjs: standalone poller (systemd hermyhq-render.service) TTS via edge-tts → Remotion render via Chrome headless → ffmpeg mux + thumbnail; ≤60s gate; marks YoutubeScript rendered - API: POST /api/youtube/render (queue approved→render_queued), GET status, GET /render/file (stream video/thumb with Range support) - Schema: YoutubeScript.videoUrl/thumbnailUrl + YoutubeRender model - UI: youtube tab gains Renders view (preview/review/approve/reject/ download), Render Shorts button on approved scripts - Prisma db push applied to prod DB
This commit is contained in:
1 parent
2a051875f3
commit
33e85ce8bf
11 files changed
+3272
-89
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { CaptionLine, SvgBackground, ProgressBar, BrandTag } from "./parts";
|
||||
|
||||
/** A single timed segment: one text card rendered for `duration` frames. */
|
||||
const Segment: React.FC<{
|
||||
text: string;
|
||||
highlight?: string;
|
||||
label?: string;
|
||||
size: number;
|
||||
from: number;
|
||||
duration: number;
|
||||
}> = ({ text, highlight, label, size, from, duration }) => (
|
||||
<AbsoluteFill>
|
||||
<SvgBackground />
|
||||
{label ? <BrandTag label={label} /> : null}
|
||||
<CaptionLine text={text} highlight={highlight} size={size} />
|
||||
<ProgressBar total={duration} />
|
||||
{/* current time indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "6%",
|
||||
bottom: "7.5%",
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 22,
|
||||
fontFamily: "monospace",
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
{(from / 30).toFixed(2)}s
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
|
||||
/** Composition props driven by the worker (JSON). */
|
||||
export const ShortsVideo: React.FC<{
|
||||
segments: {
|
||||
text: string;
|
||||
duration: number; // frames
|
||||
highlight?: string;
|
||||
label?: string;
|
||||
size?: number;
|
||||
}[];
|
||||
from?: number;
|
||||
duration?: number;
|
||||
}> = ({ segments }) => {
|
||||
const baseSize = 68;
|
||||
let acc = 0;
|
||||
return (
|
||||
<>
|
||||
{segments.map((s, i) => {
|
||||
const from = acc;
|
||||
acc += s.duration;
|
||||
return (
|
||||
<Segment
|
||||
key={i}
|
||||
text={s.text}
|
||||
highlight={s.highlight}
|
||||
label={s.label}
|
||||
size={s.size ?? baseSize}
|
||||
from={from}
|
||||
duration={s.duration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTotalDuration = (segments: { duration: number }[]) =>
|
||||
segments.reduce((a, s) => a + s.duration, 0);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { registerRoot, Composition } from "remotion";
|
||||
import { ShortsVideo, useTotalDuration } from "./ShortsVideo";
|
||||
|
||||
const DEFAULT_SEGMENTS = [
|
||||
{ text: "Demo shorts — configure --props to customize.", duration: 60 },
|
||||
];
|
||||
|
||||
const Shorts: React.FC = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="Shorts"
|
||||
component={ShortsVideo as any}
|
||||
durationInFrames={60}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{ segments: DEFAULT_SEGMENTS }}
|
||||
calculateMetadata={({ props }) => {
|
||||
const segs = props.segments?.length ? props.segments : DEFAULT_SEGMENTS;
|
||||
return { durationInFrames: useTotalDuration(segs) };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
registerRoot(Shorts);
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
Easing,
|
||||
spring,
|
||||
} from "remotion";
|
||||
|
||||
/** Animated diagonal gradient background + soft floating blobs. */
|
||||
export const SvgBackground: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const drift = (speed: number, amp: number) =>
|
||||
Math.sin((frame / 60) * speed) * amp;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#05060a" }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(160deg, #0b1020 0%, #141a33 45%, #1b1030 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* floating gradient blobs (radial gradients are pre-softened — no blur filter) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: width * 0.9,
|
||||
height: width * 0.9,
|
||||
left: width * 0.1 + drift(1, 60),
|
||||
top: height * 0.05 + drift(0.7, 40),
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(99,102,241,0.35) 0%, rgba(99,102,241,0.18) 40%, transparent 68%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
right: -width * 0.15 + drift(1.3, 50),
|
||||
bottom: height * 0.18 + drift(0.9, 30),
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(236,72,153,0.28) 0%, rgba(236,72,153,0.14) 40%, transparent 68%)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Big bold caption line with spring-in + fade + word-by-word pop. */
|
||||
export const CaptionLine: React.FC<{
|
||||
text: string;
|
||||
highlight?: string;
|
||||
size: number;
|
||||
}> = ({ text, highlight, size }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
|
||||
const enter = interpolate(frame, [0, 12], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const words = text.split(" ");
|
||||
const charDur = Math.max(2, Math.floor(fps * 0.13));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "6%",
|
||||
right: "6%",
|
||||
top: "18%",
|
||||
transform: `translateY(${(1 - pop) * 30}px) scale(${0.96 + pop * 0.04})`,
|
||||
opacity: enter,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.92)",
|
||||
fontSize: size,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.12,
|
||||
letterSpacing: "-0.02em",
|
||||
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 24px rgba(0,0,0,0.45)",
|
||||
}}
|
||||
>
|
||||
{words.map((w, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
opacity: interpolate(
|
||||
frame,
|
||||
[i * charDur, i * charDur + charDur * 0.6],
|
||||
[0, 1],
|
||||
{ extrapolateRight: "clamp" }
|
||||
),
|
||||
display: "inline-block",
|
||||
marginRight: "0.28em",
|
||||
color:
|
||||
highlight && w.toLowerCase().includes(highlight.toLowerCase())
|
||||
? "#a5b4fc"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Bottom progress bar that fills over the segment duration. */
|
||||
export const ProgressBar: React.FC<{ total: number }> = ({ total }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const pct = interpolate(frame, [0, total], [0, 100], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "6%",
|
||||
right: "6%",
|
||||
bottom: "7%",
|
||||
height: 7,
|
||||
borderRadius: 99,
|
||||
background: "rgba(255,255,255,0.14)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 99,
|
||||
background: "linear-gradient(90deg,#6366f1,#ec4899)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Small brand tag. */
|
||||
export const BrandTag: React.FC<{ label: string }> = ({ label }) => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "6%",
|
||||
top: "7%",
|
||||
padding: "8px 18px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
border: "1px solid rgba(255,255,255,0.16)",
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
fontSize: 26,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.06em",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
Reference in new issue
Block a user