Compare commits

...
23 Commits
Author SHA1 Message Date
asepharyana 4601f612a4 feat(shorts): 4 more scene types (quote, podium, gridlist, scoreboard) — 15 scene types total
Build & Deploy Hermy HQ (Nix + Attic) / build-and-deploy (push) Canceled after 0s
Build & Deploy Hermy HQ (Nix + Attic) / cleanup (push) Canceled after 0s
2026-09-23 08:36:17 +07:00
asepharyana 3b019b2462 feat(shorts): assets on title/cta/hook scenes too (not just caption) 2026-09-23 00:51:39 +07:00
asepharyana fb9d3667b7 feat(shorts): 10 more assets (bouncy count, split, banner, track, cloud, fan, camera, world clock, hands, padlock) + label-aware picker for HOOK/CTA 2026-09-23 00:41:35 +07:00
asepharyana 2c92ec3bff feat(shorts): 11 reusable animated assets (charts, ring, gauge, ticker, countdown, nodeflow, pulse, checklist, HUD, stat) + label-aware asset picker 2026-09-23 00:27:58 +07:00
asepharyana 20ecbf1f98 feat(shorts): 11 animated bg motifs (grid/dots/waves/stars/zigzag/brackets) + per-script seed shuffle 2026-09-22 22:58:11 +07:00
asepharyana 005d9880e6 feat(shorts): worker force scenes for AI-tips — terminal/compare/fix/bullets/bang 2026-09-22 22:42:29 +07:00
asepharyana a992d73eda feat(shorts): creative scenes — terminal hook, DON'T/DO compare, bullets, bang; MiniKaraoke strip 2026-09-22 22:38:30 +07:00
asepharyana e7fc629e54 fix(shorts): pass category through generate -> scripts POST -> DB 2026-09-22 22:21:13 +07:00
asepharyana 897c33eeac feat(shorts): AI agent tips category — ideas, script structure, tips voice, category column 2026-09-22 22:16:02 +07:00
MythEclipse 85ce16d6d7 fix(news): fetch article directly (og:title/desc + <p>) with Jina fallback
Jina Reader returned 404/garbage for many news sites. Now fetch the page
HTML directly, parse og:title/og:description + author + article<time>
+ <p> paragraphs; Jina only as fallback. Verified: theverge AI page →
real title 'Artificial Intelligence', 8 paragraphs.
2026-09-22 21:46:04 +07:00
MythEclipse 9c6ea226f8 feat(shorts): render source content (tweets/threads/news) as visual cards
- New scripts/source-fetch.mjs: fetch tweet/thread via fxtwitter API +
  Jina fallback; news via r.jina.ai (title/author/excerpt/paragraphs)
- New src/remotion/cards.tsx: TweetCard, ThreadCard, NewsCard — mono,
  flat, white cards w/ avatar initial, handle, text, stats (views/likes/
  replies/retweets) or headline+excerpt; ThreadCard stacks tweets
- ShortsVideo: new scene types tweet/thread/news; source card scene
  REPLACES the story segment's visual (duration kept identical so the
  audio/video mux stays in sync) — the voiceover keeps playing while the
  actual tweet/news is shown on screen
- Worker: fetch sourceUrl when present → sourceToCard() → inject card
- Schema: YoutubeScript.sourceUrl + sourceData (Json)
- UI: approved scripts get a '🔗 Tweet/News URL' input; Render Shorts
  saves sourceUrl then queues the render
- Verified: card scene renders (bright%≈20.8 vs caption 3.9 in pixel
  histogram); TSC clean; frame extraction OK
2026-09-22 21:40:59 +07:00
MythEclipse 4a61766ee4 style(shorts): monochrome flat design — no gradients, no purple
User directive: no gradient, no purple (consistent with GMW black-white
monochrome theme). Full redesign of parts.tsx:
- Solid near-black scene backgrounds (5 subtle variants: circle, plus,
  barcode, rings, diagonal motifs as faint white SVG line art)
- GlassCard → flat white card (black text) — high contrast inversion
- CaptionKaraoke: black on white card, active sentence full-opacity
- KineticTitle: white on black; accent word = inverted white block w/ black ink
- StatCounter: big number framed by 2 offset rectangle outlines (editorial)
- BrandTag: white outline pill (flat), ProgressBar: solid white fill
- Removed ALL color props/palettes; removed gradient backgrounds/orbs
Perf: lighter than v2 (7.1MB vs 10.3MB); ~2min render per 32s short
2026-09-22 21:17:14 +07:00
MythEclipse 1db00c4182 feat(shorts): cinematic v2 visual design + synced karaoke captions
- Fix critical bug: scenes were stacked without <Sequence> — every segment
  rendered simultaneously, last scene covering the whole video (root cause
  of 'too simple/ugly' visual)
- Scene variety: kinetic word-by-word title (hook), karaoke caption card,
  big-number stat scene (auto-detected: $12B, 65%, 3.2M), CTA center
- Karaoke captions synced to TTS: edge-tts SentenceBoundary timing via new
  scripts/tts-words.py (mp3 + sentences.json per segment); active sentence
  bright, others dimmed
- Rich background per scene: 4 color palettes (indigo/pink, cyan/teal,
  purple/pink, orange/red), drifting orbs, dot grid, ken-burns motion
