Compare commits
23
Commits
2cfa033ed9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4601f612a4 | ||
|
|
3b019b2462 | ||
|
|
fb9d3667b7 | ||
|
|
2c92ec3bff | ||
|
|
20ecbf1f98 | ||
|
|
005d9880e6 | ||
|
|
a992d73eda | ||
|
|
e7fc629e54 | ||
|
|
897c33eeac | ||
|
|
85ce16d6d7 | ||
|
|
9c6ea226f8 | ||
|
|
4a61766ee4 | ||
|
|
1db00c4182 | ||
|
|
c0849dde62 | ||
|
|
33e85ce8bf | ||
|
|
2a051875f3 | ||
|
|
e194f37dbb | ||
|
|
92b5af7d9d | ||
|
|
bf30e3910e | ||
|
|
9d527d749e | ||
|
|
5a30971ded | ||
|
|
b5ba9bdf96 | ||
|
|
c66dde5bc1 |
No files matched your search
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
@@ -45,3 +45,4 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
/services/client-pulse-bot/dist/
|
||||
/tmp/checkcat.mjs
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -2,7 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Vercel-compatible settings
|
||||
output: undefined, // default — Vercel handles this automatically
|
||||
output: "standalone", // Nix deploy bundles the standalone server
|
||||
images: {
|
||||
unoptimized: false,
|
||||
},
|
||||
|
||||
Generated
+2471
-85
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -15,7 +15,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^1.6.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@remotion/cli": "^4.0.527",
|
||||
"@remotion/google-fonts": "^4.0.527",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/formidable": "^3.4.6",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
@@ -32,7 +36,8 @@
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0"
|
||||
"recharts": "^3.7.0",
|
||||
"remotion": "^4.0.527"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -133,6 +133,7 @@ model YoutubeIdea {
|
||||
funnelStage String?
|
||||
status String @default("pending")
|
||||
rejectedReason String?
|
||||
category String? @default("brands")
|
||||
sourceUrl String?
|
||||
sourceSummary String?
|
||||
competitionScore String? // "gap" | "moderate" | "crowded"
|
||||
@@ -165,6 +166,15 @@ model YoutubeScript {
|
||||
seoChapters Json?
|
||||
titleVariants Json?
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -179,6 +189,22 @@ model YoutubeFeedback {
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
Config.setOverwriteOutput(true);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(/&/g, "&").replace(/ /g, " ").replace(/"/g, '"').replace(/'/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));
|
||||
@@ -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]))
|
||||
@@ -45,8 +45,9 @@ function fetchArticle(url: string): string {
|
||||
}
|
||||
|
||||
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 });
|
||||
const isTips = category === "ai-agent-tips";
|
||||
|
||||
const apiKey = process.env.OPENAI_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)
|
||||
- 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
|
||||
- Content: founder branding, marketing, distribution > product, culture > features
|
||||
- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable
|
||||
${pastScriptsRef}
|
||||
${feedbackContext}
|
||||
- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable`}
|
||||
${pastScriptsRef}
|
||||
${feedbackContext}
|
||||
|
||||
OUTPUT FORMAT — valid JSON only:
|
||||
{
|
||||
"title": "Brand/Person — Core Lesson (short)",
|
||||
"hook": "Opening 2-4 lines. Scroll-stopping.",
|
||||
"storySetup": "The backstory context. 3-5 sentences.",
|
||||
"conflict": "The tension pivot or controversial statement. 3-5 sentences.",
|
||||
"insight": "The aha moment payoff lesson for founders. 3-5 sentences.",
|
||||
"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.${isTips ? " The situation: why builders get this wrong." : ""}",
|
||||
"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.${isTips ? " THE concrete fix/pattern — specific, actionable, names the tool/technique." : ""}",
|
||||
"cta": "One clear action. One line.",
|
||||
"caption": "One punchy line without spoilers.",
|
||||
"onScreenText": "What appears on screen during the hook",
|
||||
@@ -284,6 +290,7 @@ Return JSON: { "results": [{ "claim": "...", "verdict": "correct|incorrect|uncer
|
||||
sourceUrls: factCheck.sourceUrls,
|
||||
},
|
||||
status: "draft",
|
||||
category: category || "brands",
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
}
|
||||
@@ -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 braveKey = process.env.BRAVE_API_KEY || "";
|
||||
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 */ }
|
||||
|
||||
// -- STEP 1: Ask GPT to generate search queries --
|
||||
const queryGenResult = callOpenAI(apiKey, {
|
||||
model: "gpt-4o-mini",
|
||||
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.
|
||||
const querySystem = isTips
|
||||
? `You generate search queries to find practical, actionable AI AGENT tips and best practices 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.
|
||||
|
||||
@@ -81,8 +94,15 @@ DO NOT search for stories already covered: ${coveredTopics}
|
||||
DO NOT search for ideas already generated: ${existingTitles}
|
||||
${rejectionContext}
|
||||
|
||||
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." }
|
||||
Output JSON: {"queries":["search query 1","search query 2",...]}`;
|
||||
|
||||
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,
|
||||
response_format: { type: "json_object" }
|
||||
@@ -104,10 +124,23 @@ Output JSON: {"queries":["search query 1","search query 2",...]}` },
|
||||
).join("\n\n");
|
||||
|
||||
// -- STEP 3: Feed real articles to GPT to create ideas --
|
||||
const ideaGenResult = callOpenAI(apiKey, {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search.
|
||||
const ideaSystem = isTips
|
||||
? `You create viral short-form video IDEAS about practical AI AGENT tips for builders, based on REAL articles 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:
|
||||
- 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
|
||||
${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"}]}` },
|
||||
{ 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.` }
|
||||
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"}]}`;
|
||||
|
||||
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,
|
||||
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,
|
||||
hookType: idea.hookType || null,
|
||||
funnelStage: idea.funnelStage || null,
|
||||
category,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/youtube/render/file?scriptId=xxx&kind=video|thumb
|
||||
* Streams the rendered MP4 or thumbnail JPG from the local render dir.
|
||||
* Only serves files for scripts that are in a rendered state.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const scriptId = req.nextUrl.searchParams.get("scriptId");
|
||||
const kind = req.nextUrl.searchParams.get("kind") || "video";
|
||||
if (!scriptId) return NextResponse.json({ error: "scriptId required" }, { status: 400 });
|
||||
|
||||
const script = await prisma.youtubeScript.findUnique({ where: { id: scriptId } });
|
||||
if (!script) return NextResponse.json({ error: "script not found" }, { status: 404 });
|
||||
|
||||
const filePath = kind === "thumb" ? script.thumbnailUrl : script.videoUrl;
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return NextResponse.json({ error: "render not ready" }, { status: 404 });
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = ext === ".jpg" ? "image/jpeg" : ext === ".mp4" ? "video/mp4" : "application/octet-stream";
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
// Range support for <video> seeking (bytes=start-end)
|
||||
const range = req.headers.get("range");
|
||||
if (range) {
|
||||
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
||||
const start = m && m[1] ? parseInt(m[1], 10) : 0;
|
||||
const end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1;
|
||||
if (start >= stat.size || start > end) {
|
||||
return new NextResponse(null, { status: 416, headers: { "Content-Range": `bytes */${stat.size}` } });
|
||||
}
|
||||
const stream = fs.createReadStream(filePath, { start, end });
|
||||
return new NextResponse(stream as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(end - start + 1),
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return new NextResponse(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(stat.size),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/youtube/render {scriptId}
|
||||
* Queue a script for shorts rendering: status approved → render_queued.
|
||||
* The standalone worker (hermyhq-render.service) picks it up and renders.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const { scriptId } = await req.json().catch(() => ({}));
|
||||
if (!scriptId) return NextResponse.json({ error: "scriptId required" }, { status: 400 });
|
||||
|
||||
const script = await prisma.youtubeScript.findUnique({ where: { id: scriptId } });
|
||||
if (!script) return NextResponse.json({ error: "script not found" }, { status: 404 });
|
||||
if (script.status !== "approved") {
|
||||
return NextResponse.json(
|
||||
{ error: `script must be 'approved' to render (current: ${script.status})` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.youtubeScript.update({
|
||||
where: { id: scriptId },
|
||||
data: { status: "render_queued" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, scriptId, status: updated.status });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/youtube/render?scriptId=xxx
|
||||
* Current render status + file paths for a script.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const scriptId = req.nextUrl.searchParams.get("scriptId");
|
||||
if (!scriptId) return NextResponse.json({ error: "scriptId required" }, { status: 400 });
|
||||
|
||||
const render = await prisma.youtubeRender.findUnique({ where: { scriptId } });
|
||||
const script = await prisma.youtubeScript.findUnique({ where: { id: scriptId } });
|
||||
if (!script) return NextResponse.json({ error: "script not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
scriptId,
|
||||
scriptStatus: script.status,
|
||||
videoUrl: script.videoUrl,
|
||||
thumbnailUrl: script.thumbnailUrl,
|
||||
render: render
|
||||
? {
|
||||
status: render.status,
|
||||
durationMs: render.durationMs,
|
||||
error: render.error,
|
||||
metadata: render.metadata,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export async function POST(req: NextRequest) {
|
||||
fullScript: body.fullScript || null,
|
||||
status: body.status || "draft",
|
||||
rejectedReason: body.rejectedReason || null,
|
||||
category: body.category || "brands",
|
||||
// SEO Package
|
||||
seoTitle: body.seoTitle || null,
|
||||
seoDescription: body.seoDescription || null,
|
||||
|
||||
+192
-9
@@ -22,15 +22,17 @@ const LongformTab = dynamic(() => import("@/app/longform/page"), { ssr: false, l
|
||||
const OutlierFeed = dynamic(() => import("@/components/OutlierFeed"), { ssr: false, loading: () => <TabSkeleton /> });
|
||||
|
||||
interface Idea {
|
||||
id: string;
|
||||
title: string;
|
||||
hook: string;
|
||||
angle: string;
|
||||
hook?: string;
|
||||
angle?: string;
|
||||
hookType?: string;
|
||||
funnelStage?: string;
|
||||
rejectedReason?: string;
|
||||
status?: "pending" | "rejected" | "approved";
|
||||
category?: string;
|
||||
sourceUrl?: string;
|
||||
sourceSummary?: string;
|
||||
rejectedReason?: string;
|
||||
status?: "pending" | "rejected" | "approved";
|
||||
}
|
||||
|
||||
interface Script {
|
||||
@@ -48,7 +50,7 @@ interface Script {
|
||||
rejectedReason?: string;
|
||||
factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] };
|
||||
fullScript?: string;
|
||||
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected";
|
||||
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected" | "rendered" | "render_queued";
|
||||
createdAt: string;
|
||||
// SEO Package
|
||||
seoTitle?: string;
|
||||
@@ -57,6 +59,9 @@ interface Script {
|
||||
seoChapters?: { time: string; label: string }[];
|
||||
titleVariants?: string[];
|
||||
thumbnailConcept?: string;
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// 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 [scripts, setScripts] = useState<Script[]>([]);
|
||||
const [activeView, setActiveView] = useState<"longform" | "shorts" | "performance" | "outliers">("longform");
|
||||
const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "tofilm" | "filmed">("ideas");
|
||||
const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "renders" | "tofilm" | "filmed">("ideas");
|
||||
const [generatingIdeas, setGeneratingIdeas] = useState(false);
|
||||
const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set());
|
||||
const [batchGenerating, setBatchGenerating] = useState(false);
|
||||
const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending");
|
||||
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 rejectRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -136,7 +142,7 @@ export default function YouTubePage() {
|
||||
const res = await fetch("/api/youtube/generate", {
|
||||
method: "POST",
|
||||
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) {
|
||||
const data = await res.json();
|
||||
@@ -202,6 +208,33 @@ export default function YouTubePage() {
|
||||
const rejectedScripts = scripts.filter(s => s.status === "rejected");
|
||||
const tofilmScripts = scripts.filter(s => s.status === "tofilm");
|
||||
const filmedScripts = scripts.filter(s => s.status === "filmed");
|
||||
const renderedScripts = scripts.filter(s => s.status === "rendered");
|
||||
const renderQueuedScripts = scripts.filter(s => s.status === "render_queued");
|
||||
const renderAll = [...renderQueuedScripts, ...renderedScripts];
|
||||
const [rendersBusy, setRendersBusy] = useState<Set<string>>(new Set());
|
||||
|
||||
async function queueRender(id: string) {
|
||||
setRendersBusy(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await fetch("/api/youtube/render", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scriptId: id }),
|
||||
});
|
||||
if (res.ok) fetchScripts();
|
||||
else alert((await res.json().catch(() => ({}))).error || "Render queue failed");
|
||||
} catch { /* empty */ }
|
||||
setRendersBusy(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
}
|
||||
|
||||
async function setRenderStatus(id: string, status: "tofilm" | "rejected" | "draft") {
|
||||
await fetch("/api/youtube/scripts", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
fetchScripts();
|
||||
}
|
||||
|
||||
// small ghost copy button
|
||||
const copyBtn = "text-[10px] px-2 py-0.5 rounded-[var(--r-sm)] bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--line)] hover:text-[var(--text)] transition-colors";
|
||||
@@ -421,6 +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" && (
|
||||
<>
|
||||
<button
|
||||
@@ -490,9 +581,10 @@ export default function YouTubePage() {
|
||||
{/* SHORTS SUB-NAV */}
|
||||
{activeView === "shorts" && (
|
||||
<div className="flex gap-2 mt-4 mb-2">
|
||||
{([
|
||||
{([
|
||||
{ key: "ideas" as const, label: "💡 Ideas", count: ideas.length },
|
||||
{ key: "scripts" as const, label: "📜 Scripts", count: draftScripts.length },
|
||||
{ key: "renders" as const, label: "🎞 Renders", count: renderAll.length },
|
||||
{ key: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length },
|
||||
{ key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length },
|
||||
]).map(tab => (
|
||||
@@ -540,11 +632,24 @@ export default function YouTubePage() {
|
||||
}}
|
||||
>{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}
|
||||
onClick={async () => {
|
||||
setGeneratingIdeas(true);
|
||||
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();
|
||||
} catch { /* empty */ }
|
||||
setGeneratingIdeas(false);
|
||||
@@ -666,6 +771,84 @@ export default function YouTubePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RENDERS VIEW */}
|
||||
{activeView === "shorts" && shortsView === "renders" && (
|
||||
<div className="space-y-3">
|
||||
{renderAll.length === 0 ? (
|
||||
<Panel>
|
||||
<EmptyState icon={<span className="text-3xl">🎞️</span>} title="No renders yet" hint="Approve a script, then hit ✨ Render Shorts" />
|
||||
</Panel>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{renderAll.map(s => {
|
||||
const isRendered = s.status === "rendered";
|
||||
const isQueued = s.status === "render_queued";
|
||||
return (
|
||||
<Panel key={s.id} className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-[var(--text)] line-clamp-2 leading-snug">{s.title}</p>
|
||||
{isQueued && (
|
||||
<span className="shrink-0 text-[9px] px-2 py-1 rounded-full animate-pulse" style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 14%, transparent)" }}>
|
||||
⏳ rendering
|
||||
</span>
|
||||
)}
|
||||
{isRendered && (
|
||||
<span className="shrink-0 text-[9px] px-2 py-1 rounded-full" style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 14%, transparent)" }}>
|
||||
✓ rendered
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRendered ? (
|
||||
<div className="rounded-[var(--r-md)] overflow-hidden border border-[var(--line)] bg-black aspect-[9/16]">
|
||||
<video
|
||||
src={`/api/youtube/render/file?scriptId=${s.id}&kind=video`}
|
||||
poster={`/api/youtube/render/file?scriptId=${s.id}&kind=thumb`}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--r-md)] border border-[var(--line)] bg-[var(--surface-2)] aspect-[9/16] flex items-center justify-center">
|
||||
<span className="text-xs text-[var(--text-3)] animate-pulse">🎬 Rendering…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isRendered && (
|
||||
<>
|
||||
<a
|
||||
href={`/api/youtube/render/file?scriptId=${s.id}&kind=video`}
|
||||
download={`${s.title.slice(0, 40).replace(/[^a-z0-9]+/gi, "-")}.mp4`}
|
||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
|
||||
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
|
||||
>⬇ Download</a>
|
||||
<button
|
||||
onClick={() => setRenderStatus(s.id, "tofilm")}
|
||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
|
||||
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
|
||||
>✓ Approved → Film</button>
|
||||
<button
|
||||
onClick={() => setRenderStatus(s.id, "rejected")}
|
||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors"
|
||||
style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }}
|
||||
>✗ Reject</button>
|
||||
</>
|
||||
)}
|
||||
{isQueued && (
|
||||
<Button size="sm" variant="ghost" onClick={() => updateScript(s.id, { status: "approved" })}>↩ Cancel</Button>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TO FILM VIEW */}
|
||||
{activeView === "shorts" && shortsView === "tofilm" && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)`,
|
||||
}}
|
||||
>
|
||||
“
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user