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:
MythEclipse
2026-09-22 20:16:25 +07:00
parent 2a051875f3
commit 33e85ce8bf
11 changed files with 3272 additions and 89 deletions
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import fs from "fs";
import path from "path";
export const dynamic = "force-dynamic";
/**
* GET /api/youtube/render/file?scriptId=xxx&kind=video|thumb
* Streams the rendered MP4 or thumbnail JPG from the local render dir.
* Only serves files for scripts that are in a rendered state.
*/
export async function GET(req: NextRequest) {
const scriptId = req.nextUrl.searchParams.get("scriptId");
const kind = req.nextUrl.searchParams.get("kind") || "video";
if (!scriptId) return NextResponse.json({ error: "scriptId required" }, { status: 400 });
const script = await prisma.youtubeScript.findUnique({ where: { id: scriptId } });
if (!script) return NextResponse.json({ error: "script not found" }, { status: 404 });
const filePath = kind === "thumb" ? script.thumbnailUrl : script.videoUrl;
if (!filePath || !fs.existsSync(filePath)) {
return NextResponse.json({ error: "render not ready" }, { status: 404 });
}
const ext = path.extname(filePath).toLowerCase();
const contentType = ext === ".jpg" ? "image/jpeg" : ext === ".mp4" ? "video/mp4" : "application/octet-stream";
const stat = fs.statSync(filePath);
// Range support for <video> seeking (bytes=start-end)
const range = req.headers.get("range");
if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range);
const start = m && m[1] ? parseInt(m[1], 10) : 0;
const end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1;
if (start >= stat.size || start > end) {
return new NextResponse(null, { status: 416, headers: { "Content-Range": `bytes */${stat.size}` } });
}
const stream = fs.createReadStream(filePath, { start, end });
return new NextResponse(stream as any, {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Length": String(end - start + 1),
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600",
},
});
}
const stream = fs.createReadStream(filePath);
return new NextResponse(stream as any, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(stat.size),
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600",
},
});
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
/**
* POST /api/youtube/render {scriptId}
* Queue a script for shorts rendering: status approved → render_queued.
* The standalone worker (hermyhq-render.service) picks it up and renders.
*/
export async function POST(req: NextRequest) {
const { scriptId } = await req.json().catch(() => ({}));
if (!scriptId) return NextResponse.json({ error: "scriptId required" }, { status: 400 });
const script = await prisma.youtubeScript.findUnique({ where: { id: scriptId } });
if (!script) return NextResponse.json({ error: "script not found" }, { status: 404 });
if (script.status !== "approved") {
return NextResponse.json(
{ error: `script must be 'approved' to render (current: ${script.status})` },
{ status: 409 }
);
}
const updated = await prisma.youtubeScript.update({
where: { id: scriptId },
data: { status: "render_queued" },
});
return NextResponse.json({ ok: true, scriptId, status: updated.status });
}
/**
* GET /api/youtube/render?scriptId=xxx
* Current render status + file paths for a script.
*/
export async function GET(req: NextRequest) {
const scriptId = req.nextUrl.searchParams.get("scriptId");
if (!scriptId) return NextResponse.json({ error: "scriptId required" }, { status: 400 });
const render = await prisma.youtubeRender.findUnique({ where: { scriptId } });
const script = await prisma.youtubeScript.findUnique({ where: { id: scriptId } });
if (!script) return NextResponse.json({ error: "script not found" }, { status: 404 });
return NextResponse.json({
scriptId,
scriptStatus: script.status,
videoUrl: script.videoUrl,
thumbnailUrl: script.thumbnailUrl,
render: render
? {
status: render.status,
durationMs: render.durationMs,
error: render.error,
metadata: render.metadata,
}
: null,
});
}
+150 -3
View File
@@ -48,7 +48,7 @@ interface Script {
rejectedReason?: string;
factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] };
fullScript?: string;
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected";
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected" | "rendered" | "render_queued";
createdAt: string;
// SEO Package
seoTitle?: string;
@@ -73,7 +73,7 @@ export default function YouTubePage() {
const [generatedScript, setGeneratedScript] = useState<Script | null>(null);
const [scripts, setScripts] = useState<Script[]>([]);
const [activeView, setActiveView] = useState<"longform" | "shorts" | "performance" | "outliers">("longform");
const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "tofilm" | "filmed">("ideas");
const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "renders" | "tofilm" | "filmed">("ideas");
const [generatingIdeas, setGeneratingIdeas] = useState(false);
const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set());
const [batchGenerating, setBatchGenerating] = useState(false);
@@ -202,6 +202,33 @@ export default function YouTubePage() {
const rejectedScripts = scripts.filter(s => s.status === "rejected");
const tofilmScripts = scripts.filter(s => s.status === "tofilm");
const filmedScripts = scripts.filter(s => s.status === "filmed");
const renderedScripts = scripts.filter(s => s.status === "rendered");
const renderQueuedScripts = scripts.filter(s => s.status === "render_queued");
const renderAll = [...renderQueuedScripts, ...renderedScripts];
const [rendersBusy, setRendersBusy] = useState<Set<string>>(new Set());
async function queueRender(id: string) {
setRendersBusy(prev => new Set(prev).add(id));
try {
const res = await fetch("/api/youtube/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scriptId: id }),
});
if (res.ok) fetchScripts();
else alert((await res.json().catch(() => ({}))).error || "Render queue failed");
} catch { /* empty */ }
setRendersBusy(prev => { const n = new Set(prev); n.delete(id); return n; });
}
async function setRenderStatus(id: string, status: "tofilm" | "rejected" | "draft") {
await fetch("/api/youtube/scripts", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, status }),
});
fetchScripts();
}
// small ghost copy button
const copyBtn = "text-[10px] px-2 py-0.5 rounded-[var(--r-sm)] bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--line)] hover:text-[var(--text)] transition-colors";
@@ -421,6 +448,47 @@ export default function YouTubePage() {
</>
)}
{script.status === "approved" && (
<button
onClick={(e) => { e.stopPropagation(); queueRender(script.id); }}
disabled={rendersBusy.has(script.id)}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors disabled:opacity-60"
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
>{rendersBusy.has(script.id) ? "⏳ Queuing..." : "✨ Render Shorts"}</button>
)}
{script.status === "render_queued" && (
<>
<span className="text-xs px-3 py-1.5 rounded-[var(--r-md)] animate-pulse" style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 14%, transparent)" }}>
⏳ Rendering...
</span>
<Button size="sm" variant="ghost" onClick={() => updateScript(script.id, { status: "approved" })}>↩ Cancel</Button>
</>
)}
{script.status === "rendered" && (
<>
<a
href={`/api/youtube/render/file?scriptId=${script.id}&kind=video`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
>▶ Preview</a>
<button
onClick={(e) => { e.stopPropagation(); setRenderStatus(script.id, "tofilm"); }}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
>✓ Approve</button>
<button
onClick={(e) => { e.stopPropagation(); setRenderStatus(script.id, "rejected"); }}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors"
style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }}
>✗ Reject</button>
</>
)}
{script.status === "tofilm" && (
<>
<button
@@ -490,9 +558,10 @@ export default function YouTubePage() {
{/* SHORTS SUB-NAV */}
{activeView === "shorts" && (
<div className="flex gap-2 mt-4 mb-2">
{([
{([
{ key: "ideas" as const, label: "💡 Ideas", count: ideas.length },
{ key: "scripts" as const, label: "📜 Scripts", count: draftScripts.length },
{ key: "renders" as const, label: "🎞 Renders", count: renderAll.length },
{ key: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length },
{ key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length },
]).map(tab => (
@@ -666,6 +735,84 @@ export default function YouTubePage() {
</div>
)}
{/* RENDERS VIEW */}
{activeView === "shorts" && shortsView === "renders" && (
<div className="space-y-3">
{renderAll.length === 0 ? (
<Panel>
<EmptyState icon={<span className="text-3xl">🎞️</span>} title="No renders yet" hint="Approve a script, then hit ✨ Render Shorts" />
</Panel>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{renderAll.map(s => {
const isRendered = s.status === "rendered";
const isQueued = s.status === "render_queued";
return (
<Panel key={s.id} className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium text-[var(--text)] line-clamp-2 leading-snug">{s.title}</p>
{isQueued && (
<span className="shrink-0 text-[9px] px-2 py-1 rounded-full animate-pulse" style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 14%, transparent)" }}>
⏳ rendering
</span>
)}
{isRendered && (
<span className="shrink-0 text-[9px] px-2 py-1 rounded-full" style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 14%, transparent)" }}>
✓ rendered
</span>
)}
</div>
{isRendered ? (
<div className="rounded-[var(--r-md)] overflow-hidden border border-[var(--line)] bg-black aspect-[9/16]">
<video
src={`/api/youtube/render/file?scriptId=${s.id}&kind=video`}
poster={`/api/youtube/render/file?scriptId=${s.id}&kind=thumb`}
controls
playsInline
preload="metadata"
className="w-full h-full object-contain"
/>
</div>
) : (
<div className="rounded-[var(--r-md)] border border-[var(--line)] bg-[var(--surface-2)] aspect-[9/16] flex items-center justify-center">
<span className="text-xs text-[var(--text-3)] animate-pulse">🎬 Rendering…</span>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{isRendered && (
<>
<a
href={`/api/youtube/render/file?scriptId=${s.id}&kind=video`}
download={`${s.title.slice(0, 40).replace(/[^a-z0-9]+/gi, "-")}.mp4`}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
>⬇ Download</a>
<button
onClick={() => setRenderStatus(s.id, "tofilm")}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
>✓ Approved → Film</button>
<button
onClick={() => setRenderStatus(s.id, "rejected")}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors"
style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }}
>✗ Reject</button>
</>
)}
{isQueued && (
<Button size="sm" variant="ghost" onClick={() => updateScript(s.id, { status: "approved" })}>↩ Cancel</Button>
)}
</div>
</Panel>
);
})}
</div>
)}
</div>
)}
{/* TO FILM VIEW */}
{activeView === "shorts" && shortsView === "tofilm" && (
<div className="space-y-3">
+72
View File
@@ -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);
+26
View File
@@ -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);
+170
View File
@@ -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>
);