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
|
next-env.d.ts
|
||||||
.env*.local
|
.env*.local
|
||||||
/services/client-pulse-bot/dist/
|
/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 = {
|
const nextConfig: NextConfig = {
|
||||||
// Vercel-compatible settings
|
// Vercel-compatible settings
|
||||||
output: undefined, // default — Vercel handles this automatically
|
output: "standalone", // Nix deploy bundles the standalone server
|
||||||
images: {
|
images: {
|
||||||
unoptimized: false,
|
unoptimized: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2471
-85
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -15,7 +15,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.6.0",
|
"@auth/prisma-adapter": "^1.6.0",
|
||||||
|
"@fontsource/inter": "^5.3.0",
|
||||||
|
"@fontsource/montserrat": "^5.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@remotion/cli": "^4.0.527",
|
||||||
|
"@remotion/google-fonts": "^4.0.527",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/formidable": "^3.4.6",
|
"@types/formidable": "^3.4.6",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
@@ -32,7 +36,8 @@
|
|||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0",
|
||||||
|
"remotion": "^4.0.527"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ model YoutubeIdea {
|
|||||||
funnelStage String?
|
funnelStage String?
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
rejectedReason String?
|
rejectedReason String?
|
||||||
|
category String? @default("brands")
|
||||||
sourceUrl String?
|
sourceUrl String?
|
||||||
sourceSummary String?
|
sourceSummary String?
|
||||||
competitionScore String? // "gap" | "moderate" | "crowded"
|
competitionScore String? // "gap" | "moderate" | "crowded"
|
||||||
@@ -165,6 +166,15 @@ model YoutubeScript {
|
|||||||
seoChapters Json?
|
seoChapters Json?
|
||||||
titleVariants Json?
|
titleVariants Json?
|
||||||
thumbnailConcept String?
|
thumbnailConcept String?
|
||||||
|
// Render output (shorts)
|
||||||
|
videoUrl String?
|
||||||
|
thumbnailUrl String?
|
||||||
|
render YoutubeRender?
|
||||||
|
// Source content (tweet/thread/news) rendered as visual cards
|
||||||
|
sourceUrl String?
|
||||||
|
sourceData Json?
|
||||||
|
// Content category (brands | ai-agent-tips | ...)
|
||||||
|
category String? @default("brands")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -179,6 +189,22 @@ model YoutubeFeedback {
|
|||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model YoutubeRender {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scriptId String @unique
|
||||||
|
script YoutubeScript @relation(fields: [scriptId], references: [id], onDelete: Cascade)
|
||||||
|
status String @default("queued") // queued | rendering | rendered | failed
|
||||||
|
videoPath String?
|
||||||
|
thumbnailPath String?
|
||||||
|
durationMs Int?
|
||||||
|
error String?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
model LongformScript {
|
model LongformScript {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
|
|||||||
@@ -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) {
|
export async function POST(req: NextRequest) {
|
||||||
const { topic, sourceUrl, sourceSummary } = await req.json();
|
const { topic, sourceUrl, sourceSummary, category } = await req.json();
|
||||||
if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 });
|
if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 });
|
||||||
|
const isTips = category === "ai-agent-tips";
|
||||||
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY || "";
|
const apiKey = process.env.OPENAI_API_KEY || "";
|
||||||
const braveKey = process.env.BRAVE_API_KEY || "";
|
const braveKey = process.env.BRAVE_API_KEY || "";
|
||||||
@@ -171,20 +172,25 @@ ${sourceContext || "No sources available — be EXTRA cautious with claims, hedg
|
|||||||
- Include a "factClaims" array listing every specific factual claim in the script (dates, numbers, events, quotes)
|
- Include a "factClaims" array listing every specific factual claim in the script (dates, numbers, events, quotes)
|
||||||
- Each claim MUST be traceable to the source material above
|
- Each claim MUST be traceable to the source material above
|
||||||
|
|
||||||
## THE USER'S ANGLE
|
## THE USER'S ANGLE${isTips ? `
|
||||||
|
- Builds and ships AI agents (coding agents, MCP, agent loops) — the tips are for OTHER builders
|
||||||
|
- Voice: direct, first-principles, tells you the mistake you are probably making
|
||||||
|
- Every tip must be concrete and actionable — no fluff
|
||||||
|
- The viewer is technical: they write code and wire tools together
|
||||||
|
- Ground each tip in a real failure/lesson: "I ran this in production and learned X"` : `
|
||||||
- Runs a marketing-focused business
|
- Runs a marketing-focused business
|
||||||
- Content: founder branding, marketing, distribution > product, culture > features
|
- Content: founder branding, marketing, distribution > product, culture > features
|
||||||
- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable
|
- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable`}
|
||||||
${pastScriptsRef}
|
${pastScriptsRef}
|
||||||
${feedbackContext}
|
${feedbackContext}
|
||||||
|
|
||||||
OUTPUT FORMAT — valid JSON only:
|
OUTPUT FORMAT — valid JSON only:
|
||||||
{
|
{
|
||||||
"title": "Brand/Person — Core Lesson (short)",
|
"title": "Brand/Person — Core Lesson (short)",
|
||||||
"hook": "Opening 2-4 lines. Scroll-stopping.",
|
"hook": "Opening 2-4 lines. Scroll-stopping.${isTips ? " Lead with the COMMON MISTAKE or counter-intuitive truth about AI agents." : ""}",
|
||||||
"storySetup": "The backstory context. 3-5 sentences.",
|
"storySetup": "The backstory context. 3-5 sentences.${isTips ? " The situation: why builders get this wrong." : ""}",
|
||||||
"conflict": "The tension pivot or controversial statement. 3-5 sentences.",
|
"conflict": "The tension pivot or controversial statement. 3-5 sentences.${isTips ? " The failure mode / what everyone does that backfires." : ""}",
|
||||||
"insight": "The aha moment payoff lesson for founders. 3-5 sentences.",
|
"insight": "The aha moment payoff lesson for founders. 3-5 sentences.${isTips ? " THE concrete fix/pattern — specific, actionable, names the tool/technique." : ""}",
|
||||||
"cta": "One clear action. One line.",
|
"cta": "One clear action. One line.",
|
||||||
"caption": "One punchy line without spoilers.",
|
"caption": "One punchy line without spoilers.",
|
||||||
"onScreenText": "What appears on screen during the hook",
|
"onScreenText": "What appears on screen during the hook",
|
||||||
@@ -284,6 +290,7 @@ Return JSON: { "results": [{ "claim": "...", "verdict": "correct|incorrect|uncer
|
|||||||
sourceUrls: factCheck.sourceUrls,
|
sourceUrls: factCheck.sourceUrls,
|
||||||
},
|
},
|
||||||
status: "draft",
|
status: "draft",
|
||||||
|
category: category || "brands",
|
||||||
createdAt: new Date().toISOString().split("T")[0],
|
createdAt: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,11 @@ function braveSearch(query: string, braveKey: string, count = 5): { title: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST(req?: Request) {
|
||||||
|
const body = req ? await req.json().catch(() => ({})) : {};
|
||||||
|
const category: string = body.category || "brands";
|
||||||
|
const isTips = category === "ai-agent-tips";
|
||||||
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY || "";
|
const apiKey = process.env.OPENAI_API_KEY || "";
|
||||||
const braveKey = process.env.BRAVE_API_KEY || "";
|
const braveKey = process.env.BRAVE_API_KEY || "";
|
||||||
if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 });
|
if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 });
|
||||||
@@ -65,10 +69,19 @@ export async function POST() {
|
|||||||
try { coveredTopics = fs.readFileSync(SCRIPTS_FILE, "utf-8").match(/### .+/g)?.join(", ") || ""; } catch { /* ok - file may not exist on Vercel */ }
|
try { coveredTopics = fs.readFileSync(SCRIPTS_FILE, "utf-8").match(/### .+/g)?.join(", ") || ""; } catch { /* ok - file may not exist on Vercel */ }
|
||||||
|
|
||||||
// -- STEP 1: Ask GPT to generate search queries --
|
// -- STEP 1: Ask GPT to generate search queries --
|
||||||
const queryGenResult = callOpenAI(apiKey, {
|
const querySystem = isTips
|
||||||
model: "gpt-4o-mini",
|
? `You generate search queries to find practical, actionable AI AGENT tips and best practices for a viral short-form video creator.
|
||||||
messages: [
|
|
||||||
{ role: "system", content: `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator.
|
The creator makes videos for builders/developers who ship AI agents (coding agents, MCP servers, tool-using LLMs, agent loops).
|
||||||
|
Topics to cover: agent architecture, tool design, context window management, evals/testing, prompt engineering for agents, MCP, sub-agents, cost control, reliability/failure modes, memory, permissions/safety, orchestration patterns.
|
||||||
|
|
||||||
|
Generate 10 diverse search queries for REAL, concrete tips (engineering blogs, docs, HN discussions, practitioner posts). Prefer "best practices", "lessons learned", "common mistakes", "how to", "patterns".
|
||||||
|
|
||||||
|
DO NOT search for ideas already generated: ${existingTitles}
|
||||||
|
${rejectionContext}
|
||||||
|
|
||||||
|
Output JSON: {"queries":["search query 1","search query 2",...]}`
|
||||||
|
: `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator.
|
||||||
|
|
||||||
The creator covers a WIDE mix: mainstream brands (Red Bull, Nike, Barbie, Tesla, Apple, McDonald's, etc.), tech, pop culture, and business strategy. Most videos are about mainstream brands with a marketing/founder lesson.
|
The creator covers a WIDE mix: mainstream brands (Red Bull, Nike, Barbie, Tesla, Apple, McDonald's, etc.), tech, pop culture, and business strategy. Most videos are about mainstream brands with a marketing/founder lesson.
|
||||||
|
|
||||||
@@ -81,8 +94,15 @@ DO NOT search for stories already covered: ${coveredTopics}
|
|||||||
DO NOT search for ideas already generated: ${existingTitles}
|
DO NOT search for ideas already generated: ${existingTitles}
|
||||||
${rejectionContext}
|
${rejectionContext}
|
||||||
|
|
||||||
Output JSON: {"queries":["search query 1","search query 2",...]}` },
|
Output JSON: {"queries":["search query 1","search query 2",...]}`;
|
||||||
{ role: "user", content: "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." }
|
|
||||||
|
const queryGenResult = callOpenAI(apiKey, {
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: querySystem },
|
||||||
|
{ role: "user", content: isTips
|
||||||
|
? "10 diverse search queries for real, actionable AI agent tips and lessons."
|
||||||
|
: "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." }
|
||||||
],
|
],
|
||||||
temperature: 0.95,
|
temperature: 0.95,
|
||||||
response_format: { type: "json_object" }
|
response_format: { type: "json_object" }
|
||||||
@@ -104,10 +124,23 @@ Output JSON: {"queries":["search query 1","search query 2",...]}` },
|
|||||||
).join("\n\n");
|
).join("\n\n");
|
||||||
|
|
||||||
// -- STEP 3: Feed real articles to GPT to create ideas --
|
// -- STEP 3: Feed real articles to GPT to create ideas --
|
||||||
const ideaGenResult = callOpenAI(apiKey, {
|
const ideaSystem = isTips
|
||||||
model: "gpt-4o-mini",
|
? `You create viral short-form video IDEAS about practical AI AGENT tips for builders, based on REAL articles found via web search.
|
||||||
messages: [
|
|
||||||
{ role: "system", content: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search.
|
CRITICAL RULES:
|
||||||
|
- Each idea MUST be one concrete, actionable tip/lesson from the search results below
|
||||||
|
- Each idea MUST include a sourceUrl from the search results
|
||||||
|
- Each idea MUST include a sourceSummary (1-2 sentence summary of the real tip/lesson)
|
||||||
|
- Hook must stop the scroll in 3 seconds — lead with a common MISTAKE or counter-intuitive truth
|
||||||
|
- Examples of strong hooks: "Your AI agent fails because of one missing tool", "Stop giving your agent 40 tools", "Nobody tells you this about MCP servers"
|
||||||
|
- The angle must name a SPECIFIC technique/tool/pattern (MCP, tool schemas, evals, context pruning, sub-agents...) — never vague "use AI better"
|
||||||
|
- Mix funnel stages: 5 TOF (wide builder appeal), 3 MOF (specific workflows), 2 BOF (advanced architecture)
|
||||||
|
- DO NOT repeat these existing ideas: ${existingTitles}
|
||||||
|
- Each hook should have normal punctuation (commas, periods)
|
||||||
|
${rejectionContext}
|
||||||
|
|
||||||
|
Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"specific technique/pattern","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real tip"}]}`
|
||||||
|
: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search.
|
||||||
|
|
||||||
CRITICAL RULES:
|
CRITICAL RULES:
|
||||||
- Each idea MUST be based on a REAL story from the search results below
|
- Each idea MUST be based on a REAL story from the search results below
|
||||||
@@ -124,8 +157,15 @@ CRITICAL RULES:
|
|||||||
- Always name the SPECIFIC brand/person — never be vague
|
- Always name the SPECIFIC brand/person — never be vague
|
||||||
${rejectionContext}
|
${rejectionContext}
|
||||||
|
|
||||||
Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}` },
|
Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}`;
|
||||||
{ role: "user", content: `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` }
|
|
||||||
|
const ideaGenResult = callOpenAI(apiKey, {
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: ideaSystem },
|
||||||
|
{ role: "user", content: isTips
|
||||||
|
? `Based on these REAL articles found via web search, create 10 AI AGENT TIP video ideas. Each idea MUST be one concrete tip and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.`
|
||||||
|
: `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` }
|
||||||
],
|
],
|
||||||
temperature: 0.85,
|
temperature: 0.85,
|
||||||
response_format: { type: "json_object" }
|
response_format: { type: "json_object" }
|
||||||
@@ -149,6 +189,7 @@ Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scrol
|
|||||||
angle: idea.angle || null,
|
angle: idea.angle || null,
|
||||||
hookType: idea.hookType || null,
|
hookType: idea.hookType || null,
|
||||||
funnelStage: idea.funnelStage || null,
|
funnelStage: idea.funnelStage || null,
|
||||||
|
category,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
fullScript: body.fullScript || null,
|
||||||
status: body.status || "draft",
|
status: body.status || "draft",
|
||||||
rejectedReason: body.rejectedReason || null,
|
rejectedReason: body.rejectedReason || null,
|
||||||
|
category: body.category || "brands",
|
||||||
// SEO Package
|
// SEO Package
|
||||||
seoTitle: body.seoTitle || null,
|
seoTitle: body.seoTitle || null,
|
||||||
seoDescription: body.seoDescription || null,
|
seoDescription: body.seoDescription || null,
|
||||||
|
|||||||
+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 /> });
|
const OutlierFeed = dynamic(() => import("@/components/OutlierFeed"), { ssr: false, loading: () => <TabSkeleton /> });
|
||||||
|
|
||||||
interface Idea {
|
interface Idea {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
hook: string;
|
hook?: string;
|
||||||
angle: string;
|
angle?: string;
|
||||||
hookType?: string;
|
hookType?: string;
|
||||||
funnelStage?: string;
|
funnelStage?: string;
|
||||||
rejectedReason?: string;
|
category?: string;
|
||||||
status?: "pending" | "rejected" | "approved";
|
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
sourceSummary?: string;
|
sourceSummary?: string;
|
||||||
|
rejectedReason?: string;
|
||||||
|
status?: "pending" | "rejected" | "approved";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Script {
|
interface Script {
|
||||||
@@ -48,7 +50,7 @@ interface Script {
|
|||||||
rejectedReason?: string;
|
rejectedReason?: string;
|
||||||
factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] };
|
factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] };
|
||||||
fullScript?: string;
|
fullScript?: string;
|
||||||
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected";
|
status: "draft" | "approved" | "tofilm" | "filmed" | "rejected" | "rendered" | "render_queued";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
// SEO Package
|
// SEO Package
|
||||||
seoTitle?: string;
|
seoTitle?: string;
|
||||||
@@ -57,6 +59,9 @@ interface Script {
|
|||||||
seoChapters?: { time: string; label: string }[];
|
seoChapters?: { time: string; label: string }[];
|
||||||
titleVariants?: string[];
|
titleVariants?: string[];
|
||||||
thumbnailConcept?: string;
|
thumbnailConcept?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// funnel stage tone: TOF = accent, MOF = warn, BOF = up
|
// funnel stage tone: TOF = accent, MOF = warn, BOF = up
|
||||||
@@ -73,12 +78,13 @@ export default function YouTubePage() {
|
|||||||
const [generatedScript, setGeneratedScript] = useState<Script | null>(null);
|
const [generatedScript, setGeneratedScript] = useState<Script | null>(null);
|
||||||
const [scripts, setScripts] = useState<Script[]>([]);
|
const [scripts, setScripts] = useState<Script[]>([]);
|
||||||
const [activeView, setActiveView] = useState<"longform" | "shorts" | "performance" | "outliers">("longform");
|
const [activeView, setActiveView] = useState<"longform" | "shorts" | "performance" | "outliers">("longform");
|
||||||
const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "tofilm" | "filmed">("ideas");
|
const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "renders" | "tofilm" | "filmed">("ideas");
|
||||||
const [generatingIdeas, setGeneratingIdeas] = useState(false);
|
const [generatingIdeas, setGeneratingIdeas] = useState(false);
|
||||||
const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set());
|
const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set());
|
||||||
const [batchGenerating, setBatchGenerating] = useState(false);
|
const [batchGenerating, setBatchGenerating] = useState(false);
|
||||||
const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending");
|
const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending");
|
||||||
const [scriptTab, setScriptTab] = useState<"draft" | "approved" | "rejected">("draft");
|
const [scriptTab, setScriptTab] = useState<"draft" | "approved" | "rejected">("draft");
|
||||||
|
const [ideaCategory, setIdeaCategory] = useState<"brands" | "ai-agent-tips">("brands");
|
||||||
const [rejectModal, setRejectModal] = useState<{ type: "idea" | "script"; id: string; title: string } | null>(null);
|
const [rejectModal, setRejectModal] = useState<{ type: "idea" | "script"; id: string; title: string } | null>(null);
|
||||||
const rejectRef = React.useRef<HTMLTextAreaElement>(null);
|
const rejectRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -136,7 +142,7 @@ export default function YouTubePage() {
|
|||||||
const res = await fetch("/api/youtube/generate", {
|
const res = await fetch("/api/youtube/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary }),
|
body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary, category: idea.category || ideaCategory }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -202,6 +208,33 @@ export default function YouTubePage() {
|
|||||||
const rejectedScripts = scripts.filter(s => s.status === "rejected");
|
const rejectedScripts = scripts.filter(s => s.status === "rejected");
|
||||||
const tofilmScripts = scripts.filter(s => s.status === "tofilm");
|
const tofilmScripts = scripts.filter(s => s.status === "tofilm");
|
||||||
const filmedScripts = scripts.filter(s => s.status === "filmed");
|
const filmedScripts = scripts.filter(s => s.status === "filmed");
|
||||||
|
const renderedScripts = scripts.filter(s => s.status === "rendered");
|
||||||
|
const renderQueuedScripts = scripts.filter(s => s.status === "render_queued");
|
||||||
|
const renderAll = [...renderQueuedScripts, ...renderedScripts];
|
||||||
|
const [rendersBusy, setRendersBusy] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
async function queueRender(id: string) {
|
||||||
|
setRendersBusy(prev => new Set(prev).add(id));
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/youtube/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scriptId: id }),
|
||||||
|
});
|
||||||
|
if (res.ok) fetchScripts();
|
||||||
|
else alert((await res.json().catch(() => ({}))).error || "Render queue failed");
|
||||||
|
} catch { /* empty */ }
|
||||||
|
setRendersBusy(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRenderStatus(id: string, status: "tofilm" | "rejected" | "draft") {
|
||||||
|
await fetch("/api/youtube/scripts", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, status }),
|
||||||
|
});
|
||||||
|
fetchScripts();
|
||||||
|
}
|
||||||
|
|
||||||
// small ghost copy button
|
// small ghost copy button
|
||||||
const copyBtn = "text-[10px] px-2 py-0.5 rounded-[var(--r-sm)] bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--line)] hover:text-[var(--text)] transition-colors";
|
const copyBtn = "text-[10px] px-2 py-0.5 rounded-[var(--r-sm)] bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--line)] hover:text-[var(--text)] transition-colors";
|
||||||
@@ -421,6 +454,64 @@ export default function YouTubePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{script.status === "approved" && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
placeholder="🔗 Tweet / News URL (opsional)"
|
||||||
|
defaultValue={script.sourceUrl || ""}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onChange={e => { e.currentTarget.dataset.url = e.target.value; }}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] w-48 outline-none"
|
||||||
|
style={{ background: "color-mix(in srgb, #fff 8%, transparent)", border: "1px solid color-mix(in srgb, #fff 18%, transparent)", color: "inherit" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const v = (e.currentTarget.parentElement?.querySelector("input[data-url]") as HTMLInputElement)?.value || "";
|
||||||
|
if (v.trim()) {
|
||||||
|
updateScript(script.id, { status: "approved", sourceUrl: v.trim() });
|
||||||
|
}
|
||||||
|
queueRender(script.id);
|
||||||
|
}}
|
||||||
|
disabled={rendersBusy.has(script.id)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors disabled:opacity-60"
|
||||||
|
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
|
||||||
|
>{rendersBusy.has(script.id) ? "⏳ Queuing..." : "✨ Render Shorts"}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{script.status === "render_queued" && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs px-3 py-1.5 rounded-[var(--r-md)] animate-pulse" style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 14%, transparent)" }}>
|
||||||
|
⏳ Rendering...
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => updateScript(script.id, { status: "approved" })}>↩ Cancel</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{script.status === "rendered" && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={`/api/youtube/render/file?scriptId=${script.id}&kind=video`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
|
||||||
|
>▶ Preview</a>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRenderStatus(script.id, "tofilm"); }}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
|
||||||
|
>✓ Approve</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRenderStatus(script.id, "rejected"); }}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors"
|
||||||
|
style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }}
|
||||||
|
>✗ Reject</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{script.status === "tofilm" && (
|
{script.status === "tofilm" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -490,9 +581,10 @@ export default function YouTubePage() {
|
|||||||
{/* SHORTS SUB-NAV */}
|
{/* SHORTS SUB-NAV */}
|
||||||
{activeView === "shorts" && (
|
{activeView === "shorts" && (
|
||||||
<div className="flex gap-2 mt-4 mb-2">
|
<div className="flex gap-2 mt-4 mb-2">
|
||||||
{([
|
{([
|
||||||
{ key: "ideas" as const, label: "💡 Ideas", count: ideas.length },
|
{ key: "ideas" as const, label: "💡 Ideas", count: ideas.length },
|
||||||
{ key: "scripts" as const, label: "📜 Scripts", count: draftScripts.length },
|
{ key: "scripts" as const, label: "📜 Scripts", count: draftScripts.length },
|
||||||
|
{ key: "renders" as const, label: "🎞 Renders", count: renderAll.length },
|
||||||
{ key: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length },
|
{ key: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length },
|
||||||
{ key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length },
|
{ key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length },
|
||||||
]).map(tab => (
|
]).map(tab => (
|
||||||
@@ -540,11 +632,24 @@ export default function YouTubePage() {
|
|||||||
}}
|
}}
|
||||||
>{batchGenerating ? `✨ Generating ${selectedIdeas.size}...` : `Generate ${selectedIdeas.size} Script${selectedIdeas.size > 1 ? "s" : ""}`}</Button>
|
>{batchGenerating ? `✨ Generating ${selectedIdeas.size}...` : `Generate ${selectedIdeas.size} Script${selectedIdeas.size > 1 ? "s" : ""}`}</Button>
|
||||||
)}
|
)}
|
||||||
|
<select
|
||||||
|
value={ideaCategory}
|
||||||
|
onChange={(e) => setIdeaCategory(e.target.value as "brands" | "ai-agent-tips")}
|
||||||
|
className="text-xs bg-[var(--surface)] border border-[var(--border)] rounded-lg px-2 py-1.5 text-[var(--muted)]"
|
||||||
|
title="Content category for idea generation"
|
||||||
|
>
|
||||||
|
<option value="brands">Brands & Founders</option>
|
||||||
|
<option value="ai-agent-tips">AI Agent Tips</option>
|
||||||
|
</select>
|
||||||
<Button size="sm" variant="ghost" disabled={generatingIdeas}
|
<Button size="sm" variant="ghost" disabled={generatingIdeas}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setGeneratingIdeas(true);
|
setGeneratingIdeas(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/youtube/ideas/generate", { method: "POST" });
|
const res = await fetch("/api/youtube/ideas/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ category: ideaCategory }),
|
||||||
|
});
|
||||||
if (res.ok) fetchIdeas();
|
if (res.ok) fetchIdeas();
|
||||||
} catch { /* empty */ }
|
} catch { /* empty */ }
|
||||||
setGeneratingIdeas(false);
|
setGeneratingIdeas(false);
|
||||||
@@ -666,6 +771,84 @@ export default function YouTubePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* RENDERS VIEW */}
|
||||||
|
{activeView === "shorts" && shortsView === "renders" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{renderAll.length === 0 ? (
|
||||||
|
<Panel>
|
||||||
|
<EmptyState icon={<span className="text-3xl">🎞️</span>} title="No renders yet" hint="Approve a script, then hit ✨ Render Shorts" />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{renderAll.map(s => {
|
||||||
|
const isRendered = s.status === "rendered";
|
||||||
|
const isQueued = s.status === "render_queued";
|
||||||
|
return (
|
||||||
|
<Panel key={s.id} className="p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium text-[var(--text)] line-clamp-2 leading-snug">{s.title}</p>
|
||||||
|
{isQueued && (
|
||||||
|
<span className="shrink-0 text-[9px] px-2 py-1 rounded-full animate-pulse" style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 14%, transparent)" }}>
|
||||||
|
⏳ rendering
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isRendered && (
|
||||||
|
<span className="shrink-0 text-[9px] px-2 py-1 rounded-full" style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 14%, transparent)" }}>
|
||||||
|
✓ rendered
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRendered ? (
|
||||||
|
<div className="rounded-[var(--r-md)] overflow-hidden border border-[var(--line)] bg-black aspect-[9/16]">
|
||||||
|
<video
|
||||||
|
src={`/api/youtube/render/file?scriptId=${s.id}&kind=video`}
|
||||||
|
poster={`/api/youtube/render/file?scriptId=${s.id}&kind=thumb`}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--r-md)] border border-[var(--line)] bg-[var(--surface-2)] aspect-[9/16] flex items-center justify-center">
|
||||||
|
<span className="text-xs text-[var(--text-3)] animate-pulse">🎬 Rendering…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{isRendered && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={`/api/youtube/render/file?scriptId=${s.id}&kind=video`}
|
||||||
|
download={`${s.title.slice(0, 40).replace(/[^a-z0-9]+/gi, "-")}.mp4`}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
|
||||||
|
>⬇ Download</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setRenderStatus(s.id, "tofilm")}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }}
|
||||||
|
>✓ Approved → Film</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRenderStatus(s.id, "rejected")}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors"
|
||||||
|
style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }}
|
||||||
|
>✗ Reject</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isQueued && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => updateScript(s.id, { status: "approved" })}>↩ Cancel</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TO FILM VIEW */}
|
{/* TO FILM VIEW */}
|
||||||
{activeView === "shorts" && shortsView === "tofilm" && (
|
{activeView === "shorts" && shortsView === "tofilm" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -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