尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
An automated, faceless YouTube Shorts pipeline: an AI agent picks a topic, writes a short "fun fact" script, generates a vertical video (background image + text overlay + royalty-free music), and waits for a human to approve it before it ever reaches YouTube.
Built to explore agentic pipelines with a real human-in-the-loop gate, running entirely on free tiers (GitHub Actions, Supabase, Vercel, Claude Pro).
Live example: Dose of Facts is a YouTube channel running on this pipeline — every video is generated, reviewed, and published end-to-end by the flow described below.檢視完整 README ↗An automated, faceless YouTube Shorts pipeline: an AI agent picks a topic, writes a short "fun fact" script, generates a vertical video (background image + text overlay + royalty-free music), and waits for a human to approve it before it ever reaches YouTube.
Built to explore agentic pipelines with a real human-in-the-loop gate, running entirely on free tiers (GitHub Actions, Supabase, Vercel, Claude Pro).
Live example: Dose of Facts is a YouTube channel running on this pipeline — every video is generated, reviewed, and published end-to-end by the flow described below.
sharp, SVG overlay), pick a royalty-free track, and render a 1080×1920 MP4 with FFmpeg.pending_review. The owner watches it and clicks Approve or Reject.Three systems, each doing the thing it's actually good at:
flowchart LR
subgraph GHA["GitHub Actions"]
GEN["generate.yml<br/>(cron / dispatch)"]
PUB["publish.yml<br/>(dispatch)"]
CLEAN["cleanup.yml<br/>(daily cron)"]
MIG["migrate.yml<br/>(on push to<br/>supabase/migrations/**)"]
end
subgraph SB["Supabase"]
PG[("Postgres<br/>topics / videos / run_logs")]
ST[("Storage<br/>videos bucket (mp4s)")]
end
subgraph VC["Vercel"]
FE["React dashboard<br/>(anon key + RLS)"]
API["api/dispatch.ts<br/>(owner-authenticated)"]
end
CLAUDE(["Claude Code CLI<br/>(producer / evaluator)"])
PEXELS(["Pexels API"])
YT(["YouTube Data API v3"])
GEN -- "service-role key" --> PG
GEN -- "service-role key" --> ST
GEN --> CLAUDE
GEN --> PEXELS
PUB -- "service-role key" --> PG
PUB -- "service-role key" --> ST
PUB --> YT
CLEAN -- "service-role key" --> ST
MIG -- "SUPABASE_ACCESS_TOKEN /<br/>SUPABASE_DB_PASSWORD" --> PG
FE -- "anon key, RLS-scoped<br/>to the owner" --> PG
FE -- "signed URLs" --> ST
FE -- "Generate now /<br/>Approve -> publish" --> API
API -- "repository_dispatch<br/>(fine-grained PAT)" --> GEN
API -- "repository_dispatch<br/>(fine-grained PAT)" --> PUB
Why this split: Vercel Hobby functions are too small/short-lived for FFmpeg and can't hold the heavy pipeline secrets. All rendering and YouTube publishing run in GitHub Actions, which has FFmpeg preinstalled, generous free minutes, and can safely hold the Supabase service-role key (bypasses RLS) and the YouTube refresh token. Vercel hosts only the dashboard, using the public anon key — Postgres Row Level Security does the real access control, scoping every row to a single owner. The Vercel serverless route (api/dispatch.ts) holds neither secret; it only forwards a repository_dispatch event using a repo-scoped GitHub PAT, after checking the caller is the owner via their Supabase session.
sequenceDiagram
participant Cron as Actions Scheduler
participant Gen as generate.yml
participant Claude as Claude Code CLI
participant Pexels
participant DB as Supabase (Postgres + Storage)
participant UI as Dashboard (Vercel)
participant Dispatch as api/dispatch.ts
participant Pub as publish.yml
participant YT as YouTube
Cron->>Gen: scheduled trigger (or dashboard "Generate now")
Gen->>DB: insert videos row (status=generating)
Gen->>Claude: producer call -> draft spec
Claude->>Claude: evaluator call -> critique / retry loop
Gen->>Pexels: fetch 3 candidate photos
Gen->>Claude: vision judge picks best photo
Gen->>Gen: composite text (sharp) + render mp4 (ffmpeg)
Gen->>DB: upload mp4 to Storage, set status=pending_review
UI->>DB: poll queue (TanStack Query)
UI->>UI: owner watches video, clicks Approve
UI->>Dispatch: POST /api/dispatch {event: publish, video_id}
Dispatch->>DB: verify caller is_owner()
Dispatch->>Pub: repository_dispatch: publish_video
Pub->>DB: set status=publishing, download mp4
Pub->>YT: videos.insert (title/description incl. #Shorts)
Pub->>DB: set status=published, store youtube_id/url
videos.status state machinestateDiagram-v2
[*] --> generating
generating --> pending_review: render + upload succeed
pending_review --> approved: owner clicks Approve
pending_review --> rejected: owner clicks Reject
approved --> publishing: publish.yml starts
publishing --> published: YouTube upload succeeds
generating --> failed
pending_review --> failed
approved --> failed
publishing --> failed
rejected --> [*]
published --> [*]
failed --> [*]
rejected and failed are terminal; failed is reachable from any active state on an unhandled error (with the error message stored on the row).
useEffect + fetch — all data access goes through TanStack Query hooks.tsx), sharp for text compositing, FFmpeg for rendering, googleapis for the YouTube upload.src/ React dashboard (features/<name>/{api.ts, use*.ts, pages})
api/ Vercel serverless route (api/dispatch.ts)
scripts/ Pipeline scripts run by GitHub Actions (generate/publish/cleanup)
scripts/lib/ Shared pipeline logic (Supabase admin client, Claude prompts, rendering)
scripts/evals/ Real, model-graded prompt evals (not run in normal CI)
supabase/migrations/ Single consolidated schema migration
assets/music/ Curated royalty-free tracks + attribution
.github/workflows/ generate / publish / cleanup / migrate / ci / evals
supabase db push locally (after supabase link --project-ref <ref>) or paste supabase/migrations/0001_initial_schema.sql into the SQL editor. This single migration creates every table, the is_owner() RLS helper, all RLS policies, and the private videos Storage bucket.insert into public.app_config (id, owner_id)
select true, id from auth.users where email = '<owner-email>'
on conflict (id) do update set owner_id = excluded.owner_id;
Until this row exists, is_owner() returns false and RLS hides every row from every authenticated session.migrate.yml.Add these under Settings → Secrets and variables → Actions (see .env.example for the full annotated list):
CLAUDE_CODE_OAUTH_TOKEN, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_PROJECT_REF, SUPABASE_ACCESS_TOKEN, SUPABASE_DB_PASSWORD, PEXELS_API_KEY, YT_CLIENT_ID, YT_CLIENT_SECRET, YT_REFRESH_TOKEN, and optionally YT_PRIVACY_STATUS.
Get a Claude Code OAuth token with claude setup-token (requires a Claude Pro/Max subscription; the token is Claude-Code-only, not usable with the Messages API, and lasts about a year — note the rotation date). Get a Pexels key at pexels.com/api.
https://www.googleapis.com/auth/youtube.upload (e.g. via the OAuth Playground or a small local script).YT_CLIENT_ID / YT_CLIENT_SECRET / YT_REFRESH_TOKEN as Actions secrets.Note: uploads from an unverified API project land as private regardless of privacyStatus — publish them from YouTube Studio, or apply for a compliance audit to unlock public/unlisted uploads via the API.
VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY (frontend), plus GITHUB_DISPATCH_TOKEN (a fine-grained PAT scoped to this repo, contents+metadata read is enough since it only triggers repository_dispatch), GITHUB_REPOSITORY (owner/repo), and SUPABASE_URL/SUPABASE_ANON_KEY for api/dispatch.ts to validate the caller's session.generate.yml's schedule: cron is what makes this run on its own — adjust the cron expression to taste, or trigger it manually from the Actions tab (workflow_dispatch, with an optional topic input) or the dashboard's "Generate now" button.
npm install
npm run dev # Vite dev server (dashboard only — the pipeline runs in Actions)
npm run build # tsc -b && vite build
npm run typecheck
npm run lint
npm run format # prettier --write .
Two separate suites:
npm run test — deterministic Vitest unit tests (*.test.ts(x)), mocking the claude CLI and Supabase. Runs in CI (ci.yml) on every push/PR.npm run eval — real, model-graded prompt evals (scripts/evals/*.eval.ts) that spawn the live claude CLI: a producer eval grades real output with an LLM judge, and an evaluator eval checks the evaluator itself accepts good specs and rejects bad ones. These need CLAUDE_CODE_OAUTH_TOKEN and cost real tokens, so they're excluded from npm run test and only run via evals.yml when a prompt file changes.Run a single test file with npx vitest run <path>.
See .env.example for the complete, annotated list of every variable the project reads, grouped by where it's configured (local .env.local, GitHub Actions secrets, Vercel environment variables).
public.is_owner(), which checks auth.uid() against a single row in app_config. Anonymous and any non-owner authenticated session sees zero rows.scripts/lib/supabaseAdmin.ts is the only file that reads the service-role key). The Vercel route holds only a repo-scoped dispatch PAT and the public anon key.api/dispatch.ts requires a valid Supabase session and re-checks is_owner() server-side before firing any repository_dispatch event; client-supplied fields are validated and length-capped.permissions: contents: read — none of them need to write to the repo via GITHUB_TOKEN.ci.yml and evals.yml run on pull_request (never pull_request_target), so GitHub automatically withholds repository secrets from fork-originated runs — ci.yml needs none anyway, and evals.yml's eval step is expected to fail (not leak anything) on external contributors' PRs.For the deeper implementation rationale (why each architectural choice was made, prompt design, the producer/evaluator loop, etc.) see CLAUDE.md.