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:
Generated
+2451
-85
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -16,6 +16,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.6.0",
|
"@auth/prisma-adapter": "^1.6.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@remotion/cli": "^4.0.527",
|
||||||
|
"@remotion/google-fonts": "^4.0.527",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/formidable": "^3.4.6",
|
"@types/formidable": "^3.4.6",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
@@ -32,7 +34,8 @@
|
|||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0",
|
||||||
|
"remotion": "^4.0.527"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ model YoutubeScript {
|
|||||||
seoChapters Json?
|
seoChapters Json?
|
||||||
titleVariants Json?
|
titleVariants Json?
|
||||||
thumbnailConcept String?
|
thumbnailConcept String?
|
||||||
|
// Render output (shorts)
|
||||||
|
videoUrl String?
|
||||||
|
thumbnailUrl String?
|
||||||
|
render YoutubeRender?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -179,6 +183,22 @@ model YoutubeFeedback {
|
|||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model YoutubeRender {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scriptId String @unique
|
||||||
|
script YoutubeScript @relation(fields: [scriptId], references: [id], onDelete: Cascade)
|
||||||
|
status String @default("queued") // queued | rendering | rendered | failed
|
||||||
|
videoPath String?
|
||||||
|
thumbnailPath String?
|
||||||
|
durationMs Int?
|
||||||
|
error String?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
model LongformScript {
|
model LongformScript {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Hermy HQ — Shorts render worker.
|
||||||
|
*
|
||||||
|
* Polls the DB for YoutubeScript rows with status "approved" (or "render_queued")
|
||||||
|
* and renders them into a 9:16 MP4 short:
|
||||||
|
* 1. TTS → per-segment mp3 (edge-tts, free)
|
||||||
|
* 2. Time → ffprobe each mp3, build Remotion segment timeline (≤60s)
|
||||||
|
* 3. Render → Remotion (Chrome headless) → silent MP4
|
||||||
|
* 4. Mux → ffmpeg: silent MP4 + concatenated TTS audio → final MP4
|
||||||
|
* 5. Thumb → ffmpeg snapshot frame → JPG
|
||||||
|
* 6. DB → status "rendered", videoUrl/thumbnailUrl/duration + render row
|
||||||
|
*
|
||||||
|
* Run as: systemd service `hermyhq-render.service` (poll loop).
|
||||||
|
* Env: DATABASE_URL (via env file), RENDER_DIR=/data/renders,
|
||||||
|
* EDGE_TTS_BIN, TTS_VOICE, CHROME_BIN, REMOTION_ROOT.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const RENDER_DIR = process.env.RENDER_DIR || "/data/renders";
|
||||||
|
const EDGE_TTS_BIN =
|
||||||
|
process.env.EDGE_TTS_BIN ||
|
||||||
|
(existsSync("/home/code/.hermes/hermes-agent/venv/bin/edge-tts")
|
||||||
|
? "/home/code/.hermes/hermes-agent/venv/bin/edge-tts"
|
||||||
|
: "edge-tts");
|
||||||
|
const VOICE = process.env.TTS_VOICE || "en-US-ChristopherNeural";
|
||||||
|
const CHROME = process.env.CHROME_BIN || "/usr/bin/google-chrome-stable";
|
||||||
|
const MAX_TOTAL_SEC = 60;
|
||||||
|
const FPS = 30;
|
||||||
|
const POLL_MS = Number(process.env.POLL_MS || 5000);
|
||||||
|
const SCRATCH = process.env.RENDER_SCRATCH || "/tmp/hamc-render";
|
||||||
|
|
||||||
|
mkdirSync(RENDER_DIR, { recursive: true });
|
||||||
|
mkdirSync(SCRATCH, { recursive: true });
|
||||||
|
|
||||||
|
const REMOTION_ROOT = path.resolve(process.env.REMOTION_ROOT || "./src/remotion");
|
||||||
|
const REMOTION_ENTRY = path.join(REMOTION_ROOT, "index.tsx");
|
||||||
|
|
||||||
|
function sh(cmd, args, opts = {}) {
|
||||||
|
const r = spawnSync(cmd, args, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (r.status !== 0 && !opts.allowFail) {
|
||||||
|
throw new Error(`${cmd} ${args.join(" ")} failed (${r.status}): ${(r.stderr || r.stdout).slice(0, 2000)}`);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tts(text, outBase) {
|
||||||
|
const out = `${outBase}.mp3`;
|
||||||
|
sh(EDGE_TTS_BIN, ["--voice", VOICE, "--text", text, "--write-media", out], {
|
||||||
|
timeout: 60_000,
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
return { file: out, durationSec: ffprobeDuration(out) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffprobeDuration(file) {
|
||||||
|
const r = sh("ffprobe", [
|
||||||
|
"-v", "error", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1", file,
|
||||||
|
], { allowFail: true });
|
||||||
|
const v = parseFloat((r.stdout || "").trim());
|
||||||
|
return Number.isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffprobeSize(file) {
|
||||||
|
const r = sh("ffprobe", [
|
||||||
|
"-v", "error", "-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=width,height",
|
||||||
|
"-of", "csv=s=x:p=0", file,
|
||||||
|
], { allowFail: true });
|
||||||
|
return (r.stdout || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(...a) { console.log(new Date().toISOString(), ...a); }
|
||||||
|
|
||||||
|
/** Build segment text list from a YoutubeScript's structured fields. */
|
||||||
|
function buildSegments(script) {
|
||||||
|
const segs = [];
|
||||||
|
const push = (text, label, highlight) => {
|
||||||
|
const clean = (text || "").trim();
|
||||||
|
const chunk = clean.length > 180 ? clean.slice(0, 180) + "…" : clean;
|
||||||
|
if (chunk) segs.push({ text: chunk, label, highlight });
|
||||||
|
};
|
||||||
|
push(script.hook, "HOOK", script.hook);
|
||||||
|
push(script.onScreenText, "ON SCREEN", script.onScreenText);
|
||||||
|
push(script.storySetup, "THE STORY", null);
|
||||||
|
push(script.conflict, "THE CONFLICT", null);
|
||||||
|
push(script.insight, "THE INSIGHT", null);
|
||||||
|
push(script.cta, "CTA", null);
|
||||||
|
if (segs.length === 0) push(script.fullScript || script.title, script.title || "SHORTS", null);
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one script → {mp4, thumb, durationSec, segments}. */
|
||||||
|
async function renderScript(script) {
|
||||||
|
const id = script.id;
|
||||||
|
const work = path.join(SCRATCH, id);
|
||||||
|
mkdirSync(work, { recursive: true });
|
||||||
|
|
||||||
|
// 1. TTS per segment
|
||||||
|
const baseSegs = buildSegments(script);
|
||||||
|
log(`[${id}] TTS: ${baseSegs.length} segments`);
|
||||||
|
const voiced = [];
|
||||||
|
for (let i = 0; i < baseSegs.length; i++) {
|
||||||
|
const s = baseSegs[i];
|
||||||
|
const t = tts(s.text, path.join(work, `seg${i}`));
|
||||||
|
voiced.push({ ...s, ...t });
|
||||||
|
log(`[${id}] seg${i}: ${t.durationSec.toFixed(2)}s "${s.text.slice(0, 40)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Trim to ≤ MAX_TOTAL_SEC (keep head, drop tail)
|
||||||
|
let totalSec = voiced.reduce((a, s) => a + s.durationSec, 0);
|
||||||
|
if (totalSec > MAX_TOTAL_SEC) {
|
||||||
|
log(`[${id}] total ${totalSec.toFixed(2)}s > ${MAX_TOTAL_SEC}s, trimming tail`);
|
||||||
|
const keep = [];
|
||||||
|
let acc = 0;
|
||||||
|
for (const s of voiced) {
|
||||||
|
if (acc + s.durationSec <= MAX_TOTAL_SEC) { keep.push(s); acc += s.durationSec; }
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (keep.length === 0) keep.push(voiced[0]); // always keep ≥1 segment
|
||||||
|
voiced.length = 0;
|
||||||
|
voiced.push(...keep);
|
||||||
|
totalSec = voiced.reduce((a, s) => a + s.durationSec, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remotion timeline (frames)
|
||||||
|
const timeline = voiced.map((s, i) => ({
|
||||||
|
text: s.text,
|
||||||
|
duration: Math.max(30, Math.round(s.durationSec * FPS)),
|
||||||
|
size: baseSegs.length <= 4 ? 80 : 68,
|
||||||
|
label: s.label,
|
||||||
|
highlight: s.highlight || undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Render silent video via Remotion CLI (props = segments JSON)
|
||||||
|
const silentMp4 = path.join(work, "silent.mp4");
|
||||||
|
log(`[${id}] Remotion render ${timeline.length} segs → silent.mp4`);
|
||||||
|
sh("npx", [
|
||||||
|
"--yes", "remotion", "render", REMOTION_ENTRY, "Shorts", silentMp4,
|
||||||
|
"--codec=h264", "--crf=20", "--browser-executable=" + CHROME,
|
||||||
|
"--props=" + JSON.stringify({ segments: timeline }),
|
||||||
|
"--concurrency=6",
|
||||||
|
"--log=info",
|
||||||
|
], { timeout: 600_000, env: { ...process.env } });
|
||||||
|
|
||||||
|
// Verify silent video has expected duration (sanity)
|
||||||
|
const silentDur = ffprobeDuration(silentMp4);
|
||||||
|
if (!(silentDur > 0.5)) throw new Error(`silent render empty: ${silentDur}s`);
|
||||||
|
|
||||||
|
// 5. Concat TTS → m4a
|
||||||
|
const concatFile = path.join(work, "concat.txt");
|
||||||
|
writeFileSync(concatFile, voiced.map(s => `file '${s.file}'`).join("\n"));
|
||||||
|
const audioM4a = path.join(work, "audio.m4a");
|
||||||
|
sh("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", concatFile, "-c:a", "aac", audioM4a], { timeout: 120_000 });
|
||||||
|
|
||||||
|
// 6. Final mux
|
||||||
|
const finalMp4 = path.join(RENDER_DIR, `${id}.mp4`);
|
||||||
|
sh("ffmpeg", [
|
||||||
|
"-y", "-i", silentMp4, "-i", audioM4a,
|
||||||
|
"-c:v", "copy", "-c:a", "aac", "-shortest",
|
||||||
|
"-movflags", "+faststart", finalMp4,
|
||||||
|
], { timeout: 120_000 });
|
||||||
|
|
||||||
|
// 7. Thumbnail at 1s
|
||||||
|
const thumb = path.join(RENDER_DIR, `${id}.jpg`);
|
||||||
|
sh("ffmpeg", ["-y", "-ss", "1", "-i", finalMp4, "-vframes", "1", "-q:v", "2", thumb], { timeout: 30_000 });
|
||||||
|
|
||||||
|
const dur = ffprobeDuration(finalMp4);
|
||||||
|
if (!(dur > 0.5)) throw new Error(`final render empty: ${dur}s`);
|
||||||
|
return { mp4: finalMp4, thumb, durationSec: dur, segments: voiced.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRendered(script, result) {
|
||||||
|
await prisma.youtubeRender.upsert({
|
||||||
|
where: { scriptId: script.id },
|
||||||
|
update: {
|
||||||
|
status: "rendered",
|
||||||
|
videoPath: result.mp4,
|
||||||
|
thumbnailPath: result.thumb,
|
||||||
|
durationMs: Math.round(result.durationSec * 1000),
|
||||||
|
metadata: { segments: result.segments, size: ffprobeSize(result.mp4), version: 1 },
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
scriptId: script.id,
|
||||||
|
status: "rendered",
|
||||||
|
videoPath: result.mp4,
|
||||||
|
thumbnailPath: result.thumb,
|
||||||
|
durationMs: Math.round(result.durationSec * 1000),
|
||||||
|
metadata: { segments: result.segments, size: ffprobeSize(result.mp4), version: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.youtubeScript.update({
|
||||||
|
where: { id: script.id },
|
||||||
|
data: { status: "rendered", videoUrl: result.mp4, thumbnailUrl: result.thumb },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failScript(script, err) {
|
||||||
|
log(`[${script.id}] FAILED:`, err.message || err);
|
||||||
|
await prisma.youtubeRender.upsert({
|
||||||
|
where: { scriptId: script.id },
|
||||||
|
update: { status: "failed", error: String(err.message || err).slice(0, 2000) },
|
||||||
|
create: { scriptId: script.id, status: "failed", error: String(err.message || err).slice(0, 2000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log(`worker ready: dir=${RENDER_DIR} chrome=${CHROME} tts=${EDGE_TTS_BIN} poll=${POLL_MS}ms`);
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const jobs = await prisma.youtubeScript.findMany({
|
||||||
|
where: { status: { in: ["approved", "render_queued"] } },
|
||||||
|
take: 1,
|
||||||
|
orderBy: { updatedAt: "asc" },
|
||||||
|
});
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
await sleep(POLL_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const job = jobs[0];
|
||||||
|
log(`[${job.id}] picking up "${job.title}"`);
|
||||||
|
await prisma.youtubeScript.update({ where: { id: job.id }, data: { status: "render_queued" } });
|
||||||
|
const result = await renderScript(job);
|
||||||
|
await markRendered(job, result);
|
||||||
|
log(`[${job.id}] DONE ${result.durationSec.toFixed(2)}s @ ${result.mp4}`);
|
||||||
|
} catch (e) {
|
||||||
|
log("main loop error:", e.message || e);
|
||||||
|
try {
|
||||||
|
const job = await prisma.youtubeScript.findFirst({ where: { status: "render_queued" } });
|
||||||
|
if (job) await failScript(job, e);
|
||||||
|
} catch {}
|
||||||
|
await sleep(POLL_MS * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => { await prisma.$disconnect(); process.exit(0); });
|
||||||
|
process.on("SIGTERM", async () => { await prisma.$disconnect(); process.exit(0); });
|
||||||
|
|
||||||
|
main().catch(async (e) => {
|
||||||
|
console.error("FATAL", e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -48,7 +48,7 @@ interface Script {
|
|||||||
rejectedReason?: string;
|
rejectedReason?: string;
|
||||||
factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] };
|
factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] };
|
||||||
fullScript?: string;
|
fullScript?: string;
|
||||||
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected";
|
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected" | "rendered" | "render_queued";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
// SEO Package
|
// SEO Package
|
||||||
seoTitle?: string;
|
seoTitle?: string;
|
||||||
@@ -73,7 +73,7 @@ export default function YouTubePage() {
|
|||||||
const [generatedScript, setGeneratedScript] = useState<Script | null>(null);
|
const [generatedScript, setGeneratedScript] = useState<Script | null>(null);
|
||||||
const [scripts, setScripts] = useState<Script[]>([]);
|
const [scripts, setScripts] = useState<Script[]>([]);
|
||||||
const [activeView, setActiveView] = useState<"longform" | "shorts" | "performance" | "outliers">("longform");
|
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 [generatingIdeas, setGeneratingIdeas] = useState(false);
|
||||||
const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set());
|
const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set());
|
||||||
const [batchGenerating, setBatchGenerating] = useState(false);
|
const [batchGenerating, setBatchGenerating] = useState(false);
|
||||||
@@ -202,6 +202,33 @@ export default function YouTubePage() {
|
|||||||
const rejectedScripts = scripts.filter(s => s.status === "rejected");
|
const rejectedScripts = scripts.filter(s => s.status === "rejected");
|
||||||
const tofilmScripts = scripts.filter(s => s.status === "tofilm");
|
const tofilmScripts = scripts.filter(s => s.status === "tofilm");
|
||||||
const filmedScripts = scripts.filter(s => s.status === "filmed");
|
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
|
// 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";
|
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" && (
|
{script.status === "tofilm" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -490,9 +558,10 @@ export default function YouTubePage() {
|
|||||||
{/* SHORTS SUB-NAV */}
|
{/* SHORTS SUB-NAV */}
|
||||||
{activeView === "shorts" && (
|
{activeView === "shorts" && (
|
||||||
<div className="flex gap-2 mt-4 mb-2">
|
<div className="flex gap-2 mt-4 mb-2">
|
||||||
{([
|
{([
|
||||||
{ key: "ideas" as const, label: "💡 Ideas", count: ideas.length },
|
{ key: "ideas" as const, label: "💡 Ideas", count: ideas.length },
|
||||||
{ key: "scripts" as const, label: "📜 Scripts", count: draftScripts.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: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length },
|
||||||
{ key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length },
|
{ key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length },
|
||||||
]).map(tab => (
|
]).map(tab => (
|
||||||
@@ -666,6 +735,84 @@ export default function YouTubePage() {
|
|||||||
</div>
|
</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 */}
|
{/* TO FILM VIEW */}
|
||||||
{activeView === "shorts" && shortsView === "tofilm" && (
|
{activeView === "shorts" && shortsView === "tofilm" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -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