ChannelHelm started as a pipeline that turns one video into a publishing kit. v1.7 adds the business layer on top of the pipeline: know what everything costs, automate routine approval decisions by policy, take in whole backlogs at once, tune the system per business vertical, and query what the entire catalogue knows — not just what one package says.
/atlas).
Each feature answers an operator question the pipeline alone couldn't: what does this cost me? Do I really have to click approve 40 times? How do I onboard a 200-video backlog? Why does a podcast brand get video-shaped output? What has this channel already covered?
Every LLM call, image, and render lands in an append-only ledger. Per-brand monthly budgets gate new generation spend.
Per-brand, per-asset-type trust thresholds. High-confidence assets skip review — with a full audit trail.
Paste up to 50 URLs or point at a folder. Dedupe + per-line outcomes; one bad line never sinks the batch.
Six presets tune the default profile, prompt tone, and asset fan-out so output matches the business, not a generic creator.
Topics + entities from every analysis, indexed and queryable. Coverage signals close the loop back into planning.
Once hosted models enter the provider mix, a pipeline that fans out a dozen LLM calls plus image generations plus renders per video has a real, invisible bill. Cost awareness is the difference between "this feels expensive" and "this brand spent $4.20 this month, mostly on thumbnails." And budgets turn awareness into policy: a brand can never silently overrun.
Every LLM call, generated image, and clip render lands in the append-only
cost_events table:
| Field | Meaning |
|---|---|
category | llm · image · render |
brand_id / package_id / asset_id / job_kind | Attribution — best available at the call site |
input_tokens / output_tokens | LLM token usage from the provider response |
image_count, duration_ms | Image events / render wall-clock |
cost_usd | Priced cost (see pricing below) |
Capture happens at the single LLM choke point (complete() in the provider
integration), at Runware image generation (provider-reported USD), and per rendered clip
(wall-clock). Recording is best-effort: a ledger failure never fails the
work that incurred the cost.
Local models cost $0 (and still record token volume — you see usage, not
just spend). Hosted models are priced by the COST_RATES_JSON runtime setting on
/settings: a JSON array of rules where match is a case-insensitive
substring of the model name and the first hit wins.
// COST_RATES_JSON — USD per million tokens, first match wins [ { "match": "gpt-4o-mini", "input_per_mtok": 0.15, "output_per_mtok": 0.60 }, { "match": "sonnet", "input_per_mtok": 3.00, "output_per_mtok": 15.00 } ]
COST_RATES_JSON.
Set "Monthly budget (USD)" on a brand. When the UTC calendar month's spend reaches the budget, new generation spend stops:
| Surface | At budget |
|---|---|
generate_asset worker | gated fails fast with a readable error |
| AI thumbnail generation | degrades falls back to the free ffmpeg frame-extraction path |
| Studio interactive regenerate | gated refuses with the budget message |
| Pipeline stages (transcribe / visual / fuse / analyze) | never gated |
| Dispatch / publishing | never gated |
The principle: a budget pauses drafting new copy, never understanding videos or shipping approved work. Nothing you've already approved gets stuck behind a cap.
/performance → "Costs — month to date": per-brand spend by
category, token volume, render minutes, budget status (warns at 80%), plus the top packages
by spend.
After a few dozen packages you know which asset types you trust. If you've approved every high-confidence LinkedIn post for a brand without edits, the review click is pure friction — and at multi-brand scale it's the bottleneck. Auto-approve turns that earned trust into an explicit, per-brand policy instead of a habit.
Rules live on the brand form (brands.auto_approve_rules) as per-asset-type
thresholds:
[ { "asset_type": "linkedin_post", "min_score": 0.8 } ]
When generate_asset produces an asset, it derives a quality score from the
payload — in order of preference:
| Signal | Score |
|---|---|
Explicit confidence label | high = 0.9 · medium = 0.6 · low = 0.3 |
Else: best options[].score | as scored |
Else: top-level score | as scored |
| No quality signal at all | never auto-approves |
If the score clears the rule's threshold, the asset skips ready_for_review and
routes through the existing auto-dispatch path.
Every auto-approved asset is stamped with
payload.auto_approved = {score, threshold, reason, at} — so you can
always answer "why did this go out without me?" after the fact. Policy without auditability
would be a footgun; this is the receipt.
*_plan assets are never auto-approvable — plans gate clip rendering, and approving a plan triggers renders.rendered_* assets are never auto-approvable — rendered rows have their own lifecycle.
The evaluation logic is a pure function (src/lib/auto-approve.ts) and is
unit-tested.
One-at-a-time intake is fine for a weekly upload; it's hopeless for agency-scale onboarding
or mining a years-deep back catalogue. Bulk ingest makes "take this entire channel" a single
operation on /sources/new → "Bulk ingest (backlog)", with two
input modes:
| Mode | Limit | How it works |
|---|---|---|
| Paste URLs | 50 / run | One YouTube URL per line (# comments allowed). Lines are validated, deduped against each other AND against the brand's existing sources, then each runs the normal source → package → ingest flow. |
| Local folder | 25 files / run | Absolute path on this Mac; mp4 / mov / webm / m4v / mkv / mp3 / m4a / wav. Each file is copied into MEDIA_ROOT/{brand}/{src}/original.{ext} and ingested like an upload. Dedupe is by title (filename without extension) per brand. |
Every line gets its own verdict — created (with a package link),
duplicate, invalid, or error. One bad
line never sinks the batch. Each created source enqueues with the standard idempotency key
(ingest:{sourceId}), so re-running a batch is safe.
transcription_only
profile (audio-only, no visual phase), then revive selected packages at a richer
profile once you know which ones deserve it.
A generic pipeline writes generic copy. A podcaster never wants "as you can see on screen…" in a post; a webinar brand cares about LinkedIn and article briefs far more than vertical clips; an agency client needs conservative, brand-safe phrasing by default. Verticals encode that fit once, per brand, instead of the operator correcting it in review forever.
| Preset | Tuned for |
|---|---|
| YouTube creator | The default video-first shape — full packaging, Shorts, social fan-out. |
| Podcaster | Audio-first: fast_audio_only by default, tone guidance forbids referencing on-screen visuals, skips long_clip_plan. |
| Webinar / B2B | Fronts linkedin_post + article_brief at higher queue priority — the derivatives that matter ship first. |
| Educator | Instructional tone; emphasis on chaptered, reference-style derivatives. |
| Agency client | Brand-safe, conservative copy by default — review-friendly output for someone else's brand. |
| Local business | Local-audience tone and the network mix a storefront actually uses. |
Each preset (catalogue in src/lib/verticals.ts, stored as brands.vertical) adjusts three things — all advisory, all overridable:
fast_audio_only).skipAssetTypes are not generated at all; emphasizeAssetTypes enqueue at higher queue priority so the most relevant derivatives are ready first.Brands without a vertical behave exactly as before — generic is still a valid choice.
Each package knows what it covers; nothing knew what the channel covers.
After 50 videos, "have we done this topic? when? how hard?" was a memory exercise. Atlas
indexes the topics[] and entities[] the pipeline already extracts
per package into atlas_entries — zero additional LLM calls —
and makes the whole catalogue queryable at /atlas.
| Signal | Definition | Why it matters |
|---|---|---|
| Revisit candidates | Topics covered ≥2× whose last coverage is >90 days old | Proven themes going stale — your safest follow-up bets. |
| One-shot topics | High-weight topics covered exactly once | Strong material with no follow-up — unmined depth. |
One click writes a ranked idea onto the Plan board
(evidence.source = 'atlas_coverage_gap', provenance.model = 'atlas.v1'),
closing the loop from catalogue knowledge back into pre-production. The Atlas isn't a report —
it feeds the Idea Board.
Sync is automatic at the tail of analyze_intelligence (delete + insert per
package, idempotent — Backlog Revival refreshes it too). To index a catalogue that predates
v1.7, run the one-time backfill:
pnpm tsx scripts/backfill-atlas.ts
Migration 0027_verticals_bi_wave.sql adds two tables — cost_events
(cst_ ids) and atlas_entries (atl_ ids, unique on
(package_id, kind, normalized_label)) — plus three brand columns:
vertical, monthly_budget_usd, and auto_approve_rules.
All five features are covered by pure-function test suites (pnpm test).