- Real fonts: @fontsource Inter 700 + Montserrat 800 (display/captions)
- Glass card captions, progress bar per-scene, safe zone 900x1400
- Word-boundary truncation for stat labels (no mid-word cuts)
- Perf kept: no blur/SVG-noise (software renderer); ~2 min per 32s short
2026-09-22 21:03:22 +07:00
MythEclipse c0849dde62 docs: mark yt-shorts-render spec implemented 2026-09-22 20:21:26 +07:00
MythEclipse 33e85ce8bf 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
2026-09-22 20:16:25 +07:00
MythEclipse 2a051875f3 ci: remove probe-key workflow (diagnosis complete) 2026-09-20 16:30:42 +07:00
MythEclipse e194f37dbb ci: show ssh-keygen error in deploy 2026-09-20 16:17:32 +07:00
MythEclipse 92b5af7d9d ci: probe key format deeper 2026-09-20 16:13:22 +07:00
MythEclipse bf30e3910e ci: probe SSH key secret round-trip 2026-09-20 16:12:28 +07:00
MythEclipse 9d527d749e ci(deploy): base64-encode SSH key in secret to survive Actions multiline handling 2026-09-20 16:07:32 +07:00
MythEclipse 5a30971ded ci(deploy): Nix flake + GitHub Actions pipeline for hermyhq (Next.js 16 standalone)
- flake.nix: build hermyhq via npm ci + next build (standalone), package .next/standalone + static + public behind /nix/var/nix/profiles/hermyhq
- next.config.ts: output: 'standalone' (Nix deploy bundles self-contained server)
- .github/workflows/deploy.yml: nix-installer (DeterminateSystems) + attic push pub:asepharyana + VPS nix-store realise (fallback ssh copy) + nix-env profile + systemctl restart hermyhq + GC cleanup
2026-09-20 15:59:41 +07:00
asepharyana b5ba9bdf96 feat: add dependabot-auto-merge.yml 2026-09-20 03:01:10 +07:00
asepharyana c66dde5bc1 feat: add dependabot.yml 2026-09-20 03:01:09 +07:00
26 changed files with 6159 additions and 117 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18
View File
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development:
dependency-type: "development"
update-types:
- "minor"
- "patch"
+139
View File
@@ -0,0 +1,139 @@
name: Build & Deploy Hermy HQ (Nix + Attic)
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: hermyhq-deploy
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v22
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
extra-substituters = https://attic.asepharyana.my.id/asepharyana
extra-trusted-public-keys = asepharyana:zBpY6vNI1nDJ4mU6W4q880BB0JB1qb3gRKkO/tGSMiQ=
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build hermyhq
id: build
run: |
nix build .#hermyhq --impure --option sandbox false --print-build-logs
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
echo "Build OK hermyhq: $STORE_PATH"
- name: Push to Attic cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
run: |
if [ -z "$ATTIC_TOKEN" ]; then
echo "ATTIC_TOKEN not set; skipping attic push"
exit 0
fi
STORE_PATH="${{ steps.build.outputs.store-path }}"
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
ATTIC_BIN=""
if command -v attic >/dev/null 2>&1; then
ATTIC_BIN="$(command -v attic)"
elif nix-store --realise "$ATTIC_DIR" 2>/tmp/attic-bootstrap.err; then
echo "✅ Pulled attic client from attic cache (HTTPS substituter)"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
else
echo "attic client unavailable on runner; skipping push (VPS will fall back)"
exit 0
fi
mkdir -p "$HOME/.config/attic"
cat > "$HOME/.config/attic/config.toml" <<CONF
default-server = "pub"
[servers.pub]
endpoint = "https://attic.asepharyana.my.id"
token = "$ATTIC_TOKEN"
CONF
push_ok=""
for attempt in 1 2 3; do
if "$ATTIC_BIN" push pub:asepharyana "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
echo "✅ Pushed $STORE_PATH to attic directly from runner"
push_ok=1
break
fi
echo "⚠️ Direct attic push attempt $attempt/3 failed; retrying in 10s..."
sleep 10
done
if [ -z "$push_ok" ]; then
echo "⚠️ Direct attic push failed after 3 attempts (VPS will try nix-store --realise then ssh copy)"
fi
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy hermyhq to VPS
run: |
STORE_PATH="${{ steps.build.outputs.store-path }}"
echo "=== Realising hermyhq: $STORE_PATH (substitute from attic, fallback ssh copy) ==="
if ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null; then
echo "Substituted hermyhq from Attic cache"
else
echo "Attic substitute failed; falling back to ssh copy"
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
fi
echo "=== Updating profile ==="
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/hermyhq --set '$STORE_PATH'"
echo "=== Restarting service ==="
ssh "$VPS_USER@$VPS_HOST" \
"sudo systemctl daemon-reload && sudo systemctl restart hermyhq && for i in \$(seq 1 15); do state=\$(sudo systemctl is-active hermyhq 2>/dev/null || echo inactive); [ \"\$state\" = \"active\" ] && break; sleep 2; done; echo \"final-state=\$state\"; [ \"\$state\" = \"active\" ]"
echo "✅ hermyhq deployed"
cleanup:
needs: build-and-deploy
if: always()
runs-on: ubuntu-latest
steps:
- name: Nix GC on VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
+1
View File
@@ -45,3 +45,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
.env*.local .env*.local
/services/client-pulse-bot/dist/ /services/client-pulse-bot/dist/
/tmp/checkcat.mjs
+64
View File
@@ -0,0 +1,64 @@
# Spec: AI-Generated Shorts Render (YT → MP4)
**Status:** ✅ IMPLEMENTED & DEPLOYED (2026-09-22)
**Date:** 2026-09-22
**Repo:** asepharyana/hermes-agent-mission-control
**Deploy:** Nix + Attic + systemd (hermyhq + hermyhq-render) di VPS
## Goal
Konversi script YouTube **approved** → video Shorts (9:16, ≤60s) otomatis:
TTS voiceover + visual bergerak + teks, direview di dashboard sebelum publish.
## Lingkungan (verified)
- VPS: ffmpeg 8.0.1, Google Chrome stable, 26 fonts, Bing TTS (edge-tts) reachable
- **Port fix**: hermyhq 4023→4024 (4023 = pr-agent-bun). Caddy `hamc.*` → 4024
- DB: PostgreSQL hermyhq (Prisma 6.19.2)
## Arsitektur
```
UI (youtube tab, approved)
→ POST /api/youtube/render {scriptId} (set status render_queued)
→ hermyhq-render.service (worker poll loop, /opt/hermyhq-renderer)
TTS (edge-tts) per segment
→ Remotion render (Chrome headless) silent 1080×1920
→ ffmpeg concat audio + mux + thumbnail
→ DB: status rendered, videoUrl/thumbnailUrl + YoutubeRender row
→ UI (Renders tab): preview video + [✓ Approved→tofilm] [✗ Reject] [⬇ Download]
```
## Files
- `src/remotion/` — ShortsVideo.tsx (segments → 9:16 video), parts.tsx (bg/caption/progress), index.tsx (registerRoot + calculateMetadata)
- `scripts/render-worker.mjs` — standalone poller (systemd hermyhq-render.service)
- `src/app/api/youtube/render/route.ts` — POST queue / GET status
- `src/app/api/youtube/render/file/route.ts` — stream MP4/JPG (Range support)
- `src/app/youtube/page.tsx` — Renders tab + Render Shorts button + review actions
- `prisma/schema.prisma` — YoutubeRender model, YoutubeScript.videoUrl/thumbnailUrl
- `remotion.config.ts` — Remotion config (Chrome executable)
## Verified (prod, 2026-09-22)
- Worker E2E: seed script approved → TTS 5 segmen (32.38s total) → Remotion render
→ ffmpeg mux → `/data/renders/<id>.mp4` (h264 1080×1920 + aac, 2.9MB) + .jpg
- DB: status `rendered`, YoutubeRender row (durationMs 32384, metadata)
- hermyhq-render.service: active, auto-restart
- CI: Build & Deploy success (push 33e85ce), dashboard live di 4024
- API: 401 Unauthorized dari luar (NextAuth protected — expected, dashboard-only)
## Remaining / Next
- **Login OAuth**: dashboard butuh login (Google OAuth belum configure di prod? — check ALLOWED_EMAILS)
- **UI test**: login → approve script → Render Shorts → preview di Renders tab
- **Upload ke YouTube API**: butuh OAuth (deferred)
- **Music background**: butuh sumber royalty-free (deferred)
- **Render speed**: ~5.6 fps (software Chrome), 32s short ≈ 2 menit render — OK untuk sekarang
## Keputusan
- Worker terpisah (systemd) — robust vs restart dashboard
- Remotion (programmatic) vs Veo/Runway — gratis, jalan di VPS, deterministic
- edge-tts gratis, verified reachable
- Tanpa musik background (hak cipta)
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1789724158,
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+104
View File
@@ -0,0 +1,104 @@
{
description = "Hermy HQ — Hermes mission control dashboard (Next.js 16 standalone, Nix build)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
let
pkgs = import nixpkgs { inherit system; };
# Source filter: exclude build artifacts + heavy/irrelevant dirs
# (path: literals ignore .gitignore; filter explicitly).
filterSource = { dir, ignore }: builtins.path {
path = dir;
name = "source";
filter = (path: type: let base = baseNameOf path; in !(builtins.elem base ignore));
};
src = filterSource {
dir = ./.;
ignore = [ ".next" "node_modules" "src-tauri" "data" "hermes-bridge" "scanner" ".git" ];
};
nodejs = pkgs.nodejs_22;
npmInstall = ''
export HOME=$TMPDIR/home
export npm_config_cache=$TMPDIR/npm-cache
mkdir -p $npm_config_cache
# SSL/TLS certs (Nix sandbox lacks system CA bundle)
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
# prisma needs openssl at runtime for its engine
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
npm ci --ignore-scripts 2>&1
# prisma generate (postinstall normally, but --ignore-scripts skipped it)
npx prisma generate 2>&1
'';
hermyhq = pkgs.stdenv.mkDerivation {
pname = "hermyhq";
version = "1.0.0";
src = src;
nativeBuildInputs = [ nodejs pkgs.cacert ];
buildPhase = npmInstall + ''
echo "=== Building Next.js SSR (standalone) ==="
export NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_ is baked at build time — the dashboard shows the owner name
export NEXT_PUBLIC_OWNER_NAME="''${NEXT_PUBLIC_OWNER_NAME:-MythEclipse}"
npx next build 2>&1
'';
installPhase = ''
echo "=== Packaging standalone server ==="
mkdir -p $out/lib/hermyhq/standalone
cp -r .next/standalone/. $out/lib/hermyhq/standalone/
mkdir -p $out/lib/hermyhq/standalone/.next
cp -r .next/static $out/lib/hermyhq/standalone/.next/static
cp -r public $out/lib/hermyhq/standalone/public 2>/dev/null || true
# Remove dangling symlinks (pnpm/npm hoisted layout)
find $out/lib/hermyhq/standalone -type l \
! -exec test -e {} \; -delete 2>/dev/null || true
mkdir -p $out/bin
printf '%s\n' \
"#!${pkgs.runtimeShell}" \
"cd $out/lib/hermyhq/standalone" \
"export HOSTNAME=127.0.0.1" \
"exec ${nodejs}/bin/node server.js" > $out/bin/hermyhq
chmod +x $out/bin/hermyhq
'';
meta = {
description = "Hermy HQ — Next.js 16 mission-control dashboard";
platforms = pkgs.lib.platforms.linux;
};
};
in {
packages = {
inherit hermyhq;
default = hermyhq;
};
devShells.default = pkgs.mkShell {
buildInputs = [ nodejs pkgs.cacert ];
shellHook = ''
echo "Hermy HQ dev shell ready — node $(node --version)"
'';
};
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Vercel-compatible settings // Vercel-compatible settings
output: undefined, // default — Vercel handles this automatically output: "standalone", // Nix deploy bundles the standalone server
images: { images: {
unoptimized: false, unoptimized: false,
}, },
+2471 -85
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -15,7 +15,11 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.6.0", "@auth/prisma-adapter": "^1.6.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/montserrat": "^5.3.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 +36,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",
+26
View File
@@ -133,6 +133,7 @@ model YoutubeIdea {
funnelStage String? funnelStage String?
status String @default("pending") status String @default("pending")
rejectedReason String? rejectedReason String?
category String? @default("brands")
sourceUrl String? sourceUrl String?
sourceSummary String? sourceSummary String?
competitionScore String? // "gap" | "moderate" | "crowded" competitionScore String? // "gap" | "moderate" | "crowded"
@@ -165,6 +166,15 @@ model YoutubeScript {
seoChapters Json? seoChapters Json?
titleVariants Json? titleVariants Json?
thumbnailConcept String? thumbnailConcept String?
// Render output (shorts)
videoUrl String?
thumbnailUrl String?
render YoutubeRender?
// Source content (tweet/thread/news) rendered as visual cards
sourceUrl String?
sourceData Json?
// Content category (brands | ai-agent-tips | ...)
category String? @default("brands")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -179,6 +189,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
+2
View File
@@ -0,0 +1,2 @@
import { Config } from "@remotion/cli/config";
Config.setOverwriteOutput(true);
+396
View File
@@ -0,0 +1,396 @@
#!/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, readFileSync } from "fs";
import path from "path";
import { execFileSync } from "child_process";
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 VOICE_TIPS = process.env.TTS_VOICE_TIPS || "en-US-GuyNeural"; // tech/explainer
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");
const TTS_PYTHON =
process.env.TTS_PYTHON ||
(existsSync("/home/code/.hermes/hermes-agent/venv/bin/python3")
? "/home/code/.hermes/hermes-agent/venv/bin/python3"
: "python3");
const TTS_WORDS_PY = path.resolve(process.env.TTS_WORDS_PY || "./scripts/tts-words.py");
const SOURCE_FETCH_MJS = path.resolve(process.env.SOURCE_FETCH_MJS || "./scripts/source-fetch.mjs");
/** Fetch source content (tweet/thread/news) for a URL via source-fetch.mjs. */
function fetchSource(url) {
if (!url) return null;
try {
const out = execFileSync(process.execPath, [SOURCE_FETCH_MJS, url], {
timeout: 40_000,
encoding: "utf-8",
maxBuffer: 4 * 1024 * 1024,
});
return JSON.parse(out);
} catch {
return null;
}
}
/** Convert fetched source data → src card shape for Remotion scenes. */
function sourceToCard(fetched) {
if (!fetched || fetched.type === "error") return null;
if (fetched.type === "tweet") {
const root = fetched.tweets?.find((t) => t.isRoot) || fetched.tweets?.[0];
if (!root) return null;
if (fetched.tweets.length > 1) {
return {
type: "thread",
handle: fetched.handle,
tweets: fetched.tweets.map((t) => ({ text: t.text, name: t.name })),
};
}
return {
type: "tweet",
handle: root.handle,
name: root.name,
text: root.text,
likes: root.likes,
retweets: root.retweets,
replies: root.replies,
views: root.views,
};
}
if (fetched.type === "news") {
return {
type: "news",
title: fetched.title,
excerpt: fetched.excerpt,
author: fetched.author,
source: new URL(fetched.url).hostname.replace(/^www\./, ""),
};
}
return null;
}
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, voice = VOICE) {
const out = `${outBase}.mp3`;
const srt = `${outBase}.sentences.json`;
sh(
TTS_PYTHON,
[TTS_WORDS_PY, text, voice, out, srt],
{ timeout: 60_000, env: { ...process.env } }
);
let sentences = [];
try {
sentences = JSON.parse(readFileSync(srt, "utf-8"));
} catch { /* fallback: single sentence */ }
if (!sentences.length) sentences = [{ w: text, start: 0, end: 0 }];
return { file: out, durationSec: ffprobeDuration(out), sentences };
}
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); }
/** Truncate at a word boundary (never cut mid-word). */
function clipWords(s, max) {
if (s.length <= max) return s;
const cut = s.slice(0, max);
const sp = cut.lastIndexOf(" ");
return (sp > max * 0.5 ? cut.slice(0, sp) : cut).trim() + "…";
}
/** Detect a stat worth rendering as a big-number scene: $12B, 65%, 3.2M. */
function extractStatData(text) {
const m = text.match(/(\$?\d+(?:\.\d+)?\s?(?:[BMK]%|billion|million|%))/i);
if (!m) return null;
const rest = text.replace(m[0], "").replace(/^[^A-Za-z0-9]+/, "").trim();
if (rest.length < 5) return null;
return { value: m[1].trim(), label: clipWords(rest, 70) };
}
/** Build segment text list from a YoutubeScript's structured fields. */
function buildSegments(script) {
const isTips = script.category === "ai-agent-tips";
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 });
};
// For AI-agent tips: labels = MISTAKE / THE FIX instead of THE STORY / ...
if (isTips) {
push(script.hook, "HOOK", script.hook);
push(script.onScreenText, "ON SCREEN", script.onScreenText);
push(script.storySetup, "THE MISTAKE", null);
push(script.conflict, "WHY IT FAILS", null);
push(script.insight, "THE FIX", null);
push(script.cta, "CTA", null);
} else {
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}`), script.category === "ai-agent-tips" ? VOICE_TIPS : VOICE);
voiced.push({ ...s, ...t });
log(`[${id}] seg${i}: ${t.durationSec.toFixed(2)}s "${s.text.slice(0, 40)}"`);
}
// 1b. Fetch source content (tweet/thread/news) if script has sourceUrl
let sourceCard = null;
if (script.sourceUrl) {
log(`[${id}] fetching source: ${script.sourceUrl}`);
const fetched = fetchSource(script.sourceUrl);
sourceCard = sourceToCard(fetched);
log(`[${id}] source → ${sourceCard ? sourceCard.type : "fetch failed (skip card)"}`);
}
// 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) => {
const t = {
text: s.text,
duration: Math.max(30, Math.round(s.durationSec * FPS)),
label: s.label,
sentences: s.sentences || undefined,
stat: extractStatData(s.text),
};
// AI-agent tips: force scene per label for the terminal/compare/bullets/bang
// variety (brands keep their own kinetic/caption mapping).
const L = (s.label || "").toUpperCase();
if (script.category === "ai-agent-tips") {
if (L === "HOOK") t.scene = "terminal";
else if (L === "THE MISTAKE" || L === "WHY IT FAILS") t.scene = "compare";
else if (L === "THE FIX") t.scene = "fix";
else if (L === "THE INSIGHT") t.scene = "bullets";
else if (L === "THE CONFLICT") t.scene = "bang";
else if (L === "CTA") t.scene = "cta";
}
return t;
});
// Inject source card scene — REPLACES the visual of the 2nd segment
// (story). Total timeline duration stays == audio duration, so the mux
// stays in sync (no -shortest tail loss). The story voiceover keeps
// playing while the actual tweet/news card is shown on screen.
if (sourceCard && timeline.length >= 2) {
const idx = Math.min(1, timeline.length - 1);
const cardScene = {
text: sourceCard.type === "news" ? (sourceCard.title || "") : (sourceCard.text || sourceCard.tweets?.[0]?.text || ""),
duration: timeline[idx].duration, // same duration — replaces, not adds
label: sourceCard.type === "news" ? "SOURCE" : "ORIGINAL",
source: sourceCard,
};
timeline.splice(idx, 1, cardScene);
log(`[${id}] replaced seg${idx} visual with ${sourceCard.type} card (${cardScene.duration / FPS}s)`);
}
// 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`);
// Deterministic per-script seed → background motifs + scene variety shuffle
// per video (same script re-render keeps same look; different scripts differ).
let seed = 0;
for (const ch of id) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
seed %= 110;
sh("npx", [
"--yes", "remotion", "render", REMOTION_ENTRY, "Shorts", silentMp4,
"--codec=h264", "--crf=20", "--browser-executable=" + CHROME,
"--props=" + JSON.stringify({ segments: timeline, seed }),
"--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);
});
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* Fetch source content for shorts rendering.
*
* Supports:
* - Twitter/X: thread via fxtwitter API (status pages → JSON)
* url https://x.com/user/status/1234 (or twitter.com)
* returns { tweets: [{ id, handle, name, text, media?, likes, retweets, replies, views }] }
* - News/article: via Jina Reader (r.jina.ai) — text extraction
* url any http(s) article
* returns { title, url, author, published, text }
*
* Usage: node source-fetch.mjs <url> [--thread] [--maxTweets N]
* Output: JSON to stdout: { type: "tweet"|"news"|"error", ... }
*/
const MAX_TWEETS = Number(process.argv.find((a, i) => process.argv[i - 1] === "--maxTweets") || 6);
function err(msg, extra = {}) {
console.log(JSON.stringify({ type: "error", error: msg, ...extra }));
process.exit(1);
}
function isTweetUrl(u) {
return /(?:twitter\.com|x\.com)\/\w+\/status\/\d+/i.test(u);
}
function parseTweetId(u) {
const m = u.match(/status\/(\d+)/i);
return m ? m[1] : null;
}
function parseHandle(u) {
const m = u.match(/(?:twitter\.com|x\.com)\/([A-Za-z0-9_]+)/i);
return m ? m[1] : null;
}
async function fetchTwitter(u) {
const id = parseTweetId(u);
const handle = parseHandle(u);
if (!id || !handle) err("invalid tweet url");
// Try fxtwitter API for the tweet + thread context
const api = `https://api.fxtwitter.com/${handle}/status/${id}`;
const res = await fetch(api, { headers: { "User-Agent": "hamc-shorts/1.0" } });
if (!res.ok) err(`fxtwitter ${res.status}`);
const data = await res.json();
const t = data?.tweet;
if (!t) err("no tweet data", { raw: JSON.stringify(data).slice(0, 200) });
// Collect thread: current tweet + replies chain (tweet.replies / quoted)
const tweets = [];
const pushTweet = (tw, isRoot = false) => {
if (!tw) return;
const text = (tw.text || tw.rawText || "").trim();
if (!text) return;
tweets.push({
id: tw.id || tw.id_str || null,
handle: tw.author?.screen_name || handle,
name: tw.author?.name || tw.author?.user_name || handle,
text: text.slice(0, 500), // safety
media: tw.media?.all?.length ? tw.media.all.map((m) => ({ type: m.type, url: m.url, thumb: m.thumbnail_url })) : [],
likes: tw.likes ?? null,
retweets: tw.retweets ?? null,
replies: tw.replies ?? null,
views: tw.views ?? null,
isRoot,
});
};
pushTweet(t, true);
// Try to pull thread replies if fxtwitter exposed them (data.tweet.replies can be a list)
const replies = Array.isArray(t.replies) ? t.replies : Array.isArray(data?.tweet?.thread) ? data.tweet.thread : [];
for (const r of replies.slice(0, MAX_TWEETS - 1)) pushTweet(r);
if (tweets.length === 1) {
// No thread from API; attempt simple scrape via Jina on the status URL (best effort)
try {
const j = await fetch(`https://r.jina.ai/${u}`, {
headers: { "User-Agent": "Mozilla/5.0", "X-Return-Format": "text" },
signal: AbortSignal.timeout(20000),
});
if (j.ok) {
const text = (await j.text()).slice(0, 4000);
// split into thread-ish sentences by newlines
const lines = text.split("\n").map((s) => s.trim()).filter((s) => s.length > 20 && !s.startsWith("http") && !/^\d+\s*(like|retweet|reply|view)s?$/i.test(s));
if (lines.length > 1) {
tweets.push(...lines.slice(0, MAX_TWEETS - 1).map((txt, i) => ({
id: null, handle, name: tweets[0].name, text: txt.slice(0, 500), isRoot: false,
})));
}
}
} catch { /* keep single tweet */ }
}
return { type: "tweet", id, handle, tweets };
}
async function fetchNews(u) {
// Strategy: fetch the page directly, extract OG meta + readable text
// with lightweight heuristics. Jina Reader kept as a fallback only.
let html = "";
try {
const res = await fetch(u, {
headers: {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
Accept: "text/html,application/xhtml+xml",
},
redirect: "follow",
signal: AbortSignal.timeout(25000),
});
if (res.ok) html = await res.text();
} catch { /* fallthrough to Jina */ }
// Parse OG meta + title + author + paragraphs from raw HTML.
let title = "";
let author = "";
let published = "";
const ogTitle = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
const ogDesc = html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i);
const htmlTitle = html.match(/<title[^>]*>([^<]+)<\/title>/i);
const authorM = html.match(/<meta[^>]+name=["']author["'][^>]+content=["']([^"']+)["']/i);
const dateM = html.match(/<meta[^>]+(?:property|name)=["'](?:article:published_time|date|publishdate)["'][^>]+content=["']([^"']+)["']/i);
if (ogTitle) title = ogTitle[1].trim();
if (!title && htmlTitle) title = htmlTitle[1].trim().replace(/\s*[-|]\s*[^-|]*$/, "").trim();
if (ogDesc) { /* keep for excerpt */ }
if (authorM) author = authorM[1].trim();
if (dateM) published = dateM[1].trim();
// Extract paragraphs from <p> tags (strip tags/links).
const bodyParas = [];
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/gi;
let m;
while ((m = pRe.exec(html)) && bodyParas.length < 10) {
const txt = m[1]
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&").replace(/&nbsp;/g, " ").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
if (txt.length > 60 && txt.length < 600) bodyParas.push(txt);
}
// Fallback: Jina Reader (may fail on sites that block it).
let excerpt = ogDesc ? ogDesc[1].trim().slice(0, 400) : (bodyParas.join("\n\n") || "");
if (!bodyParas.length && !excerpt) {
try {
const j = await fetch(`https://r.jina.ai/${u}`, {
headers: { "User-Agent": "Mozilla/5.0 (shorts-renderer/1.0)", "X-Return-Format": "markdown" },
signal: AbortSignal.timeout(25000),
});
if (j.ok) {
const text = (await j.text()).trim();
const cleaned = text
.replace(/^URL Source:.*$/m, "")
.replace(/^Markdown Content:.*$/m, "")
.split(/\n\s*\n/)
.map((s) => s.replace(/^#{1,4}\s+/, "").trim())
.filter((s) => s.length > 40 && s.length < 500)
.filter((s) => !/^!\[/.test(s));
if (!title) {
const h1 = text.match(/^#\s+(.+)$/m);
if (h1) title = h1[1].trim();
}
excerpt = cleaned.slice(0, 2).join("\n\n");
bodyParas.push(...cleaned.slice(0, 6));
}
} catch { /* keep what we have */ }
}
if (!title && !excerpt) err("could not extract article content");
return {
type: "news",
url: u,
title: (title || u).slice(0, 200),
author: author.slice(0, 100),
published: published.slice(0, 60),
excerpt: excerpt.slice(0, 600),
paragraphs: bodyParas.slice(0, 8),
};
}
async function main() {
const url = process.argv[2];
if (!url) err("usage: source-fetch.mjs <url>");
const out = isTweetUrl(url) ? await fetchTwitter(url) : await fetchNews(url);
console.log(JSON.stringify(out));
}
main().catch((e) => err(e.message));
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""TTS with per-sentence timing (karaoke captions).
Usage: tts-words.py <text> <voice> <out.mp3> <out.sentences.json>
Writes mp3 + JSON [{w, start, end}] (seconds, relative to segment start).
Uses edge-tts SentenceBoundary stream events.
"""
import asyncio
import json
import sys
import edge_tts
async def main(text: str, voice: str, mp3_path: str, sentences_path: str) -> None:
comm = edge_tts.Communicate(text, voice)
sentences = []
with open(mp3_path, "wb") as f:
async for chunk in comm.stream():
if chunk.get("type") == "audio":
f.write(chunk.get("data", b""))
elif chunk.get("type") == "SentenceBoundary":
start = chunk.get("offset", 0) / 1e7
dur = chunk.get("duration", 0) / 1e7
sentences.append(
{"w": chunk.get("text", ""), "start": round(start, 3), "end": round(start + dur, 3)}
)
with open(sentences_path, "w") as f:
json.dump(sentences, f)
if __name__ == "__main__":
asyncio.run(main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]))
+16 -9
View File
@@ -45,8 +45,9 @@ function fetchArticle(url: string): string {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const { topic, sourceUrl, sourceSummary } = await req.json(); const { topic, sourceUrl, sourceSummary, category } = await req.json();
if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 }); if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 });
const isTips = category === "ai-agent-tips";
const apiKey = process.env.OPENAI_API_KEY || ""; const apiKey = process.env.OPENAI_API_KEY || "";
const braveKey = process.env.BRAVE_API_KEY || ""; const braveKey = process.env.BRAVE_API_KEY || "";
@@ -171,20 +172,25 @@ ${sourceContext || "No sources available — be EXTRA cautious with claims, hedg
- Include a "factClaims" array listing every specific factual claim in the script (dates, numbers, events, quotes) - Include a "factClaims" array listing every specific factual claim in the script (dates, numbers, events, quotes)
- Each claim MUST be traceable to the source material above - Each claim MUST be traceable to the source material above
## THE USER'S ANGLE ## THE USER'S ANGLE${isTips ? `
- Builds and ships AI agents (coding agents, MCP, agent loops) — the tips are for OTHER builders
- Voice: direct, first-principles, tells you the mistake you are probably making
- Every tip must be concrete and actionable — no fluff
- The viewer is technical: they write code and wire tools together
- Ground each tip in a real failure/lesson: "I ran this in production and learned X"` : `
- Runs a marketing-focused business - Runs a marketing-focused business
- Content: founder branding, marketing, distribution > product, culture > features - Content: founder branding, marketing, distribution > product, culture > features
- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable - Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable`}
${pastScriptsRef} ${pastScriptsRef}
${feedbackContext} ${feedbackContext}
OUTPUT FORMAT — valid JSON only: OUTPUT FORMAT — valid JSON only:
{ {
"title": "Brand/Person — Core Lesson (short)", "title": "Brand/Person — Core Lesson (short)",
"hook": "Opening 2-4 lines. Scroll-stopping.", "hook": "Opening 2-4 lines. Scroll-stopping.${isTips ? " Lead with the COMMON MISTAKE or counter-intuitive truth about AI agents." : ""}",
"storySetup": "The backstory context. 3-5 sentences.", "storySetup": "The backstory context. 3-5 sentences.${isTips ? " The situation: why builders get this wrong." : ""}",
"conflict": "The tension pivot or controversial statement. 3-5 sentences.", "conflict": "The tension pivot or controversial statement. 3-5 sentences.${isTips ? " The failure mode / what everyone does that backfires." : ""}",
"insight": "The aha moment payoff lesson for founders. 3-5 sentences.", "insight": "The aha moment payoff lesson for founders. 3-5 sentences.${isTips ? " THE concrete fix/pattern — specific, actionable, names the tool/technique." : ""}",
"cta": "One clear action. One line.", "cta": "One clear action. One line.",
"caption": "One punchy line without spoilers.", "caption": "One punchy line without spoilers.",
"onScreenText": "What appears on screen during the hook", "onScreenText": "What appears on screen during the hook",
@@ -284,6 +290,7 @@ Return JSON: { "results": [{ "claim": "...", "verdict": "correct|incorrect|uncer
sourceUrls: factCheck.sourceUrls, sourceUrls: factCheck.sourceUrls,
}, },
status: "draft", status: "draft",
category: category || "brands",
createdAt: new Date().toISOString().split("T")[0], createdAt: new Date().toISOString().split("T")[0],
}); });
} }
+54 -13
View File
@@ -33,7 +33,11 @@ function braveSearch(query: string, braveKey: string, count = 5): { title: strin
} }
} }
export async function POST() { export async function POST(req?: Request) {
const body = req ? await req.json().catch(() => ({})) : {};
const category: string = body.category || "brands";
const isTips = category === "ai-agent-tips";
const apiKey = process.env.OPENAI_API_KEY || ""; const apiKey = process.env.OPENAI_API_KEY || "";
const braveKey = process.env.BRAVE_API_KEY || ""; const braveKey = process.env.BRAVE_API_KEY || "";
if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 }); if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 });
@@ -65,10 +69,19 @@ export async function POST() {
try { coveredTopics = fs.readFileSync(SCRIPTS_FILE, "utf-8").match(/### .+/g)?.join(", ") || ""; } catch { /* ok - file may not exist on Vercel */ } try { coveredTopics = fs.readFileSync(SCRIPTS_FILE, "utf-8").match(/### .+/g)?.join(", ") || ""; } catch { /* ok - file may not exist on Vercel */ }
// -- STEP 1: Ask GPT to generate search queries -- // -- STEP 1: Ask GPT to generate search queries --
const queryGenResult = callOpenAI(apiKey, { const querySystem = isTips
model: "gpt-4o-mini", ? `You generate search queries to find practical, actionable AI AGENT tips and best practices for a viral short-form video creator.
messages: [
{ role: "system", content: `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator. The creator makes videos for builders/developers who ship AI agents (coding agents, MCP servers, tool-using LLMs, agent loops).
Topics to cover: agent architecture, tool design, context window management, evals/testing, prompt engineering for agents, MCP, sub-agents, cost control, reliability/failure modes, memory, permissions/safety, orchestration patterns.
Generate 10 diverse search queries for REAL, concrete tips (engineering blogs, docs, HN discussions, practitioner posts). Prefer "best practices", "lessons learned", "common mistakes", "how to", "patterns".
DO NOT search for ideas already generated: ${existingTitles}
${rejectionContext}
Output JSON: {"queries":["search query 1","search query 2",...]}`
: `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator.
The creator covers a WIDE mix: mainstream brands (Red Bull, Nike, Barbie, Tesla, Apple, McDonald's, etc.), tech, pop culture, and business strategy. Most videos are about mainstream brands with a marketing/founder lesson. The creator covers a WIDE mix: mainstream brands (Red Bull, Nike, Barbie, Tesla, Apple, McDonald's, etc.), tech, pop culture, and business strategy. Most videos are about mainstream brands with a marketing/founder lesson.
@@ -81,8 +94,15 @@ DO NOT search for stories already covered: ${coveredTopics}
DO NOT search for ideas already generated: ${existingTitles} DO NOT search for ideas already generated: ${existingTitles}
${rejectionContext} ${rejectionContext}
Output JSON: {"queries":["search query 1","search query 2",...]}` }, Output JSON: {"queries":["search query 1","search query 2",...]}`;
{ role: "user", content: "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." }
const queryGenResult = callOpenAI(apiKey, {
model: "gpt-4o-mini",
messages: [
{ role: "system", content: querySystem },
{ role: "user", content: isTips
? "10 diverse search queries for real, actionable AI agent tips and lessons."
: "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." }
], ],
temperature: 0.95, temperature: 0.95,
response_format: { type: "json_object" } response_format: { type: "json_object" }
@@ -104,10 +124,23 @@ Output JSON: {"queries":["search query 1","search query 2",...]}` },
).join("\n\n"); ).join("\n\n");
// -- STEP 3: Feed real articles to GPT to create ideas -- // -- STEP 3: Feed real articles to GPT to create ideas --
const ideaGenResult = callOpenAI(apiKey, { const ideaSystem = isTips
model: "gpt-4o-mini", ? `You create viral short-form video IDEAS about practical AI AGENT tips for builders, based on REAL articles found via web search.
messages: [
{ role: "system", content: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search. CRITICAL RULES:
- Each idea MUST be one concrete, actionable tip/lesson from the search results below
- Each idea MUST include a sourceUrl from the search results
- Each idea MUST include a sourceSummary (1-2 sentence summary of the real tip/lesson)
- Hook must stop the scroll in 3 seconds — lead with a common MISTAKE or counter-intuitive truth
- Examples of strong hooks: "Your AI agent fails because of one missing tool", "Stop giving your agent 40 tools", "Nobody tells you this about MCP servers"
- The angle must name a SPECIFIC technique/tool/pattern (MCP, tool schemas, evals, context pruning, sub-agents...) — never vague "use AI better"
- Mix funnel stages: 5 TOF (wide builder appeal), 3 MOF (specific workflows), 2 BOF (advanced architecture)
- DO NOT repeat these existing ideas: ${existingTitles}
- Each hook should have normal punctuation (commas, periods)
${rejectionContext}
Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"specific technique/pattern","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real tip"}]}`
: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search.
CRITICAL RULES: CRITICAL RULES:
- Each idea MUST be based on a REAL story from the search results below - Each idea MUST be based on a REAL story from the search results below
@@ -124,8 +157,15 @@ CRITICAL RULES:
- Always name the SPECIFIC brand/person — never be vague - Always name the SPECIFIC brand/person — never be vague
${rejectionContext} ${rejectionContext}
Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}` }, Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}`;
{ role: "user", content: `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` }
const ideaGenResult = callOpenAI(apiKey, {
model: "gpt-4o-mini",
messages: [
{ role: "system", content: ideaSystem },
{ role: "user", content: isTips
? `Based on these REAL articles found via web search, create 10 AI AGENT TIP video ideas. Each idea MUST be one concrete tip and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.`
: `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` }
], ],
temperature: 0.85, temperature: 0.85,
response_format: { type: "json_object" } response_format: { type: "json_object" }
@@ -149,6 +189,7 @@ Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scrol
angle: idea.angle || null, angle: idea.angle || null,
hookType: idea.hookType || null, hookType: idea.hookType || null,
funnelStage: idea.funnelStage || null, funnelStage: idea.funnelStage || null,
category,
status: "pending", status: "pending",
}, },
}); });
+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,
});
}
+1
View File
@@ -29,6 +29,7 @@ export async function POST(req: NextRequest) {
fullScript: body.fullScript || null, fullScript: body.fullScript || null,
status: body.status || "draft", status: body.status || "draft",
rejectedReason: body.rejectedReason || null, rejectedReason: body.rejectedReason || null,
category: body.category || "brands",
// SEO Package // SEO Package
seoTitle: body.seoTitle || null, seoTitle: body.seoTitle || null,
seoDescription: body.seoDescription || null, seoDescription: body.seoDescription || null,
+191 -8
View File
@@ -22,15 +22,17 @@ const LongformTab = dynamic(() => import("@/app/longform/page"), { ssr: false, l
const OutlierFeed = dynamic(() => import("@/components/OutlierFeed"), { ssr: false, loading: () => <TabSkeleton /> }); const OutlierFeed = dynamic(() => import("@/components/OutlierFeed"), { ssr: false, loading: () => <TabSkeleton /> });
interface Idea { interface Idea {
id: string;
title: string; title: string;
hook: string; hook?: string;
angle: string; angle?: string;
hookType?: string; hookType?: string;
funnelStage?: string; funnelStage?: string;
rejectedReason?: string; category?: string;
status?: "pending" | "rejected" | "approved";
sourceUrl?: string; sourceUrl?: string;
sourceSummary?: string; sourceSummary?: string;
rejectedReason?: string;
status?: "pending" | "rejected" | "approved";
} }
interface Script { interface Script {
@@ -48,7 +50,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;
@@ -57,6 +59,9 @@ interface Script {
seoChapters?: { time: string; label: string }[]; seoChapters?: { time: string; label: string }[];
titleVariants?: string[]; titleVariants?: string[];
thumbnailConcept?: string; thumbnailConcept?: string;
videoUrl?: string;
thumbnailUrl?: string;
sourceUrl?: string;
} }
// funnel stage tone: TOF = accent, MOF = warn, BOF = up // funnel stage tone: TOF = accent, MOF = warn, BOF = up
@@ -73,12 +78,13 @@ 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);
const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending"); const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending");
const [scriptTab, setScriptTab] = useState<"draft" | "approved" | "rejected">("draft"); const [scriptTab, setScriptTab] = useState<"draft" | "approved" | "rejected">("draft");
const [ideaCategory, setIdeaCategory] = useState<"brands" | "ai-agent-tips">("brands");
const [rejectModal, setRejectModal] = useState<{ type: "idea" | "script"; id: string; title: string } | null>(null); const [rejectModal, setRejectModal] = useState<{ type: "idea" | "script"; id: string; title: string } | null>(null);
const rejectRef = React.useRef<HTMLTextAreaElement>(null); const rejectRef = React.useRef<HTMLTextAreaElement>(null);
@@ -136,7 +142,7 @@ export default function YouTubePage() {
const res = await fetch("/api/youtube/generate", { const res = await fetch("/api/youtube/generate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary }), body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary, category: idea.category || ideaCategory }),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -202,6 +208,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 +454,64 @@ export default function YouTubePage() {
</> </>
)} )}
{script.status === "approved" && (
<>
<input
placeholder="🔗 Tweet / News URL (opsional)"
defaultValue={script.sourceUrl || ""}
onClick={e => e.stopPropagation()}
onChange={e => { e.currentTarget.dataset.url = e.target.value; }}
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] w-48 outline-none"
style={{ background: "color-mix(in srgb, #fff 8%, transparent)", border: "1px solid color-mix(in srgb, #fff 18%, transparent)", color: "inherit" }}
/>
<button
onClick={(e) => {
e.stopPropagation();
const v = (e.currentTarget.parentElement?.querySelector("input[data-url]") as HTMLInputElement)?.value || "";
if (v.trim()) {
updateScript(script.id, { status: "approved", sourceUrl: v.trim() });
}
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
@@ -493,6 +584,7 @@ export default function YouTubePage() {
{([ {([
{ 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 => (
@@ -540,11 +632,24 @@ export default function YouTubePage() {
}} }}
>{batchGenerating ? `✨ Generating ${selectedIdeas.size}...` : `Generate ${selectedIdeas.size} Script${selectedIdeas.size > 1 ? "s" : ""}`}</Button> >{batchGenerating ? `✨ Generating ${selectedIdeas.size}...` : `Generate ${selectedIdeas.size} Script${selectedIdeas.size > 1 ? "s" : ""}`}</Button>
)} )}
<select
value={ideaCategory}
onChange={(e) => setIdeaCategory(e.target.value as "brands" | "ai-agent-tips")}
className="text-xs bg-[var(--surface)] border border-[var(--border)] rounded-lg px-2 py-1.5 text-[var(--muted)]"
title="Content category for idea generation"
>
<option value="brands">Brands & Founders</option>
<option value="ai-agent-tips">AI Agent Tips</option>
</select>
<Button size="sm" variant="ghost" disabled={generatingIdeas} <Button size="sm" variant="ghost" disabled={generatingIdeas}
onClick={async () => { onClick={async () => {
setGeneratingIdeas(true); setGeneratingIdeas(true);
try { try {
const res = await fetch("/api/youtube/ideas/generate", { method: "POST" }); const res = await fetch("/api/youtube/ideas/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category: ideaCategory }),
});
if (res.ok) fetchIdeas(); if (res.ok) fetchIdeas();
} catch { /* empty */ } } catch { /* empty */ }
setGeneratingIdeas(false); setGeneratingIdeas(false);
@@ -666,6 +771,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">
+451
View File
@@ -0,0 +1,451 @@
import React from "react";
import { AbsoluteFill, Sequence } from "remotion";
import {
SvgBackground,
BrandTag,
ProgressBar,
GlassCard,
CaptionKaraoke,
KineticTitle,
StatCounter,
SlideMotion,
TerminalScene,
CompareScene,
BulletScene,
BangScene,
QuoteScene,
PodiumScene,
GridListScene,
ScoreboardScene,
MiniKaraoke,
} from "./parts";
import { TweetCard, ThreadCard, NewsCard } from "./cards";
import {
ChartBars,
ChartLine,
RingProgress,
Gauge,
TickerTape,
Countdown,
NodeFlow,
PulseBars,
Checklist,
HudFrame,
StatBlock,
BouncyCount,
SplitText,
Banner,
TrackProgress,
CloudTag,
CardFan,
CameraPip,
WorldClock,
PercentageHands,
PadlockPulse,
} from "./assets";
/** Asset pool — a visual building block sits above the caption card on
* regular caption scenes, chosen deterministically per (index, seed) so
* every video looks different. Reused across scenes via asset picker. */
const ASSET_POOL = [
"chartbars",
"chartline",
"ring",
"gauge",
"ticker",
"countdown",
"nodeflow",
"pulse",
"checklist",
"hud",
"stat",
"bouncy",
"split",
"banner",
"track",
"cloud",
"fan",
"camera",
"world",
"hands",
"lock",
] as const;
type AssetKey = (typeof ASSET_POOL)[number];
const AssetDecor: React.FC<{ index: number; seed: number; text: string; label: string }> = ({ index, seed = 0, text, label }) => {
// skip asset only for source cards — they already render a big visual.
const L = (label || "").toUpperCase();
// Label-aware asset choice: THE STORY → growth charts, THE CONFLICT → bang
// energy (pulse/ring), THE INSIGHT → checklist/nodeflow. Falls back to the
// seeded pool for anything else, so videos vary regardless.
let key: AssetKey;
if (L === "THE STORY") {
key = (["chartbars", "chartline", "ticker", "stat", "bouncy"] as AssetKey[])[(index + seed) % 5];
} else if (L === "THE CONFLICT") {
key = (["pulse", "ring", "gauge", "pulse", "hands"] as AssetKey[])[(index + seed) % 5];
} else if (L === "THE INSIGHT") {
key = (["checklist", "nodeflow", "stat", "checklist", "track"] as AssetKey[])[(index + seed) % 5];
} else if (L === "THE MISTAKE" || L === "WHY IT FAILS") {
key = (["pulse", "lock", "ring", "hands"] as AssetKey[])[(index + seed) % 4];
} else if (L === "THE FIX") {
key = (["checklist", "track", "checklist", "fan"] as AssetKey[])[(index + seed) % 4];
} else if (L === "HOOK") {
key = (["bouncy", "banner", "bouncy", "split"] as AssetKey[])[(index + seed) % 4];
} else if (L === "CTA") {
key = (["banner", "bouncy", "hands", "banner"] as AssetKey[])[(index + seed) % 4];
} else {
key = ASSET_POOL[(index + seed) % ASSET_POOL.length];
}
const words = text.split(" ").length;
// only show asset that "fits" — for now deterministic pick is fine
const delay = (index + seed) % 6;
const render = () => {
switch (key) {
case "chartbars": return <ChartBars bars={5} delay={delay} />;
case "chartline": return <ChartLine delay={delay} />;
case "ring": return <RingProgress pct={0.55 + ((seed + index) % 4) * 0.12} delay={delay} />;
case "gauge": return <Gauge value={0.5 + ((seed + index) % 5) * 0.08} label={words > 5 ? "SIGNAL" : "GAUGE"} delay={delay} />;
case "ticker": return <TickerTape text={text.slice(0, 60)} />;
case "countdown": return <Countdown end={3} from={5 + (seed % 4)} delay={delay} />;
case "nodeflow": return <NodeFlow delay={delay} />;
case "pulse": return <PulseBars bars={12} delay={delay} />;
case "checklist": return <Checklist items={["PLAN", "BUILD", "SHIP"]} delay={delay} />;
case "hud": return <HudFrame label={words > 4 ? "SYSTEM" : "DATA"} />;
case "stat": return <StatBlock value={`${(seed % 8) + 3}${index % 2 ? "M" : "%"}`} sub="REACH" />;
case "bouncy": return <BouncyCount to={50 + ((seed + index) % 50)} label={words > 6 ? "PROGRESS" : "SCORE"} suffix="%" delay={delay} />;
case "split": return <SplitText a={words > 5 ? "OLD" : "DON'T"} b={words > 5 ? "NEW" : "DO"} delay={delay} />;
case "banner": return <Banner title={words > 6 ? "KEY TAKEAWAY" : "REMEMBER"} sub={text.toUpperCase().slice(0, 40)} delay={delay} />;
case "track": return <TrackProgress steps={["01", "02", "03", "04"]} delay={delay} />;
case "cloud": return <CloudTag tags={["AGENT", "MEMORY", "TOOLS", "PROMPTS"]} delay={delay} />;
case "fan": return <CardFan delay={delay} />;
case "camera": return <CameraPip label="REC" delay={delay} />;
case "world": return <WorldClock delay={delay} />;
case "hands": return <PercentageHands delay={delay} />;
case "lock": return <PadlockPulse closed={index % 2 === 1} delay={delay} />;
default: return null;
}
};
return (
<div
style={{
position: "absolute",
left: "8%", right: "8%", top: "18%",
display: "flex", alignItems: "center", justifyContent: "center",
flexDirection: "column",
height: "36%",
width: "84%",
pointerEvents: "none",
}}
>
{render()}
</div>
);
};
/** Sentence timing (karaoke), relative to segment start. */
export type Sentence = { w: string; start: number; end: number };
export type Segment = {
text: string;
duration: number; // frames
label?: string;
sentences?: Sentence[];
/** optional explicit scene override */
scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news" | "terminal" | "compare" | "fix" | "bullets" | "bang" | "quote" | "podium" | "gridlist" | "scoreboard";
stat?: { value: string; label: string };
/** source content (tweet/thread/news) — rendered as a visual card scene */
source?: {
type: "tweet" | "thread" | "news";
handle?: string;
name?: string;
text?: string;
isRoot?: boolean;
likes?: number | null;
retweets?: number | null;
replies?: number | null;
views?: number | null;
tweets?: { text: string; name?: string }[];
title?: string;
excerpt?: string;
author?: string;
source?: string;
};
};
/** Truncate at a word boundary (never cut mid-word). */
function clipWords(s: string, max: number): string {
if (s.length <= max) return s;
const cut = s.slice(0, max);
const sp = cut.lastIndexOf(" ");
return (sp > max * 0.5 ? cut.slice(0, sp) : cut).trim() + "…";
}
/** Detect a big number/stat worth showing as a stat scene. */
function extractStat(text: string): { value: string; label: string } | null {
const m = text.match(/(\$?\d+(?:[.,]\d+)?\s?[BMTK%]?)\s+(?:million|billion|trillion|percent|people|users|x)?/i);
if (m && /\d/.test(m[1]) && /[$%]|\d\s?[BMTK]\b/i.test(m[1])) {
const rest = text.replace(m[0], "").trim();
const label = clipWords(rest, 60);
if (label) return { value: m[1].trim(), label };
}
if (/\$?\d+(\.\d+)?\s?(billion|B|%|million|M)\b/i.test(text)) {
const m2 = text.match(/(\$?\d+(?:\.\d+)?\s?(?:billion|B|%|million|M))/i);
if (m2) {
const rest = text.replace(m2[0], "").trim();
return { value: m2[1], label: clipWords(rest, 60) || "…" };
}
}
return null;
}
/** Pick a scene for this segment (variety — pattern interrupt every cut). */
function pickScene(seg: Segment, index: number, seed = 0): Segment["scene"] | "caption" {
if (seg.scene) return seg.scene;
const label = (seg.label || "").toUpperCase();
const i = (index + seed) % 5;
// AI-tips scenes handled by worker override; here we only route the shared
// creative variety (bang for twist, bullets for insight) — HOOK stays title.
if (label === "THE MISTAKE" || label === "WHY IT FAILS") return "compare";
if (label === "THE FIX") return "fix";
if (label === "THE INSIGHT") return "bullets";
if (label === "THE CONFLICT") return "bang";
if (label === "HOOK" || label === "ON SCREEN") return "title";
if (label === "CTA") return "cta";
if (seg.source) return seg.source.type as "tweet" | "thread" | "news";
if (seg.stat) return "stat";
const stat = extractStat(seg.text);
if (stat) return "stat";
// creative scene variety (round 2): quote/podium/grid/scoreboard rotation
const j = (index + seed) % 8;
if (j === 3) return "quote";
if (j === 5) return "podium";
if (j === 6) return "gridlist";
if (j === 7) return "scoreboard";
if (i % 3 === 2) return "title";
return "caption";
}
/** One scene rendered for `duration` frames. */
const Scene: React.FC<{
seg: Segment;
index: number;
from: number;
seed?: number;
}> = ({ seg, index, from, seed = 0 }) => {
const { duration, label, text } = seg;
const scene = pickScene(seg, index, seed);
const stat = seg.stat || (scene === "stat" ? extractStat(text) : null);
const sentences =
seg.sentences && seg.sentences.length
? seg.sentences
: [{ w: text, start: 0, end: duration / 30 }];
const src = seg.source;
// creative scenes: big visual + compact caption strip below
if (scene === "terminal") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<TerminalScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "compare" || scene === "fix") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<CompareScene text={text} side={scene === "fix" ? "good" : "bad"} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "bullets") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<BulletScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "bang") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<BangScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "quote") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<QuoteScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "podium") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<PodiumScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "gridlist") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<GridListScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "scoreboard") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<ScoreboardScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "caption" || scene === "title" || scene === "cta") {
// regular scenes get an animated asset above the card (variety!)
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
{scene === "caption" || scene === "title" || scene === "cta" ? (
<AssetDecor index={index} seed={seed} text={text} label={label || ""} />
) : null}
<KineticTitle text={titleFor(seg)} size={54} />
<GlassCard>
<CaptionKaraoke sentences={sentences} size={44} />
</GlassCard>
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
{scene === "tweet" && src ? (
<TweetCard
handle={src.handle || ""}
name={src.name || src.handle || ""}
text={src.text || text}
likes={src.likes}
retweets={src.retweets}
replies={src.replies}
views={src.views}
/>
) : scene === "thread" && src?.tweets ? (
<ThreadCard handle={src.handle || ""} tweets={src.tweets} />
) : scene === "news" && src ? (
<NewsCard
title={src.title || text}
excerpt={src.excerpt}
author={src.author}
source={src.source}
/>
) : scene === "stat" && stat ? (
<StatCounter value={stat.value} label={stat.label} />
) : (
<KineticTitle text={titleFor(seg)} size={54} />
)}
<ProgressBar total={duration} />
{/* time code (subtle, right side) */}
<div
style={{
position: "absolute",
right: "6%",
bottom: "6.4%",
color: "rgba(255,255,255,0.42)",
fontSize: 24,
fontFamily: "monospace",
letterSpacing: "0.1em",
}}
>
{(from / 30).toFixed(1)}s
</div>
</SlideMotion>
</AbsoluteFill>
);
};
/** Section headline above the caption card. */
function titleFor(seg: Segment): string {
const label = (seg.label || "").toUpperCase();
const map: Record<string, string> = {
"THE STORY": "The story",
"THE CONFLICT": "The twist",
"THE INSIGHT": "The lesson",
};
if (map[label]) return map[label];
return label ? label.charAt(0) + label.slice(1).toLowerCase() : "";
}
/** Composition: each scene time-gated by Sequence (frame resets per scene). */
export const ShortsVideo: React.FC<{ segments: Segment[]; seed?: number }> = ({ segments, seed = 0 }) => {
let acc = 0;
return (
<>
{segments.map((s, i) => {
const from = acc;
acc += s.duration;
return (
<Sequence key={i} from={from} durationInFrames={s.duration}>
<Scene seg={s} index={i} from={from} seed={seed} />
</Sequence>
);
})}
</>
);
};
export const useTotalDuration = (segments: { duration: number }[]) =>
segments.reduce((a, s) => a + s.duration, 0);
+575
View File
@@ -0,0 +1,575 @@
import React from "react";
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
import { FONT_DISPLAY, FONT_BODY } from "./parts";
/* ═══════════════════════════════════════════════════════════════════════
REUSABLE MONO ASSETS — animated visual building blocks.
All monochrome flat, transform/opacity only (no blur/SVG filters → fast
software render). Each is a self-contained component sized to fit the
"asset zone" of a scene (typically placed above the caption card).
PITFALL (verified): a flex-row container whose children use `flex:1`
collapses to width 0 when it is itself a horizontal flex item (intrinsic
width = sum of flex-basis:0 items = 0). Always give root wrappers an
explicit width so assets render regardless of parent layout.
═══════════════════════════════════════════════════════════════════════ */
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
/** Growing bar chart — editorial "growth" visual. */
export const ChartBars: React.FC<{ bars?: number; delay?: number }> = ({ bars = 5, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const heights = [0.42, 0.66, 0.5, 0.82, 0.62, 0.92, 0.72, 0.55, 0.88, 0.6];
const MAX_H = 180;
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 18, height: MAX_H, width: "100%" }}>
{Array.from({ length: bars }).map((_, i) => {
const h = heights[(i + Math.floor(delay)) % heights.length];
const grow = interpolate(frame, [delay + i * 3, delay + i * 3 + 12], [0.05, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
const px = Math.max(2, Math.round(h * MAX_H * grow));
return (
<div
key={i}
style={{
flex: 1,
minWidth: 40,
height: px,
background: i % 2 === 0 ? "#ffffff" : "rgba(255,255,255,0.5)",
borderRadius: 6,
}}
/>
);
})}
</div>
);
};
/** Line chart that draws itself left→right (clip-width animation). */
export const ChartLine: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = interpolate(frame, [delay, delay + fps * 0.9], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<svg width={520} height={320} viewBox="0 0 520 320">
{[60, 140, 220].map((y) => (
<line key={y} x1={20} y1={y} x2={500} y2={y} stroke="rgba(255,255,255,0.15)" strokeWidth={1.5} />
))}
<path
d="M0 280 C 50 260, 90 200, 130 210 S 220 130, 260 150 S 350 60, 390 80 S 470 30, 520 40"
fill="none"
stroke="rgba(255,255,255,0.9)"
strokeWidth={5}
strokeLinecap="round"
/>
{/* reveal mask */}
<rect x={0} y={0} width={520 * (1 - p)} height={320} fill="#000" />
<circle cx={520} cy={40} r={7 + Math.sin(frame / 6) * 3} fill="#ffffff" opacity={p} />
</svg>
);
};
/** Ring/donut progress (percent). */
export const RingProgress: React.FC<{ pct?: number; size?: number; delay?: number }> = ({ pct = 0.72, size = 220, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 20, stiffness: 60 } });
const r = 80;
const c = 2 * Math.PI * r;
const filled = Math.max(0, Math.min(1, pct * p));
return (
<svg width={size} height={size} viewBox="0 0 200 200">
<circle cx={100} cy={100} r={r} fill="none" stroke="rgba(255,255,255,0.18)" strokeWidth={14} />
<circle
cx={100} cy={100} r={r} fill="none" stroke="#ffffff" strokeWidth={14} strokeLinecap="round"
strokeDasharray={`${c * filled} ${c}`}
transform="rotate(-90 100 100)"
/>
<text x={100} y={112} textAnchor="middle" fill="#fff" fontSize={52} fontFamily={FONT_DISPLAY} fontWeight={800}>
{Math.round(filled * 100)}
<tspan fontSize={30} fill="rgba(255,255,255,0.7)">%</tspan>
</text>
</svg>
);
};
/** Gauge with sweeping needle. */
export const Gauge: React.FC<{ value?: number; label?: string; delay?: number }> = ({ value = 0.62, label = "ACCURACY", delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const v = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 16, stiffness: 70 } });
const angle = -90 + 180 * value * v; // -90°..+90°
return (
<svg width={400} height={260} viewBox="0 0 400 260">
<path d="M 30 220 A 170 170 0 0 1 370 220" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={16} strokeLinecap="round" />
<path
d="M 30 220 A 170 170 0 0 1 370 220"
fill="none" stroke="#ffffff" strokeWidth={16} strokeLinecap="round"
pathLength={1} strokeDasharray={value * v} strokeDashoffset={0}
/>
<g transform={`rotate(${angle} 200 220)`}>
<line x1={200} y1={220} x2={200} y2={70} stroke="#ffffff" strokeWidth={6} strokeLinecap="round" />
<circle cx={200} cy={70} r={8} fill="#fff" />
</g>
<circle cx={200} cy={220} r={14} fill="#fff" />
<text x={200} y={150} textAnchor="middle" fill="#fff" fontSize={40} fontFamily={MONO}>
{Math.round(value * 100)}%
</text>
<text x={200} y={250} textAnchor="middle" fill="rgba(255,255,255,0.65)" fontSize={26} fontFamily={FONT_BODY} fontWeight={700} letterSpacing="0.2em">
{label}
</text>
</svg>
);
};
/** Scrolling ticker tape (endless marquee text). */
export const TickerTape: React.FC<{ text: string; speed?: number }> = ({ text, speed = 90 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame / fps;
const offset = (t * speed) % 50; // percent
const doubled = `${text} • ${text} • ${text}`;
return (
<div style={{ overflow: "hidden", whiteSpace: "nowrap", borderTop: "2px solid rgba(255,255,255,0.5)", borderBottom: "2px solid rgba(255,255,255,0.5)", padding: "14px 0" }}>
<div
style={{
display: "inline-block",
transform: `translateX(-${offset}%)`,
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 36, letterSpacing: "0.06em",
color: "#fff", textTransform: "uppercase",
}}
>
{doubled}
{doubled}
</div>
</div>
);
};
/** Big countdown / counter (rolling digits). */
export const Countdown: React.FC<{ end?: number; from?: number; delay?: number }> = ({ end = 3, from = 5, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = (frame - delay) / fps;
const val = Math.max(end, Math.round(from - t));
return (
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 240, lineHeight: 1, color: "#fff" }}>{val}</div>
<div style={{ fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, letterSpacing: "0.3em", color: "rgba(255,255,255,0.6)", marginTop: 10 }}>SECONDS</div>
</div>
);
};
/** Node graph + data pulse traveling an edge (AI/workflow vibe). */
export const NodeFlow: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = (frame - delay) / fps;
const pulse = (t % 1.4) / 1.4;
const nodes = [
{ x: 60, y: 160 },
{ x: 200, y: 60 },
{ x: 200, y: 260 },
{ x: 350, y: 160 },
];
const edges = [
[0, 1],
[0, 2],
[1, 3],
[2, 3],
];
return (
<svg width={420} height={320} viewBox="0 0 420 320">
{edges.map(([a, b], i) => {
const x1 = nodes[a].x, y1 = nodes[a].y, x2 = nodes[b].x, y2 = nodes[b].y;
// pulse position
const px = x1 + (x2 - x1) * pulse;
const py = y1 + (y2 - y1) * pulse;
return (
<g key={i}>
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgba(255,255,255,0.3)" strokeWidth={3} />
<circle cx={px} cy={py} r={6} fill="#fff" opacity={0.9} />
</g>
);
})}
{nodes.map((n, i) => (
<g key={i}>
<rect x={n.x - 24} y={n.y - 24} width={48} height={48} rx={12} fill="none" stroke="#fff" strokeWidth={3} />
<circle cx={n.x} cy={n.y} r={5} fill="#fff" />
</g>
))}
</svg>
);
};
/** Pulse bars — audio-equalizer energy. */
export const PulseBars: React.FC<{ bars?: number; delay?: number }> = ({ bars = 12, delay = 0 }) => {
const frame = useCurrentFrame();
const t = (frame - delay) / 60;
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, height: 220 }}>
{Array.from({ length: bars }).map((_, i) => {
const base = 0.25 + 0.3 * Math.abs(Math.sin(i * 1.7 + t * 4));
return (
<div
key={i}
style={{
width: 16, height: `${base * 100}%`, background: "#ffffff", borderRadius: 4,
transform: `scaleY(${0.4 + 0.6 * Math.abs(Math.sin(i * 1.3 + t * 3.2))})`,
transformOrigin: "center",
}}
/>
);
})}
</div>
);
};
/** Animated checklist — boxes tick one by one. */
export const Checklist: React.FC<{ items?: string[]; delay?: number }> = ({ items = ["PLAN", "BUILD", "SHIP"], delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{items.map((it, i) => {
const p = spring({ frame: Math.max(0, frame - delay - i * 14), fps, config: { damping: 16, stiffness: 100 } });
const done = p > 0.35;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 20, opacity: Math.max(0, Math.min(1, p * 1.4)) }}>
<div
style={{
width: 52, height: 52, borderRadius: 12,
border: "2px solid #fff",
display: "flex", alignItems: "center", justifyContent: "center",
background: done ? "#fff" : "transparent",
}}
>
{done && <svg width={30} height={30} viewBox="0 0 30 30"><path d="M7 16 L13 22 L23 9" stroke="#000" strokeWidth={5} fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</div>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 34, letterSpacing: "0.08em", color: "#fff" }}>
{it}
</div>
</div>
);
})}
</div>
);
};
/** HUD corner frame + crosshair center (tech overlay asset). */
export const HudFrame: React.FC<{ label?: string }> = ({ label = "SYSTEM" }) => {
const frame = useCurrentFrame();
const t = frame / 60;
const c = 36;
const gap = 26;
return (
<svg width={700} height={440} viewBox="0 0 700 440">
{[
`M ${gap} ${gap} L ${gap + c} ${gap} L ${gap + c} ${gap + c / 2} M ${gap} ${gap} L ${gap} ${gap + c} L ${gap + c / 2} ${gap + c}`,
`M ${700 - gap} ${gap} L ${700 - gap - c} ${gap} L ${700 - gap - c} ${gap + c / 2} M ${700 - gap} ${gap} L ${700 - gap} ${gap + c} L ${700 - gap - c / 2} ${gap + c}`,
`M ${gap} ${440 - gap} L ${gap + c} ${440 - gap} L ${gap + c} ${440 - gap - c / 2} M ${gap} ${440 - gap} L ${gap} ${440 - gap - c} L ${gap + c / 2} ${440 - gap - c}`,
`M ${700 - gap} ${440 - gap} L ${700 - gap - c} ${440 - gap} L ${700 - gap - c} ${440 - gap - c / 2} M ${700 - gap} ${440 - gap} L ${700 - gap} ${440 - gap - c} L ${700 - gap - c / 2} ${440 - gap - c}`,
].map((d, i) => (
<path key={i} d={d} fill="none" stroke="rgba(255,255,255,0.8)" strokeWidth={3} />
))}
{/* scanning line */}
<line x1={60} y1={60 + ((t * 90) % 320)} x2={640} y2={60 + ((t * 90) % 320)} stroke="rgba(255,255,255,0.25)" strokeWidth={1.5} />
{/* center crosshair */}
<circle cx={350} cy={220} r={40 + Math.sin(t * 2) * 6} fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth={2} />
<line x1={340} y1={220} x2={360} y2={220} stroke="#fff" strokeWidth={3} />
<line x1={350} y1={210} x2={350} y2={230} stroke="#fff" strokeWidth={3} />
<text x={350} y={412} textAnchor="middle" fill="rgba(255,255,255,0.7)" fontSize={26} fontFamily={MONO} letterSpacing="0.3em">
{label}
</text>
</svg>
);
};
/** Percent stat block — big value + thin bar (editorial). */
export const StatBlock: React.FC<{ value: string; sub: string; delay?: number }> = ({ value, sub, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 14, stiffness: 80 } });
return (
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 170, lineHeight: 1, color: "#fff", transform: `scale(${0.8 + pop * 0.2})` }}>
{value}
</div>
<div style={{ margin: "18px auto 0", width: "70%", height: 8, borderRadius: 99, background: "rgba(255,255,255,0.2)", overflow: "hidden" }}>
<div style={{ width: `${pop * 100}%`, height: "100%", background: "#fff", borderRadius: 99 }} />
</div>
<div style={{ fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, color: "rgba(255,255,255,0.8)", marginTop: 18, textTransform: "uppercase", letterSpacing: "0.12em" }}>
{sub}
</div>
</div>
);
};
/* ═══════════════════════════════════════════════════════════════════════
ROUND 2 — 11 more reusable assets (mono flat, transform-only).
═══════════════════════════════════════════════════════════════════════ */
/** Bouncy counter — digits roll up to a target value (rolling-wrap odometer). */
export const BouncyCount: React.FC<{ to?: number; label?: string; delay?: number; suffix?: string }> = ({ to = 100, label = "GOAL", delay = 0, suffix = "%" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 18, stiffness: 55 } });
const val = Math.round(to * p);
return (
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 190, lineHeight: 1, color: "#fff" }}>
{val}
<span style={{ fontSize: 70, color: "rgba(255,255,255,0.6)" }}>{suffix}</span>
</div>
<div style={{ fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, letterSpacing: "0.3em", color: "rgba(255,255,255,0.6)", textTransform: "uppercase", marginTop: 8 }}>
{label}
</div>
</div>
);
};
/** Split reveal — two words slide apart from a single point with a thin divider. */
export const SplitText: React.FC<{ a?: string; b?: string; delay?: number }> = ({ a = "BEFORE", b = "AFTER", delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 18, stiffness: 70 } });
const dist = 60 + (1 - p) * 220;
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 40, width: "100%" }}>
<div style={{ transform: `translateX(${-dist}px)`, opacity: p, fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 56, letterSpacing: "0.1em", color: "rgba(255,255,255,0.55)" }}>
{a}
</div>
<div style={{ width: 3, height: 90, background: "#fff", transform: `scaleY(${p})` }} />
<div style={{ transform: `translateX(${dist}px)`, opacity: p, fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 56, letterSpacing: "0.1em", color: "#fff" }}>
{b}
</div>
</div>
);
};
/** Dipping label — a pill badge that slides in with a "value" line underneath. */
export const Banner: React.FC<{ title?: string; sub?: string; delay?: number }> = ({ title = "KEY TAKEAWAY", sub = "SHIP EARLY, LEARN FAST", delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 16, stiffness: 90 } });
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 18 }}>
<div style={{ transform: `translateY(${(1 - p) * 60}px)`, opacity: p, fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 44, letterSpacing: "0.2em", color: "#fff", border: "2px solid rgba(255,255,255,0.8)", padding: "16px 34px", borderRadius: 99 }}>
{title}
</div>
<div style={{ opacity: Math.max(0, p * 2 - 1), fontFamily: FONT_BODY, fontWeight: 700, fontSize: 32, color: "rgba(255,255,255,0.75)", letterSpacing: "0.05em" }}>
{sub}
</div>
</div>
);
};
/** Track progress — vertical step ladder filling in order. */
export const TrackProgress: React.FC<{ steps?: string[]; delay?: number }> = ({ steps = ["01", "02", "03", "04"], delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = Math.max(0, (frame - delay) / fps);
const active = Math.min(steps.length, Math.floor(t * 1.6));
return (
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
{steps.map((s, i) => {
const on = i < active;
return (
<div key={i} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 10 }}>
<div style={{
width: 62, height: 62, borderRadius: 18, border: "2px solid #fff",
background: on ? "#fff" : "transparent", color: on ? "#000" : "#fff",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 26,
transform: on ? "scale(1.08)" : "scale(1)",
}}>{s}</div>
{i < steps.length - 1 && <div style={{ width: 26, height: 4, borderRadius: 99, background: on ? "#fff" : "rgba(255,255,255,0.3)" }} />}
</div>
);
})}
</div>
);
};
/** Cloud tag — a cluster of keyword pills drifting slowly. */
export const CloudTag: React.FC<{ tags?: string[]; delay?: number }> = ({ tags = ["AGENT", "MEMORY", "TOOLS", "PROMPTS"], delay = 0 }) => {
const frame = useCurrentFrame();
const t = (frame - delay) / 60;
return (
<div style={{ position: "relative", width: 600, height: 260, display: "flex", alignItems: "center", justifyContent: "center" }}>
{tags.map((tag, i) => {
const ang = (i / tags.length) * Math.PI * 2 + t * 0.22;
const x = 260 + Math.cos(ang) * 170;
const y = 130 + Math.sin(ang) * 75;
return (
<div key={i} style={{
position: "absolute", left: x - 70, top: y - 24,
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 28, letterSpacing: "0.06em",
color: "#fff", border: "2px solid rgba(255,255,255,0.7)", borderRadius: 99, padding: "12px 22px",
opacity: 0.75 + 0.25 * Math.sin(ang * 2),
}}>
{tag}
</div>
);
})}
{/* center dot */}
<div style={{ width: 14, height: 14, borderRadius: 99, background: "#fff", opacity: 0.9 }} />
</div>
);
};
/** Card fan — three tapering cards fanning out from a pivot (stacked deck). */
export const CardFan: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 16, stiffness: 70 } });
const cards = [
{ rot: -24, x: -120, txt: "IDEA" },
{ rot: 0, x: 0, txt: "BUILD" },
{ rot: 24, x: 120, txt: "SHIP" },
];
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: 300, width: "100%" }}>
{cards.map((c, i) => (
<div key={i} style={{
position: "absolute",
transform: `translateX(${c.x * p}px) rotate(${c.rot * p}deg)`,
width: 200, height: 280, borderRadius: 18,
border: "3px solid #fff", background: i === 1 ? "#fff" : "transparent",
color: i === 1 ? "#000" : "#fff",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 34, letterSpacing: "0.1em",
opacity: 0.4 + 0.6 * p,
}}>
{c.txt}
</div>
))}
</div>
);
};
/** Camera pip — viewfinder frame + REC dot pulsing + corner brackets. */
export const CameraPip: React.FC<{ label?: string; delay?: number }> = ({ label = "REC", delay = 0 }) => {
const frame = useCurrentFrame();
const t = Math.max(0, (frame - delay) / 60);
const blink = Math.floor(t * 2) % 2 === 0;
const c = 30, gap = 22;
return (
<div style={{ position: "relative", width: 560, height: 340, border: "3px solid rgba(255,255,255,0.5)", borderRadius: 16, display: "flex", alignItems: "center", justifyContent: "center" }}>
{[
`M ${gap} ${gap + c} L ${gap} ${gap} L ${gap + c} ${gap}`,
`M ${560 - gap - c} ${gap} L ${560 - gap} ${gap} L ${560 - gap} ${gap + c}`,
`M ${gap} ${340 - gap - c} L ${gap} ${340 - gap} L ${gap + c} ${340 - gap}`,
`M ${560 - gap - c} ${340 - gap} L ${560 - gap} ${340 - gap} L ${560 - gap} ${340 - gap - c}`,
].map((d, i) => (
<svg key={i} width={560} height={340} style={{ position: "absolute", inset: 0 }} viewBox="0 0 560 340">
<path d={d} fill="none" stroke="#fff" strokeWidth={4} />
</svg>
))}
<div style={{ opacity: blink ? 1 : 0.25, display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 18, height: 18, borderRadius: 99, background: "#fff" }} />
<div style={{ fontFamily: MONO, fontSize: 30, color: "#fff", letterSpacing: "0.2em" }}>{label}</div>
</div>
{/* rule-of-thirds grid */}
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", justifyContent: "space-evenly", padding: "0 10%" }}>
{[0, 1].map((i) => <div key={i} style={{ width: "100%", height: 1, background: "rgba(255,255,255,0.12)" }} />)}
</div>
</div>
);
};
/** World grid clock — 4 tiles with city:time, one highlighted cycling. */
export const WorldClock: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const t = Math.max(0, (frame - delay) / 60);
const active = Math.floor(t) % 4;
const cities = [
{ city: "JKT", off: 7 },
{ city: "NYC", off: -4 },
{ city: "LON", off: 1 },
{ city: "TYO", off: 9 },
];
const hh = (off: number) => ((9 + off + 24) % 24);
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, width: 520 }}>
{cities.map((c, i) => (
<div key={i} style={{
border: `2px solid ${i === active ? "#fff" : "rgba(255,255,255,0.25)"}`,
borderRadius: 14, padding: "18px 20px", textAlign: "center",
background: i === active ? "#fff" : "transparent",
color: i === active ? "#000" : "#fff",
}}>
<div style={{ fontFamily: MONO, fontSize: 30, letterSpacing: "0.1em" }}>{c.city}</div>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 46, lineHeight: 1.15 }}>{String(hh(c.off)).padStart(2, "0")}:00</div>
</div>
))}
</div>
);
};
/** Percentage hands — two pill hands meeting a rising % meter (score vibe). */
export const PercentageHands: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 18, stiffness: 60 } });
const val = Math.round(68 + 27 * p);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 24, width: "100%" }}>
<div style={{ display: "flex", gap: 26 }}>
{[0, 1].map((i) => (
<div key={i} style={{
width: 90, height: 150, borderRadius: 45,
border: "3px solid #fff", background: "transparent",
transform: `translateY(${(1 - p) * 90}px)`,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<div style={{ width: 40, height: i === 0 ? 26 : 20, borderRadius: 99, background: "#fff", transform: "rotate(-30deg)" }} />
<div style={{ width: 40, height: 22, borderRadius: 99, background: "#fff", marginLeft: -8, transform: "rotate(30deg)" }} />
</div>
))}
</div>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 90, lineHeight: 1, color: "#fff" }}>
{val}<span style={{ fontSize: 44, color: "rgba(255,255,255,0.6)" }}>%</span>
</div>
<div style={{ width: "70%", height: 8, borderRadius: 99, background: "rgba(255,255,255,0.2)", overflow: "hidden" }}>
<div style={{ width: `${val}%`, height: "100%", background: "#fff", borderRadius: 99 }} />
</div>
</div>
);
};
/** Padlock pulse — lock icon with expanding ripple ring + OPEN/CLOSED state. */
export const PadlockPulse: React.FC<{ closed?: boolean; delay?: number }> = ({ closed = false, delay = 0 }) => {
const frame = useCurrentFrame();
const t = Math.max(0, (frame - delay) / 60);
const ringR = 40 + (t % 2) * 46;
const ringO = Math.max(0, 1 - (t % 2)) * 0.8;
return (
<div style={{ position: "relative", width: 260, height: 280, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 20 }}>
<div style={{ position: "relative", width: 180, height: 180, display: "flex", alignItems: "center", justifyContent: "center" }}>
{/* ripple rings */}
{[0, 1].map((i) => {
const r = 40 + (((t + i * 1) % 2)) * 46;
const o = Math.max(0, 1 - ((t + i * 1) % 2)) * 0.7;
return (
<div key={i} style={{
position: "absolute", width: r * 2, height: r * 2, borderRadius: 99,
border: "2px solid rgba(255,255,255,0.6)", opacity: o,
}} />
);
})}
{/* lock body */}
<div style={{ width: 110, height: 84, borderRadius: 14, border: "3px solid #fff", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 14, height: 26, borderRadius: 99, background: "#fff", opacity: closed ? 1 : 0.35 }} />
</div>
{/* shackle */}
<div style={{ position: "absolute", top: 22, width: 66, height: 60, borderRadius: 33, border: "3px solid #fff", borderBottom: "none" }} />
</div>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 34, letterSpacing: "0.25em", color: "#fff" }}>
{closed ? "LOCKED" : "OPEN"}
</div>
</div>
);
};
+277
View File
@@ -0,0 +1,277 @@
import React from "react";
import { AbsoluteFill, useCurrentFrame, spring, useVideoConfig } from "remotion";
import { FONT_BODY, FONT_DISPLAY } from "./parts";
/* ─────────────────────────────────────────────────────────────────────────
Source-content cards — mono, flat, editorial.
Rendered as scenes between/around TTS caption scenes so the video shows
the actual tweet/thread/news, not just subtitles.
───────────────────────────────────────────────────────────────────────── */
const WHITE = "#ffffff";
const BLACK = "#000000";
const GREY = "#8a8a90";
function slug(s: string, n = 12): string {
return (s || "").length > n ? s.slice(0, n - 1) + "…" : s;
}
function splitLines(text: string, perLine = 34): string[] {
const words = (text || "").split(/\s+/).filter(Boolean);
const lines: string[] = [];
let cur = "";
for (const w of words) {
if ((cur + " " + w).trim().length > perLine) {
if (cur) lines.push(cur.trim());
cur = w;
} else {
cur = (cur + " " + w).trim();
}
}
if (cur) lines.push(cur.trim());
return lines.slice(0, 7); // cap
}
/** Staggered fade-up for card text blocks. */
function useStagger(total: number, step = 6) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } });
return (i: number) => {
const o = spring({ frame: frame - i * step, fps, config: { damping: 16, stiffness: 90 }, delay: 0 });
return {
opacity: Math.max(0, Math.min(1, frame - i * step) / 8),
transform: `translateY(${(1 - o) * 22}px)`,
};
};
}
/** ── Tweet / Thread card ─────────────────────────────────────────────── */
export const TweetCard: React.FC<{
handle: string;
name: string;
text: string;
likes?: number | null;
retweets?: number | null;
replies?: number | null;
views?: number | null;
}> = ({ handle, name, text, likes, retweets, replies, views }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } });
const count = (v?: number | null) => (v == null ? null : formatCount(v));
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
padding: "0 7%",
}}
>
<div
style={{
width: "100%",
background: WHITE,
borderRadius: 28,
padding: "42px 44px",
transform: `translateY(${(1 - pop) * 30}px) scale(${0.94 + pop * 0.06})`,
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
}}
>
{/* header: avatar initial + name/handle */}
<div style={{ display: "flex", alignItems: "center", marginBottom: 30 }}>
<div
style={{
width: 74,
height: 74,
borderRadius: 40,
background: BLACK,
color: WHITE,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 36,
fontWeight: 800,
fontFamily: FONT_DISPLAY,
marginRight: 22,
}}
>
{(name || handle || "?")[0].toUpperCase()}
</div>
<div>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 40, color: BLACK, lineHeight: 1.1 }}>
{slug(name, 22)}
</div>
<div style={{ fontFamily: FONT_BODY, fontWeight: 600, fontSize: 30, color: GREY }}>
@{handle}
</div>
</div>
</div>
{/* tweet body */}
<div
style={{
fontFamily: FONT_BODY,
fontWeight: 600,
fontSize: 46,
lineHeight: 1.35,
color: BLACK,
letterSpacing: "-0.01em",
marginBottom: 34,
whiteSpace: "pre-wrap",
}}
>
{text}
</div>
{/* stats row */}
<div style={{ display: "flex", gap: 40, fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, color: GREY }}>
{count(views) !== null && <span>▶ {count(views)}</span>}
{count(replies) !== null && <span>↩ {count(replies)}</span>}
{count(retweets) !== null && <span>↻ {count(retweets)}</span>}
{count(likes) !== null && <span>♥ {count(likes)}</span>}
</div>
</div>
</AbsoluteFill>
);
};
/** ── Thread card (multiple tweets stacked) ───────────────────────────── */
export const ThreadCard: React.FC<{
handle: string;
tweets: { text: string; name?: string }[];
}> = ({ handle, tweets }) => {
const anim = useStagger(tweets.length);
return (
<AbsoluteFill style={{ justifyContent: "center", padding: "0 6%" }}>
<div
style={{
fontFamily: FONT_DISPLAY,
fontWeight: 800,
fontSize: 38,
color: WHITE,
marginBottom: 24,
letterSpacing: "0.02em",
}}
>
🧵 @{handle} · thread
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{tweets.map((t, i) => (
<div
key={i}
style={{
background: i % 2 === 0 ? WHITE : "#e4e4e8",
borderRadius: 20,
padding: "24px 28px",
fontFamily: FONT_BODY,
fontWeight: 600,
fontSize: 36,
lineHeight: 1.3,
color: BLACK,
opacity: anim(i).opacity,
transform: anim(i).transform,
}}
>
{splitLines(t.text, 30).join("\n")}
</div>
))}
</div>
</AbsoluteFill>
);
};
/** ── News / article card ─────────────────────────────────────────────── */
export const NewsCard: React.FC<{
title: string;
excerpt?: string;
author?: string;
source?: string;
}> = ({ title, excerpt, author, source }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } });
const lines = splitLines(excerpt || title, 30);
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", padding: "0 7%" }}>
<div
style={{
width: "100%",
background: WHITE,
borderRadius: 28,
padding: "46px 44px",
transform: `translateY(${(1 - pop) * 30}px)`,
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
}}
>
{/* news kicker */}
<div
style={{
display: "inline-block",
background: BLACK,
color: WHITE,
fontFamily: FONT_BODY,
fontWeight: 800,
fontSize: 26,
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 18px",
borderRadius: 8,
marginBottom: 26,
}}
>
{source || "News"}
</div>
{/* headline */}
<div
style={{
fontFamily: FONT_DISPLAY,
fontWeight: 800,
fontSize: 54,
lineHeight: 1.15,
color: BLACK,
letterSpacing: "-0.02em",
marginBottom: 24,
}}
>
{splitLines(title, 26).join("\n")}
</div>
{/* excerpt */}
<div
style={{
fontFamily: FONT_BODY,
fontWeight: 500,
fontSize: 36,
lineHeight: 1.4,
color: "#3a3a40",
borderTop: "2px solid #000",
paddingTop: 22,
whiteSpace: "pre-wrap",
}}
>
{lines.join("\n")}
</div>
{/* footer */}
{(author || source) && (
<div style={{ fontFamily: FONT_BODY, fontWeight: 600, fontSize: 26, color: GREY, marginTop: 20 }}>
{author ? `— ${author}` : ""}
</div>
)}
</div>
</AbsoluteFill>
);
};
/** Helper: format numbers compactly (1234 → 1.2K). */
export function formatCount(n: number): string {
if (n == null) return "0";
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return String(n);
}
+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, seed: 0 }}
calculateMetadata={({ props }) => {
const segs = props.segments?.length ? props.segments : DEFAULT_SEGMENTS;
return { durationInFrames: useTotalDuration(segs) };
}}
/>
);
};
registerRoot(Shorts);
+916
View File
@@ -0,0 +1,916 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from "remotion";
import "@fontsource/inter/700.css";
import "@fontsource/montserrat/800.css";
/* ─────────────────────────────────────────────────────────────────────────
Shorts design system — MONOCHROME FLAT.
No gradients, no purple. Black & white, flat shapes, bold typography.
Fonts: Montserrat 800 (display), Inter 700 (captions/body).
Safe zone: 900×1400 centered (universal TikTok/Reels/Shorts).
Perf rule: NO blur / SVG noise filters (software Chrome ~10x slower).
───────────────────────────────────────────────────────────────────────── */
export const FONT_DISPLAY = "Montserrat, Inter, system-ui, sans-serif";
export const FONT_BODY = "Inter, system-ui, sans-serif";
/** Solid near-black scene backgrounds (subtle variety, still monochrome). */
const SCENES = [
{ bg: "#0d0d0f", motif: "circles" }, // concentric ring
{ bg: "#121216", motif: "plus" }, // plus marks
{ bg: "#101014", motif: "barcode" }, // vertical bars
{ bg: "#15151a", motif: "rings" }, // target rings
{ bg: "#0f0f12", motif: "diag" }, // diagonal ticks
{ bg: "#0c0c10", motif: "grid" }, // blueprint grid
{ bg: "#141418", motif: "dots" }, // dot matrix
{ bg: "#0e0e12", motif: "waves" }, // sine waves
{ bg: "#131317", motif: "stars" }, // rotating asterisks
{ bg: "#111115", motif: "zigzag" }, // zigzag bands
{ bg: "#0b0b0e", motif: "brackets" }, // blueprint corner frame
];
/** Flat mono background: solid color + faint white SVG motif per scene.
* Each motif animates with its own cheap (transform/opacity) motion. */
export const SvgBackground: React.FC<{ variant?: number }> = ({ variant = 0 }) => {
const { width, height } = useVideoConfig();
const frame = useCurrentFrame();
const t = frame / 60;
const s = SCENES[variant % SCENES.length];
const stroke = "rgba(255,255,255,0.15)";
const strokeSoft = "rgba(255,255,255,0.07)";
const W = width;
const H = height;
return (
<AbsoluteFill style={{ background: s.bg }}>
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
style={{ position: "absolute", inset: 0 }}
>
{s.motif === "circles" && (
<>
<g transform={`scale(${1 + Math.sin(t * 1.6) * 0.05}) translate(${W * (0.82 - 0.82 * Math.sin(t * 1.6) * 0.05)}, ${H * (0.2 - 0.2 * Math.sin(t * 1.6) * 0.05)})`}>
<circle cx={0} cy={0} r={240} fill="none" stroke={stroke} strokeWidth={1.5} />
<circle cx={0} cy={0} r={300} fill="none" stroke={strokeSoft} strokeWidth={1} />
</g>
<circle cx={W * 0.14} cy={H * 0.78} r={90} fill="none" stroke={strokeSoft} strokeWidth={1} />
</>
)}
{s.motif === "plus" && (
<>
{[0.12, 0.22, 0.5, 0.72, 0.88].map((x, i) => (
<g key={i} transform={`translate(${W * x}, ${H * (0.2 + i * 0.16) + Math.sin(t + i * 1.2) * 14})`} stroke={stroke} strokeWidth={2} opacity={0.55 + 0.45 * Math.sin(t + i * 1.2)}>
<line x1={-13} y1={0} x2={13} y2={0} />
<line x1={0} y1={-13} x2={0} y2={13} />
</g>
))}
</>
)}
{s.motif === "barcode" && (
<>
{[30, 70, 110, 150, 190, 230, 270, 310].map((x, i) => (
<rect key={i} x={x} y={H * 0.12} width={i % 3 === 0 ? 6 : 3} height={H * 0.3} fill={stroke} opacity={0.5 + 0.5 * Math.sin(t * 2 + i * 0.9)} />
))}
{[W - 330, W - 290, W - 250, W - 210, W - 170, W - 130, W - 90].map((x, i) => (
<rect key={i} x={x} y={H * 0.62} width={i % 2 === 0 ? 5 : 2} height={H * 0.26} fill={stroke} opacity={0.4 + 0.6 * Math.sin(t * 2.2 + i * 1.1)} />
))}
</>
)}
{s.motif === "rings" && (
<g transform={`scale(${1 + Math.sin(t * 0.9) * 0.04}) translate(${W * 0.5 * (1 - (1 + Math.sin(t * 0.9) * 0.04))}, ${H * 0.34 * (1 - (1 + Math.sin(t * 0.9) * 0.04))})`}>
<circle cx={W * 0.5} cy={H * 0.34} r={150} fill="none" stroke={stroke} strokeWidth={2} />
<circle cx={W * 0.5} cy={H * 0.34} r={120} fill="none" stroke={strokeSoft} strokeWidth={1.5} />
<circle cx={W * 0.5} cy={H * 0.34} r={90} fill="none" stroke={stroke} strokeWidth={1} />
</g>
)}
{s.motif === "diag" && (
<>
{[0, 1, 2, 3, 4, 5].map((i) => (
<line key={i} x1={W * 0.1 + i * 46 - ((t * 14) % 46)} y1={H * 0.05} x2={W * 0.25 + i * 46 - ((t * 14) % 46)} y2={H * 0.45} stroke={strokeSoft} strokeWidth={2} />
))}
{[0, 1, 2, 3].map((i) => (
<line key={i} x1={W * 0.68 + i * 40 + ((t * 10) % 40)} y1={H * 0.55} x2={W * 0.8 + i * 40 + ((t * 10) % 40)} y2={H * 0.95} stroke={stroke} strokeWidth={2} />
))}
</>
)}
{s.motif === "grid" && (
<>
{[0.15, 0.3, 0.45, 0.6, 0.75, 0.9].map((y, i) => (
<line key={i} x1={W * 0.06} y1={H * y} x2={W * 0.94} y2={H * y} stroke={strokeSoft} strokeWidth={1} />
))}
{[0.1, 0.25, 0.4, 0.55, 0.7, 0.85].map((x, i) => (
<line key={i} x1={W * x} y1={H * 0.06} x2={W * x} y2={H * 0.94} stroke={strokeSoft} strokeWidth={1} />
))}
<line x1={W * 0.06} y1={H * (0.15 + 0.15 * ((t * 0.4) % 1))} x2={W * 0.94} y2={H * (0.15 + 0.15 * ((t * 0.4) % 1))} stroke={stroke} strokeWidth={2} />
</>
)}
{s.motif === "dots" && (
<>
{[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map((x, i) => (
<g key={i}>
{[0.15, 0.3, 0.45, 0.6, 0.75, 0.9].map((y, j) => (
<circle key={j} cx={W * x} cy={H * y} r={2.6} fill={stroke} opacity={0.35 + 0.65 * Math.sin(t * 1.4 + i * 1.3 + j * 0.8)} />
))}
</g>
))}
</>
)}
{s.motif === "waves" && (
<>
<polyline
points={`${W * 0.02},${H * 0.3} ${W * 0.1},${H * (0.28 + Math.sin(t) * 0.03)} ${W * 0.25},${H * (0.32 + Math.sin(t + 0.8) * 0.03)} ${W * 0.4},${H * (0.28 + Math.sin(t + 1.6) * 0.03)} ${W * 0.55},${H * (0.32 + Math.sin(t + 2.4) * 0.03)} ${W * 0.7},${H * (0.28 + Math.sin(t + 3.2) * 0.03)} ${W * 0.85},${H * (0.32 + Math.sin(t + 4) * 0.03)} ${W * 0.98},${H * 0.3}`}
fill="none" stroke={stroke} strokeWidth={2}
/>
<polyline
points={`${W * 0.02},${H * 0.72} ${W * 0.15},${H * (0.74 + Math.sin(t * 1.3) * 0.03)} ${W * 0.3},${H * (0.7 + Math.sin(t * 1.3 + 1) * 0.03)} ${W * 0.5},${H * (0.74 + Math.sin(t * 1.3 + 2) * 0.03)} ${W * 0.65},${H * (0.7 + Math.sin(t * 1.3 + 3) * 0.03)} ${W * 0.8},${H * (0.74 + Math.sin(t * 1.3 + 4) * 0.03)} ${W * 0.98},${H * 0.72}`}
fill="none" stroke={strokeSoft} strokeWidth={1.5}
/>
</>
)}
{s.motif === "stars" && (
<>
{[
{ x: 0.16, y: 0.2, s: 30, ph: 0 },
{ x: 0.8, y: 0.28, s: 22, ph: 1.4 },
{ x: 0.3, y: 0.72, s: 26, ph: 2.6 },
{ x: 0.72, y: 0.8, s: 18, ph: 3.8 },
{ x: 0.88, y: 0.62, s: 34, ph: 0.7 },
].map((st, i) => (
<g key={i} transform={`translate(${W * st.x}, ${H * st.y}) rotate(${t * 16 + st.ph * 40})`} stroke={stroke} strokeWidth={2}>
{[0, 1, 2, 3, 4, 5].map((k) => (
<line key={k} x1={0} y1={-st.s * 0.5} x2={0} y2={st.s * 0.5} transform={`rotate(${k * 30})`} />
))}
</g>
))}
</>
)}
{s.motif === "zigzag" && (
<>
{[0.14, 0.3, 0.46].map((y, i) => (
<polyline
key={i}
points={`${-40 + ((t * 26) % 80)},${H * y} ${20 + ((t * 26) % 80)},${H * (y - 0.05)} ${80 + ((t * 26) % 80)},${H * y} ${140 + ((t * 26) % 80)},${H * (y - 0.05)} ${200 + ((t * 26) % 80)},${H * y} ${260 + ((t * 26) % 80)},${H * (y - 0.05)} ${320 + ((t * 26) % 80)},${H * y} ${380 + ((t * 26) % 80)},${H * (y - 0.05)} ${440 + ((t * 26) % 80)},${H * y} ${500 + ((t * 26) % 80)},${H * (y - 0.05)} ${560 + ((t * 26) % 80)},${H * y}`}
fill="none" stroke={i === 1 ? stroke : strokeSoft} strokeWidth={2}
/>
))}
</>
)}
{s.motif === "brackets" && (
<>
<g transform={`translate(${Math.sin(t * 0.6) * 6}, ${Math.cos(t * 0.6) * 6})`} stroke={stroke} strokeWidth={3} fill="none">
<path d={`M ${W * 0.04} ${H * 0.9} L ${W * 0.04} ${H * 0.08} L ${W * 0.1} ${H * 0.08}`} />
<path d={`M ${W * 0.96} ${H * 0.1} L ${W * 0.96} ${H * 0.92} L ${W * 0.9} ${H * 0.92}`} />
</g>
<circle cx={W * 0.5} cy={H * 0.5} r={Math.abs(Math.sin(t * 0.5)) * 90} fill="none" stroke={strokeSoft} strokeWidth={1} />
<circle cx={W * 0.5} cy={H * 0.5} r={Math.max(0, Math.sin(t * 0.5 + Math.PI)) * 90} fill="none" stroke={strokeSoft} strokeWidth={1} />
</>
)}
</svg>
{/* slow drift to keep the frame alive */}
<div
style={{
position: "absolute",
inset: 0,
transform: `translateY(${Math.sin(t * 0.8) * 10}px)`,
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
/** Flat card (solid white — inversion against the black background). */
export const GlassCard: React.FC<{
children: React.ReactNode;
style?: React.CSSProperties;
}> = ({ children, style }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 15, stiffness: 110 } });
return (
<div
style={{
position: "absolute",
left: "5%",
right: "5%",
bottom: "13%",
padding: "30px 34px",
borderRadius: 20,
background: "#f2f2f4",
transform: `translateY(${(1 - pop) * 26}px)`,
...style,
}}
>
{children}
</div>
);
};
/** Karaoke caption: active sentence full-black, others grey (on white card). */
export const CaptionKaraoke: React.FC<{
sentences: { w: string; start: number; end: number }[];
size?: number;
}> = ({ sentences, size = 46 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const now = frame / fps;
let activeIdx = 0;
for (let i = 0; i < sentences.length; i++) {
if (now >= sentences[i].start) { activeIdx = i; break; }
}
return (
<div
style={{
fontFamily: FONT_BODY,
fontWeight: 700,
fontSize: size,
lineHeight: 1.3,
color: "#000000",
letterSpacing: "-0.01em",
}}
>
{sentences.map((s, i) => (
<span key={i} style={{ opacity: i === activeIdx ? 1 : 0.35 }}>
{s.w}
{i < sentences.length - 1 ? " " : ""}
</span>
))}
</div>
);
};
/** Big display title, word-by-word kinetic entrance, accent = inverted block. */
export const KineticTitle: React.FC<{
text: string;
size?: number;
accentIndex?: number;
accentColor?: string;
position?: "top" | "center";
}> = ({ text, size = 88, accentIndex = -1, position = "top" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 13, stiffness: 100 } });
const words = text.split(" ");
const dur = Math.max(1, Math.floor(fps * 0.15));
return (
<div
style={{
position: "absolute",
left: "5%",
right: "5%",
top: position === "center" ? "30%" : "12%",
transform: `translateY(${(1 - pop) * 40}px)`,
}}
>
{words.map((w, i) => (
<span
key={i}
style={{
display: "inline-block",
marginRight: "0.18em",
marginBottom: "0.08em",
fontFamily: FONT_DISPLAY,
fontWeight: 800,
fontSize: size,
lineHeight: 1.1,
letterSpacing: "-0.03em",
color: i === accentIndex ? "#000000" : "#ffffff",
background: i === accentIndex ? "#ffffff" : "transparent",
padding: i === accentIndex ? "0 0.1em 0.02em" : "0",
borderRadius: 10,
opacity: interpolate(frame, [i * dur, i * dur + dur * 0.5], [0, 1], {
extrapolateRight: "clamp",
}),
transform: `translateY(${interpolate(frame, [i * dur, i * dur + dur * 0.6], [26, 0], {
extrapolateRight: "clamp",
})}px)`,
}}
>
{w}
</span>
))}
</div>
);
};
/** Huge stat with a thin rectangle frame behind (flat, editorial). */
export const StatCounter: React.FC<{
value: string;
label: string;
}> = ({ value, label }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
return (
<div
style={{
position: "absolute",
left: "10%",
right: "10%",
top: "26%",
textAlign: "center",
transform: `scale(${0.85 + pop * 0.15})`,
}}
>
<div
style={{
position: "absolute",
inset: "-28px -40px",
border: "2px solid rgba(255,255,255,0.85)",
borderRadius: 16,
transform: `rotate(${(1 - pop) * 6}deg)`,
}}
/>
<div
style={{
position: "absolute",
inset: "-16px -24px",
border: "1px solid rgba(255,255,255,0.25)",
borderRadius: 12,
transform: `translate(${(1 - pop) * 10}px, ${(1 - pop) * 10}px)`,
}}
/>
<div
style={{
position: "relative",
fontFamily: FONT_DISPLAY,
fontWeight: 800,
fontSize: 134,
color: "#ffffff",
lineHeight: 1,
letterSpacing: "-0.04em",
}}
>
{value}
</div>
<div
style={{
position: "relative",
fontFamily: FONT_BODY,
fontWeight: 700,
fontSize: 34,
color: "rgba(255,255,255,0.85)",
marginTop: 22,
lineHeight: 1.3,
textTransform: "uppercase",
letterSpacing: "0.02em",
}}
>
{label}
</div>
</div>
);
};
/** Top-left scene label (flat outline pill). */
export const BrandTag: React.FC<{ label: string }> = ({ label }) => (
<div
style={{
position: "absolute",
left: "6%",
top: "6%",
padding: "10px 22px",
borderRadius: 999,
border: "2px solid #ffffff",
color: "#ffffff",
fontSize: 28,
fontWeight: 700,
fontFamily: FONT_BODY,
letterSpacing: "0.14em",
textTransform: "uppercase",
background: "transparent",
}}
>
{label}
</div>
);
/** Bottom progress bar — solid white fill on faint track. */
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: "6%",
height: 7,
borderRadius: 99,
background: "rgba(255,255,255,0.18)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 99,
background: "#ffffff",
}}
/>
</div>
);
};
/** Slow ken-burns style camera motion wrapper (transform only — cheap). */
export const SlideMotion: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame / fps;
const y = Math.sin(t * 1.4) * 12;
const scale = 1 + Math.sin(t * 0.8) * 0.012;
return (
<AbsoluteFill style={{ transform: `translateY(${y}px) scale(${scale})` }}>
{children}
</AbsoluteFill>
);
};
/* ═══════════════════════════════════════════════════════════════════════
CREATIVE SCENES — visual variety beyond the caption card.
All monochrome flat, transform/opacity only (no blur/SVG filters).
═══════════════════════════════════════════════════════════════════════ */
/** Monospace terminal typeface. */
const FONT_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
/** Line icons drawn with strokes (animate in via opacity/offset). */
export const LineIcon: React.FC<{
kind: "check" | "cross" | "node" | "bolt";
size?: number;
delay?: number;
}> = ({ kind, size = 64, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame: frame - delay, fps, config: { damping: 16, stiffness: 120 } });
const s = size;
const stroke = "currentColor";
return (
<svg
width={s}
height={s}
viewBox="0 0 64 64"
style={{ color: "#ffffff", opacity: pop, transform: `scale(${0.7 + pop * 0.3})` }}
>
{kind === "check" && (
<>
<circle cx={32} cy={32} r={27} fill="none" stroke={stroke} strokeWidth={4} />
<path d="M20 33 L28 41 L45 23" fill="none" stroke={stroke} strokeWidth={5} strokeLinecap="round" strokeLinejoin="round" />
</>
)}
{kind === "cross" && (
<>
<circle cx={32} cy={32} r={27} fill="none" stroke={stroke} strokeWidth={4} />
<path d="M23 23 L41 41 M41 23 L23 41" fill="none" stroke={stroke} strokeWidth={5} strokeLinecap="round" />
</>
)}
{kind === "node" && (
<>
<circle cx={16} cy={20} r={7} fill="none" stroke={stroke} strokeWidth={3} />
<circle cx={36} cy={44} r={7} fill="none" stroke={stroke} strokeWidth={3} />
<circle cx={50} cy={16} r={7} fill="none" stroke={stroke} strokeWidth={3} />
<path d="M20 25 L32 38 M32 38 L44 22 M22 18 L44 16" fill="none" stroke={stroke} strokeWidth={3} />
</>
)}
{kind === "bolt" && (
<path d="M36 8 L16 36 L30 36 L26 56 L48 26 L33 26 Z" fill="none" stroke={stroke} strokeWidth={4} strokeLinejoin="round" />
)}
</svg>
);
};
/** Terminal scene — "$" prompt + typed commands, blinking cursor (AI-agent vibe). */
export const TerminalScene: React.FC<{ text: string; size?: number }> = ({ text, size = 40 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Split into command line(s) + output lines (roughly 4-6 words each)
const words = text.split(" ");
const lines: string[] = [];
let cur = "";
for (const w of words) {
if ((cur + " " + w).trim().split(" ").length > 5) { lines.push(cur.trim()); cur = w; }
else cur = (cur + " " + w).trim();
}
if (cur) lines.push(cur.trim());
const cmd = lines[0] || "$ agent --fix";
const output = lines.slice(1);
// typing effect for command
const typeDur = Math.max(6, Math.floor(fps * 0.8)); // frames to type full command
const chars = Math.min(cmd.length, Math.floor((frame / typeDur) * cmd.length));
const typed = cmd.slice(0, chars);
const blink = frame % (fps * 1.1) < fps * 0.55;
const pop = spring({ frame, fps, config: { damping: 15, stiffness: 100 } });
return (
<div
style={{
position: "absolute",
left: "7%",
right: "7%",
top: "24%",
background: "#000000",
border: "2px solid #ffffff",
borderRadius: 18,
padding: "34px 30px",
fontFamily: FONT_MONO,
transform: `translateY(${(1 - pop) * 24}px)`,
}}
>
<div style={{ display: "flex", gap: 10, marginBottom: 22 }}>
{[0, 1, 2].map((i) => (
<div key={i} style={{ width: 14, height: 14, borderRadius: 99, border: "2px solid #fff", opacity: 1 - i * 0.25 }} />
))}
</div>
<div style={{ fontSize: size, color: "#ffffff", lineHeight: 1.45, letterSpacing: "-0.01em" }}>
<span style={{ color: "rgba(255,255,255,0.55)" }}>$ </span>
{typed}
<span style={{ opacity: blink ? 1 : 0, background: "#fff", color: "#000", padding: "0 3px", marginLeft: 2 }}>▌</span>
</div>
{output.map((l, i) => {
const o = Math.max(0, Math.min(1, (frame - typeDur - i * 10) / 12));
return (
<div key={i} style={{ fontSize: size * 0.62, color: "rgba(255,255,255,0.78)", lineHeight: 1.5, opacity: o, marginTop: 12 }}>
{l}
</div>
);
})}
</div>
);
};
/** DON'T (✗) vs DO (✓) — split compare panel. */
export const CompareScene: React.FC<{ text: string; side: "bad" | "good"; size?: number }> = ({ text, side, size = 38 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 15, stiffness: 100 } });
const words = text.split(" ");
const chunk = Math.max(3, Math.ceil(words.length / 2));
const halves = [words.slice(0, chunk).join(" "), words.slice(chunk).join(" ")];
const good = side === "good";
return (
<div
style={{
position: "absolute",
left: "6%",
right: "6%",
top: "26%",
display: "flex",
alignItems: "stretch",
gap: 18,
transform: `translateY(${(1 - pop) * 30}px)`,
}}
>
<div
style={{
flex: 1,
border: `2px solid ${good ? "rgba(255,255,255,0.35)" : "#ffffff"}`,
borderRadius: 16,
padding: "30px 22px",
background: good ? "transparent" : "#ffffff",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 18,
opacity: good ? 0.5 : 1,
}}
>
<LineIcon kind="cross" size={52} />
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 26, letterSpacing: "0.12em",
color: good ? "#fff" : "#000",
}}
>
DON'T
</div>
</div>
<div style={{ alignSelf: "center", fontFamily: FONT_DISPLAY, fontSize: 44, color: "#fff" }}>→</div>
<div
style={{
flex: 1.4,
border: `2px solid ${good ? "#ffffff" : "rgba(255,255,255,0.35)"}`,
borderRadius: 16,
padding: "30px 22px",
background: good ? "#ffffff" : "transparent",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 18,
opacity: good ? 1 : 0.5,
}}
>
<LineIcon kind="check" size={52} />
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 26, letterSpacing: "0.12em",
color: good ? "#000" : "#fff",
}}
>
DO THIS
</div>
</div>
<div
style={{
position: "absolute",
left: 0, right: 0, bottom: -16,
textAlign: "center",
}}
/>
</div>
);
};
/** Numbered bullet list (for insights: "1. one idea 2. …") — animated per bullet. */
export const BulletScene: React.FC<{ text: string; size?: number }> = ({ text, size = 40 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
const per = Math.max(3, Math.ceil(words.length / 3));
const bullets = [
words.slice(0, per).join(" "),
words.slice(per, per * 2).join(" "),
words.slice(per * 2).join(" "),
].filter(Boolean);
return (
<div
style={{
position: "absolute",
left: "8%",
right: "8%",
top: "24%",
display: "flex",
flexDirection: "column",
gap: 26,
}}
>
{bullets.map((b, i) => {
const start = i * 10;
const o = Math.max(0, Math.min(1, (frame - start) / 12));
const y = (1 - o) * 30;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 20, opacity: o, transform: `translateY(${y}px)` }}>
<div
style={{
width: 62, height: 62, borderRadius: 14, border: "2px solid #fff",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 32, color: "#fff",
background: "#000",
}}
>
{i + 1}
</div>
<div style={{ flex: 1, fontFamily: FONT_BODY, fontWeight: 700, fontSize: size * 0.72, lineHeight: 1.35, color: "#ffffff" }}>
{b}
</div>
</div>
);
})}
</div>
);
};
/** Bang scene — one keyword bursts center-stage with radial rays (the twist). */
export const BangScene: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
const keyword = words.length > 4 ? words.slice(0, 3).join(" ") : text;
const rest = words.length > 4 ? words.slice(3).join(" ") : "";
const boom = spring({ frame, fps, config: { damping: 12, stiffness: 90 } });
const rays = 12;
return (
<div style={{ position: "absolute", left: 0, right: 0, top: "30%", textAlign: "center" }}>
{/* radial rays */}
<svg width={560} height={560} viewBox="0 0 560 560" style={{ position: "absolute", left: "50%", top: "50%", transform: "translate(-50%,-50%)", opacity: 0.9 * boom }}>
{Array.from({ length: rays }).map((_, i) => {
const a = (i / rays) * Math.PI * 2;
const x1 = 280 + Math.cos(a) * 90;
const y1 = 280 + Math.sin(a) * 90;
const x2 = 280 + Math.cos(a) * (240 + (i % 3) * 20);
const y2 = 280 + Math.sin(a) * (240 + (i % 3) * 20);
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgba(255,255,255,0.5)" strokeWidth={3} />;
})}
</svg>
<div
style={{
position: "relative",
fontFamily: FONT_DISPLAY, fontWeight: 800,
fontSize: 92, lineHeight: 1.05, letterSpacing: "-0.03em",
color: "#ffffff",
transform: `scale(${0.7 + boom * 0.3})`,
}}
>
{keyword}
</div>
{rest && (
<div
style={{
position: "relative",
marginTop: 26, padding: "14px 30px", display: "inline-block",
background: "#ffffff", color: "#000000", borderRadius: 12,
fontFamily: FONT_BODY, fontWeight: 700, fontSize: 32,
opacity: boom,
}}
>
{rest}
</div>
)}
</div>
);
};
/** Compact karaoke caption strip — subtitle support below the big visual. */
export const MiniKaraoke: React.FC<{
sentences: { w: string; start: number; end: number }[];
size?: number;
}> = ({ sentences, size = 26 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const now = frame / fps;
let activeIdx = 0;
for (let i = 0; i < sentences.length; i++) {
if (now >= sentences[i].start) { activeIdx = i; break; }
}
return (
<div
style={{
position: "absolute",
left: "8%", right: "8%", bottom: "10.5%",
textAlign: "center",
fontFamily: FONT_BODY, fontWeight: 700, fontSize: size,
lineHeight: 1.35, color: "rgba(255,255,255,0.92)",
textShadow: "0 2px 0 rgba(0,0,0,0.9)",
}}
>
{sentences.map((s, i) => (
<span key={i} style={{ opacity: i === activeIdx ? 1 : 0.4 }}>
{s.w}
{i < sentences.length - 1 ? " " : ""}
</span>
))}
</div>
);
};
/* ═══════════════════════════════════════════════════════════════════════
SCENE ROUND 2 — 4 more creative scene types (mono flat).
═══════════════════════════════════════════════════════════════════════ */
/** Quote scene — oversized quotation mark + emphasized excerpt + attribution. */
export const QuoteScene: React.FC<{ text: string; size?: number }> = ({ text, size = 50 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 16, stiffness: 70 } });
const words = text.split(" ");
const quote = words.length > 8 ? words.slice(0, -3).join(" ") : text;
const byline = words.length > 8 ? words.slice(-3).join(" ") : "";
return (
<div style={{ position: "absolute", left: "10%", right: "10%", top: "24%", textAlign: "center" }}>
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 190, lineHeight: 0.6,
color: "rgba(255,255,255,0.25)", transform: `translateY(${(1 - p) * 40}px)`,
}}
>
&ldquo;
</div>
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size, lineHeight: 1.2,
letterSpacing: "-0.02em", color: "#ffffff", marginTop: -20,
opacity: p,
}}
>
{quote}
</div>
{byline && (
<div
style={{
marginTop: 26, display: "inline-block", borderTop: "2px solid rgba(255,255,255,0.6)",
paddingTop: 14, fontFamily: FONT_BODY, fontWeight: 700, fontSize: 26,
color: "rgba(255,255,255,0.75)", letterSpacing: "0.12em", textTransform: "uppercase",
opacity: p,
}}
>
— {byline}
</div>
)}
</div>
);
};
/** Podium scene — 1-2-3 columns with the middle one elevated (ranking). */
export const PodiumScene: React.FC<{ text: string; size?: number }> = ({ text, size = 36 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 16, stiffness: 80 } });
const words = text.split(" ");
const picks = words.length >= 3 ? [words[0], words[1], words[2]] : ["TOP", "3", "PICKS"];
const heights = [200, 280, 150];
return (
<div style={{ position: "absolute", left: 0, right: 0, top: "26%", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: 30 }}>
{picks.map((w, i) => (
<div key={i} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 14 }}>
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size,
color: i === 1 ? "#000" : "#fff", textAlign: "center",
transform: `translateY(${(1 - p) * 80}px)`, opacity: p,
width: 210, lineHeight: 1.15,
}}
>
{w}
</div>
<div
style={{
width: 210, height: 70 + (heights[i] - 70) * p,
background: i === 1 ? "#fff" : "rgba(255,255,255,0.25)",
border: "2px solid #fff", borderRadius: 14,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 44, color: i === 1 ? "#000" : "#fff",
}}
>
{i + 1}
</div>
</div>
))}
</div>
);
};
/** Grid list — 2×2 keyword tiles popping in with check corners. */
export const GridListScene: React.FC<{ text: string; size?: number }> = ({ text, size = 34 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
const tiles = words.length >= 4 ? words.slice(0, 4) : ["AGENT", text.slice(0, 12).toUpperCase(), "TOOLS", "MEMORY"];
return (
<div style={{ position: "absolute", left: "14%", right: "14%", top: "30%", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
{tiles.map((w, i) => {
const p = spring({ frame: Math.max(0, frame - i * 6), fps, config: { damping: 16, stiffness: 100 } });
const r = i % 2 === 0 ? 14 : 20;
return (
<div
key={i}
style={{
border: "2px solid rgba(255,255,255,0.9)", borderRadius: 18,
minHeight: 140, display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size,
color: "#fff", textAlign: "center", padding: "0 14px",
transform: `scale(${0.4 + p * 0.6}) rotate(${(r - 14) * (1 - p)}deg)`,
opacity: p,
}}
>
{w}
</div>
);
})}
</div>
);
};
/** Scoreboard — Q&A panel (question highlighted, answer slides in). */
export const ScoreboardScene: React.FC<{ text: string; size?: number }> = ({ text, size = 38 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 16, stiffness: 75 } });
const words = text.split(" ");
const q = words.slice(0, Math.ceil(words.length / 2)).join(" ");
const a = words.slice(Math.ceil(words.length / 2)).join(" ");
return (
<div style={{ position: "absolute", left: "10%", right: "10%", top: "26%" }}>
<div
style={{
background: "#fff", color: "#000", borderRadius: 18, padding: "26px 34px",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size, lineHeight: 1.2,
transform: `translateY(${(1 - p) * 60}px)`, opacity: p,
}}
>
<span style={{ opacity: 0.5, fontSize: size - 14, marginRight: 14 }}>Q</span>
{q}
</div>
<div
style={{
marginTop: 22, border: "2px solid #fff", borderRadius: 18, padding: "26px 34px",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size - 6, lineHeight: 1.2,
color: "#fff", transform: `translateY(${(1 - p) * 60}px)`, opacity: Math.max(0, p * 2 - 1),
}}
>
<span style={{ opacity: 0.6, fontSize: size - 14, marginRight: 14 }}>A</span>
{a}
</div>
</div>
);
};